Skip to content

Commit 00a0951

Browse files
committed
feat: Add Judgement (Discord)
1 parent 71c8068 commit 00a0951

5 files changed

Lines changed: 205 additions & 16 deletions

File tree

src/cache/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { JudgementGame } from '@/discord/commands/judgement';
12
import type { Games } from '@/ps/games';
23
import type { BaseGame } from '@/ps/games/game';
34
import type { DiscCommand, PSCommand } from '@/types/chat';
@@ -23,3 +24,4 @@ export const PSGames: { [key in keyof Games]?: Record<string, BaseGame> } = {};
2324

2425
// Discord
2526
export const DiscCommands: { [key: string]: DiscCommand & { path: string; isAlias?: boolean; slash: SlashCommandBuilder } } = {};
27+
export const DiscGames: { judgement: Record<string, JudgementGame> } = { judgement: {} };

src/discord/commands/judgement.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { ActionRowBuilder, type ComponentType, PermissionsBitField, type TextChannel, UserSelectMenuBuilder } from 'discord.js';
2+
3+
import { DiscGames } from '@/cache';
4+
import { clientId } from '@/config/discord';
5+
import { ChatError } from '@/utils/chatError';
6+
7+
import type { NoTranslate } from '@/i18n/types';
8+
import type { DiscCommand } from '@/types/chat';
9+
10+
type History = {
11+
round: number;
12+
hands: Record<string, number>;
13+
available: number;
14+
calledSum: number;
15+
};
16+
17+
export type JudgementGame = {
18+
startedBy: string;
19+
startedAt: number;
20+
options: { noRepeats?: boolean };
21+
players: string[];
22+
playerCount: number;
23+
round: number;
24+
history: History[];
25+
current: {
26+
round: number;
27+
available: number;
28+
hands: Record<string, number>;
29+
banHands?: Record<string, number>;
30+
hasStarted: boolean;
31+
};
32+
};
33+
34+
const Judgement = DiscGames.judgement;
35+
36+
const userMenu = new UserSelectMenuBuilder().setCustomId('judgment_user_selector').setMinValues(4).setMaxValues(5);
37+
38+
export const command: DiscCommand[] = [
39+
{
40+
name: 'judgement',
41+
desc: 'Creates a game',
42+
args: slash =>
43+
slash
44+
.addSubcommand(subcommand => subcommand.setName('create').setDescription('Creates a game.'))
45+
.addSubcommand(subcommand => subcommand.setName('end').setDescription('Ends the ongoing game.')),
46+
async run(interaction) {
47+
const game = Judgement[interaction.channelId];
48+
if (interaction.options.getSubcommand() === 'end') {
49+
if (!game) throw new ChatError('No game exists to end.' as NoTranslate);
50+
if (interaction.user.id !== game.startedBy && interaction.memberPermissions?.has(PermissionsBitField.Flags.KickMembers))
51+
throw new ChatError('Poke the original creator to end this.' as NoTranslate);
52+
delete Judgement[interaction.channelId];
53+
return interaction.reply('Game ended!');
54+
}
55+
if (game) throw new ChatError('Game already exists! End it first.' as NoTranslate);
56+
const userMenuComponent = new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(userMenu);
57+
const userSelectInteraction = await interaction.reply({
58+
content: "Who's playing?",
59+
components: [userMenuComponent],
60+
ephemeral: true,
61+
fetchReply: true,
62+
});
63+
try {
64+
const userSelection = await userSelectInteraction.awaitMessageComponent<ComponentType.UserSelect>({
65+
filter: author => author.user.id === interaction.user.id,
66+
time: 60_000,
67+
});
68+
const usersSelected = userSelection.users;
69+
if (usersSelected.has(clientId)) return interaction.editReply({ content: 'Screw you.', components: [] });
70+
const createdGame = (Judgement[interaction.channelId] = {
71+
startedBy: interaction.user.id,
72+
startedAt: Date.now(),
73+
options: { noRepeats: false },
74+
players: [...usersSelected.keys()],
75+
playerCount: usersSelected.size,
76+
round: 1,
77+
current: {
78+
round: 1,
79+
available: Math.floor(52 / usersSelected.size),
80+
hands: {},
81+
hasStarted: false,
82+
},
83+
history: [],
84+
});
85+
const channel = (await interaction.channel!.fetch()) as TextChannel;
86+
channel.send(
87+
[
88+
`Users: ${usersSelected.map(user => user.displayName).join(', ')}!`,
89+
"Round 1 is starting. Please note the hands you'll play with /hands.",
90+
`Tagging players: ${createdGame.players.map(playerId => `<@${playerId}>`).join(' ')}`,
91+
].join('\n')
92+
);
93+
interaction.editReply({ content: 'Players added!', components: [] });
94+
} catch (e) {
95+
interaction.editReply({ content: 'No response received.', components: [] });
96+
}
97+
},
98+
},
99+
{
100+
name: 'hands',
101+
desc: 'Calls your number of hands.',
102+
args: slash => slash.addNumberOption(option => option.setName('count').setDescription('Number of hands').setRequired(true)),
103+
async run(interaction) {
104+
const game = Judgement[interaction.channelId];
105+
if (!game) throw new ChatError('No game exists! Please make a new one with /judgement create.' as NoTranslate);
106+
const userId = interaction.user.id;
107+
if (!game.players.includes(userId)) throw new ChatError('Not you; you stink.' as NoTranslate);
108+
const count = interaction.options.getNumber('count', true);
109+
if (!(count <= game.current.available && count >= 0))
110+
throw new ChatError(
111+
`I don't think it's physically possible to call that many... (total hands available: ${game.current.available})` as NoTranslate
112+
);
113+
const oldCall: number | undefined = game.current.hands[userId];
114+
if (oldCall === count) throw new ChatError(`You already called ${oldCall} hands...` as NoTranslate);
115+
if (game.current.banHands?.[userId] === count)
116+
throw new ChatError(`You cannot call ${count} hands (cannot repeat hand count after a conflict).` as NoTranslate);
117+
game.current.hands[userId] = count;
118+
interaction.reply({
119+
content: typeof oldCall === 'number' ? `Changed called hands from ${oldCall} to ${count}.` : `You have called ${count} hands.`,
120+
ephemeral: true,
121+
});
122+
123+
if (Object.values(game.current.hands).length >= game.playerCount) {
124+
// start next round
125+
const total = Object.values(game.current.hands).reduce((a, b) => a + b, 0);
126+
if (total !== game.current.available) {
127+
if (game.options.noRepeats) game.current.banHands = game.current.hands;
128+
game.current.hands = {};
129+
throw new ChatError("The total number of hands doesn't work out! Everyone please call again." as NoTranslate);
130+
}
131+
((await interaction.channel!.fetch()) as TextChannel).send(
132+
`The round has begun! Players have called a total of **${total}** hands this round (out of ${game.current.available} total).`
133+
);
134+
game.current.hasStarted = true;
135+
}
136+
},
137+
},
138+
{
139+
name: 'next',
140+
desc: 'Runs the next round of Judgement',
141+
async run(interaction) {
142+
const game = Judgement[interaction.channelId];
143+
if (!game) throw new ChatError('No game exists! Please make a new one with /judgement create.' as NoTranslate);
144+
if (!game.current.hasStarted)
145+
throw new ChatError("The round hasn't started yet! Make sure everyone has called first." as NoTranslate);
146+
game.history.push({
147+
round: game.round,
148+
hands: game.current.hands,
149+
available: game.current.available,
150+
calledSum: Object.values(game.current.hands).reduce((a, b) => a + b, 0),
151+
});
152+
const lastRound = game.history.at(-1)!;
153+
154+
if (game.current.available === 1) {
155+
// End game
156+
interaction.reply(
157+
[
158+
`Round #${lastRound.round} has ended. Hands called: ${lastRound.calledSum} of ${lastRound.available}.`,
159+
`The game has ended! Thanks for playing!`,
160+
].join('\n')
161+
);
162+
delete Judgement[interaction.channelId];
163+
return;
164+
}
165+
166+
game.round++;
167+
game.current = {
168+
round: game.round,
169+
available: game.current.available - 1,
170+
hands: {},
171+
hasStarted: false,
172+
};
173+
174+
interaction.reply(
175+
[
176+
`Round #${lastRound.round} has ended. Hands called: ${lastRound.calledSum} of ${lastRound.available}.`,
177+
`The next round will have ${game.current.available} total hands. Call your hands using \`/hands\`!`,
178+
`Tagging players: ${game.players.map(playerId => `<@${playerId}>`).join(' ')}`,
179+
].join('\n')
180+
);
181+
},
182+
},
183+
];

