diff --git a/src/globals/prototypes.ts b/src/globals/prototypes.ts index 756fb085..61660ecc 100644 --- a/src/globals/prototypes.ts +++ b/src/globals/prototypes.ts @@ -42,6 +42,10 @@ declare global { } interface Number { + /** + * Converts a number to a letter. + * @example (2).toLetter(); // 'B' + */ toLetter(): string; times(callback: (i: number) => void): void; } @@ -54,7 +58,11 @@ Object.defineProperties(Array.prototype, { 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); + return point.reduce((arr, index) => { + if (!Array.isArray(arr)) throw new Error(`Attempting to index ${index} of ${point} on ${arr}`); + if (index >= arr.length || index < 0) throw new RangeError(`Accessing ${index} on array of length ${arr.length}`); + return arr[index]; + }, this); }, }, count: { diff --git a/src/ps/commands/games/core.tsx b/src/ps/commands/games/core.tsx index 978c3bbf..1354c623 100644 --- a/src/ps/commands/games/core.tsx +++ b/src/ps/commands/games/core.tsx @@ -201,6 +201,7 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]): async run({ message, arg, $T }) { const { game, ctx } = getGame(arg, { action: 'play', user: message.author.id }, { room: message.target, $T }); try { + if (!game.getPlayer(message.author)) throw new ChatError($T('GAME.IMPOSTOR_ALERT')); game.action(message.author, ctx, false); } catch (err) { // Regenerate the HTML if given an invalid input @@ -235,6 +236,7 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]): syntax: 'CMD [id], [move]', async run({ message, arg, $T }): Promise { const { game, ctx } = getGame(arg, { action: 'reaction', user: message.author.id }, { room: message.target, $T }); + if (!game.getPlayer(message.author)) throw new ChatError($T('GAME.IMPOSTOR_ALERT')); game.action(message.author, ctx, true); }, }, @@ -387,7 +389,7 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]): aliases: ['#'], help: 'Modifies a given game.', perms: Symbol.for('games.create'), - syntax: 'CMD [game ref] [mod]', + syntax: 'CMD [game ref], [mod]', async run({ message, arg, $T }) { const { game, ctx } = getGame(arg, { action: 'mod', user: message.author.id }, { room: message.target, $T }); if (!game.moddable?.() || !game.applyMod) throw new ChatError($T('GAME.CANNOT_MOD')); @@ -406,7 +408,7 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]): aliases: ['t'], help: "Customizes a game's theme.", perms: Symbol.for('games.create'), - syntax: 'CMD [game ref] [theme name]', + syntax: 'CMD [game ref], [theme name]', async run({ message, arg, $T }) { const { game, ctx } = getGame(arg, { action: 'any' }, { room: message.target, $T }); const result = game.setTheme(ctx); diff --git a/src/ps/games/battleship/constants.ts b/src/ps/games/battleship/constants.ts new file mode 100644 index 00000000..1e11eee5 --- /dev/null +++ b/src/ps/games/battleship/constants.ts @@ -0,0 +1,42 @@ +export enum ShipType { + Patrol = 'patrol', + Submarine = 'submarine', + Destroyer = 'destroyer', + Battleship = 'battleship', + Carrier = 'carrier', +} + +export const SHIP_DATA: Record = { + [ShipType.Patrol]: { + id: ShipType.Patrol, + name: 'Patrol', + symbol: 'P', + size: 2, + }, + [ShipType.Submarine]: { + id: ShipType.Submarine, + name: 'Submarine', + symbol: 'S', + size: 3, + }, + [ShipType.Destroyer]: { + id: ShipType.Destroyer, + name: 'Destroyer', + symbol: 'D', + size: 3, + }, + [ShipType.Battleship]: { + id: ShipType.Battleship, + name: 'Battleship', + symbol: 'B', + size: 4, + }, + [ShipType.Carrier]: { + id: ShipType.Carrier, + name: 'Carrier', + symbol: 'C', + size: 5, + }, +}; + +export const Ships = Object.values(SHIP_DATA); diff --git a/src/ps/games/battleship/index.ts b/src/ps/games/battleship/index.ts new file mode 100644 index 00000000..286470aa --- /dev/null +++ b/src/ps/games/battleship/index.ts @@ -0,0 +1,244 @@ +import { SHIP_DATA, Ships } from '@/ps/games/battleship/constants'; +import { render, renderMove, renderSelection, renderSummary } from '@/ps/games/battleship/render'; +import { type BaseContext, BaseGame } from '@/ps/games/game'; +import { createGrid } from '@/ps/games/utils'; +import { ChatError } from '@/utils/chatError'; +import { type Point, parsePointA1, pointToA1, rangePoints, sameRowOrCol, taxicab } from '@/utils/grid'; + +import type { ToTranslate, TranslatedText } from '@/i18n/types'; +import type { ShipType } from '@/ps/games/battleship/constants'; +import type { Log } from '@/ps/games/battleship/logs'; +import type { RenderCtx, SelectionInProgressState, ShipBoard, State, Turn, WinCtx } from '@/ps/games/battleship/types'; +import type { ActionResponse, BaseState, EndType, Player } from '@/ps/games/types'; +import type { User } from 'ps-client'; +import type { ReactElement } from 'react'; + +export { meta } from '@/ps/games/battleship/meta'; + +const HITS_TO_WIN = Ships.map(ship => ship.size).sum(); + +export class Battleship extends BaseGame { + winCtx?: WinCtx | { type: EndType }; + constructor(ctx: BaseContext) { + super(ctx); + super.persist(ctx); + + if (ctx.backup) return; + this.state.ready = { A: false, B: false }; + this.state.allReady = false; + this.state.board = { + ships: { A: createGrid(10, 10, () => null), B: createGrid(10, 10, () => null) }, + attacks: { A: createGrid(10, 10, () => null), B: createGrid(10, 10, () => null) }, + }; + } + + onAfterAddPlayer(player: Player): void { + this.update(player.id); + } + onReplacePlayer(_turn: BaseState['turn'], withPlayer: User): ActionResponse { + this.update(withPlayer.id); + return { success: true, data: null }; + } + + onStart(): ActionResponse { + this.turns.shuffle(this.prng); + return { success: true, data: null }; + } + onAfterStart() { + this.clearTimer(); + } + + action(user: User, input: string) { + const [action, ctx] = input.lazySplit(' ', 1); + const player = this.getPlayer(user)! as Player & { turn: Turn }; + switch (action) { + case 'set': { + if (this.state.ready[player.turn] === true) throw new ChatError("Hi you've already set your ships!" as ToTranslate); + const set = ctx.split('|').map(coords => coords.split('-').map(parsePointA1)); + const input = set.flatMap(row => row.map(point => (point ? pointToA1(point) : ''))); + try { + this.state.ready[player.turn] = { ...this.validateShipPositions(set), input }; + } catch (err) { + if (err instanceof ChatError) { + this.state.ready[player.turn] = { type: 'invalid', input, message: err.message }; + this.update(player.id); + } else throw err; + } + this.update(player.id); + this.backup(); + break; + } + case 'confirm-set': { + const currentSet = this.state.ready[player.turn]; + if (currentSet === true) throw new ChatError("Hi you've already set your ships!" as ToTranslate); + if (!currentSet || currentSet?.type === 'invalid') throw new ChatError('Set your ships first -_-' as ToTranslate); + this.state.board.ships[player.turn] = currentSet.board; + this.state.ready[player.turn] = true; + const logEntry: Log = { action: 'set', ctx: currentSet.input, time: new Date(), turn: player.turn }; + this.log.push(logEntry); + this.room.sendHTML(...renderMove(logEntry, this)); + if (this.state.ready.A === true && this.state.ready.B === true) { + this.state.allReady = true; + this.nextPlayer(); + } else { + this.update(player.id); + } + this.backup(); + break; + } + case 'hit': { + if (!this.state.allReady) this.throw('GAME.NOT_STARTED'); + const targeted = parsePointA1(ctx); + if (!targeted) this.throw(); + const [x, y] = targeted; + if (player.turn !== this.turn) this.throw(); + const opponent = this.getNext(); + let hit: ShipType | false | null; + try { + hit = this.state.board.ships[opponent].access([x, y]) ?? false; + } catch { + throw new ChatError('Invalid range given.' as ToTranslate); + } + this.state.board.attacks[player.turn][x][y] = hit; + + const point = pointToA1([x, y]); + const logEntry: Log = { + ...(hit + ? { + action: 'hit', + ctx: { ship: SHIP_DATA[hit].name, point }, + } + : { + action: 'miss', + ctx: { point }, + }), + time: new Date(), + turn: player.turn, + }; + this.log.push(logEntry); + this.room.sendHTML(...renderMove(logEntry, this)); + if (this.state.board.attacks[player.turn].flat().filter(hit => hit).length >= HITS_TO_WIN) { + // Game ends + this.winCtx = { type: 'win', winner: player, loser: this.players[opponent] }; + this.end(); + } + this.nextPlayer(); + this.update(); + break; + } + default: + this.throw(); + } + } + + 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 }); + return this.$T('GAME.ENDED', { game: this.meta.name, id: this.id }); + } + if (this.winCtx && this.winCtx.type === 'win') + return this.$T('GAME.WON_AGAINST', { + winner: this.winCtx.winner.name, + game: this.meta.name, + loser: this.winCtx.loser.name, + ctx: '', + }); + throw new Error(`winCtx not defined for BS - ${JSON.stringify(this.winCtx)}`); + } + + render(side: Turn | null): ReactElement { + if (side) { + const readyState = this.state.ready[side]; + if (readyState === false) return renderSelection.bind(this.renderCtx)(); + if (readyState && typeof readyState !== 'boolean') return renderSelection.bind(this.renderCtx)(readyState); + if (!this.state.allReady) + return renderSelection.bind(this.renderCtx)({ type: 'valid', board: this.state.board.ships[side], input: [] }, true); + } + + let ctx: RenderCtx; + if (side) { + ctx = { + type: 'player', + id: this.id, + attack: this.state.board.attacks[side], + defense: this.state.board.attacks[this.getNext(side)], + actual: this.state.board.ships[side], + active: side === this.turn, + }; + } else { + ctx = { + type: 'spectator', + id: this.id, + boards: this.state.board.attacks, + players: this.players, + }; + } + + if (this.winCtx) { + return renderSummary.bind(this.renderCtx)({ + boards: this.state.board, + players: this.players, + winCtx: this.winCtx, + }); + } else if (side === this.turn) { + ctx.header = this.$T('GAME.YOUR_TURN'); + } else if (side) { + ctx.header = this.$T('GAME.WAITING_FOR_OPPONENT'); + ctx.dimHeader = true; + } else if (this.turn) { + const current = this.players[this.turn]; + ctx.header = this.$T('GAME.WAITING_FOR_PLAYER', { player: current.name }); + } + + return render.bind(this.renderCtx)(ctx as RenderCtx); + } + + update(user?: string): void { + if (!this.started) { + if (user) { + const asPlayer = this.getPlayer(user); + if (!asPlayer) this.throw('GAME.IMPOSTOR_ALERT'); + return this.sendHTML(asPlayer.id, this.render(asPlayer.turn as Turn)); + } + // TODO: Add ping to ps-client HTML opts + Object.entries(this.players).forEach(([side, player]) => { + if (!player.out) this.sendHTML(player.id, this.render(side as Turn)); + }); + return; + } + super.update(user); + } + + validateShipPositions(input: (Point | null)[][]): Omit { + if (input.length !== Ships.length) this.throw(); + if (!input.every(points => points.length === 2 && !points.some(point => point === null))) this.throw(); + const positions = Ships.map((ship, index) => ({ ship, from: input[index][0]!, to: input[index][1]! })); + + const occupied: Record = {}; + const shipBoard: ShipBoard = createGrid(10, 10, () => null); + + positions.forEach(({ ship, from, to }) => { + if (!sameRowOrCol(from, to)) { + throw new ChatError( + `Cannot place ${ship.name} between given points ${pointToA1(from)} and ${pointToA1(to)} (not in line)` as ToTranslate + ); + } + const givenSize = taxicab(from, to) + 1; + if (givenSize !== ship.size) + throw new ChatError(`${ship.name} has size ${ship.size} but you put it in ${givenSize} cells!` as ToTranslate); + rangePoints(from, to).forEach(pointInRange => { + const point = pointToA1(pointInRange); + if (occupied[point]) { + throw new ChatError(`${point} would be occupied by both ${ship.name} and ${occupied[point]}` as ToTranslate); + } else { + occupied[point] = ship.name; + shipBoard[pointInRange[0]][pointInRange[1]] = ship.id; + } + }); + }); + + // Ship positions should be valid now + return { type: 'valid', board: shipBoard }; + } +} diff --git a/src/ps/games/battleship/logs.ts b/src/ps/games/battleship/logs.ts new file mode 100644 index 00000000..9d8dfbad --- /dev/null +++ b/src/ps/games/battleship/logs.ts @@ -0,0 +1,17 @@ +import type { Turn } from '@/ps/games/battleship/types'; +import type { BaseLog, CommonLog } from '@/ps/games/types'; +import type { Satisfies, SerializedInstance } from '@/types/common'; + +export type Log = Satisfies< + BaseLog, + { + time: Date; + turn: Turn; + } & ( + | { action: 'hit'; ctx: { ship: string; point: string } } + | { action: 'miss'; ctx: { point: string } } + | { action: 'set'; ctx: string[] } + ) +>; + +export type APILog = SerializedInstance; diff --git a/src/ps/games/battleship/meta.ts b/src/ps/games/battleship/meta.ts new file mode 100644 index 00000000..cd42ae88 --- /dev/null +++ b/src/ps/games/battleship/meta.ts @@ -0,0 +1,20 @@ +import { GamesList } from '@/ps/games/types'; +import { fromHumanTime } from '@/tools'; + +import type { Meta } from '@/ps/games/types'; + +export const meta: Meta = { + name: 'Battleship', + id: GamesList.Battleship, + aliases: ['bs'], + players: 'many', + + turns: { + A: 'A', + B: 'B', + }, + + autostart: true, + pokeTimer: fromHumanTime('30 sec'), + timer: fromHumanTime('1 min'), +}; diff --git a/src/ps/games/battleship/render.tsx b/src/ps/games/battleship/render.tsx new file mode 100644 index 00000000..48c92080 --- /dev/null +++ b/src/ps/games/battleship/render.tsx @@ -0,0 +1,257 @@ +import { SHIP_DATA, Ships } from '@/ps/games/battleship/constants'; +import { type CellRenderer, Table } from '@/ps/games/render'; +import { createGrid } from '@/ps/games/utils'; +import { Username } from '@/utils/components'; +import { Button, Form } from '@/utils/components/ps'; +import { pointToA1 } from '@/utils/grid'; +import { Logger } from '@/utils/logger'; + +import type { ToTranslate } from '@/i18n/types'; +import type { ShipType } from '@/ps/games/battleship/constants'; +import type { Battleship } from '@/ps/games/battleship/index'; +import type { Log } from '@/ps/games/battleship/logs'; +import type { + AttackBoard, + RenderCtx, + SelectionErrorState, + SelectionInProgressState, + ShipBoard, + State, + Turn, + WinCtx, +} from '@/ps/games/battleship/types'; +import type { EndType, Player } from '@/ps/games/types'; +import type { ReactElement, ReactNode } from 'react'; + +const EMPTY_BOARD: null[][] = createGrid(10, 10, () => null); + +export function renderMove(logEntry: Log, { id, players, $T, renderCtx: { msg } }: Battleship): [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 'set': + return [ + + set their ships! + , + opts, + ]; + case 'hit': + return [ + + hit the enemy {logEntry.ctx.ship}! + , + opts, + ]; + case 'miss': + return [ + + missed. + , + opts, + ]; + default: + Logger.log('Battleship had some weird move', logEntry, players); + return [ + + Well something happened, I think! Someone go poke PartMan + , + opts, + ]; + } +} + +function ShipGrid({ + boards, + clickable, + msg, +}: { + boards: AttackBoard | { defense: AttackBoard; ships: ShipBoard }; + clickable?: boolean; + msg?: string; +}): ReactElement { + const showHitsAsShips = clickable; + const missiles = !Array.isArray(boards) ? boards.defense : boards; + const ships = !Array.isArray(boards) ? boards.ships : EMPTY_BOARD; + + const Cell: CellRenderer = ({ cell: ship, i, j }) => { + const hitData = missiles.access([i, j]); + const shipData = ship ?? hitData; + const isHit = hitData === false ? false : hitData ? true : null; + + return ( + + {typeof isHit === 'boolean' && !(showHitsAsShips && isHit) ? ( +
+
+
+ ) : shipData ? ( + SHIP_DATA[shipData].symbol + ) : clickable ? ( +