Skip to content

Commit 5de33fb

Browse files
committed
games: Support themes and fix base contructor hotpatching
1 parent e90e8f3 commit 5de33fb

10 files changed

Lines changed: 155 additions & 48 deletions

File tree

src/i18n/english.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ export default {
6666
MOD_NOT_FOUND: "Could not find a mod called '{{mod}}'.",
6767
CANNOT_MOD: 'Mods cannot be applied to this game now.',
6868
APPLIED_MOD: '{{mod}} has been applied to game {{id}}.',
69+
NO_THEME_SUPPORT: '{{game}} does not support themes.',
70+
INVALID_THEME: 'Invalid theme. Valid themes are: {{themes}}.',
71+
SET_THEME: 'The theme has been set to {{theme}}.',
6972
TIMER: {
7073
PRIVATE: "Psst it's your turn to play in {{game}} [{{id}}]",
7174
PUBLIC: "{{user}} hasn't played in {{game}} [{{id}}] for {{time}}...",

src/ps/commands/games/core.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,8 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]):
386386
name: 'mod',
387387
aliases: ['#'],
388388
help: 'Modifies a given game.',
389-
syntax: 'CMD [game ref] (mod)',
389+
perms: Symbol.for('games.create'),
390+
syntax: 'CMD [game ref] [mod]',
390391
async run({ message, arg, $T }) {
391392
const { game, ctx } = getGame(arg, { action: 'mod', user: message.author.id }, { room: message.target, $T });
392393
if (!game.moddable?.() || !game.applyMod) throw new ChatError($T('GAME.CANNOT_MOD'));
@@ -398,6 +399,22 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]):
398399
},
399400
} satisfies PSCommand['children'])
400401
: {}),
402+
...(Game.meta.themes
403+
? ({
404+
theme: {
405+
name: 'theme',
406+
aliases: ['t'],
407+
help: "Customizes a game's theme.",
408+
perms: Symbol.for('games.create'),
409+
syntax: 'CMD [game ref] [theme name]',
410+
async run({ message, arg, $T }) {
411+
const { game, ctx } = getGame(arg, { action: 'any' }, { room: message.target, $T });
412+
const result = game.setTheme(ctx);
413+
message.reply(result);
414+
},
415+
},
416+
} satisfies PSCommand['children'])
417+
: {}),
401418
menu: {
402419
name: 'menu',
403420
aliases: ['m', 'list'],

src/ps/games/chess/index.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { pick } from '@/utils/pick';
88
import type { TranslatedText } from '@/i18n/types';
99
import type { Log } from '@/ps/games/chess/logs';
1010
import type { RenderCtx, State, ThemeColours, Turn, WinCtx } from '@/ps/games/chess/types';
11-
import type { ActionResponse, EndType } from '@/ps/games/common';
11+
import type { ActionResponse, EndType, Meta, Theme } from '@/ps/games/common';
1212
import type { BaseContext } from '@/ps/games/game';
1313
import type { Move, Square } from 'chess.js';
1414
import type { User } from 'ps-client';
@@ -31,13 +31,7 @@ export class Chess extends Game<State> {
3131
cache: Record<string, Record<Turn, number>> = {};
3232
lichessURL: string | null = null;
3333

34-
theme: ThemeColours = {
35-
W: '#fff',
36-
B: '#9c5624',
37-
sel: '#87cefa',
38-
hl: '#adff2fa5',
39-
last: '#ff330019',
40-
};
34+
declare meta: Omit<Meta, 'themes'> & { themes: Record<string, Theme<ThemeColours>>; defaultTheme: string };
4135

4236
constructor(ctx: BaseContext) {
4337
super(ctx);
@@ -188,7 +182,7 @@ export class Chess extends Game<State> {
188182
side,
189183
id: this.id,
190184
turn: this.turn!,
191-
theme: this.theme,
185+
theme: this.meta.themes[this.theme!].colors,
192186
small: false,
193187
};
194188
if (this.winCtx) {

src/ps/games/chess/meta.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { CHESS_THEMES, defaultTheme } from '@/ps/games/chess/themes';
12
import { GamesList } from '@/ps/games/common';
23
import { fromHumanTime } from '@/tools';
34

@@ -17,4 +18,7 @@ export const meta: Meta = {
1718
autostart: true,
1819
pokeTimer: fromHumanTime('30 sec'),
1920
timer: fromHumanTime('1 min'),
21+
22+
themes: CHESS_THEMES,
23+
defaultTheme,
2024
};

src/ps/games/chess/themes.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { ThemeColours } from '@/ps/games/chess/types';
2+
import type { Theme } from '@/ps/games/common';
3+
4+
export const defaultTheme = 'default';
5+
6+
export const CHESS_THEMES: Record<string, Theme<ThemeColours>> = {
7+
default: {
8+
id: 'default',
9+
name: 'Default',
10+
aliases: ['standard'],
11+
colors: {
12+
W: '#fff',
13+
B: '#9c5624',
14+
sel: '#87cefa',
15+
hl: '#adff2fa5',
16+
last: '#ff330019',
17+
},
18+
},
19+
snow: {
20+
id: 'snow',
21+
name: 'Snow',
22+
aliases: ['pristine', 'white'],
23+
colors: {
24+
W: '#fff',
25+
B: '#ddd',
26+
sel: '#99e6e6',
27+
hl: '#c2ff6666',
28+
last: '#ff330016',
29+
},
30+
},
31+
ocean: {
32+
id: 'ocean',
33+
name: 'Ocean',
34+
aliases: ['sea', 'deep', 'blue'],
35+
colors: {
36+
W: '#7DACC9',
37+
B: '#5486B0',
38+
sel: '#87CEFA',
39+
hl: '#00E6B8',
40+
last: null,
41+
},
42+
},
43+
spooky: {
44+
id: 'spooky',
45+
name: 'Spooky',
46+
aliases: ['halloween', 'ghost'],
47+
colors: {
48+
W: '#523f69',
49+
B: '#332849',
50+
sel: 'rgba(237,160,61)',
51+
hl: 'rgba(255,172,64,0.6)',
52+
last: 'rgba(160,160,225,0.4)',
53+
},
54+
},
55+
ii: {
56+
id: 'ii',
57+
name: 'II',
58+
aliases: ['audiino', 'candy', 'purple', 'ii88'],
59+
colors: {
60+
W: '#f08dcb',
61+
B: '#8e209f',
62+
sel: '#da8b37',
63+
hl: '#f1ca67d8',
64+
last: '#801c5fbf',
65+
},
66+
},
67+
};

src/ps/games/chess/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ export type ThemeColours = {
1212
W: string;
1313
B: string;
1414
sel: string;
15-
hl?: string;
16-
last?: string;
15+
hl: string | null;
16+
last: string | null;
1717
};
1818

1919
export type RenderCtx = {

src/ps/games/common.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,35 @@ import type { TranslatedText } from '@/i18n/types';
22
import type { ModData, ModEnum } from '@/ps/games/mods';
33
import type { Satisfies } from '@/types/common';
44

5-
export type Meta = Readonly<{
6-
// The name of the game must match the exported class after removing spaces
5+
export type Theme<Colors extends Partial<Record<string, string | null>> = Partial<Record<string, string | null>>> = {
6+
id: string;
77
name: string;
8-
id: GamesList;
9-
aliases?: readonly string[];
10-
/** Only for single-player games. Required for those. */
11-
abbr?: string;
12-
13-
players: 'single' | 'many';
14-
turns?: Readonly<Record<string, string>>;
15-
minSize?: number;
16-
maxSize?: number;
17-
18-
mods?: Readonly<{ list: ModEnum<string>; data: ModData<string> }>;
19-
20-
/** @default Assume true */
21-
autostart?: boolean;
22-
timer?: number | false;
23-
pokeTimer?: number | false | undefined;
24-
}>;
8+
aliases: string[];
9+
colors: Colors;
10+
};
11+
12+
export type Meta = Readonly<
13+
{
14+
// The name of the game must match the exported class after removing spaces
15+
name: string;
16+
id: GamesList;
17+
aliases?: readonly string[];
18+
/** Only for single-player games. Required for those. */
19+
abbr?: string;
20+
21+
players: 'single' | 'many';
22+
turns?: Readonly<Record<string, string>>;
23+
minSize?: number;
24+
maxSize?: number;
25+
26+
mods?: Readonly<{ list: ModEnum<string>; data: ModData<string> }>;
27+
28+
/** @default Assume true */
29+
autostart?: boolean;
30+
timer?: number | false;
31+
pokeTimer?: number | false | undefined;
32+
} & ({ themes: Record<string, Theme>; defaultTheme: string } | { themes?: undefined; defaultTheme?: undefined })
33+
>;
2534

2635
// Note: The values here MUST match the folder name!
2736
export enum GamesList {

src/ps/games/game.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type { EmbedBuilder } from 'discord.js';
2020
import type { Client, User } from 'ps-client';
2121
import type { ReactElement } from 'react';
2222

23-
const backupKeys = ['state', 'started', 'turn', 'turns', 'seed', 'players', 'log', 'startedAt', 'createdAt'] as const;
23+
const backupKeys = ['state', 'started', 'turn', 'turns', 'seed', 'players', 'theme', 'log', 'startedAt', 'createdAt'] as const;
2424

2525
/**
2626
* This is the shared code for all games. To check the game-specific code, refer to the
@@ -63,6 +63,8 @@ export class Game<State extends BaseState> {
6363
players: Record<BaseState['turn'], Player> = {};
6464
spectators: string[] = [];
6565

66+
theme?: string;
67+
6668
// Game-provided methods:
6769
render(side: State['turn'] | null): ReactElement;
6870
render() {
@@ -114,6 +116,8 @@ export class Game<State extends BaseState> {
114116
this.timerLength = ctx.meta.timer;
115117
this.pokeTimerLength = ctx.meta.pokeTimer ?? ctx.meta.timer;
116118
}
119+
120+
if (ctx.meta.defaultTheme) this.theme = ctx.meta.defaultTheme;
117121
}
118122
persist(ctx: BaseContext) {
119123
if (!PSGames[this.meta.id]) PSGames[this.meta.id] = {};
@@ -208,6 +212,17 @@ export class Game<State extends BaseState> {
208212
gameCache.set({ id: this.id, room: this.roomid, game: this.meta.id, backup });
209213
}
210214

215+
setTheme(input: string): TranslatedText {
216+
if (!this.meta.themes) this.throw('GAME.NO_THEME_SUPPORT', { game: this.meta.name });
217+
const themeId = toId(input);
218+
const allThemes = Object.values(this.meta.themes);
219+
const selectedTheme = allThemes.find(theme => toId(theme.name) === themeId || theme.aliases.includes(themeId));
220+
if (!selectedTheme) this.throw('GAME.INVALID_THEME', { themes: allThemes.map(theme => theme.name).list(this.$T) });
221+
this.theme = selectedTheme.id;
222+
this.update();
223+
return this.$T('GAME.SET_THEME', { theme: selectedTheme.name });
224+
}
225+
211226
renderSignups?(staff: boolean): ReactElement | null;
212227
signups(): void {
213228
if (this.started) this.throw('GAME.ALREADY_STARTED');

src/sentinel/registers/ps.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,26 +30,23 @@ export const PS_REGISTERS: Register[] = [
3030
{
3131
label: 'games',
3232
pattern: /\/ps\/games\//,
33-
reload: async filepaths => {
33+
reload: async () => {
3434
['common', 'game', 'index', 'render'].forEach(file => cachebuster(`@/ps/games/${file}`));
35-
const games = filepaths
36-
.reduce<GamesList[]>((acc, filepath) => {
37-
const match = filepath.match(/\/ps\/games\/([^/]*)\//);
38-
if (match) acc.push(match[1] as GamesList);
39-
return acc;
40-
}, [])
41-
.unique();
35+
const games = await fs.readdir(fsPath('ps', 'games'), { withFileTypes: true });
4236
await Promise.all(
43-
games.map(async game => {
44-
const files = await fs.readdir(fsPath('ps', 'games', game));
45-
files.forEach(file => cachebuster(fsPath('ps', 'games', game, file)));
37+
games
38+
.filter(game => game.isDirectory())
39+
.map(async game => {
40+
const gameDir = game.name as GamesList;
41+
const files = await fs.readdir(fsPath('ps', 'games', gameDir));
42+
files.forEach(file => cachebuster(fsPath('ps', 'games', gameDir, file)));
4643

47-
const gameImport = await import(`@/ps/games/${game}`);
48-
const { meta }: { meta: Meta } = gameImport;
49-
const { [meta.name.replaceAll(' ', '')]: instance } = gameImport;
44+
const gameImport = await import(`@/ps/games/${gameDir}`);
45+
const { meta }: { meta: Meta } = gameImport;
46+
const { [meta.name.replaceAll(' ', '')]: instance } = gameImport;
5047

51-
Games[game] = { meta, instance };
52-
})
48+
Games[gameDir] = { meta, instance };
49+
})
5350
);
5451

5552
const gameCommands = await fs.readdir(fsPath('ps', 'commands', 'games'));

src/utils/cachebuster.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export function cachebuster(_filepath: string): boolean {
55
const cache = require.cache[filepath];
66
if (!cache) return false;
77
emptyObject(cache.exports);
8+
cache.children.length = 0;
89
emptyObject(cache);
910
delete require.cache[filepath];
1011
return true;

0 commit comments

Comments
 (0)