Skip to content

Commit 68d4b45

Browse files
committed
games: Add checkword, mods, and allow nesting command files
1 parent 78a4bf0 commit 68d4b45

16 files changed

Lines changed: 277 additions & 97 deletions

File tree

src/i18n/english.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export default {
3636
INVALID_SIDE: 'Invalid side chosen! Valid sides are: {{sides}}',
3737
IN_PROGRESS: 'This game is already in progress. Weeb.',
3838
IS_FULL: 'The game has no more space for players.',
39-
NOT_FOUND: 'Could not find the game you meant...',
39+
NOT_FOUND: "Couldn't find the game you meant...",
4040
NOT_PLAYING: [
4141
"You're not a player!",
4242
"You're not playing, weeb.",
@@ -59,6 +59,9 @@ export default {
5959
NON_PLAYER_OR_SPEC: 'User not in players/spectators',
6060
YOUR_TURN: 'Your turn!',
6161
UPLOAD_FAILED: 'Failed to upload game {{id}}.',
62+
MOD_NOT_FOUND: "Could not find a mod called '{{mod}}'.",
63+
CANNOT_MOD: 'Mods cannot be applied to this game now.',
64+
APPLIED_MOD: '{{mod}} has been applied to game {{id}}.',
6265
TIMER: {
6366
PRIVATE: "Psst it's your turn to play in {{game}} [{{id}}]",
6467
PUBLIC: "{{user}} hasn't played in {{game}} [{{id}}] for {{time}}...",
@@ -79,7 +82,9 @@ export default {
7982
FIRST_MOVE_MULTIPLE_TILES: 'You may not play a single tile on the first move.',
8083
MUST_BE_CONNECTED: 'All moves in Scrabble must be connected to the rest of the tiles on the board!',
8184
MUST_PLAY_TILES: 'Your move must play at least one tile.',
82-
INVALID_WORD: '{{word}} is not a valid word.',
85+
INVALID_WORD: '{{wordList}} is not a valid word.',
86+
INVALID_WORDS: '{{wordList}} are not valid words.',
87+
VALID_WORD: '{{word}} is a valid word in {{mod}}.',
8388
HOW_TO_BLANK:
8489
"Hi, you've drawn a blank tile! A blank tile can be used as any letter, but the tile awards 0 points. You can type `BL[A]NK` (for example) to use the blank as an A. Other syntaxes supported are `BL(A)NK`, or adding an apostrophe after the blanked letter (eg: `BLA'NK`).",
8590
},
Lines changed: 23 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { PSGames } from '@/cache';
22
import { gameCache } from '@/cache/games';
33
import { Games } from '@/ps/games';
44
import { renderBackups, renderMenu } from '@/ps/games/menus';
5+
import { parseMod } from '@/ps/games/mods';
56
import { generateId } from '@/ps/games/utils';
67
import { toId } from '@/tools';
78
import { ChatError } from '@/utils/chatError';
@@ -11,7 +12,6 @@ import type { BaseGame } from '@/ps/games/game';
1112
import type { PSCommand } from '@/types/chat';
1213
import type { Room } from 'ps-client';
1314
import type { HTMLopts } from 'ps-client/classes/common';
14-
import type { ReactElement } from 'react';
1515

1616
type SearchContext =
1717
| { action: 'start'; user: string }
@@ -23,11 +23,12 @@ type SearchContext =
2323
| { action: 'sub'; user1?: string; user2?: string }
2424
| { action: 'watch'; user: string }
2525
| { action: 'unwatch'; user: string }
26+
| { action: 'mod'; user: string }
2627
| { action: 'any' };
2728

2829
type RoomContext = { room: Room; $T: TranslationFn };
2930

30-
const gameCommands = Object.entries(Games).map(([_gameId, Game]): PSCommand => {
31+
export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]): PSCommand => {
3132
const gameId = _gameId as keyof Games;
3233

3334
type GameFilter = (game: BaseGame) => boolean;
@@ -62,6 +63,8 @@ const gameCommands = Object.entries(Games).map(([_gameId, Game]): PSCommand => {
6263
return game.started && !hasJoined && !game.spectators.includes(ctx.user);
6364
case 'unwatch':
6465
return game.started && !hasJoined && game.spectators.includes(ctx.user);
66+
case 'mod':
67+
return game.moddable?.() ?? false;
6568
default:
6669
return true;
6770
}
@@ -384,6 +387,24 @@ const gameCommands = Object.entries(Games).map(([_gameId, Game]): PSCommand => {
384387
message.reply(`/closehtmlpage ${message.author.id}, ${game.id}` as NoTranslate);
385388
},
386389
},
390+
...(Game.meta.mods
391+
? ({
392+
mod: {
393+
name: 'mod',
394+
aliases: ['#'],
395+
help: 'Modifies a given game.',
396+
syntax: 'CMD [game ref] (mod)',
397+
async run({ message, arg, $T }) {
398+
const { game, ctx } = getGame(arg, { action: 'mod', user: message.author.id }, { room: message.target, $T });
399+
if (!game.moddable?.() || !game.applyMod) throw new ChatError($T('GAME.CANNOT_MOD'));
400+
const mod = parseMod(ctx, Game.meta.mods!.list, Game.meta.mods!.data);
401+
if (!mod) throw new ChatError($T('GAME.MOD_NOT_FOUND', { mod: ctx }));
402+
const applied = game.applyMod(mod);
403+
if (applied.success) message.reply(applied.data);
404+
},
405+
},
406+
} satisfies PSCommand['children'])
407+
: {}),
387408
menu: {
388409
name: 'menu',
389410
aliases: ['m', 'list'],
@@ -453,54 +474,3 @@ const gameCommands = Object.entries(Games).map(([_gameId, Game]): PSCommand => {
453474
},
454475
};
455476
});
456-
457-
const metaCommands: PSCommand = {
458-
name: 'games',
459-
help: 'Metacommands for games.',
460-
syntax: 'CMD [menu]',
461-
perms: Symbol.for('games.manage'),
462-
async run({ run }) {
463-
return run('games menu');
464-
},
465-
children: {
466-
menu: {
467-
name: 'menu',
468-
aliases: ['list', 'm'],
469-
help: 'Displays a menu of all games currently active.',
470-
syntax: 'CMD',
471-
async run({ message, broadcastHTML }) {
472-
const Menu = ({ staff }: { staff?: boolean }): ReactElement => (
473-
<>
474-
<hr />
475-
{Object.values(Games)
476-
.map(Game => (
477-
<>
478-
<h3>{Game.meta.name}</h3>
479-
{renderMenu(message.target, Game.meta, !!staff)}
480-
</>
481-
))
482-
.space(<hr />)}
483-
<br />
484-
<hr />
485-
</>
486-
);
487-
const opts: HTMLopts = { name: 'games-menu' };
488-
broadcastHTML(<Menu />, opts);
489-
message.target.sendHTML(<Menu staff />, { ...opts, rank: '%' });
490-
},
491-
},
492-
},
493-
};
494-
495-
export const command: PSCommand[] = [
496-
...gameCommands,
497-
metaCommands,
498-
{
499-
name: 'othellosequence',
500-
help: 'Sequence of fastest game in Othello.',
501-
syntax: 'CMD',
502-
async run({ broadcastHTML }) {
503-
broadcastHTML([['e6', 'f4'], ['e3', 'f6'], ['g5', 'd6'], ['e7', 'f5'], ['c5']].map(turns => turns.join(', ')).join('<br />'));
504-
},
505-
},
506-
];

src/ps/commands/games/meta.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Games } from '@/ps/games';
2+
import { renderMenu } from '@/ps/games/menus';
3+
4+
import type { PSCommand } from '@/types/chat';
5+
import type { HTMLopts } from 'ps-client/classes/common';
6+
import type { ReactElement } from 'react';
7+
8+
export const command: PSCommand = {
9+
name: 'games',
10+
help: 'Metacommands for games.',
11+
syntax: 'CMD [menu]',
12+
perms: Symbol.for('games.manage'),
13+
async run({ run }) {
14+
return run('games menu');
15+
},
16+
children: {
17+
menu: {
18+
name: 'menu',
19+
aliases: ['list', 'm'],
20+
help: 'Displays a menu of all games currently active.',
21+
syntax: 'CMD',
22+
async run({ message, broadcastHTML }) {
23+
const Menu = ({ staff }: { staff?: boolean }): ReactElement => (
24+
<>
25+
<hr />
26+
{Object.values(Games)
27+
.map(Game => (
28+
<>
29+
<h3>{Game.meta.name}</h3>
30+
{renderMenu(message.target, Game.meta, !!staff)}
31+
</>
32+
))
33+
.space(<hr />)}
34+
<br />
35+
<hr />
36+
</>
37+
);
38+
const opts: HTMLopts = { name: 'games-menu' };
39+
broadcastHTML(<Menu />, opts);
40+
message.target.sendHTML(<Menu staff />, { ...opts, rank: '%' });
41+
},
42+
},
43+
},
44+
};

src/ps/commands/games/other.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { parseMod } from '@/ps/games/mods';
2+
import { checkWord } from '@/ps/games/scrabble/checker';
3+
import { ScrabbleMods } from '@/ps/games/scrabble/constants';
4+
import { ScrabbleModData } from '@/ps/games/scrabble/mods';
5+
import { toId } from '@/tools';
6+
import { ChatError } from '@/utils/chatError';
7+
8+
import type { PSCommand } from '@/types/chat';
9+
10+
export const command: PSCommand[] = [
11+
{
12+
name: 'checkword',
13+
help: 'Checks the legality of a word according to the Scrabble dictionary.',
14+
syntax: 'CMD word[, mod]',
15+
aliases: ['cw'],
16+
async run({ broadcast, arg, $T }) {
17+
const [word, input = ScrabbleMods.CSW21] = arg.lazySplit(',', 1);
18+
const mod = parseMod(input, ScrabbleMods, ScrabbleModData);
19+
if (!mod) throw new ChatError($T('GAME.MOD_NOT_FOUND', { mod: input }));
20+
const check = checkWord(word, mod);
21+
if (!check) broadcast($T('GAME.SCRABBLE.INVALID_WORD', { wordList: word }));
22+
else broadcast($T('GAME.SCRABBLE.VALID_WORD', { word: toId(word).toUpperCase(), mod: ScrabbleModData[mod].name }));
23+
},
24+
},
25+
{
26+
name: 'othellosequence',
27+
help: 'Sequence of fastest game in Othello.',
28+
syntax: 'CMD',
29+
async run({ broadcastHTML }) {
30+
broadcastHTML([['e6', 'f4'], ['e3', 'f6'], ['g5', 'd6'], ['e7', 'f5'], ['c5']].map(turns => turns.join(', ')).join('<br />'));
31+
},
32+
},
33+
];

src/ps/games/common.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
11
import type { TranslatedText } from '@/i18n/types';
2+
import type { ModData, ModEnum } from '@/ps/games/mods';
23
import type { Satisfies } from '@/types/common';
34

4-
export type Meta = {
5+
export type Meta = Readonly<{
56
name: string;
67
id: GamesList;
78
aliases?: readonly string[];
89
/** Only for single-player games. Required for those. */
910
abbr?: string;
1011

1112
players: 'single' | 'many';
12-
turns?: Record<string, string>;
13+
turns?: Readonly<Record<string, string>>;
1314
minSize?: number;
1415
maxSize?: number;
1516

17+
mods?: Readonly<{ list: ModEnum<string>; data: ModData<string> }>;
18+
1619
/** @default Assume true */
1720
autostart?: boolean;
1821
timer?: number | false;
1922
pokeTimer?: number | false | undefined;
20-
};
23+
}>;
2124

2225
export enum GamesList {
2326
Othello = 'othello',

src/ps/games/game.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export class Game<State extends BaseState> {
8686
return 'Game ended';
8787
}
8888
trySkipPlayer?(turn: BaseState['turn']): boolean;
89+
moddable?(): boolean;
90+
applyMod?(mod: string): ActionResponse<TranslatedText>;
8991

9092
throw(msg?: Parameters<TranslationFn>[0], vars?: Parameters<TranslationFn>[1]): never {
9193
if (!msg) throw new ChatError(this.$T('GAME.INVALID_INPUT'));
@@ -218,6 +220,7 @@ export class Game<State extends BaseState> {
218220
if (staffHTML) this.room.sendHTML(staffHTML, { name: this.id, rank: '+', change: true });
219221
}
220222
}
223+
// TODO: Handle max players state
221224
renderCloseSignups?(): ReactElement;
222225
closeSignups(change = true): void {
223226
const closeSignupsHTML = (this.renderCloseSignups ?? renderCloseSignups).bind(this)();
@@ -293,7 +296,7 @@ export class Game<State extends BaseState> {
293296
return {
294297
success: true,
295298
data: {
296-
message: (staffAction ? `${player.name} has been removed from the game.` : 'You have left the game') as ToTranslate,
299+
message: (staffAction ? `${player.name} has been removed from the game.` : 'You have left the game.') as ToTranslate,
297300
},
298301
};
299302
}

src/ps/games/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GamesList } from '@/ps/games/common';
1+
import { GamesList, type Meta } from '@/ps/games/common';
22
import { ConnectFour, meta as ConnectFourMeta } from '@/ps/games/connectfour';
33
import { Mastermind, meta as MastermindMeta } from '@/ps/games/mastermind';
44
import { Othello, meta as OthelloMeta } from '@/ps/games/othello';
@@ -21,5 +21,5 @@ export const Games = {
2121
meta: ScrabbleMeta,
2222
instance: Scrabble,
2323
},
24-
};
24+
} satisfies Readonly<Record<GamesList, Readonly<{ meta: Meta; instance: unknown }>>>;
2525
export type Games = typeof Games;

src/ps/games/mods.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { toId } from '@/tools';
2+
3+
export type ModEnum<Mod extends string> = Record<string, Mod>;
4+
export type ModData<Mod extends string> = Partial<Record<Mod, { aliases?: string[] | undefined }>>;
5+
6+
export function parseMod<Mod extends string>(input: string, mods: ModEnum<Mod>, modData: ModData<Mod>): Mod | null {
7+
const query = toId(input);
8+
if (!query || query === 'constructor') return null;
9+
const direct = Object.values(mods).find(mod => mod === query);
10+
if (direct) return direct;
11+
for (const _mod in modData) {
12+
const mod = _mod as Mod;
13+
if (modData[mod]?.aliases?.includes(query)) return mod;
14+
}
15+
return null;
16+
}

src/ps/games/scrabble/checker.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { abilities, items, moves, pokedex } from 'ps-client/data';
2+
3+
import { Dictionaries, ScrabbleMods } from '@/ps/games/scrabble/constants';
4+
import { CRAZYMONS_SCORING, POKEMON_SCORING, ScrabbleModData } from '@/ps/games/scrabble/mods';
5+
6+
import type { WordScore } from '@/ps/games/scrabble/types';
7+
8+
function isPokeWord(word: string): boolean {
9+
if (word in abilities) return true;
10+
if (word in items) return true;
11+
if (word in moves) return true;
12+
if (word in pokedex) return true;
13+
return false;
14+
}
15+
16+
export function checkWord(word: string, appliedMod: ScrabbleMods | null): WordScore | null {
17+
const mod = appliedMod ?? ScrabbleMods.CSW21;
18+
const modData = ScrabbleModData[mod];
19+
const dictionary = Dictionaries[modData.dict];
20+
if (!dictionary) throw new Error(`Unrecognized dictionary ${modData.dict}`);
21+
let query = word.toLowerCase();
22+
switch (mod) {
23+
case ScrabbleMods.POKEMON:
24+
case ScrabbleMods.CRAZYMONS: {
25+
if (isPokeWord(query)) {
26+
if (mod === ScrabbleMods.POKEMON) return POKEMON_SCORING;
27+
if (mod === ScrabbleMods.CRAZYMONS) return CRAZYMONS_SCORING;
28+
}
29+
break;
30+
}
31+
case ScrabbleMods.CLABBERS: {
32+
query = query.split('').sort().join('');
33+
break;
34+
}
35+
}
36+
if (query in dictionary) return [1, 0];
37+
return null;
38+
}

src/ps/games/scrabble/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,11 @@ export const Dictionaries = {
105105
[DICTIONARY.CLABBERS]: Clabbers,
106106
// TypeScript gets its pants in a twirl here with JSON stuff
107107
} as unknown as Record<DICTIONARY, Record<string, boolean>>;
108+
109+
export enum ScrabbleMods {
110+
CSW19 = 'csw19',
111+
CSW21 = 'csw21',
112+
CLABBERS = 'clabbers',
113+
POKEMON = 'pokemon',
114+
CRAZYMONS = 'crazymons',
115+
}

0 commit comments

Comments
 (0)