From dc0d9920fb59d6d3e0469f759dbd5adb3db44f2f Mon Sep 17 00:00:00 2001 From: Scott Hadfield Date: Sun, 29 Mar 2026 10:21:00 -0700 Subject: [PATCH] Sync game completion to both players' records When the opponent ends a game (checkmate, resignation), persist the result to the current player's own PDS record. Previously only the opponent's record was updated, leaving the other player's record stuck as 'active'. Verify opponent-claimed results before trusting them: - Checkmate/stalemate/etc: verified from PGN via chess.js - Resignation: only accepted if opponent claims they lost - Draw agreement: not accepted from opponent's record alone Add storedResult to game store so resignation/draw results display in the result panel (chess.js only detects positional endings). Reconciliation runs in three places: - Real-time via Jetstream handler - On page load when opponent's record shows completed - Catch-all: if chess.js detects game over but status is stale Also fix: only enter waitForOpponent when game is actually waiting, and hide connection indicator on completed games. --- src/lib/stores/game.svelte.ts | 9 +- src/routes/game/[did]/[rkey]/+page.svelte | 105 ++++++++++++++++++++-- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/lib/stores/game.svelte.ts b/src/lib/stores/game.svelte.ts index 1b226a7..bfd59c0 100644 --- a/src/lib/stores/game.svelte.ts +++ b/src/lib/stores/game.svelte.ts @@ -13,6 +13,7 @@ let whiteDid: string | undefined = $state(undefined); let blackDid: string | undefined = $state(undefined); let status: GameRecord['status'] = $state('waiting'); let pendingPromotion: { orig: string; dest: string } | null = $state(null); +let storedResult: { result: '1-0' | '0-1' | '1/2-1/2'; reason: string } | null = $state(null); export const game = { get chess() { return chess; }, @@ -24,7 +25,7 @@ export const game = { get dests(): Dests { return toDests(chess); }, get lastMove() { return lastMoveSquares(chess); }, get isInCheck() { return chess.inCheck(); }, - get result() { return gameResult(chess); }, + get result() { return storedResult ?? gameResult(chess); }, get status() { return status; }, get moveCount() { return chess.history().length; }, get whiteHandle() { return whiteHandle; }, @@ -53,6 +54,7 @@ export const game = { blackDid = options.blackDid; status = options.status ?? 'active'; pendingPromotion = null; + storedResult = null; }, tryMove(orig: string, dest: string): boolean { @@ -97,6 +99,10 @@ export const game = { status = s; }, + setResult(result: '1-0' | '0-1' | '1/2-1/2', reason: string) { + storedResult = { result, reason }; + }, + reset() { chess = new Chess(); myColor = 'white'; @@ -106,5 +112,6 @@ export const game = { blackDid = undefined; status = 'waiting'; pendingPromotion = null; + storedResult = null; }, }; diff --git a/src/routes/game/[did]/[rkey]/+page.svelte b/src/routes/game/[did]/[rkey]/+page.svelte index 1c97eb9..965b726 100644 --- a/src/routes/game/[did]/[rkey]/+page.svelte +++ b/src/routes/game/[did]/[rkey]/+page.svelte @@ -12,7 +12,9 @@ getGame, getGamePublic, updateGame, createGame, findGameRecordByParent, findGameRecordByParentPublic, } from '$lib/atproto'; - import { makePgn } from '$lib/game-logic'; + import { makePgn, gameResult } from '$lib/game-logic'; + import { Chess } from 'chess.js'; + import type { GameRecord } from '$lib/types'; import { JetstreamConnection } from '$lib/jetstream'; import { resolveIdentity } from '$lib/microcosm'; import { composeChallengePost, postToBluesky } from '$lib/bluesky'; @@ -33,6 +35,37 @@ let connected = $state(false); let lastPersistedPgn = ''; + /** + * Verify an opponent's claimed game result. Returns a trusted + * { result, resultReason } if verifiable, or null if the claim + * can't be trusted. + */ + function verifyOpponentResult( + opponentRecord: Record, + opponentColor: 'white' | 'black' + ): { result: string; resultReason: string } | null { + // Try to verify from the PGN (checkmate, stalemate, etc.) + const pgn = opponentRecord.pgn as string; + if (pgn) { + const chess = new Chess(); + try { chess.loadPgn(pgn); } catch { return null; } + const verified = gameResult(chess); + if (verified) return { result: verified.result, resultReason: verified.reason }; + } + + // Resignation: only trust if the opponent claims THEY lost + const reason = opponentRecord.resultReason as string; + const result = opponentRecord.result as string; + if (reason === 'resignation' && result) { + const opponentLost = + (opponentColor === 'white' && result === '0-1') || + (opponentColor === 'black' && result === '1-0'); + if (opponentLost) return { result, resultReason: reason }; + } + + return null; + } + onMount(() => { return () => jsConnections.forEach(c => c.destroy()); }); @@ -100,6 +133,10 @@ status: record.status === 'waiting' && isOwner ? 'waiting' : record.status, }); + if (record.status === 'completed' && record.result && record.resultReason) { + game.setResult(record.result, record.resultReason); + } + // Reconcile state by reading both records const parentUri = `at://${ownerDid}/blue.checkmate.game/${rkey}`; @@ -115,6 +152,10 @@ if (opponentResult?.record.pgn) { game.applyOpponentMove(opponentResult.record.pgn); } + if (record.status === 'active' && opponentResult?.record.status === 'completed') { + const opponentColor = myColor === 'white' ? 'black' : 'white'; + await reconcileCompletion(opponentResult.record, opponentColor); + } } } else if (isParticipant) { const myResult = await findGameRecordByParent(auth.agent!, auth.did!, parentUri); @@ -123,11 +164,27 @@ if (myResult.record.pgn) { game.applyOpponentMove(myResult.record.pgn); } + // Check if opponent's (owner's) record shows completed + if (myResult.record.status !== 'completed' && record.status === 'completed') { + const opponentColor = myColor === 'white' ? 'black' : 'white'; + await reconcileCompletion(record, opponentColor); + } } else { await joinGame(record, myColor); } } + // Catch-all: if chess.js detects game over but our record is stale, sync it + if (game.status === 'active' && game.result && auth.agent && myRkey) { + game.setStatus('completed'); + await updateGame(auth.agent, myRkey, { + pgn: makePgn(game.chess, game.whiteDid, game.blackDid), + status: 'completed', + result: game.result.result, + resultReason: game.result.reason, + }); + } + lastPersistedPgn = game.pgn; resolvePlayerHandles(record.white, record.black); @@ -137,7 +194,7 @@ const opponentDid = myColor === 'white' ? record.black : record.white; if (opponentDid && game.status === 'active') { connectJetstream(opponentDid); - } else if (isOwner) { + } else if (isOwner && game.status === 'waiting') { waitForOpponent(); } } @@ -145,6 +202,24 @@ loading = false; } + async function reconcileCompletion(opponentRecord: GameRecord, opponentColor: 'white' | 'black') { + const verified = verifyOpponentResult( + opponentRecord as unknown as Record, + opponentColor + ); + if (verified && auth.agent && myRkey) { + game.setStatus('completed'); + game.setResult(verified.result as '1-0' | '0-1' | '1/2-1/2', verified.resultReason); + const finalPgn = makePgn(game.chess, game.whiteDid, game.blackDid); + await updateGame(auth.agent, myRkey, { + pgn: finalPgn, + status: 'completed', + result: verified.result, + resultReason: verified.resultReason, + }); + } + } + async function reconcileSpectator(record: any, parentUri: string) { // Find the non-owner's child record and use the longer PGN const nonOwnerDid = record.white === ownerDid ? record.black : record.white; @@ -202,14 +277,26 @@ const js = new JetstreamConnection({ opponentDid, agent: auth.agent ?? undefined, - onGameUpdate: (record) => { + onGameUpdate: async (record) => { const pgn = record.pgn as string; if (pgn) { game.applyOpponentMove(pgn); } const status = record.status as string; if (status === 'completed') { - game.setStatus('completed'); + const opponentColor = game.myColor === 'white' ? 'black' : 'white'; + const verified = verifyOpponentResult(record, opponentColor); + if (verified && auth.agent && myRkey) { + game.setStatus('completed'); + game.setResult(verified.result as '1-0' | '0-1' | '1/2-1/2', verified.resultReason); + const finalPgn = pgn || makePgn(game.chess, game.whiteDid, game.blackDid); + await updateGame(auth.agent, myRkey, { + pgn: finalPgn, + status: 'completed', + result: verified.result, + resultReason: verified.resultReason, + }); + } } }, onConnectionChange: (isConnected) => { @@ -570,10 +657,12 @@ -
- - {connected ? 'Live' : 'Reconnecting...'} -
+ {#if game.status !== 'completed'} +
+ + {connected ? 'Live' : 'Reconnecting...'} +
+ {/if} {#if game.pendingPromotion && !isSpectator} -- 2.51.2