feat(bot): pokeBot + broadcastSinceLast hooks into ws.ts handlers

Replace broadcastNewAnnouncements/broadcastUpdate with watermark-based
broadcastSinceLast; add pokeBot helper; make all state-mutating handlers
async; hook pokeBot after every mutation so the CasualBrain fires on
each turn without oracle access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude (blind_chess)
2026-04-28 14:13:24 -04:00
parent 58e1fc5bd8
commit a9660c0694
+60 -37
View File
@@ -10,6 +10,7 @@ import {
claimSlot,
findTokenInGame,
getGame,
getBotDriver,
} from './games.js';
import type { Game } from './state.js';
import { GRACE_MS } from './state.js';
@@ -19,6 +20,36 @@ import { buildView } from './view.js';
import { consumeCommitToken } from './ratelimit.js';
import { endGame, finalizeIfEnded } from './game-end.js';
async function pokeBot(game: Game): Promise<void> {
const driver = getBotDriver(game.id);
if (!driver) return;
try {
await driver.onStateChange();
} catch (err) {
// Don't throw out of message handlers — log and continue.
// eslint-disable-next-line no-console
console.error('[bot driver error]', { gameId: game.id, err });
}
}
function broadcastSinceLast(
game: Game,
extra?: { touchedPieceFor?: Color; touchedPiece?: import('@blind-chess/shared').Square },
): void {
for (const c of ['w', 'b'] as const) {
const lastIdx = game.lastBroadcastIdx[c];
const all = game.announcements;
const slice = all.slice(lastIdx).filter((a) => a.audience === 'both' || a.audience === c);
sendUpdateTo(
game,
c,
slice,
extra?.touchedPieceFor === c ? { touchedPiece: extra.touchedPiece } : undefined,
);
game.lastBroadcastIdx[c] = all.length;
}
}
interface SocketCtx {
socket: WebSocket;
game: Game | null;
@@ -61,7 +92,7 @@ function onMessage(ctx: SocketCtx, data: unknown): void {
}
const msg = result.data as ClientMessage;
if (msg.type === 'hello') return onHello(ctx, msg);
if (msg.type === 'hello') { void onHello(ctx, msg); return; }
if (msg.type === 'pong') return;
if (!ctx.game || !ctx.color) {
@@ -69,14 +100,14 @@ function onMessage(ctx: SocketCtx, data: unknown): void {
}
switch (msg.type) {
case 'commit': return onCommit(ctx, msg);
case 'resign': return onResign(ctx);
case 'offer-draw': return onOfferDraw(ctx);
case 'respond-draw': return onRespondDraw(ctx, msg.accept);
case 'commit': void onCommit(ctx, msg); return;
case 'resign': void onResign(ctx); return;
case 'offer-draw': void onOfferDraw(ctx); return;
case 'respond-draw': void onRespondDraw(ctx, msg.accept); return;
}
}
function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hello' }>): void {
async function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hello' }>): Promise<void> {
const game = getGame(msg.gameId);
if (!game) return sendError(ctx.socket, 'game_not_found');
@@ -128,13 +159,14 @@ function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hello' }>)
// Notify peer that we're connected.
notifyPeer(game, color, true);
// If activation just happened, push update to both.
// If activation just happened, poke bot then broadcast.
if (game.status === 'active') {
broadcastUpdate(game);
await pokeBot(game);
broadcastSinceLast(game);
}
}
function onCommit(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'commit' }>): void {
async function onCommit(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'commit' }>): Promise<void> {
const game = ctx.game!;
const color = ctx.color!;
@@ -154,17 +186,19 @@ function onCommit(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'commit' }
return;
case 'announce':
// Announcement to actor; opponent is unaffected unless audience=both.
broadcastNewAnnouncements(game, result.announcements);
// Non-turn-ending — no bot poke.
broadcastSinceLast(game);
return;
case 'applied':
// Move applied. Check end conditions.
// Move applied. Check end conditions, then poke bot.
finalizeIfEnded(game, result.announcements);
broadcastNewAnnouncements(game, result.announcements);
await pokeBot(game);
broadcastSinceLast(game);
return;
}
}
function onResign(ctx: SocketCtx): void {
async function onResign(ctx: SocketCtx): Promise<void> {
const game = ctx.game!;
const color = ctx.color!;
if (game.status !== 'active') return;
@@ -173,19 +207,22 @@ function onResign(ctx: SocketCtx): void {
const a = announce(color === 'w' ? 'white_resigned' : 'black_resigned', 'both', ply);
game.announcements.push(a);
endGame(game, 'resign', color === 'w' ? 'b' : 'w');
broadcastNewAnnouncements(game, [a]);
await pokeBot(game); // bot.onStateChange will see status=finished and dispose
broadcastSinceLast(game);
}
function onOfferDraw(ctx: SocketCtx): void {
async function onOfferDraw(ctx: SocketCtx): Promise<void> {
const game = ctx.game!;
const color = ctx.color!;
if (game.status !== 'active') return;
game.drawOffer = { from: color, at: Date.now() };
// Push update to both so opponent sees the drawOffer field.
broadcastUpdate(game);
// Poke bot — it may auto-respond to the draw offer.
await pokeBot(game);
// broadcastSinceLast sends drawOffer field via sendUpdateTo's existing logic.
broadcastSinceLast(game);
}
function onRespondDraw(ctx: SocketCtx, accept: boolean): void {
async function onRespondDraw(ctx: SocketCtx, accept: boolean): Promise<void> {
const game = ctx.game!;
const color = ctx.color!;
if (!game.drawOffer || game.drawOffer.from === color) return;
@@ -195,11 +232,11 @@ function onRespondDraw(ctx: SocketCtx, accept: boolean): void {
game.announcements.push(a);
game.drawOffer = null;
endGame(game, 'draw_agreed', null);
broadcastNewAnnouncements(game, [a]);
} else {
game.drawOffer = null;
broadcastUpdate(game);
}
await pokeBot(game);
broadcastSinceLast(game);
}
function onClose(ctx: SocketCtx): void {
@@ -218,7 +255,7 @@ function onClose(ctx: SocketCtx): void {
}
}
function maybeAbandon(game: Game, color: Color): void {
async function maybeAbandon(game: Game, color: Color): Promise<void> {
if (game.status !== 'active') return;
const slot = game.players[color];
if (!slot) return;
@@ -229,25 +266,11 @@ function maybeAbandon(game: Game, color: Color): void {
game.announcements.push(a);
const winner = game.players[color === 'w' ? 'b' : 'w']?.socket ? (color === 'w' ? 'b' : 'w') : null;
endGame(game, 'abandoned', winner);
broadcastNewAnnouncements(game, [a]);
await pokeBot(game); // dispose bot if game ended
broadcastSinceLast(game);
}
function broadcastNewAnnouncements(
game: Game,
newAnnouncements: ReadonlyArray<import('@blind-chess/shared').Announcement>,
): void {
for (const c of ['w', 'b'] as const) {
const filtered = newAnnouncements.filter((a) => a.audience === 'both' || a.audience === c);
sendUpdateTo(game, c, filtered);
}
}
function broadcastUpdate(game: Game): void {
for (const c of ['w', 'b'] as const) {
sendUpdateTo(game, c, []);
}
}
function sendUpdateTo(
game: Game,