Chess on the ATmosphere checkmate.blue
chess
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

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.

authored by

Scott Hadfield and committed by tangled.org dc0d9920 988a2768

+105 -9
+8 -1
src/lib/stores/game.svelte.ts
··· 13 13 let blackDid: string | undefined = $state(undefined); 14 14 let status: GameRecord['status'] = $state('waiting'); 15 15 let pendingPromotion: { orig: string; dest: string } | null = $state(null); 16 + let storedResult: { result: '1-0' | '0-1' | '1/2-1/2'; reason: string } | null = $state(null); 16 17 17 18 export const game = { 18 19 get chess() { return chess; }, ··· 24 25 get dests(): Dests { return toDests(chess); }, 25 26 get lastMove() { return lastMoveSquares(chess); }, 26 27 get isInCheck() { return chess.inCheck(); }, 27 - get result() { return gameResult(chess); }, 28 + get result() { return storedResult ?? gameResult(chess); }, 28 29 get status() { return status; }, 29 30 get moveCount() { return chess.history().length; }, 30 31 get whiteHandle() { return whiteHandle; }, ··· 53 54 blackDid = options.blackDid; 54 55 status = options.status ?? 'active'; 55 56 pendingPromotion = null; 57 + storedResult = null; 56 58 }, 57 59 58 60 tryMove(orig: string, dest: string): boolean { ··· 97 99 status = s; 98 100 }, 99 101 102 + setResult(result: '1-0' | '0-1' | '1/2-1/2', reason: string) { 103 + storedResult = { result, reason }; 104 + }, 105 + 100 106 reset() { 101 107 chess = new Chess(); 102 108 myColor = 'white'; ··· 106 112 blackDid = undefined; 107 113 status = 'waiting'; 108 114 pendingPromotion = null; 115 + storedResult = null; 109 116 }, 110 117 };
+97 -8
src/routes/game/[did]/[rkey]/+page.svelte
··· 12 12 getGame, getGamePublic, updateGame, createGame, 13 13 findGameRecordByParent, findGameRecordByParentPublic, 14 14 } from '$lib/atproto'; 15 - import { makePgn } from '$lib/game-logic'; 15 + import { makePgn, gameResult } from '$lib/game-logic'; 16 + import { Chess } from 'chess.js'; 17 + import type { GameRecord } from '$lib/types'; 16 18 import { JetstreamConnection } from '$lib/jetstream'; 17 19 import { resolveIdentity } from '$lib/microcosm'; 18 20 import { composeChallengePost, postToBluesky } from '$lib/bluesky'; ··· 33 35 let connected = $state(false); 34 36 let lastPersistedPgn = ''; 35 37 38 + /** 39 + * Verify an opponent's claimed game result. Returns a trusted 40 + * { result, resultReason } if verifiable, or null if the claim 41 + * can't be trusted. 42 + */ 43 + function verifyOpponentResult( 44 + opponentRecord: Record<string, unknown>, 45 + opponentColor: 'white' | 'black' 46 + ): { result: string; resultReason: string } | null { 47 + // Try to verify from the PGN (checkmate, stalemate, etc.) 48 + const pgn = opponentRecord.pgn as string; 49 + if (pgn) { 50 + const chess = new Chess(); 51 + try { chess.loadPgn(pgn); } catch { return null; } 52 + const verified = gameResult(chess); 53 + if (verified) return { result: verified.result, resultReason: verified.reason }; 54 + } 55 + 56 + // Resignation: only trust if the opponent claims THEY lost 57 + const reason = opponentRecord.resultReason as string; 58 + const result = opponentRecord.result as string; 59 + if (reason === 'resignation' && result) { 60 + const opponentLost = 61 + (opponentColor === 'white' && result === '0-1') || 62 + (opponentColor === 'black' && result === '1-0'); 63 + if (opponentLost) return { result, resultReason: reason }; 64 + } 65 + 66 + return null; 67 + } 68 + 36 69 onMount(() => { 37 70 return () => jsConnections.forEach(c => c.destroy()); 38 71 }); ··· 100 133 status: record.status === 'waiting' && isOwner ? 'waiting' : record.status, 101 134 }); 102 135 136 + if (record.status === 'completed' && record.result && record.resultReason) { 137 + game.setResult(record.result, record.resultReason); 138 + } 139 + 103 140 // Reconcile state by reading both records 104 141 const parentUri = `at://${ownerDid}/blue.checkmate.game/${rkey}`; 105 142 ··· 115 152 if (opponentResult?.record.pgn) { 116 153 game.applyOpponentMove(opponentResult.record.pgn); 117 154 } 155 + if (record.status === 'active' && opponentResult?.record.status === 'completed') { 156 + const opponentColor = myColor === 'white' ? 'black' : 'white'; 157 + await reconcileCompletion(opponentResult.record, opponentColor); 158 + } 118 159 } 119 160 } else if (isParticipant) { 120 161 const myResult = await findGameRecordByParent(auth.agent!, auth.did!, parentUri); ··· 122 163 myRkey = myResult.rkey; 123 164 if (myResult.record.pgn) { 124 165 game.applyOpponentMove(myResult.record.pgn); 166 + } 167 + // Check if opponent's (owner's) record shows completed 168 + if (myResult.record.status !== 'completed' && record.status === 'completed') { 169 + const opponentColor = myColor === 'white' ? 'black' : 'white'; 170 + await reconcileCompletion(record, opponentColor); 125 171 } 126 172 } else { 127 173 await joinGame(record, myColor); 128 174 } 129 175 } 130 176 177 + // Catch-all: if chess.js detects game over but our record is stale, sync it 178 + if (game.status === 'active' && game.result && auth.agent && myRkey) { 179 + game.setStatus('completed'); 180 + await updateGame(auth.agent, myRkey, { 181 + pgn: makePgn(game.chess, game.whiteDid, game.blackDid), 182 + status: 'completed', 183 + result: game.result.result, 184 + resultReason: game.result.reason, 185 + }); 186 + } 187 + 131 188 lastPersistedPgn = game.pgn; 132 189 133 190 resolvePlayerHandles(record.white, record.black); ··· 137 194 const opponentDid = myColor === 'white' ? record.black : record.white; 138 195 if (opponentDid && game.status === 'active') { 139 196 connectJetstream(opponentDid); 140 - } else if (isOwner) { 197 + } else if (isOwner && game.status === 'waiting') { 141 198 waitForOpponent(); 142 199 } 143 200 } ··· 145 202 loading = false; 146 203 } 147 204 205 + async function reconcileCompletion(opponentRecord: GameRecord, opponentColor: 'white' | 'black') { 206 + const verified = verifyOpponentResult( 207 + opponentRecord as unknown as Record<string, unknown>, 208 + opponentColor 209 + ); 210 + if (verified && auth.agent && myRkey) { 211 + game.setStatus('completed'); 212 + game.setResult(verified.result as '1-0' | '0-1' | '1/2-1/2', verified.resultReason); 213 + const finalPgn = makePgn(game.chess, game.whiteDid, game.blackDid); 214 + await updateGame(auth.agent, myRkey, { 215 + pgn: finalPgn, 216 + status: 'completed', 217 + result: verified.result, 218 + resultReason: verified.resultReason, 219 + }); 220 + } 221 + } 222 + 148 223 async function reconcileSpectator(record: any, parentUri: string) { 149 224 // Find the non-owner's child record and use the longer PGN 150 225 const nonOwnerDid = record.white === ownerDid ? record.black : record.white; ··· 202 277 const js = new JetstreamConnection({ 203 278 opponentDid, 204 279 agent: auth.agent ?? undefined, 205 - onGameUpdate: (record) => { 280 + onGameUpdate: async (record) => { 206 281 const pgn = record.pgn as string; 207 282 if (pgn) { 208 283 game.applyOpponentMove(pgn); 209 284 } 210 285 const status = record.status as string; 211 286 if (status === 'completed') { 212 - game.setStatus('completed'); 287 + const opponentColor = game.myColor === 'white' ? 'black' : 'white'; 288 + const verified = verifyOpponentResult(record, opponentColor); 289 + if (verified && auth.agent && myRkey) { 290 + game.setStatus('completed'); 291 + game.setResult(verified.result as '1-0' | '0-1' | '1/2-1/2', verified.resultReason); 292 + const finalPgn = pgn || makePgn(game.chess, game.whiteDid, game.blackDid); 293 + await updateGame(auth.agent, myRkey, { 294 + pgn: finalPgn, 295 + status: 'completed', 296 + result: verified.result, 297 + resultReason: verified.resultReason, 298 + }); 299 + } 213 300 } 214 301 }, 215 302 onConnectionChange: (isConnected) => { ··· 570 657 571 658 <MoveList chess={game.chess} /> 572 659 573 - <div class="flex items-center gap-2 text-xs text-text-secondary"> 574 - <span class="inline-block h-2 w-2 rounded-full" class:bg-success={connected} class:bg-danger={!connected}></span> 575 - {connected ? 'Live' : 'Reconnecting...'} 576 - </div> 660 + {#if game.status !== 'completed'} 661 + <div class="flex items-center gap-2 text-xs text-text-secondary"> 662 + <span class="inline-block h-2 w-2 rounded-full" class:bg-success={connected} class:bg-danger={!connected}></span> 663 + {connected ? 'Live' : 'Reconnecting...'} 664 + </div> 665 + {/if} 577 666 </div> 578 667 579 668 {#if game.pendingPromotion && !isSpectator}