diff --git a/packages/server/src/captures.ts b/packages/server/src/captures.ts new file mode 100644 index 0000000..0564675 --- /dev/null +++ b/packages/server/src/captures.ts @@ -0,0 +1,20 @@ +import type { CaptureTally, Color } from '@blind-chess/shared'; +import type { Game } from './state.js'; + +/** + * Per-viewer capture tally derived from move history. `byYou` is the set of + * opponent pieces this viewer has captured; `byOpponent` is the set of this + * viewer's pieces the opponent has captured. Pure function of move history — + * no live board state, no opponent positions. + */ +export function captureTally(game: Game, viewer: Color): CaptureTally { + const byYou: Record = {}; + const byOpponent: Record = {}; + for (const rec of game.moveHistory) { + const captured = rec.capturedPieceType; + if (!captured) continue; + const bucket = rec.by === viewer ? byYou : byOpponent; + bucket[captured] = (bucket[captured] ?? 0) + 1; + } + return { byYou, byOpponent }; +} diff --git a/packages/server/src/ws.ts b/packages/server/src/ws.ts index 3c1a167..54e1015 100644 --- a/packages/server/src/ws.ts +++ b/packages/server/src/ws.ts @@ -19,6 +19,7 @@ import { announce } from './translator.js'; import { buildView } from './view.js'; import { consumeCommitToken } from './ratelimit.js'; import { endGame, finalizeIfEnded } from './game-end.js'; +import { captureTally } from './captures.js'; async function pokeBot(game: Game): Promise { const driver = getBotDriver(game.id); @@ -147,6 +148,7 @@ async function onHello(ctx: SocketCtx, msg: Extract { + it('counts captures per viewer', () => { + const moveHistory: MoveRecord[] = [ + rec('w', 'p'), rec('b'), rec('w', 'n'), + rec('b', 'p'), rec('w', 'p'), + ]; + const game = { moveHistory } as unknown as Game; + expect(captureTally(game, 'w')).toEqual({ + byYou: { p: 2, n: 1 }, byOpponent: { p: 1 }, + }); + expect(captureTally(game, 'b')).toEqual({ + byYou: { p: 1 }, byOpponent: { p: 2, n: 1 }, + }); + }); + + it('returns empty tallies when there are no captures', () => { + const game = { moveHistory: [rec('w'), rec('b')] } as unknown as Game; + expect(captureTally(game, 'w')).toEqual({ byYou: {}, byOpponent: {} }); + }); +}); diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index e4c05d2..ab78403 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -1,5 +1,5 @@ import type { - BoardView, Color, GameId, GameStatus, Mode, PlayerToken, + BoardView, CaptureTally, Color, GameId, GameStatus, Mode, PlayerToken, PromotionType, Square, EndReason, } from './types.js'; import type { Announcement } from './moderator.js'; @@ -34,6 +34,7 @@ export type ServerMessage = mode: Mode; highlightingEnabled: boolean; opponentConnected: boolean; + captures: CaptureTally; aiOpponent?: { color: Color; brain: 'casual' | 'recon' }; } | { @@ -45,6 +46,7 @@ export type ServerMessage = drawOffer?: { from: Color } | null; endReason?: EndReason; winner?: Color | null; + captures: CaptureTally; aiOpponent?: { color: Color; brain: 'casual' | 'recon' }; } | { type: 'peer-status'; color: Color; connected: boolean; graceUntil?: number } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index ea1ad60..fda4063 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -61,3 +61,12 @@ export function squareAt(fileIdx: number, rankIdx: number): Square | null { const r = String.fromCharCode('1'.charCodeAt(0) + rankIdx); return `${f}${r}` as Square; } + +/** Count of pieces by type — used for the capture tally. */ +export type PieceTally = Partial>; + +/** Per-viewer capture tally: what you took, and what you lost. */ +export interface CaptureTally { + byYou: PieceTally; + byOpponent: PieceTally; +}