src/discord/loaders/commands.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,21 +41,24 @@ export async function loadCommands(): Promise<void> {
4141
await Promise.all(
4242
commands.map(async commandFile => {
4343
const requirePath = fsPath('discord', 'commands', commandFile);
44-
const { command }: { command: DiscCommand } = await import(requirePath);
45-
if (!command) return;
46-
[command.name, ...(command.aliases ?? [])].forEach((commandName, isAlias) => {
47-
const slash = new SlashCommandBuilder().setName(commandName).setDescription(command.desc);
48-
if (command.flags?.serverOnly) slash.setDMPermission(false); // TODO: This is deprecated?
49-
if (command.args) command.args(slash);
44+
const { command: commandEntries }: { command: DiscCommand | DiscCommand[] } = await import(requirePath);
45+
if (!commandEntries) return;
46+
const commands = Array.isArray(commandEntries) ? commandEntries : [commandEntries];
47+
commands.forEach(command =>
48+
[command.name, ...(command.aliases ?? [])].forEach((commandName, isAlias) => {
49+
const slash = new SlashCommandBuilder().setName(commandName).setDescription(command.desc);
50+
if (command.flags?.serverOnly) slash.setDMPermission(false); // TODO: This is deprecated?
51+
if (command.args) command.args(slash);
5052

51-
DiscCommands[commandName] = {
52-
...command,
53-
name: commandName,
54-
path: commandFile,
55-
isAlias: !!isAlias,
56-
slash,
57-
};
58-
});
53+
DiscCommands[commandName] = {
54+
...command,
55+
name: commandName,
56+
path: commandFile,
57+
isAlias: !!isAlias,
58+
slash,
59+
};
60+
})
61+
);
5962
})
6063
);
6164
await registerCommands();

src/globals/prototypes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,12 @@ Object.defineProperties(Array.prototype, {
138138
configurable: false,
139139
value: function <T = unknown, S = unknown>(this: T[], spacer: S): (T | S)[] {
140140
if (this.length === 0 || this.length === 1) return this;
141-
return this.slice(1).reduce(
141+
return this.slice(1).reduce<(T | S)[]>(
142142
(acc, term) => {
143143
acc.push(spacer, term);
144144
return acc;
145145
},
146-
[this[0]] as (T | S)[]
146+
[this[0]]
147147
);
148148
},
149149
},

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"compilerOptions": {
33
"baseUrl": "src",
44
"lib": ["esnext"],
5+
"target": "esnext",
56
"noEmit": true,
67
"jsx": "react-jsx",
78
"skipLibCheck": true,

0 commit comments

Comments
 (0)