feat(server): per-viewer capture tally on joined and update messages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, number> = {};
|
||||||
|
const byOpponent: Record<string, number> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { announce } from './translator.js';
|
|||||||
import { buildView } from './view.js';
|
import { buildView } from './view.js';
|
||||||
import { consumeCommitToken } from './ratelimit.js';
|
import { consumeCommitToken } from './ratelimit.js';
|
||||||
import { endGame, finalizeIfEnded } from './game-end.js';
|
import { endGame, finalizeIfEnded } from './game-end.js';
|
||||||
|
import { captureTally } from './captures.js';
|
||||||
|
|
||||||
async function pokeBot(game: Game): Promise<void> {
|
async function pokeBot(game: Game): Promise<void> {
|
||||||
const driver = getBotDriver(game.id);
|
const driver = getBotDriver(game.id);
|
||||||
@@ -147,6 +148,7 @@ async function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hell
|
|||||||
mode: game.mode,
|
mode: game.mode,
|
||||||
highlightingEnabled: game.highlightingEnabled,
|
highlightingEnabled: game.highlightingEnabled,
|
||||||
opponentConnected: !!game.players[color === 'w' ? 'b' : 'w']?.socket,
|
opponentConnected: !!game.players[color === 'w' ? 'b' : 'w']?.socket,
|
||||||
|
captures: captureTally(game, color),
|
||||||
aiOpponent: game.aiOpponent,
|
aiOpponent: game.aiOpponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -284,6 +286,7 @@ function sendUpdateTo(
|
|||||||
drawOffer,
|
drawOffer,
|
||||||
endReason: game.endReason,
|
endReason: game.endReason,
|
||||||
winner: game.winner ?? null,
|
winner: game.winner ?? null,
|
||||||
|
captures: captureTally(game, color),
|
||||||
aiOpponent: game.aiOpponent,
|
aiOpponent: game.aiOpponent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { captureTally } from '../../src/captures.js';
|
||||||
|
import type { Game, MoveRecord } from '../../src/state.js';
|
||||||
|
import type { Color, PieceType } from '@blind-chess/shared';
|
||||||
|
|
||||||
|
function rec(by: Color, capturedPieceType?: PieceType): MoveRecord {
|
||||||
|
return {
|
||||||
|
ply: 1, by, from: 'e2', to: 'e4', san: 'e4',
|
||||||
|
capturedPieceType, flags: {}, at: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('captureTally', () => {
|
||||||
|
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: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
BoardView, Color, GameId, GameStatus, Mode, PlayerToken,
|
BoardView, CaptureTally, Color, GameId, GameStatus, Mode, PlayerToken,
|
||||||
PromotionType, Square, EndReason,
|
PromotionType, Square, EndReason,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import type { Announcement } from './moderator.js';
|
import type { Announcement } from './moderator.js';
|
||||||
@@ -34,6 +34,7 @@ export type ServerMessage =
|
|||||||
mode: Mode;
|
mode: Mode;
|
||||||
highlightingEnabled: boolean;
|
highlightingEnabled: boolean;
|
||||||
opponentConnected: boolean;
|
opponentConnected: boolean;
|
||||||
|
captures: CaptureTally;
|
||||||
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -45,6 +46,7 @@ export type ServerMessage =
|
|||||||
drawOffer?: { from: Color } | null;
|
drawOffer?: { from: Color } | null;
|
||||||
endReason?: EndReason;
|
endReason?: EndReason;
|
||||||
winner?: Color | null;
|
winner?: Color | null;
|
||||||
|
captures: CaptureTally;
|
||||||
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
||||||
}
|
}
|
||||||
| { type: 'peer-status'; color: Color; connected: boolean; graceUntil?: number }
|
| { type: 'peer-status'; color: Color; connected: boolean; graceUntil?: number }
|
||||||
|
|||||||
@@ -61,3 +61,12 @@ export function squareAt(fileIdx: number, rankIdx: number): Square | null {
|
|||||||
const r = String.fromCharCode('1'.charCodeAt(0) + rankIdx);
|
const r = String.fromCharCode('1'.charCodeAt(0) + rankIdx);
|
||||||
return `${f}${r}` as Square;
|
return `${f}${r}` as Square;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Count of pieces by type — used for the capture tally. */
|
||||||
|
export type PieceTally = Partial<Record<PieceType, number>>;
|
||||||
|
|
||||||
|
/** Per-viewer capture tally: what you took, and what you lost. */
|
||||||
|
export interface CaptureTally {
|
||||||
|
byYou: PieceTally;
|
||||||
|
byOpponent: PieceTally;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user