From 000f31a714c3c3e2ab2cc7ea8fbd344faddeb560 Mon Sep 17 00:00:00 2001 From: Parth Mane Date: Sat, 21 Jun 2025 15:30:25 +0530 Subject: [PATCH 1/4] games: Start adding Battleship --- src/globals/prototypes.ts | 6 +- src/ps/commands/games/core.tsx | 2 + src/ps/games/battleship/constants.ts | 42 ++++++++++ src/ps/games/battleship/index.ts | 119 +++++++++++++++++++++++++++ src/ps/games/battleship/logs.ts | 15 ++++ src/ps/games/battleship/meta.ts | 20 +++++ src/ps/games/battleship/render.tsx | 15 ++++ src/ps/games/battleship/types.ts | 25 ++++++ src/ps/games/game.ts | 7 +- src/ps/games/index.ts | 5 ++ src/ps/games/types.ts | 1 + src/utils/grid.ts | 19 +++++ 12 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 src/ps/games/battleship/constants.ts create mode 100644 src/ps/games/battleship/index.ts create mode 100644 src/ps/games/battleship/logs.ts create mode 100644 src/ps/games/battleship/meta.ts create mode 100644 src/ps/games/battleship/render.tsx create mode 100644 src/ps/games/battleship/types.ts diff --git a/src/globals/prototypes.ts b/src/globals/prototypes.ts index 756fb085..9eda65a2 100644 --- a/src/globals/prototypes.ts +++ b/src/globals/prototypes.ts @@ -54,7 +54,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..7aa620ee 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); }, }, 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..019da3cb --- /dev/null +++ b/src/ps/games/battleship/index.ts @@ -0,0 +1,119 @@ +import { Ships } from '@/ps/games/battleship/constants'; +import { render } from '@/ps/games/battleship/render'; +import { type BaseContext, BaseGame, createGrid } from '@/ps/games/game'; +import { ChatError } from '@/utils/chatError'; +import { type Point, parsePointA1, pointToA1, rangePoints, sameRowOrCol } from '@/utils/grid'; + +import type { ToTranslate, TranslatedText } from '@/i18n/types'; +import type { ShipType } from '@/ps/games/battleship/constants'; +import type { State, Turn } from '@/ps/games/battleship/types'; +import type { ActionResponse, Player } from '@/ps/games/types'; +import type { User } from 'ps-client'; + +export { meta } from '@/ps/games/battleship/meta'; + +export class Battleship extends BaseGame { + constructor(ctx: BaseContext) { + super(ctx); + super.persist(ctx); + + if (ctx.backup) return; + this.state.ready = { A: false, B: 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) }, + }; + } + + onAddPlayer(user: User, ctx: string): ActionResponse> { + // TODO: ship selection + } + + onStart(): ActionResponse { + this.turns.shuffle(this.prng); + return { success: true, data: null }; + } + + action(user: User, input: string) { + const [action, ctx] = input.lazySplit(' ', 1); + // TODO: Pseudo start + 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)); + this.validateShipPositions(set); + // Ship positions are valid + // TODO + break; + } + case 'confirm-set': { + if (this.state.ready[player.turn] === true) throw new ChatError("Hi you've already set your ships!" as ToTranslate); + break; + } + case 'hit': { + 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 | null; + try { + hit = this.state.board.ships[opponent].access([x, y]); + } catch { + throw new ChatError('Invalid range given.' as ToTranslate); + } + this.state.board.attacks[player.turn][x][y] = hit; + this.nextPlayer(); + this.update(); + // TODO: Check wincons + break; + } + default: + this.throw(); + } + } + + render(side: string | null) { + // TODO + return render.bind(this.renderCtx)({ side, turn: this.turn }); + } + + update(user?: string): void { + if (!this.started) { + // TODO: Render ship selection screen / preview! + return; + } + super.update(user); + } + + onEnd() { + return 'Done' as TranslatedText; + } + + validateShipPositions(input: (Point | null)[][]) { + 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 = {}; + + 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 + ); + } + 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; + } + }); + }); + + // Ship positions should be valid now + } +} diff --git a/src/ps/games/battleship/logs.ts b/src/ps/games/battleship/logs.ts new file mode 100644 index 00000000..dc5e1112 --- /dev/null +++ b/src/ps/games/battleship/logs.ts @@ -0,0 +1,15 @@ +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: 'play'; + ctx: number; + } +>; + +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..9fb6899d --- /dev/null +++ b/src/ps/games/battleship/render.tsx @@ -0,0 +1,15 @@ +import { Form } from '@/utils/components/ps'; + +export function render(this: { msg: string }, ctx: { side: string | null; turn: string | null; text: string[] }) { + return ( + <> +
{ctx.text.join(', ')}
+ {ctx.side === ctx.turn ? ( +
+ + +
+ ) : null} + + ); +} diff --git a/src/ps/games/battleship/types.ts b/src/ps/games/battleship/types.ts new file mode 100644 index 00000000..a6e966a8 --- /dev/null +++ b/src/ps/games/battleship/types.ts @@ -0,0 +1,25 @@ +import type { ShipType } from '@/ps/games/battleship/constants'; +import type { Player } from '@/ps/games/types'; +import type { Point } from '@/utils/grid'; + +export type Turn = 'A' | 'B'; + +export type ShipBoard = (ShipType | null)[][]; +export type AttackBoard = (ShipType | null)[][]; +export type Boards = { ships: Record; attacks: Record }; + +export type State = { + turn: Turn; + ready: Record>; // object only when previewing ships but not ready yet + board: Boards; +}; + +export type RenderCtx = { + id: string; + header?: string; + dimHeader?: boolean; +} & ( + | { type: 'player'; attack: AttackBoard; defense: AttackBoard; actual: ShipBoard | null } + | { type: 'spectator'; boards: Boards['attacks'] } +); +export type WinCtx = ({ type: 'win' } & Record<'winner' | 'loser', Player>) | { type: 'draw' }; diff --git a/src/ps/games/game.ts b/src/ps/games/game.ts index bc274334..15969d40 100644 --- a/src/ps/games/game.ts +++ b/src/ps/games/game.ts @@ -245,6 +245,11 @@ export class BaseGame { if (closeSignupsHTML) this.room.sendHTML(closeSignupsHTML, { name: this.id, change }); } + getPlayer(user: User | string): Player | null { + const userId = typeof user === 'string' ? toId(user) : user.id; + return Object.values(this.players).find(player => player.id === userId) ?? null; + } + 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) this.throw('GAME.IS_FULL'); @@ -366,7 +371,7 @@ export class BaseGame { const tryStart = this.onStart?.(); if (tryStart?.success === false) return tryStart; this.started = true; - if (!this.turns.length) this.turns = Object.keys(this.players).shuffle(); + if (!this.turns.length) this.turns = Object.keys(this.players).shuffle(this.prng); this.nextPlayer(); this.startedAt = new Date(); this.setTimer('Game started'); diff --git a/src/ps/games/index.ts b/src/ps/games/index.ts index 6a64c25e..d03c387e 100644 --- a/src/ps/games/index.ts +++ b/src/ps/games/index.ts @@ -1,3 +1,4 @@ +import { Battleship, meta as BattleshipMeta } from '@/ps/games/battleship'; import { Chess, meta as ChessMeta } from '@/ps/games/chess'; import { ConnectFour, meta as ConnectFourMeta } from '@/ps/games/connectfour'; import { LightsOut, meta as LightsOutMeta } from '@/ps/games/lightsout'; @@ -8,6 +9,10 @@ import { SnakesLadders, meta as SnakesLaddersMeta } from '@/ps/games/snakesladde import { GamesList, type Meta } from '@/ps/games/types'; export const Games = { + [GamesList.Battleship]: { + meta: BattleshipMeta, + instance: Battleship, + }, [GamesList.Chess]: { meta: ChessMeta, instance: Chess, diff --git a/src/ps/games/types.ts b/src/ps/games/types.ts index c5ea58f8..e03b7e69 100644 --- a/src/ps/games/types.ts +++ b/src/ps/games/types.ts @@ -34,6 +34,7 @@ export type Meta = Readonly< // Note: The values here MUST match the folder name! export enum GamesList { + Battleship = 'battleship', Chess = 'chess', ConnectFour = 'connectfour', LightsOut = 'lightsout', diff --git a/src/utils/grid.ts b/src/utils/grid.ts index d587b338..3a63ab2f 100644 --- a/src/utils/grid.ts +++ b/src/utils/grid.ts @@ -13,6 +13,21 @@ export function parsePoint(input: string): Point | null { return [+matched[1], +matched[2]]; } +/** + * Assumes grid follows top-down A-Z, left-right 1-9 + * @param input + * @example parsePointA1('B5'); // [1, 4] + */ +export function parsePointA1(input: string): Point | null { + const matched = input.match(/^([a-z])(\d+)$/i); + if (!matched) return null; + return [matched[1].toUpperCase().charCodeAt(0) - 'A'.charCodeAt(0), +matched[2] - 1]; +} + +export function pointToA1(input: Point): string { + return `${(input[0] + 1).toLetter()}${input[1] + 1}`; +} + export function coincident(point1: Point, point2: Point): boolean { return point1[0] === point2[0] && point1[1] === point2[1]; } @@ -21,6 +36,10 @@ export function taxicab(from: Point, to: Point): number { return Math.abs(to[0] - from[0]) + Math.abs(to[1] - from[1]); } +export function sameRowOrCol(point: Point, ref: Point): boolean { + return point[0] === ref[0] || point[1] === ref[1]; +} + export function rangePoints(from: Point, to: Point, length?: number): Point[] { let count: number | undefined = length; if (!length) { From 75186fc1111f71af2fb376ff226b9163561fb21c Mon Sep 17 00:00:00 2001 From: Parth Mane Date: Fri, 1 Aug 2025 00:49:39 +0530 Subject: [PATCH 2/4] games: Add all the render code for Battleship --- src/globals/prototypes.ts | 4 + src/ps/commands/games/core.tsx | 4 +- src/ps/games/battleship/index.ts | 173 ++++++++++++++++--- src/ps/games/battleship/logs.ts | 8 +- src/ps/games/battleship/render.tsx | 262 +++++++++++++++++++++++++++-- src/ps/games/battleship/types.ts | 15 +- src/ps/games/game.ts | 51 +++++- src/ps/games/scrabble/render.tsx | 8 +- 8 files changed, 466 insertions(+), 59 deletions(-) diff --git a/src/globals/prototypes.ts b/src/globals/prototypes.ts index 9eda65a2..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; } diff --git a/src/ps/commands/games/core.tsx b/src/ps/commands/games/core.tsx index 7aa620ee..1354c623 100644 --- a/src/ps/commands/games/core.tsx +++ b/src/ps/commands/games/core.tsx @@ -389,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')); @@ -408,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/index.ts b/src/ps/games/battleship/index.ts index 019da3cb..7ffeba2c 100644 --- a/src/ps/games/battleship/index.ts +++ b/src/ps/games/battleship/index.ts @@ -1,18 +1,25 @@ -import { Ships } from '@/ps/games/battleship/constants'; -import { render } from '@/ps/games/battleship/render'; -import { type BaseContext, BaseGame, createGrid } from '@/ps/games/game'; +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 } from '@/utils/grid'; +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 { State, Turn } from '@/ps/games/battleship/types'; -import type { ActionResponse, Player } from '@/ps/games/types'; +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 }; + allReady = false; constructor(ctx: BaseContext) { super(ctx); super.persist(ctx); @@ -25,48 +32,96 @@ export class Battleship extends BaseGame { }; } - onAddPlayer(user: User, ctx: string): ActionResponse> { - // TODO: ship selection + 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); - // TODO: Pseudo start 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)); - this.validateShipPositions(set); - // Ship positions are valid - // TODO + 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); break; } case 'confirm-set': { - if (this.state.ready[player.turn] === true) throw new ChatError("Hi you've already set your ships!" as ToTranslate); + 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.allReady = true; + this.nextPlayer(); + } else { + this.update(player.id); + } break; } case 'hit': { + if (!this.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 | null; + let hit: ShipType | false | null; try { - hit = this.state.board.ships[opponent].access([x, y]); + 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(); - // TODO: Check wincons break; } default: @@ -74,29 +129,92 @@ export class Battleship extends BaseGame { } } - render(side: string | null) { - // TODO - return render.bind(this.renderCtx)({ side, turn: this.turn }); + 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.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) { - // TODO: Render ship selection screen / preview! + 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); } - onEnd() { - return 'Done' as TranslatedText; - } - - validateShipPositions(input: (Point | null)[][]) { + 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)) { @@ -104,16 +222,21 @@ export class Battleship extends BaseGame { `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 index dc5e1112..9d8dfbad 100644 --- a/src/ps/games/battleship/logs.ts +++ b/src/ps/games/battleship/logs.ts @@ -7,9 +7,11 @@ export type Log = Satisfies< { time: Date; turn: Turn; - action: 'play'; - ctx: number; - } + } & ( + | { 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/render.tsx b/src/ps/games/battleship/render.tsx index 9fb6899d..48c92080 100644 --- a/src/ps/games/battleship/render.tsx +++ b/src/ps/games/battleship/render.tsx @@ -1,15 +1,257 @@ -import { Form } from '@/utils/components/ps'; +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'; -export function render(this: { msg: string }, ctx: { side: string | null; turn: string | null; text: string[] }) { - return ( +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 => ( <> -
{ctx.text.join(', ')}
- {ctx.side === ctx.turn ? ( -
- - -
- ) : null} +
+ {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 ? ( +