diff --git a/packages/server/src/ws.ts b/packages/server/src/ws.ts index af391d3..fdcfa3a 100644 --- a/packages/server/src/ws.ts +++ b/packages/server/src/ws.ts @@ -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 { + 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): void { +async function onHello(ctx: SocketCtx, msg: Extract): Promise { const game = getGame(msg.gameId); if (!game) return sendError(ctx.socket, 'game_not_found'); @@ -128,13 +159,14 @@ function onHello(ctx: SocketCtx, msg: Extract) // 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): void { +async function onCommit(ctx: SocketCtx, msg: Extract): Promise { const game = ctx.game!; const color = ctx.color!; @@ -154,17 +186,19 @@ function onCommit(ctx: SocketCtx, msg: Extract { 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 { 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 { 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 { 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, -): 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,