diff --git a/.husky/pre-commit b/.husky/pre-commit index dae8d76a..09a9d4f9 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,6 +2,7 @@ . "$(dirname -- "$0")/_/husky.sh" bash < { + access>(pos: number[]): V; + count(): Record; + count(map: true): Map; + filterMap(cb: (element: T, index: number, thisArray: T[]) => X | undefined): X | undefined; + list($T?: TranslationFn | string): string; random(rng?: RNGSource): T; - sample(amount: number, rng?: RNGSource): T[]; remove(...toRemove: T[]): T[]; + sample(amount: number, rng?: RNGSource): T[]; shuffle(rng?: RNGSource): T[]; - filterMap(cb: (element: T, index: number, thisArray: T[]) => X | undefined): X | undefined; - unique(): T[]; - list($T?: TranslationFn | string): string; + sortBy(getSort: (term: T, thisArray: T[]) => unknown, dir?: 'asc' | 'desc'): T[]; space(spacer: S): (T | S)[]; - count(): Record; + sum(): T; + unique(): T[]; } interface ReadonlyArray { - random(rng?: RNGSource): T; - sample(amount: number, rng?: RNGSource): T[]; + access>(pos: number[]): V; + count(): Record; + count(map: true): Map; filterMap(cb: (element: T, index: number, thisArray: T[]) => X | undefined): X | undefined; - unique(): T[]; list($T?: TranslationFn): string; + random(rng?: RNGSource): T; + sample(amount: number, rng?: RNGSource): T[]; space(spacer: S): (T | S)[]; - count(): Record; + sum(): T; + unique(): T[]; } interface String { - lazySplit(match: string | RegExp, cases: number): string[]; gsub(match: RegExp, replace: string | ((arg: string, ...captures: string[]) => string)): string; + lazySplit(match: string | RegExp, cases: number): string[]; } interface Number { @@ -36,6 +44,37 @@ declare global { } Object.defineProperties(Array.prototype, { + access: { + enumerable: false, + writable: false, + configurable: false, + value: function >(this: T[], point: number[]): V { + // eslint-disable-next-line -- Consumer-side responsibility for type safety + return point.reduce((arr, index) => arr[index], this); + }, + }, + count: { + enumerable: false, + writable: false, + configurable: false, + value: function ( + this: T[], + map?: boolean + ): Record | Map { + if (map) { + return this.reduce>((out, term) => { + if (!out.has(term)) out.set(term, 1); + else out.set(term, out.get(term)! + 1); + return out; + }, new Map()); + } + return (this as string[]).reduce>((out, term) => { + out[term] ??= 0; + out[term]++; + return out; + }, {}); + }, + }, filterMap: { enumerable: false, writable: false, @@ -51,17 +90,18 @@ Object.defineProperties(Array.prototype, { } }, }, - remove: { + list: { enumerable: false, writable: false, configurable: false, - value: function (this: T[], ...terms: T[]): boolean { - let out = true; - terms.forEach(term => { - if (this.indexOf(term) >= 0) this.splice(this.indexOf(term), 1); - else out = false; - }); - return out; + value: function (this: T[], $T?: TranslationFn | string): string { + const conjunction = typeof $T === 'string' ? $T : ($T?.('GRAMMAR.AND') ?? 'and'); + if (this.length === 0) return ''; + if (this.length === 1) return this.toString(); + if (this.length === 2) return this.map(term => term.toString()).join(` ${conjunction} `); + return `${this.slice(0, -1) + .map(term => term.toString()) + .join(', ')}, ${conjunction} ${this.at(-1)!.toString()}`; }, }, random: { @@ -72,11 +112,24 @@ Object.defineProperties(Array.prototype, { return this[sample(this.length, useRNG(rng))]; }, }, + remove: { + enumerable: false, + writable: false, + configurable: false, + value: function (this: T[], ...terms: T[]): boolean { + let out = true; + terms.forEach(term => { + if (this.indexOf(term) >= 0) this.splice(this.indexOf(term), 1); + else out = false; + }); + return out; + }, + }, sample: { enumerable: false, writable: false, configurable: false, - value: function T(this: T[], amount: number, rng?: RNGSource): T[] { + value: function T(this: T[], amount: number, rng?: RNGSource): T[] { const RNG = useRNG(rng); const sample = Array.from(this), out: T[] = []; @@ -93,7 +146,7 @@ Object.defineProperties(Array.prototype, { enumerable: false, writable: false, configurable: false, - value: function (this: T[], rng?: RNGSource): T[] { + value: function (this: T[], rng?: RNGSource): T[] { const RNG = useRNG(rng); for (let i = this.length - 1; i > 0; i--) { const j = Math.floor(RNG() * (i + 1)); @@ -102,41 +155,24 @@ Object.defineProperties(Array.prototype, { return Array.from(this); }, }, - unique: { - enumerable: false, - writable: false, - configurable: false, - value: function (this: T[]): T[] { - const output: T[] = []; - const cache = new Set(); - for (let i = 0; i < this.length; i++) { - if (!cache.has(this[i])) { - cache.add(this[i]); - output.push(this[i]); - } - } - return output; - }, - }, - list: { + sortBy: { enumerable: false, writable: false, configurable: false, - value: function (this: T[], $T?: TranslationFn | string): string { - const conjunction = typeof $T === 'string' ? $T : ($T?.('GRAMMAR.AND') ?? 'and'); - if (this.length === 0) return ''; - if (this.length === 1) return this.toString(); - if (this.length === 2) return this.map(term => term.toString()).join(` ${conjunction} `); - return `${this.slice(0, -1) - .map(term => term.toString()) - .join(', ')}, ${conjunction} ${this.at(-1)!.toString()}`; + value: function (this: T[], getSort: (term: T, thisArray: T[]) => W, dir?: 'asc' | 'desc'): T[] { + const cache = this.reduce>((map, term) => { + map.set(term, getSort(term, this)); + return map; + }, new Map()); + // TODO: Check if this order is right + return this.sort((a, b) => ((dir === 'desc' ? cache.get(a)! > cache.get(b)! : cache.get(b)! < cache.get(a)!) ? 1 : -1)); }, }, space: { enumerable: false, writable: false, configurable: false, - value: function (this: T[], spacer: S): (T | S)[] { + value: function (this: T[], spacer: S): (T | S)[] { if (this.length === 0 || this.length === 1) return this; return this.slice(1).reduce<(T | S)[]>( (acc, term) => { @@ -147,22 +183,49 @@ Object.defineProperties(Array.prototype, { ); }, }, - count: { + sum: { enumerable: false, writable: false, configurable: false, - value: function (this: T[]): Record { - const out = {} as Record; - this.forEach(term => { - out[term] ??= 0; - out[term]++; - }); - return out; + // Types here intentionally don't include string because gah + value: function (this: number[]): number { + return this.reduce((sum, term) => sum + term); + }, + }, + unique: { + enumerable: false, + writable: false, + configurable: false, + value: function (this: T[]): T[] { + const output: T[] = []; + const cache = new Set(); + for (let i = 0; i < this.length; i++) { + if (!cache.has(this[i])) { + cache.add(this[i]); + output.push(this[i]); + } + } + return output; }, }, }); Object.defineProperties(String.prototype, { + gsub: { + enumerable: false, + writable: false, + configurable: false, + value: function (this: string, match: RegExp, replacer: string | ((substring: string, ...args: string[]) => string)): string { + let output = this.toString(); + while (true) { + // TypeScript what the heck + const next = typeof replacer === 'string' ? output.replace(match, replacer) : output.replace(match, replacer); + if (next === output) break; + output = next; + } + return output; + }, + }, lazySplit: { enumerable: false, writable: false, @@ -191,21 +254,6 @@ Object.defineProperties(String.prototype, { return out; }, }, - gsub: { - enumerable: false, - writable: false, - configurable: false, - value: function (this: string, match: RegExp, replacer: string | ((substring: string, ...args: string[]) => string)): string { - let output = this.toString(); - while (true) { - // TypeScript what the heck - const next = typeof replacer === 'string' ? output.replace(match, replacer) : output.replace(match, replacer); - if (next === output) break; - output = next; - } - return output; - }, - }, }); Object.defineProperties(Number.prototype, { diff --git a/src/i18n/english.ts b/src/i18n/english.ts index b346c833..4694ea68 100644 --- a/src/i18n/english.ts +++ b/src/i18n/english.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-len */ + export default { GRAMMAR: { AND: 'and', @@ -42,6 +44,7 @@ export default { 'Prayer not found. Or something like that.', ], NOT_STARTED: 'The game has not started yet.', + CANNOT_START: 'Cannot start the game! Check the players.', NOT_WATCHING: "You aren't watching this game, though...", NOW_WATCHING: 'You are now watching the game of {{game}} between {{players}}.', NO_LONGER_WATCHING: 'You are no longer watching the game of {{game}} between {{players}}.', @@ -50,6 +53,7 @@ export default { STASHED: 'Successfully stashed game {{id}}.', SUB: '{{out}} has been subbed with {{in}}!', WATCHING_NOTHING: "You don't seem to need to rejoin anything...", + WON: '{{winner}} won!', WON_AGAINST: '{{winner}} won the game of {{game}} against {{loser}}{{ctx}}!', WAITING: 'Waiting for you to play...', NON_PLAYER_OR_SPEC: 'User not in players/spectators', @@ -59,6 +63,26 @@ export default { PRIVATE: "Psst it's your turn to play in {{game}} [{{id}}]", PUBLIC: "{{user}} hasn't played in {{game}} [{{id}}] for {{time}}...", }, + + MASTERMIND: { + ENDED: 'The game of Mastermind was ended for {{player}}.', + FAILED: '{{player}} was unable to guess {{solution}} in {{cap}} guesses.', + }, + SCRABBLE: { + NO_SELECTED: 'You must select a cell to play from first. Please use the buttons!', + TILE_MISMATCH: "That move doesn't seem to line up with the tiles on the board - tried to place {{placed}} on {{actual}}.", + MISSING_LETTER: "You don't have any tiles for {{letter}}.", + INSUFFICIENT_LETTERS: 'You only have {{actual}} tiles of {{letter}} instead of the {{required}} needed.', + BAG_SIZE: 'There are {{amount}} tiles left in the bag.', + TOO_MUCH_PASSING: 'The game ended due to too many passes!', + FIRST_MOVE_CENTER: 'The first move must pass through the center of the board!', + FIRST_MOVE_MULTIPLE_TILES: 'You may not play a single tile on the first move.', + MUST_BE_CONNECTED: 'All moves in Scrabble must be connected to the rest of the tiles on the board!', + MUST_PLAY_TILES: 'Your move must play at least one tile.', + INVALID_WORD: '{{word}} is not a valid word.', + HOW_TO_BLANK: + "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`).", + }, }, COMMANDS: { diff --git a/src/ps/commands/games.tsx b/src/ps/commands/games.tsx index 14c55856..60e304ea 100644 --- a/src/ps/commands/games.tsx +++ b/src/ps/commands/games.tsx @@ -47,7 +47,7 @@ const gameCommands = Object.entries(Games).map(([_gameId, Game]): PSCommand => { (game.sides && Object.keys(game.players).length < game.turns.length) || Object.keys(game.players).length < game.meta.maxSize!; switch (ctx.action) { case 'start': - return game.startable ?? false; + return game.startable() ?? false; case 'join': return !game.started && !hasJoined && hasSpace; case 'play': @@ -142,13 +142,20 @@ const gameCommands = Object.entries(Games).map(([_gameId, Game]): PSCommand => { help: 'Creates a new game.', syntax: 'CMD [mods?]', perms: Game.meta.players === 'single' ? 'regular' : Symbol.for('games.create'), - async run({ message, args, $T }) { + async run({ message, args, $T, run }) { if (Game.meta.players === 'single') { if (Object.values(PSGames[gameId] ?? {}).find(game => message.author.id in game.players)) { throw new ChatError($T('GAME.ALREADY_JOINED')); } } - const id = Game.meta.players === 'single' ? `#${Game.meta.abbr}-${message.author.id}` : generateId(); + const id = + // TODO: Revert this bit once Scrabble is stable + Game.meta.id === 'scrabble' + ? '#TEMP' + : Game.meta.players === 'single' + ? `#${Game.meta.abbr}-${message.author.id}` + : generateId(); + if (PSGames[gameId]?.[id]) throw new ChatError($T('GAME.ALREADY_STARTED')); const game = new Game.instance({ id, meta: Game.meta, room: message.target, $T, args, by: message.author }); if (game.meta.players === 'many') { message.reply( @@ -169,7 +176,7 @@ const gameCommands = Object.entries(Games).map(([_gameId, Game]): PSCommand => { const { game, ctx } = getGame(arg, { action: 'join', user: message.author.id }, { room: message.target, $T }); const res = game.addPlayer(message.author, ctx); if (!res.success) throw new ChatError(res.error); - const turnMsg = 'turns' in Game.meta ? ` as ${Game.meta.turns[res.data!.as as keyof typeof Game.meta.turns]}` : ''; + const turnMsg = Game.meta.turns ? ` as ${Game.meta.turns[res.data!.as]}` : ''; message.reply( `${message.author.name} joined the game of ${Game.meta.name}${turnMsg}${ ctx === '-' ? ' (randomly chosen)' : '' @@ -186,15 +193,40 @@ const gameCommands = Object.entries(Games).map(([_gameId, Game]): PSCommand => { syntax: 'CMD [id], [move]', async run({ message, arg, $T }) { const { game, ctx } = getGame(arg, { action: 'play', user: message.author.id }, { room: message.target, $T }); - game.action(message.author, ctx, false); + try { + game.action(message.author, ctx, false); + } catch (err) { + // Regenerate the HTML if given an invalid input + if (err instanceof ChatError) { + game.update(message.author.id); + throw err; + } + } }, }, + ...(Game.meta.autostart === false + ? ({ + start: { + name: 'start', + aliases: ['s', 'go', 'g'], + help: 'Starts a game if it does not have an auto-start.', + syntax: 'CMD [id]', + perms: Symbol.for('games.create'), + async run({ message, arg, $T }): Promise { + const { game } = getGame(arg, { action: 'start', user: message.author.id }, { room: message.target, $T }); + if (!game.startable()) throw new ChatError($T('GAME.CANNOT_START')); + game.start(); + game.closeSignups(false); + }, + }, + } satisfies PSCommand['children']) + : {}), reaction: { name: 'reaction', aliases: ['x', '!!'], help: 'Performs an out-of-turn action.', syntax: 'CMD [id], [move]', - async run({ message, arg, $T }) { + async run({ message, arg, $T }): Promise { const { game, ctx } = getGame(arg, { action: 'reaction', user: message.author.id }, { room: message.target, $T }); game.action(message.author, ctx, true); }, @@ -206,7 +238,8 @@ const gameCommands = Object.entries(Games).map(([_gameId, Game]): PSCommand => { async run({ message, arg, $T }) { if (!('external' in Game.instance.prototype)) throw new ChatError($T('GAME.COMMAND_NOT_ENABLED')); const { game, ctx } = getGame(arg, { action: 'audience', user: message.author.id }, { room: message.target, $T }); - game.external!(message.author, ctx); + if (!game.external) throw new ChatError($T('CMD_NOT_FOUND')); + game.external(message.author, ctx); }, }, end: { @@ -332,7 +365,7 @@ const gameCommands = Object.entries(Games).map(([_gameId, Game]): PSCommand => { }, unwatch: { name: 'unwatch', - aliases: ['uw', 'unspectate', 'uspec'], + aliases: ['uw', 'unspectate', 'uspec', 'unspec'], help: 'Unwatches the given game.', syntax: 'CMD [game ref]', async run({ message, arg, $T }) { diff --git a/src/ps/commands/timer.ts b/src/ps/commands/timer.ts index c5bad31d..3b45ae30 100644 --- a/src/ps/commands/timer.ts +++ b/src/ps/commands/timer.ts @@ -6,17 +6,14 @@ import { Timer } from '@/utils/timer'; import type { PSCommand } from '@/types/chat'; import type { PSMessage } from '@/types/ps'; -const $ = { - messageToId(message: PSMessage) { - return 'PS-' + message.target.id + '-' + message.author.id; - }, -}; +function messageToId(message: PSMessage): string { + return 'PS-' + message.target.id + '-' + message.author.id; +} export const command: PSCommand = { name: 'timer', help: 'Sets a timer for the given interval (in human units!)', syntax: 'CMD (time, written out) (// reason)?', - static: $, children: { status: { name: 'status', @@ -24,7 +21,7 @@ export const command: PSCommand = { help: 'Displays the current timer status', syntax: 'CMD', async run({ message, $T }) { - const id = $.messageToId(message); + const id = messageToId(message); const timer = Timers[id]; if (!timer) throw new ChatError($T('COMMANDS.TIMER.NONE_RUNNING')); const timeLeft = toHumanTime(timer.endTime - Date.now(), undefined, $T); @@ -37,7 +34,7 @@ export const command: PSCommand = { help: 'Cancels the ongoing timer', syntax: 'CMD', async run({ message, $T }) { - const id = $.messageToId(message); + const id = messageToId(message); const timer = Timers[id]; if (!timer) throw new ChatError($T('COMMANDS.TIMER.NONE_RUNNING')); delete Timers[id]; @@ -52,7 +49,7 @@ export const command: PSCommand = { help: 'Makes the ongoing timer execute immediately', syntax: 'CMD', async run({ message, $T }) { - const id = $.messageToId(message); + const id = messageToId(message); const timer = Timers[id]; if (!timer) throw new ChatError($T('COMMANDS.TIMER.NONE_RUNNING')); delete Timers[id]; @@ -63,7 +60,7 @@ export const command: PSCommand = { }, }, async run({ message, args, run, $T }) { - const id = $.messageToId(message); + const id = messageToId(message); if (Timers[id]) return run('timer status'); const [timeText, ...commentLines] = args.join(' ').split('//'); const comment = commentLines.join('//').trim(); diff --git a/src/ps/games/common.ts b/src/ps/games/common.ts index e250dd32..87620991 100644 --- a/src/ps/games/common.ts +++ b/src/ps/games/common.ts @@ -1,15 +1,19 @@ import type { TranslatedText } from '@/i18n/types'; +import type { Satisfies } from '@/types/common'; export type Meta = { name: string; id: GamesList; aliases?: readonly string[]; + /** Only for single-player games. Required for those. */ + abbr?: string; players: 'single' | 'many'; turns?: Record; minSize?: number; maxSize?: number; + /** @default Assume true */ autostart?: boolean; timer?: number | false; pokeTimer?: number | false | undefined; @@ -19,6 +23,7 @@ export enum GamesList { Othello = 'othello', Mastermind = 'mastermind', ConnectFour = 'connectfour', + Scrabble = 'scrabble', } export interface Player { @@ -33,3 +38,10 @@ export type BaseState = { board: unknown; turn: string }; export type ActionResponse = { success: true; data: T } | { success: false; error: TranslatedText }; export type EndType = 'regular' | 'force' | 'dq' | 'loss'; + +export type BaseLog = { action: string; time: Date; turn: string | null; ctx: unknown }; + +export type CommonLog = Satisfies< + BaseLog, + { action: 'dq' | 'forfeit'; time: Date; turn: Turn; ctx: null } +>; diff --git a/src/ps/games/connectfour/index.ts b/src/ps/games/connectfour/index.ts index 5ea22c01..17e11c7b 100644 --- a/src/ps/games/connectfour/index.ts +++ b/src/ps/games/connectfour/index.ts @@ -3,18 +3,20 @@ import { EmbedBuilder } from 'discord.js'; import { WINNER_ICON } from '@/discord/constants/emotes'; import { render } from '@/ps/games/connectfour/render'; import { Game, createGrid } from '@/ps/games/game'; -import { ChatError } from '@/utils/chatError'; import { repeat } from '@/utils/repeat'; import type { TranslatedText } from '@/i18n/types'; import type { EndType } from '@/ps/games/common'; +import type { Log } from '@/ps/games/connectfour/logs'; import type { Board, RenderCtx, State, Turn, WinCtx } from '@/ps/games/connectfour/types'; import type { BaseContext } from '@/ps/games/game'; import type { User } from 'ps-client'; +import type { ReactElement } from 'react'; export { meta } from '@/ps/games/connectfour/meta'; export class ConnectFour extends Game { + log: Log[] = []; winCtx?: WinCtx | { type: EndType }; cache: Record> = {}; constructor(ctx: BaseContext) { @@ -26,16 +28,16 @@ export class ConnectFour extends Game { } action(user: User, ctx: string): void { - if (!this.started) throw new ChatError(this.$T('GAME.NOT_STARTED')); - if (user.id !== this.players[this.turn!].id) throw new ChatError(this.$T('GAME.IMPOSTOR_ALERT')); + if (!this.started) this.throw('GAME.NOT_STARTED'); + if (user.id !== this.players[this.turn!].id) this.throw('GAME.IMPOSTOR_ALERT'); const col = parseInt(ctx); - if (isNaN(col)) throw new ChatError(this.$T('GAME.INVALID_INPUT')); + if (isNaN(col)) this.throw(); const res = this.play(col, this.turn!); - if (!res) throw new ChatError(this.$T('GAME.INVALID_INPUT')); + if (!res) this.throw(); } play(col: number, turn: Turn): Board | null | boolean { - if (this.turn !== turn) throw new ChatError(this.$T('GAME.IMPOSTOR_ALERT')); + if (this.turn !== turn) this.throw('GAME.IMPOSTOR_ALERT'); const board = this.state.board; if (board[0][col]) return null; @@ -123,7 +125,7 @@ export class ConnectFour extends Game { ); } - render(side: Turn) { + render(side: Turn | null): ReactElement { const ctx: RenderCtx = { board: this.state.board, id: this.id, diff --git a/src/ps/games/connectfour/logs.ts b/src/ps/games/connectfour/logs.ts new file mode 100644 index 00000000..f2a94499 --- /dev/null +++ b/src/ps/games/connectfour/logs.ts @@ -0,0 +1,15 @@ +import type { BaseLog, CommonLog } from '@/ps/games/common'; +import type { Turn } from '@/ps/games/connectfour/types'; +import type { Satisfies, SerializedInstance } from '@/types/common'; + +export type Log = Satisfies< + BaseLog, + { + time: Date; + turn: Turn; + action: 'play'; + ctx: number; + } +>; + +export type APILog = SerializedInstance; diff --git a/src/ps/games/connectfour/meta.ts b/src/ps/games/connectfour/meta.ts index d2be4073..375de969 100644 --- a/src/ps/games/connectfour/meta.ts +++ b/src/ps/games/connectfour/meta.ts @@ -1,7 +1,9 @@ import { GamesList } from '@/ps/games/common'; import { fromHumanTime } from '@/tools'; -export const meta = { +import type { Meta } from '@/ps/games/common'; + +export const meta: Meta = { name: 'Connect Four', id: GamesList.ConnectFour, aliases: ['c4'], @@ -15,4 +17,4 @@ export const meta = { autostart: true, pokeTimer: fromHumanTime('30 sec'), timer: fromHumanTime('1 min'), -} as const; +}; diff --git a/src/ps/games/game.ts b/src/ps/games/game.ts index c34ea8b4..f96bcffc 100644 --- a/src/ps/games/game.ts +++ b/src/ps/games/game.ts @@ -16,13 +16,17 @@ import { Timer } from '@/utils/timer'; import type { GameModel } from '@/database/games'; import type { NoTranslate, PSRoomTranslated, ToTranslate, TranslatedText, TranslationFn } from '@/i18n/types'; -import type { ActionResponse, BaseState, EndType, Meta, Player } from '@/ps/games/common'; +import type { ActionResponse, BaseLog, BaseState, EndType, Meta, Player } from '@/ps/games/common'; import type { EmbedBuilder } from 'discord.js'; import type { Client, User } from 'ps-client'; import type { ReactElement } from 'react'; const backupKeys = ['state', 'started', 'turn', 'turns', 'seed', 'players', 'log', 'startedAt', 'createdAt'] as const; +/** + * This is the shared code for all games. To check the game-specific code, refer to the + * extended constructor in `$game/index.ts` and go through the `action` method. + */ export class Game { meta: Meta; id: string; @@ -34,10 +38,9 @@ export class Game { roomid: string; // @ts-expect-error -- State isn't initialized yet state: State = {}; - log: { action: string; time: Date; turn: State['turn'] | null; ctx: unknown }[] = []; + log: BaseLog[] = []; sides: boolean; - startable?: boolean; started: boolean = false; createdAt: Date = new Date(); startedAt?: Date; @@ -63,7 +66,7 @@ export class Game { render() { return null as unknown as ReactElement; } - renderEmbed?(): EmbedBuilder; + renderEmbed?(): EmbedBuilder | null; action(user: User, ctx: string, reaction: boolean): void; action() {} @@ -73,7 +76,7 @@ export class Game { onAddPlayer?(user: User, ctx: string): ActionResponse>; onLeavePlayer?(player: Player, ctx: string | User): ActionResponse; onForfeitPlayer?(player: Player, ctx: string | User): ActionResponse; - onReplacePlayer?(turn: BaseState['turn'], withPlayer: User): ActionResponse; + onReplacePlayer?(turn: BaseState['turn'], withPlayer: User): ActionResponse>; onStart?(): ActionResponse; onEnd(type?: EndType): TranslatedText; onEnd() { @@ -81,6 +84,11 @@ export class Game { } trySkipPlayer?(turn: BaseState['turn']): boolean; + throw(msg?: Parameters[0], vars?: Parameters[1]): never { + if (!msg) throw new ChatError(this.$T('GAME.INVALID_INPUT')); + throw new ChatError(this.$T(msg, vars)); + } + constructor(ctx: BaseContext) { this.id = ctx.id; this.room = ctx.room; @@ -143,8 +151,7 @@ export class Game { setTimer(comment: string): void { if (!this.timerLength || !this.pokeTimerLength) return; - this.timer?.cancel(); - this.pokeTimer?.cancel(); + this.clearTimer(); const turn = this.turn!; const timerLength = this.timerLength; @@ -193,11 +200,17 @@ export class Game { gameCache.set({ id: this.id, room: this.roomid, game: this.meta.id, backup }); } - renderSignups?(): ReactElement; + renderSignups?(staff: boolean): ReactElement | null; signups(): void { - if (this.started) throw new ChatError(this.$T('GAME.ALREADY_STARTED')); - const signupsHTML = (this.renderSignups ?? renderSignups).bind(this)(); + if (this.started) this.throw('GAME.ALREADY_STARTED'); + const signupRenderer = (this.renderSignups ?? renderSignups).bind(this); + const signupsHTML = signupRenderer(false); if (signupsHTML) this.room.sendHTML(signupsHTML, { name: this.id }); + if (this.meta.autostart === false) { + const staffHTML = signupRenderer(true); + // TODO: Sync this rank with games.create perms + if (staffHTML) this.room.sendHTML(staffHTML, { name: this.id, rank: '+', change: true }); + } } renderCloseSignups?(): ReactElement; closeSignups(change = true): void { @@ -207,12 +220,11 @@ export class Game { addPlayer(user: User, ctx: string | null): ActionResponse<{ started: boolean; as: BaseState['turn'] }> { if (this.started) return { success: false, error: this.$T('GAME.ALREADY_STARTED') }; - if (this.meta.players === 'single' && Object.keys(this.players).length >= 1) throw new Error(this.$T('GAME.IS_FULL')); + if (this.meta.players === 'single' && Object.keys(this.players).length >= 1) this.throw('GAME.IS_FULL'); const availableSlots: number | State['turn'][] = this.sides ? this.turns.filter(turn => !this.players[turn]) : this.meta.maxSize! - Object.keys(this.players).length; - if (Object.values(this.players).some((player: Player) => player.id === user.id)) - throw new ChatError(this.$T('GAME.ALREADY_JOINED')); + if (Object.values(this.players).some((player: Player) => player.id === user.id)) this.throw('GAME.ALREADY_JOINED'); const newPlayer: Player = { name: user.name, id: user.id, @@ -235,13 +247,6 @@ export class Game { if (!extraData.success) return extraData; Object.assign(newPlayer, extraData.data); } - if (this.turns) this.startable = Array.isArray(availableSlots) && availableSlots.length === 1; - else { - const playerCount = Object.keys(this.players).length; - if (playerCount <= this.meta.maxSize!) { - if (!this.meta.minSize || playerCount >= this.meta.minSize) this.startable = true; - } - } this.players[newPlayer.turn] = newPlayer; if (this.meta.players === 'single' || (Array.isArray(availableSlots) && availableSlots.length === 1) || availableSlots === 1) { // Join was successful and game is now full @@ -289,8 +294,7 @@ export class Game { replacePlayer(_turn: BaseState['turn'], withPlayer: User): ActionResponse { const turn = _turn as State['turn']; - if (Object.values(this.players).some((player: Player) => player.id === withPlayer.id)) - throw new ChatError(this.$T('GAME.IMPOSTOR_ALERT')); + if (Object.values(this.players).some((player: Player) => player.id === withPlayer.id)) this.throw('GAME.IMPOSTOR_ALERT'); const assign: Partial = { name: withPlayer.name, id: withPlayer.id, @@ -298,6 +302,7 @@ export class Game { if (this.onReplacePlayer) { const res = this.onReplacePlayer(turn, withPlayer); if (!res.success) throw new ChatError(res.error); + // TODO: This shouldn't be needed anymore if (res.data) Object.assign(assign, res.data); } const oldPlayer = this.players[turn]; @@ -306,6 +311,18 @@ export class Game { return { success: true, data: this.$T('GAME.SUB', { in: withPlayer.name, out: oldPlayer.name }) }; } + startable(): boolean { + if (this.started) return false; + if (this.turns?.length) return this.turns.every(turn => this.players[turn]); + else { + const playerCount = Object.keys(this.players).length; + if (playerCount <= this.meta.maxSize!) { + if (!this.meta.minSize || playerCount >= this.meta.minSize) return true; + } + } + return false; + } + start(): ActionResponse { const tryStart = this.onStart?.(); if (tryStart?.success === false) return tryStart; @@ -314,6 +331,7 @@ export class Game { this.nextPlayer(); this.startedAt = new Date(); this.setTimer('Game started'); + this.backup(); return { success: true, data: undefined }; } @@ -355,7 +373,7 @@ export class Game { const asPlayer = Object.values(this.players).find(player => player.id === user); if (asPlayer) return this.sendHTML(asPlayer.id, this.render(asPlayer.turn)); if (this.spectators.includes(user)) return this.sendHTML(user, this.render(null)); - throw new ChatError(this.$T('GAME.NON_PLAYER_OR_SPEC')); + this.throw('GAME.NON_PLAYER_OR_SPEC'); } // TODO: Add ping to ps-client HTML opts Object.keys(this.players).forEach(side => this.sendHTML(this.players[side].id, this.render(side))); @@ -378,11 +396,13 @@ export class Game { } this.endedAt = new Date(); this.room.send(message); - if (this.started && this.renderEmbed && this.roomid === 'boardgames') { - const embed = this.renderEmbed(); + if (this.started && typeof this.renderEmbed === 'function' && this.roomid === 'boardgames') { // Send only for games from BG - const channel = Discord.channels.cache.get(BOT_LOG_CHANNEL); - if (channel && channel.type === ChannelType.GuildText) channel.send({ embeds: [embed] }); + const embed = this.renderEmbed(); + if (embed) { + const channel = Discord.channels.cache.get(BOT_LOG_CHANNEL); + if (channel && channel.type === ChannelType.GuildText) channel.send({ embeds: [embed] }); + } } // Upload to DB if (this.meta.players !== 'single') { diff --git a/src/ps/games/index.ts b/src/ps/games/index.ts index 781e38bf..6624dafc 100644 --- a/src/ps/games/index.ts +++ b/src/ps/games/index.ts @@ -2,6 +2,7 @@ import { GamesList } from '@/ps/games/common'; import { ConnectFour, meta as ConnectFourMeta } from '@/ps/games/connectfour'; import { Mastermind, meta as MastermindMeta } from '@/ps/games/mastermind'; import { Othello, meta as OthelloMeta } from '@/ps/games/othello'; +import { Scrabble, meta as ScrabbleMeta } from '@/ps/games/scrabble'; export const Games = { [GamesList.Othello]: { @@ -16,5 +17,9 @@ export const Games = { meta: ConnectFourMeta, instance: ConnectFour, }, + [GamesList.Scrabble]: { + meta: ScrabbleMeta, + instance: Scrabble, + }, }; export type Games = typeof Games; diff --git a/src/ps/games/mastermind/index.ts b/src/ps/games/mastermind/index.ts index e59524ed..5b56562d 100644 --- a/src/ps/games/mastermind/index.ts +++ b/src/ps/games/mastermind/index.ts @@ -2,7 +2,6 @@ import { type ReactElement } from 'react'; import { Game } from '@/ps/games/game'; import { render, renderCloseSignups } from '@/ps/games/mastermind/render'; -import { ChatError } from '@/utils/chatError'; import { sample } from '@/utils/random'; import type { ToTranslate, TranslatedText } from '@/i18n/types'; @@ -21,7 +20,7 @@ export class Mastermind extends Game { super(ctx); this.state.cap = parseInt(ctx.args.join('')); - if (this.state.cap > 12 || this.state.cap < 4) throw new ChatError(this.$T('GAME.INVALID_INPUT')); + if (this.state.cap > 12 || this.state.cap < 4) this.throw(); if (isNaN(this.state.cap)) this.state.cap = 10; super.persist(ctx); @@ -37,13 +36,13 @@ export class Mastermind extends Game { parseGuess(guess: string): Guess { const guessStr = guess.replace(/\s/g, ''); - if (!/^[0-7]{4}$/.test(guessStr)) throw new ChatError(this.$T('GAME.INVALID_INPUT')); + if (!/^[0-7]{4}$/.test(guessStr)) this.throw(); return guessStr.split('').map(n => +n) as Guess; } action(user: User, ctx: string): void { - if (!this.started) throw new ChatError(this.$T('GAME.NOT_STARTED')); - if (!(user.id in this.players)) throw new ChatError(this.$T('GAME.IMPOSTOR_ALERT')); + if (!this.started) this.throw('GAME.NOT_STARTED'); + if (!(user.id in this.players)) this.throw('GAME.IMPOSTOR_ALERT'); const guess = this.parseGuess(ctx); const result = this.guess(guess); @@ -76,10 +75,10 @@ export class Mastermind extends Game { } return { exact, moved }; } - external(user: User, ctx: string) { - if (this.state.board.length > 0) throw new ChatError(this.$T('GAME.ALREADY_STARTED')); - if (this.setBy) throw new ChatError(this.$T('TOO_LATE')); - if (user.id in this.players) throw new ChatError(this.$T('GAME.IMPOSTOR_ALERT')); + external(user: User, ctx: string): void { + if (this.state.board.length > 0) this.throw('GAME.ALREADY_STARTED'); + if (this.setBy) this.throw('TOO_LATE'); + if (user.id in this.players) this.throw('GAME.IMPOSTOR_ALERT'); this.state.solution = this.parseGuess(ctx); this.setBy = user; @@ -89,17 +88,14 @@ export class Mastermind extends Game { onEnd(type: EndType): TranslatedText { this.ended = true; const player = Object.values(this.players)[0].name; - if (type === 'dq' || type === 'force') { - return `The game of Mastermind was ended for ${player}.` as ToTranslate; - } - if (type === 'loss') { - return `${player} was unable to guess ${this.state.solution.join('')} in ${this.state.cap} guesses.` as ToTranslate; - } + if (type === 'dq' || type === 'force') return this.$T('GAME.MASTERMIND.ENDED', { player }); + if (type === 'loss') + return this.$T('GAME.MASTERMIND.FAILED', { player, solution: this.state.solution.join(''), cap: this.state.cap }); const guesses = this.state.board.length; return `${player} guessed ${this.state.solution.join('')} in ${guesses} turn${guesses === 1 ? '' : 's'}!` as ToTranslate; } - render(asPlayer: string | null) { + render(asPlayer: string | null): ReactElement { return render.bind(this.renderCtx)(this.state, asPlayer ? (this.ended ? 'over' : 'playing') : 'spectator'); } } diff --git a/src/ps/games/mastermind/meta.ts b/src/ps/games/mastermind/meta.ts index 7eb8c3db..1f8bbd87 100644 --- a/src/ps/games/mastermind/meta.ts +++ b/src/ps/games/mastermind/meta.ts @@ -1,9 +1,11 @@ import { GamesList } from '@/ps/games/common'; -export const meta = { +import type { Meta } from '@/ps/games/common'; + +export const meta: Meta = { name: 'Mastermind', id: GamesList.Mastermind, aliases: ['mm'], - players: 'single', abbr: 'mm', -} as const; + players: 'single', +}; diff --git a/src/ps/games/othello/index.ts b/src/ps/games/othello/index.ts index ed95c50f..e116aa4d 100644 --- a/src/ps/games/othello/index.ts +++ b/src/ps/games/othello/index.ts @@ -3,18 +3,19 @@ import { EmbedBuilder } from 'discord.js'; import { WINNER_ICON } from '@/discord/constants/emotes'; import { Game, createGrid } from '@/ps/games/game'; import { render } from '@/ps/games/othello/render'; -import { ChatError } from '@/utils/chatError'; import { deepClone } from '@/utils/deepClone'; import type { TranslatedText } from '@/i18n/types'; import type { EndType } from '@/ps/games/common'; import type { BaseContext } from '@/ps/games/game'; +import type { Log } from '@/ps/games/othello/logs'; import type { Board, RenderCtx, State, Turn, WinCtx } from '@/ps/games/othello/types'; import type { User } from 'ps-client'; export { meta } from '@/ps/games/othello/meta'; export class Othello extends Game { + log: Log[] = []; winCtx?: WinCtx | { type: EndType }; cache: Record> = {}; constructor(ctx: BaseContext) { @@ -42,12 +43,12 @@ export class Othello extends Game { } action(user: User, ctx: string): void { - if (!this.started) throw new ChatError(this.$T('GAME.NOT_STARTED')); - if (user.id !== this.players[this.turn!].id) throw new ChatError(this.$T('GAME.IMPOSTOR_ALERT')); + if (!this.started) this.throw('GAME.NOT_STARTED'); + if (user.id !== this.players[this.turn!].id) this.throw('GAME.IMPOSTOR_ALERT'); const [i, j] = ctx.split('-').map(num => parseInt(num)); - if (isNaN(i) || isNaN(j)) throw new ChatError(this.$T('GAME.INVALID_INPUT')); + if (isNaN(i) || isNaN(j)) this.throw(); const res = this.play([i, j], this.turn!); - if (!res) throw new ChatError(this.$T('GAME.INVALID_INPUT')); + if (!res) this.throw(); } play([i, j]: [number, number], turn: Turn): Board | null; @@ -55,7 +56,7 @@ export class Othello extends Game { play([i, j]: [number, number], turn: Turn, board = this.state.board): Board | null | boolean { const isActual = board === this.state.board; const other = this.getNext(turn); - if (isActual && this.turn !== turn) throw new ChatError(this.$T('GAME.IMPOSTOR_ALERT')); + if (isActual && this.turn !== turn) this.throw('GAME.IMPOSTOR_ALERT'); if (board[i][j]) return null; @@ -161,7 +162,7 @@ export class Othello extends Game { ]); } - render(side: Turn) { + render(side: Turn | null) { const ctx: RenderCtx = { board: this.state.board, validMoves: side === this.turn ? this.validMoves() : [], diff --git a/src/ps/games/othello/logs.ts b/src/ps/games/othello/logs.ts new file mode 100644 index 00000000..feaa4a86 --- /dev/null +++ b/src/ps/games/othello/logs.ts @@ -0,0 +1,19 @@ +import type { BaseLog } from '@/ps/games/common'; +import type { Turn } from '@/ps/games/othello/types'; +import type { Satisfies, SerializedInstance } from '@/types/common'; + +export type Log = Satisfies< + BaseLog, + { + time: Date; + turn: Turn; + } & ( + | { + action: 'play'; + ctx: [number, number]; + } + | { action: 'skip'; ctx: null } + ) +>; + +export type APILog = SerializedInstance; diff --git a/src/ps/games/othello/meta.ts b/src/ps/games/othello/meta.ts index 695d2e77..0addb811 100644 --- a/src/ps/games/othello/meta.ts +++ b/src/ps/games/othello/meta.ts @@ -1,7 +1,9 @@ import { GamesList } from '@/ps/games/common'; import { fromHumanTime } from '@/tools'; -export const meta = { +import type { Meta } from '@/ps/games/common'; + +export const meta: Meta = { name: 'Othello', id: GamesList.Othello, aliases: ['otgoodbye'], @@ -15,4 +17,4 @@ export const meta = { autostart: true, pokeTimer: fromHumanTime('30 sec'), timer: fromHumanTime('1 min'), -} as const; +}; diff --git a/src/ps/games/othello/render.tsx b/src/ps/games/othello/render.tsx index 1640dbb5..9c0b44ed 100644 --- a/src/ps/games/othello/render.tsx +++ b/src/ps/games/othello/render.tsx @@ -28,7 +28,7 @@ export function renderBoard(this: This, ctx: RenderCtx) { ); }; - return board={ctx.board} rowLabel="1-9" colLabel="A-Z" Cell={Cell} />; + return board={ctx.board} labels={{ row: '1-9', col: 'A-Z' }} Cell={Cell} />; } export function render(this: This, ctx: RenderCtx): ReactElement { diff --git a/src/ps/games/render.tsx b/src/ps/games/render.tsx index 620cf824..c8903a4d 100644 --- a/src/ps/games/render.tsx +++ b/src/ps/games/render.tsx @@ -2,9 +2,11 @@ import { Button } from '@/utils/components/ps'; import type { BaseState } from '@/ps/games/common'; import type { Game } from '@/ps/games/game'; -import type { ReactElement, ReactNode } from 'react'; +import type { CSSProperties, ReactElement, ReactNode } from 'react'; -export function renderSignups(this: Game): ReactElement { +export function renderSignups(this: Game, staff: boolean): ReactElement | null { + const startable = this.meta.autostart === false && this.startable(); + if (staff && !startable) return null; return ( <>
@@ -24,6 +26,11 @@ export function renderSignups(this: Game): React ) : null} {!this.sides ? : null} + {staff && startable ? ( + + ) : null}
); @@ -53,50 +60,55 @@ export type CellRenderer = (props: { cell: T; i: number; j: number }) => Reac export function Table({ board, - rowLabel, - colLabel, + style = {}, + labels, Cell, }: { board: T[][]; - rowLabel: Label; - colLabel: Label; + style?: CSSProperties; + labels: { row: Label; col: Label } | null; Cell: CellRenderer; }): ReactElement { - const rowLabels = getLabels(board.length, rowLabel); - const colLabels = getLabels(board[0].length, colLabel); + const rowLabels = labels ? getLabels(board.length, labels.row) : []; + const colLabels = labels ? getLabels(board[0].length, labels.col) : []; return ( - - - ))} - + {labels ? ( + + + ))} + + ) : null} {board.map((row, i) => ( - + {labels ? : null} {row.map((cell, j) => ( ))} - + {labels ? : null} ))} - - - ))} - + {labels ? ( + + + ))} + + ) : null}
- {colLabels.map(label => ( - {label} -
+ {colLabels.map(label => ( + {label} +
{rowLabels[i]}{rowLabels[i]}{rowLabels[i]}{rowLabels[i]}
- {colLabels.map(label => ( - {label} -
+ {colLabels.map(label => ( + {label} +
); diff --git a/src/ps/games/scrabble/constants.ts b/src/ps/games/scrabble/constants.ts new file mode 100644 index 00000000..8e401aea --- /dev/null +++ b/src/ps/games/scrabble/constants.ts @@ -0,0 +1,89 @@ +import type { BaseBoard as BaseBoardType } from '@/ps/games/scrabble/types'; + +export const BaseBoard: BaseBoardType = [ + ['3W', null, null, '2L', null, null, null, '3W', null, null, null, '2L', null, null, '3W'], + [null, '2W', null, null, null, '3L', null, null, null, '3L', null, null, null, '2W', null], + [null, null, '2W', null, null, null, '2L', null, '2L', null, null, null, '2W', null, null], + ['2L', null, null, '2W', null, null, null, '2L', null, null, null, '2W', null, null, '2L'], + [null, null, null, null, '2W', null, null, null, null, null, '2W', null, null, null, null], + [null, '3L', null, null, null, '3L', null, null, null, '3L', null, null, null, '3L', null], + [null, null, '2L', null, null, null, '2L', null, '2L', null, null, null, '2L', null, null], + ['3W', null, null, '2L', null, null, null, '2*', null, null, null, '2L', null, null, '3W'], + [null, null, '2L', null, null, null, '2L', null, '2L', null, null, null, '2L', null, null], + [null, '3L', null, null, null, '3L', null, null, null, '3L', null, null, null, '3L', null], + [null, null, null, null, '2W', null, null, null, null, null, '2W', null, null, null, null], + ['2L', null, null, '2W', null, null, null, '2L', null, null, null, '2W', null, null, '2L'], + [null, null, '2W', null, null, null, '2L', null, '2L', null, null, null, '2W', null, null], + [null, '2W', null, null, null, '3L', null, null, null, '3L', null, null, null, '2W', null], + ['3W', null, null, '2L', null, null, null, '3W', null, null, null, '2L', null, null, '3W'], +]; + +export const RACK_SIZE = 7; + +export const LETTER_COUNTS = { + A: 9, + B: 2, + C: 2, + D: 4, + E: 12, + F: 2, + G: 3, + H: 2, + I: 9, + J: 1, + K: 1, + L: 4, + M: 2, + N: 6, + O: 8, + P: 2, + Q: 1, + R: 6, + S: 4, + T: 6, + U: 4, + V: 2, + W: 2, + X: 1, + Y: 2, + Z: 1, + _: 2, +}; + +export const LETTER_POINTS = { + A: 1, + B: 3, + C: 3, + D: 2, + E: 1, + F: 4, + G: 2, + H: 4, + I: 1, + J: 8, + K: 5, + L: 1, + M: 3, + N: 1, + O: 1, + P: 3, + Q: 10, + R: 1, + S: 1, + T: 1, + U: 1, + V: 4, + W: 4, + X: 8, + Y: 4, + Z: 10, + _: 0, +}; + +export const SELECT_ACTION_PATTERN = /^s(?[A-Z0-9]{2})$/; +export const PLAY_ACTION_PATTERN = /^p(?[A-Z0-9]{2})(?[dr])$/; + +export enum DIRECTION { + RIGHT = 'right', + DOWN = 'down', +} diff --git a/src/ps/games/scrabble/docs.md b/src/ps/games/scrabble/docs.md new file mode 100644 index 00000000..5815b154 --- /dev/null +++ b/src/ps/games/scrabble/docs.md @@ -0,0 +1,8 @@ +Points to note: + +- Blanks are represented as `_` in string form (eg: while in bag or rack) + or as an object with `isBlank` in the BoardTile data type +- When played via command, all three of the following parse the `C` as a blank. + - `AB[C]D` + - `AB(C)D` + - `ABC'D` (can use `‘`, `` ` ``, or `’` instead) diff --git a/src/ps/games/scrabble/index.ts b/src/ps/games/scrabble/index.ts new file mode 100644 index 00000000..c58d5711 --- /dev/null +++ b/src/ps/games/scrabble/index.ts @@ -0,0 +1,459 @@ +import { EmbedBuilder } from 'discord.js'; + +import { Game, createGrid } from '@/ps/games/game'; +import { + BaseBoard, + DIRECTION, + LETTER_COUNTS, + LETTER_POINTS, + PLAY_ACTION_PATTERN, + RACK_SIZE, + SELECT_ACTION_PATTERN, +} from '@/ps/games/scrabble/constants'; +import { render, renderMove } from '@/ps/games/scrabble/render'; +import { type Point, coincident, flipPoint, multiStepPoint, rangePoints, stepPoint } from '@/utils/grid'; + +import type { TranslatedText } from '@/i18n/types'; +import type { ActionResponse, EndType, Player } from '@/ps/games/common'; +import type { BaseContext } from '@/ps/games/game'; +import type { Log } from '@/ps/games/scrabble/logs'; +import type { BoardTile, Bonus, BonusReducer, Points, RenderCtx, State, WinCtx, Word } from '@/ps/games/scrabble/types'; +import type { User } from 'ps-client'; + +export { meta } from '@/ps/games/scrabble/meta'; + +function isLetter(char: string): boolean { + return /[A-Z]/.test(char); +} + +export class Scrabble extends Game { + points: Record = LETTER_POINTS; + log: Log[] = []; + passCount: number | null = null; + selected: Point | null = null; + winCtx?: WinCtx | { type: EndType }; + + constructor(ctx: BaseContext) { + super(ctx); + super.persist(ctx); + + if (ctx.backup) return; + } + + onStart(): ActionResponse { + this.state.baseBoard = BaseBoard; + this.state.board = createGrid(BaseBoard.length, BaseBoard[0].length, () => null); + this.state.bag = Object.entries(LETTER_COUNTS) + .flatMap(([letter, count]) => letter.repeat(count).split('')) + .shuffle(this.prng); + this.state.score = {}; + this.state.best = {}; + this.state.racks = {}; + Object.keys(this.players).forEach(player => { + this.state.score[player] = 0; + this.state.racks[player] = this.state.bag.splice(0, RACK_SIZE); + }); + return { success: true, data: undefined }; + } + + action(user: User, ctx: string): void { + if (!this.started) this.throw('GAME.NOT_STARTED'); + if (user.id !== this.players[this.turn!].id) this.throw('GAME.IMPOSTOR_ALERT'); + const [action, value] = ctx.lazySplit(' ', 1) as [string | undefined, string | undefined]; + if (!action) this.throw(); + switch (action.charAt(0)) { + // Select: sXY + case 's': { + const match = action.match(SELECT_ACTION_PATTERN); + if (!match) this.throw(); + const pos = this.parsePosition(match.groups!.pos); + this.select(pos); + break; + } + // Play: pXYd WORD + case 'p': { + if (!value) this.throw(); + const match = action.match(PLAY_ACTION_PATTERN); + if (!match) this.throw(); + const pos = this.parsePosition(match.groups!.pos); + this.play(value.toUpperCase(), pos, match.groups!.dir === 'r' ? DIRECTION.RIGHT : DIRECTION.DOWN); + break; + } + // Exchange: x ABC + case 'x': { + if (!value) this.throw(); + this.exchange(value); + break; + } + // Pass: - + case '-': { + this.pass(); + break; + } + default: + this.throw(); + } + } + + select(pos: Point): void { + const turn = this.turn!; + const player = this.players[turn]; + if (!player) this.throw(); + this.selected = pos; + this.update(turn); + } + + play(word: string, pos: Point, dir: DIRECTION): void { + const board = this.state.board; + const turn = this.turn!; + const player = this.players[turn]; + if (!player) throw new Error(`Couldn't find player ${turn} in ${JSON.stringify(this.players)}`); + + const inlineStep: Point = dir === DIRECTION.RIGHT ? [0, 1] : [1, 0]; + + const rack = this.state.racks[turn]; + const rackCount = rack.count(); + const tiles: BoardTile[] = this.parseTiles(word, pos, inlineStep); + const playedTiles = tiles.filter((playedTile, index) => { + const existingTile = this.readFromBoard(multiStepPoint(pos, inlineStep, index)); + if (existingTile && existingTile.letter !== playedTile.letter) { + this.throw('GAME.SCRABBLE.TILE_MISMATCH', { placed: playedTile.letter, actual: existingTile.letter }); + } + return !existingTile; + }); + if (!playedTiles.length) this.throw('GAME.SCRABBLE.MUST_PLAY_TILES'); + const playedTilesCount = playedTiles.count(true); + + for (const [tile, count] of playedTilesCount.entries()) { + const letter = tile.blank ? '_' : tile.letter; + if (!rackCount[letter]) this.throw('GAME.SCRABBLE.MISSING_LETTER', { letter }); + if (rackCount[letter] < count) { + this.throw('GAME.SCRABBLE.INSUFFICIENT_LETTERS', { letter, actual: rackCount[letter], required: count }); + } + } + + let inlineStart = pos; + const backstep = flipPoint(inlineStep); + while (true) { + const oneBefore = stepPoint(inlineStart, backstep); + if (!this.readFromBoard(oneBefore, true)) break; + inlineStart = oneBefore; + } + let inlineEnd = multiStepPoint(pos, inlineStep, tiles.length - 1); + while (true) { + const nextTile = stepPoint(inlineEnd, inlineStep); + if (!this.readFromBoard(nextTile, true)) break; + inlineEnd = nextTile; + } + + const isFirstMove = !board.flat(2).some(tile => tile); + + let connected = isFirstMove; + let coversCenter = false; + + const inlineBonuses: BonusReducer[] = []; + const inlineTiles = rangePoints(inlineStart, inlineEnd).map(point => { + const playedTile = playedTiles.find(tile => coincident(tile.pos, point)); + const boardTile = this.readFromBoard(point); + if (boardTile) { + connected = true; + return boardTile; + } + if (playedTile) { + const bonus = this.state.baseBoard.access(point); + if (bonus) inlineBonuses.push(this.parseBonus(bonus, playedTile)); + if (bonus === '2*') coversCenter = true; + return playedTile; + } + this.throw(); + }); + const inlineWordValue = inlineTiles.map(tile => tile.letter).join(''); + const inlineScore = inlineTiles.map(tile => tile.points).sum(); + + if (isFirstMove && !coversCenter) this.throw('GAME.SCRABBLE.FIRST_MOVE_CENTER'); + if (isFirstMove && playedTiles.length < 2) this.throw('GAME.SCRABBLE.FIRST_MOVE_MULTIPLE_TILES'); + + const inlineWord: Word = { word: inlineWordValue, baseScore: inlineScore, bonuses: inlineBonuses }; + + const crossStep: Point = dir === DIRECTION.RIGHT ? [1, 0] : [0, 1]; + const crossBackstep = flipPoint(crossStep); + const crossWords = playedTiles.map(playedTile => { + let crossStart = playedTile.pos; + while (true) { + const backstep = stepPoint(crossStart, crossBackstep); + if (!this.readFromBoard(backstep, true)) break; + crossStart = backstep; + } + let crossEnd = playedTile.pos; + while (true) { + const nextTile = stepPoint(crossEnd, crossStep); + if (!this.readFromBoard(nextTile, true)) break; + crossEnd = nextTile; + } + + const crossTiles = rangePoints(crossStart, crossEnd).map(point => { + const isPlayedTile = coincident(point, playedTile.pos); + if (!isPlayedTile) connected = true; + const tile = this.readFromBoard(point); + if (tile) return tile; + if (isPlayedTile) return playedTile; + this.throw(); + }); + + const bonus = this.parseBonus(this.state.baseBoard.access(playedTile.pos), playedTile); + + return { + word: crossTiles.map(tile => tile.letter).join(''), + baseScore: crossTiles.map(tile => tile.points).sum(), + bonuses: bonus ? [bonus] : [], + }; + }); + + if (!connected) this.throw('GAME.SCRABBLE.MUST_BE_CONNECTED'); + + const words: Word[] = [inlineWord, ...crossWords].filter(entry => entry.word.length > 1); + + if (!words.length) this.throw(); + + const points = this.score(words, playedTiles.length === RACK_SIZE); + + playedTiles.forEach(playedTile => { + board[playedTile.pos[0]][playedTile.pos[1]] = playedTile; + rack.remove(playedTile.letter); + }); + + const newTiles = this.state.bag.splice(0, playedTiles.length); + rack.push(...newTiles); + if (newTiles.includes('_')) this.room.privateSend(turn, this.$T('GAME.SCRABBLE.HOW_TO_BLANK')); + + this.state.score[turn] += points.total; + + const logEntry: Log = { + action: 'play', + time: new Date(), + turn, + ctx: { points, tiles, point: pos, dir, rack, newTiles, words: words.map(word => word.word) }, + }; + this.log.push(logEntry); + this.room.sendHTML(...renderMove(logEntry, this)); + this.selected = null; + + if (this.state.bag.length === 0) this.end(); + const next = this.nextPlayer(); + if (!next) this.end(); + } + + exchange(letterList: string): void { + const turn = this.turn!; + const player = this.players[turn]; + if (!player) this.throw(); + if (!letterList || letterList.length === 0) this.throw(); + + const letters = letterList + .toUpperCase() + .replace(/[^A-Z_]/g, '') + .split(''); + if (!letters.length) this.throw(); + const letterCount = letters.count(); + + if (this.state.bag.length < letters.length) this.throw('GAME.SCRABBLE.BAG_SIZE', { amount: this.state.bag.length }); + + const rack = this.state.racks[turn]; + const rackCount = rack.count(); + + for (const [letter, required] of Object.entries(letterCount)) { + if (!rackCount[letter]) this.throw('GAME.SCRABBLE.MISSING_LETTER', { letter }); + if (rackCount[letter] < required) + this.throw('GAME.SCRABBLE.INSUFFICIENT_LETTERS', { letter, actual: rackCount[letter], required }); + } + + letters.forEach(letter => rack.remove(letter)); + + const newTiles = this.state.bag.splice(0, letters.length, ...letters); + this.state.bag.shuffle(this.prng); + rack.push(...newTiles); + if (newTiles.includes('_')) this.room.privateSend(turn, this.$T('GAME.SCRABBLE.HOW_TO_BLANK')); + + const logEntry: Log = { action: 'exchange', time: new Date(), turn, ctx: { tiles: letters, newTiles, rack } }; + this.log.push(logEntry); + this.room.sendHTML(...renderMove(logEntry, this)); + } + + pass(): void { + const turn = this.turn!; + this.passCount ??= 0; + this.passCount++; + const logEntry: Log = { action: 'pass', time: new Date(), turn, ctx: { rack: this.state.racks[turn] } }; + this.log.push(logEntry); + this.room.sendHTML(...renderMove(logEntry, this)); + if (this.passCount > Object.keys(this.players).length) { + this.end('regular'); + } + this.nextPlayer(); + } + + onEnd(type?: EndType): TranslatedText { + if (type) { + this.winCtx = { type }; + if (type === 'dq') return this.$T('GAME.ENDED_AUTOMATICALLY', { game: this.meta.name, id: this.id }); + if (type === 'regular' && this.state.bag.length > 0) return this.$T('GAME.SCRABBLE.TOO_MUCH_PASSING'); + return this.$T('GAME.ENDED', { game: this.meta.name, id: this.id }); + } + + const winners = Object.entries(this.state.score).map(([id, score]) => ({ + ...this.players[id], + score, + })); + this.winCtx = { + type: 'win', + winnerIds: winners.map(winner => winner.id), + score: this.state.score, + }; + return this.$T('GAME.WON', { winner: `${winners.list(this.$T)}` }); + } + + onReplacePlayer(oldPlayer: string, withPlayer: User): ActionResponse> { + [this.state.score, this.state.racks, this.state.best].forEach(state => { + state[withPlayer.id] = state[oldPlayer]; + delete state[oldPlayer]; + }); + return { success: true, data: {} }; + } + + render(side: string | null) { + const ctx: RenderCtx = { + id: this.id, + baseBoard: this.state.baseBoard, + board: this.state.board, + bag: this.state.bag.length, + getPoints: tile => this.points[tile], + players: Object.fromEntries( + Object.values(this.players).map(({ id, name }) => [ + id, + { + id, + name, + score: this.state.score[id], + rack: this.state.racks[id].length, + }, + ]) + ), + side, + turn: this.turn!, + selected: side && side === this.turn ? this.selected : null, + }; + if (this.winCtx) { + ctx.header = 'Game ended.'; + } else if (side && side === this.turn) { + ctx.header = 'Your turn!'; + ctx.rack = this.state.racks[side]; + } else if (side) { + ctx.header = `Waiting for ${this.players[this.turn!]?.name}...`; + ctx.dimHeader = true; + } else if (this.turn) { + const current = this.players[this.turn]; + ctx.header = `Waiting for ${current.name}${this.sides ? ` (${this.turn})` : ''}...`; + } + return render.bind(this.renderCtx)(ctx); + } + + renderEmbed(): EmbedBuilder | null { + const winners = this.winCtx && this.winCtx.type === 'win' ? this.winCtx.winnerIds : null; + if (!winners) return null; + const winnerPlayers = winners.map(winner => ({ ...this.players[winner], best: this.state.best[winner] })); + if (!winnerPlayers.length || winnerPlayers.some(player => !player)) this.throw(); + const winnerBests = winners.map(winner => this.state.best[winner]); + if (!winnerBests.length) return null; + const bestPlayer = winnerPlayers.sortBy(player => player.best?.points ?? 0, 'desc')[0]; + if (!bestPlayer?.best) return null; + const title = `${bestPlayer.name}: ${bestPlayer.best.asText} [${bestPlayer.best.points}]`; + return new EmbedBuilder().setColor('#CCC5A8').setAuthor({ name: 'Scrabble - Room Match' }).setTitle(title); + // .setURL(this.getURL()) // TODO + } + + readFromBoard([x, y]: Point, safe?: boolean): BoardTile | null { + if (x < 0 || y < 0 || x >= this.state.board.length || y >= this.state.board[0].length) { + if (safe) return null; + this.throw(); + } + return this.state.board[x][y]; + } + + parsePosition(str: string): Point { + if (str.length !== 2) this.throw(); + const coordinates = [str.charAt(0), str.charAt(1)].map(char => parseInt(char, 36)) as Point; + if (coordinates.some(coord => Number.isNaN(coord) || coord >= this.state.board.length)) this.throw(); + return coordinates; + } + + parseTiles(str: string, pos: Point, inlineStep: Point): BoardTile[] { + let cursor = 0; + const tiles: BoardTile[] = []; + const nextChar = (): string => { + const char = str.at(cursor)!; + cursor++; + return char; + }; + while (cursor < str.length) { + const char = nextChar(); + const nextPos = multiStepPoint(pos, inlineStep, tiles.length); + if (isLetter(char)) { + tiles.push({ letter: char, points: this.points[char], pos: nextPos }); + continue; + } + if (char === ' ') continue; + if ('\'"‘’`'.includes(char)) { + const lastLetter = tiles.at(-1); + if (!lastLetter) this.throw(); + lastLetter.blank = true; + lastLetter.points = 0; + continue; + } + if (char === '[') { + const letter = nextChar(); + if (!isLetter(letter)) this.throw(); + if (nextChar() !== ']') this.throw(); + tiles.push({ letter, points: 0, blank: true, pos: nextPos }); + continue; + } + if (char === '(') { + const letter = nextChar(); + if (!isLetter(letter)) this.throw(); + if (nextChar() !== ')') this.throw(); + tiles.push({ letter, points: 0, blank: true, pos: nextPos }); + continue; + } + this.throw(); + } + return tiles; + } + + parseBonus(bonus: Bonus | null, tile: BoardTile): BonusReducer { + return score => { + if (!bonus) return score; + const modifier = +bonus.charAt(0); + const additive = bonus.charAt(1) === 'L'; + return additive ? score + modifier * tile.points : score * modifier; + }; + } + + checkWord(word: string): [number, number] | null { + // TODO handle mods here + // TODO Add dictionary + if (!word) return null; + return [1, 0]; + } + + score(words: Word[], bingo: boolean): Points { + // TODO handle mods here + const bingoPoints = bingo ? 50 : 0; + const wordsPoints = Object.fromEntries( + words.map(word => { + const scoring = this.checkWord(word.word); + if (!scoring) this.throw('GAME.SCRABBLE.INVALID_WORD'); + return [word.word, word.bonuses.reduce((score, bonus) => bonus(score), word.baseScore * scoring[0] + scoring[1])]; + }) + ); + return { total: Object.values(wordsPoints).sum() + bingoPoints, bingo, words: wordsPoints }; + } +} diff --git a/src/ps/games/scrabble/logs.ts b/src/ps/games/scrabble/logs.ts new file mode 100644 index 00000000..9d792403 --- /dev/null +++ b/src/ps/games/scrabble/logs.ts @@ -0,0 +1,22 @@ +import type { BaseLog } from '@/ps/games/common'; +import type { DIRECTION } from '@/ps/games/scrabble/constants'; +import type { BoardTile, Points } from '@/ps/games/scrabble/types'; +import type { Satisfies, SerializedInstance } from '@/types/common'; +import type { Point } from '@/utils/grid'; + +export type Log = Satisfies< + BaseLog, + { + time: Date; + turn: string; + } & ( + | { + action: 'play'; + ctx: { points: Points; tiles: BoardTile[]; dir: DIRECTION; point: Point; newTiles: string[]; rack: string[]; words: string[] }; + } + | { action: 'exchange'; ctx: { tiles: string[]; newTiles: string[]; rack: string[] } } + | { action: 'pass'; ctx: { rack: string[] } } + ) +>; + +export type APILog = SerializedInstance; diff --git a/src/ps/games/scrabble/meta.ts b/src/ps/games/scrabble/meta.ts new file mode 100644 index 00000000..2a296516 --- /dev/null +++ b/src/ps/games/scrabble/meta.ts @@ -0,0 +1,18 @@ +import { GamesList } from '@/ps/games/common'; +import { fromHumanTime } from '@/tools'; + +import type { Meta } from '@/ps/games/common'; + +export const meta: Meta = { + name: 'Scrabble', + id: GamesList.Scrabble, + aliases: ['scrab'], + players: 'many', + + minSize: 2, + maxSize: 4, + + autostart: false, + pokeTimer: fromHumanTime('1 min'), + timer: fromHumanTime('2 min'), +}; diff --git a/src/ps/games/scrabble/render.tsx b/src/ps/games/scrabble/render.tsx new file mode 100644 index 00000000..6847c6ba --- /dev/null +++ b/src/ps/games/scrabble/render.tsx @@ -0,0 +1,231 @@ +import { Table } from '@/ps/games/render'; +import { Button, Form, Username } from '@/utils/components/ps'; +import { type Point, coincident } from '@/utils/grid'; +import { log } from '@/utils/logger'; + +import type { TranslationFn } from '@/i18n/types'; +import type { Player } from '@/ps/games/common'; +import type { CellRenderer } from '@/ps/games/render'; +import type { Log } from '@/ps/games/scrabble/logs'; +import type { BoardTile, Bonus, RenderCtx } from '@/ps/games/scrabble/types'; +import type { ReactElement, ReactNode } from 'react'; + +export function renderMove( + logEntry: Log, + { id, players, $T }: { id: string; players: Record; $T: TranslationFn } +): [ReactElement, { name: string }] { + const Wrapper = ({ children }: { children: ReactNode }): ReactElement => ( + <> +
+ {children} +
+ + ); + + const playerName = players[logEntry.turn]?.name; + const opts = { name: `${id}-chatlog` }; + + switch (logEntry.action) { + case 'play': + return [ + + played {logEntry.ctx.words.list($T)} for {logEntry.ctx.points.total} points! + {logEntry.ctx.points.bingo ? ' BINGO!' : null} + , + opts, + ]; + case 'exchange': + return [ + + exchanged {logEntry.ctx.tiles.length} tiles. + , + opts, + ]; + case 'pass': + return [ + + passed. + , + opts, + ]; + default: + log('Scrabble had some weird move', logEntry, players); + return [ + + Well something happened, I think! Someone go poke PartMan + , + opts, + ]; + } +} + +type This = { msg: string }; + +const LETTER_HEX = '#dc6'; +const BASE_MARGIN = 12; +const BASE_PADDING = 8; + +function encodePos([x, y]: Point): string { + return [x, y] + .map(coord => coord.toString(36)) + .join('') + .toUpperCase(); +} + +function getBackgroundHex(bonus: Bonus | null): string { + switch (bonus) { + case '2*': + case '2W': + return '#fba'; + case '2L': + return '#bcd'; + case '3W': + return '#f65'; + case '3L': + return '#59a'; + default: + return '#cca'; + } +} + +function renderBoard(this: This, ctx: RenderCtx) { + const clickable = !!ctx.side && ctx.side === ctx.turn; + const Cell: CellRenderer = ({ cell, i, j }): ReactElement => { + const baseCell = ctx.baseBoard[i][j]; + const isSelected = !!ctx.selected && coincident([i, j], ctx.selected); + return ( + + {cell ? ( + + ) : null} + {!cell && clickable ? ( + + ) : null} + {!cell && !clickable && baseCell === '2*' ? ( +
+ ) : null} + + ); + }; + + return ( + board={ctx.board} labels={null} Cell={Cell} style={{ background: '#220', borderCollapse: undefined }} /> + ); +} + +function Letter({ letter, points }: { letter: string; points: number }): ReactElement { + return ( + + {letter} + {points ? {points} : null} + + ); +} + +function Scores({ players }: { players: RenderCtx['players'] }): ReactElement[] { + return Object.values(players).map(player => { + return ( +
+ : {player.score}p ({player.rack} tiles in rack) +
+ ); + }); +} + +function renderInput(this: This, ctx: RenderCtx): ReactElement | null { + // ctx.selected is only passed for the active player + if (!ctx.selected) { + if (ctx.side && ctx.side === ctx.turn) return

Select a tile to play from.

; + return null; + } + return ( + <> +
+
+
+ +
+ +
+
+ +
+ +
+
+ + ); +} + +function InfoPanel({ bag }: { bag: number }): string { + return bag ? `${bag} tile(s) left in bag.` : 'Empty bag.'; +} + +export function render(this: This, ctx: RenderCtx): ReactElement { + return ( +
+

{ctx.header}

+ {renderBoard.bind(this)(ctx)} + {ctx.side ? ( +
+
{ctx.rack?.map(letter => )}
+ {renderInput.bind(this)(ctx)} +
+ ) : null} +
+ + +
+
+ ); +} diff --git a/src/ps/games/scrabble/types.ts b/src/ps/games/scrabble/types.ts new file mode 100644 index 00000000..c96c3801 --- /dev/null +++ b/src/ps/games/scrabble/types.ts @@ -0,0 +1,48 @@ +import type { Point } from '@/utils/grid'; + +export type BoardTile = { + letter: string; + blank?: boolean; + points: number; + pos: Point; +}; + +export type Bonus = '3W' | '2W' | '3L' | '2L' | '2*'; +export type BonusReducer = (score: number) => number; + +export type BaseBoard = (Bonus | null)[][]; +export type Board = (null | BoardTile)[][]; + +export type State = { + turn: string; + baseBoard: BaseBoard; + board: Board; + racks: Record; + score: Record; + bag: string[]; + best: Record; +}; + +export type Points = { + total: number; + bingo: boolean; + words: Record; +}; + +export type RenderCtx = { + id: string; + baseBoard: BaseBoard; + board: Board; + header?: string; + dimHeader?: boolean; + players: Record; + getPoints: (tile: string) => number; + bag: number; + rack?: string[]; + side: string | null; + turn: string; + selected?: Point | null; +}; +export type WinCtx = { type: 'win'; winnerIds: string[]; score: State['score'] } | { type: 'draw' }; + +export type Word = { word: string; baseScore: number; bonuses: BonusReducer[] }; diff --git a/src/types/common.ts b/src/types/common.ts index 98e063f5..efe22be4 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -2,10 +2,14 @@ type BaseRecursiveArray = T | BaseRecursiveArray[]; export type RecursiveArray = BaseRecursiveArray[]; +export type ArrayAtom = T extends (infer V)[] ? ArrayAtom : T; + export type RecursivePartial = { [P in keyof T]?: T[P] extends (infer U)[] ? RecursivePartial[] : T[P] extends object | undefined ? RecursivePartial : T[P]; }; +export type Satisfies = B; + // Mongoose output as JSON export type SerializedInstance = { [key in keyof T]: Exclude extends Map diff --git a/src/utils/grid.ts b/src/utils/grid.ts new file mode 100644 index 00000000..fa359417 --- /dev/null +++ b/src/utils/grid.ts @@ -0,0 +1,42 @@ +/** + * All grid operations act on INVERSE Cartesian planes. [0] is the horizontal distance from the + * original measured to the left, while [1] is the vertical distance measured downwards. + */ + +import { range } from '@/utils/range'; + +export type Point = [number, number]; + +export function coincident(point1: Point, point2: Point): boolean { + return point1[0] === point2[0] && point1[1] === point2[1]; +} + +export function taxicab(from: Point, to: Point): number { + return Math.abs(to[0] - from[0]) + Math.abs(to[1] - from[1]); +} + +export function rangePoints(from: Point, to: Point, length?: number): Point[] { + let count: number | undefined = length; + if (!length) { + const xDist = to[0] - from[0]; + const yDist = to[1] - from[1]; + if (xDist && yDist) throw new TypeError(`length was not provided for a range between points ${from} -> ${to}`); + if (xDist === 0 && yDist === 0) return [to]; + count = (xDist || yDist) + 1; + } + const xRange = range(from[0], to[0], count!); + const yRange = range(from[1], to[1], count!); + return Array.from({ length: count! }, (_, index) => [xRange[index], yRange[index]]); +} + +export function stepPoint(point: Point, by: Point): Point { + return [point[0] + by[0], point[1] + by[1]]; +} + +export function multiStepPoint(point: Point, by: Point, steps: number): Point { + return [point[0] + by[0] * steps, point[1] + by[1] * steps]; +} + +export function flipPoint(point: Point): Point { + return [-point[0], -point[1]]; +} diff --git a/src/utils/range.ts b/src/utils/range.ts new file mode 100644 index 00000000..2c80e9f7 --- /dev/null +++ b/src/utils/range.ts @@ -0,0 +1,4 @@ +export function range(from: number, to: number, count: number): number[] { + const offset = (to - from) / (count - 1); + return Array.from({ length: count }, (_, index) => from + offset * index); +} diff --git a/src/web/react/components/board.tsx b/src/web/react/components/board.tsx index 143759eb..c8ce9714 100644 --- a/src/web/react/components/board.tsx +++ b/src/web/react/components/board.tsx @@ -1,5 +1,5 @@ import type { CellRenderer } from '@/ps/games/render'; -import type { ReactElement } from 'react'; +import type { CSSProperties, ReactElement } from 'react'; type Label = 'A-Z' | 'Z-A' | '1-9' | '9-1'; @@ -14,45 +14,49 @@ function getLabels(amount: number, label: Label): string[] { export function Table({ board, - rowLabel, - colLabel, + style = {}, + labels, Cell, }: { board: T[][]; - rowLabel: Label; - colLabel: Label; + style?: CSSProperties; + labels: { row: Label; col: Label } | null; Cell: CellRenderer; }): ReactElement { - const rowLabels = getLabels(board.length, rowLabel); - const colLabels = getLabels(board[0].length, colLabel); + const rowLabels = labels ? getLabels(board.length, labels.row) : []; + const colLabels = labels ? getLabels(board[0].length, labels.col) : []; return ( - +
- - - ))} - + {labels ? ( + + + ))} + + ) : null} {board.map((row, i) => ( - + {labels ? : null} {row.map((cell, j) => ( ))} - + {labels ? : null} ))} - - - ))} - + {labels ? ( + + + ))} + + ) : null}
- {colLabels.map(label => ( - {label} -
+ {colLabels.map(label => ( + {label} +
{rowLabels[i]}{rowLabels[i]}{rowLabels[i]}{rowLabels[i]}
- {colLabels.map(label => ( - {label} -
+ {colLabels.map(label => ( + {label} +
); diff --git a/src/web/react/components/othello/index.tsx b/src/web/react/components/othello/index.tsx index 361ad705..ea323447 100644 --- a/src/web/react/components/othello/index.tsx +++ b/src/web/react/components/othello/index.tsx @@ -4,14 +4,14 @@ import { Table } from '@/web/react/components/board'; import type { GameModel } from '@/database/games'; import type { Othello } from '@/ps/games/othello'; +import type { APILog } from '@/ps/games/othello/logs'; +import type { Turn } from '@/ps/games/othello/types'; import type { CellRenderer } from '@/ps/games/render'; import type { SerializedInstance } from '@/types/common'; export type GameModelAPI = SerializedInstance & { winCtx: Othello['winCtx'] }>; -type OthelloLog = { action: 'play' | 'skip'; time: string; turn: 'W' | 'B'; ctx: [number, number] | null }; - -type Board = (null | 'W' | 'B')[][]; +type Board = (null | Turn)[][]; type GameState = { board: Board; sinceLast: number | null; at: Date; score: { W: number; B: number } }; const Cell: CellRenderer<'W' | 'B' | null> = ({ cell }) => ( @@ -24,7 +24,7 @@ const Cell: CellRenderer<'W' | 'B' | null> = ({ cell }) => ( ); const Board = memo(({ state }: { state: GameState }) => ( <> - board={state.board} rowLabel="1-9" colLabel="A-Z" Cell={Cell} /> + board={state.board} labels={{ row: '1-9', col: 'A-Z' }} Cell={Cell} /> {state.sinceLast ? `Played after ${state.sinceLast / 1000}s.` @@ -144,7 +144,7 @@ const getScore = (board: Board): { W: number; B: number } => ); export const ViewOnlyOthello = memo(({ game }: { game: GameModelAPI }): ReactElement => { - const log = useMemo(() => game.log.map(entry => JSON.parse(entry)), [game.log]); + const log = useMemo(() => game.log.map(entry => JSON.parse(entry)), [game.log]); const boardsByTurn = useMemo(() => { return log.reduce( (boards, entry) => {