Chess on the ATmosphere checkmate.blue
chess
13
fork

Configure Feed

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

Improve draw workflow

+143 -16
+32 -4
src/lib/components/GameControls.svelte
··· 2 2 type Props = { 3 3 onresign?: () => void; 4 4 ondrawoffer?: () => void; 5 + ondrawretract?: () => void; 6 + ondrawaccept?: () => void; 7 + ondrawdecline?: () => void; 5 8 gameOver?: boolean; 9 + drawOfferedByMe?: boolean; 10 + drawOfferedByOpponent?: boolean; 6 11 }; 7 12 8 - let { onresign, ondrawoffer, gameOver = false }: Props = $props(); 13 + let { 14 + onresign, ondrawoffer, ondrawretract, ondrawaccept, ondrawdecline, 15 + gameOver = false, drawOfferedByMe = false, drawOfferedByOpponent = false, 16 + }: Props = $props(); 9 17 </script> 10 18 11 19 {#if !gameOver} 20 + {#if drawOfferedByOpponent} 21 + <div class="w-full max-w-md rounded-lg border border-border bg-bg-secondary p-3 text-center"> 22 + <p class="text-sm font-semibold">Your opponent offers a draw</p> 23 + <div class="mt-2 flex justify-center gap-2"> 24 + <button 25 + onclick={() => ondrawaccept?.()} 26 + class="rounded-lg bg-success px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:brightness-110" 27 + > 28 + Accept 29 + </button> 30 + <button 31 + onclick={() => ondrawdecline?.()} 32 + class="rounded-lg border border-border px-4 py-1.5 text-sm text-text-secondary transition-colors hover:text-text-primary" 33 + > 34 + Decline 35 + </button> 36 + </div> 37 + </div> 38 + {/if} 39 + 12 40 <div class="flex gap-3"> 13 41 <button 14 42 onclick={() => onresign?.()} ··· 17 45 Resign 18 46 </button> 19 47 <button 20 - onclick={() => ondrawoffer?.()} 21 - class="rounded-lg border border-border px-4 py-2 text-sm text-text-secondary transition-colors hover:text-text-primary" 48 + onclick={() => drawOfferedByMe ? ondrawretract?.() : ondrawoffer?.()} 49 + class="rounded-lg border border-border px-4 py-2 text-sm transition-colors {drawOfferedByMe ? 'text-warning hover:text-text-primary' : 'text-text-secondary hover:text-text-primary'}" 22 50 > 23 - Offer Draw 51 + {drawOfferedByMe ? 'Retract Offer' : 'Offer Draw'} 24 52 </button> 25 53 </div> 26 54 {/if}
+25
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 drawOfferedByMe = $state(false); 17 + let drawOfferedByOpponent = $state(false); 16 18 let storedResult: { result: '1-0' | '0-1' | '1/2-1/2'; reason: string } | null = $state(null); 17 19 18 20 export const game = { ··· 33 35 get whiteDid() { return whiteDid; }, 34 36 get blackDid() { return blackDid; }, 35 37 get pendingPromotion() { return pendingPromotion; }, 38 + get drawOfferedByMe() { return drawOfferedByMe; }, 39 + get drawOfferedByOpponent() { return drawOfferedByOpponent; }, 36 40 37 41 init(options: { 38 42 pgn?: string; ··· 54 58 blackDid = options.blackDid; 55 59 status = options.status ?? 'active'; 56 60 pendingPromotion = null; 61 + drawOfferedByMe = false; 62 + drawOfferedByOpponent = false; 57 63 storedResult = null; 58 64 }, 59 65 ··· 103 109 storedResult = { result, reason }; 104 110 }, 105 111 112 + offerDraw() { 113 + drawOfferedByMe = true; 114 + }, 115 + 116 + receiveDrawOffer() { 117 + drawOfferedByOpponent = true; 118 + }, 119 + 120 + clearDrawOffers() { 121 + drawOfferedByMe = false; 122 + drawOfferedByOpponent = false; 123 + }, 124 + 125 + clearOpponentDrawOffer() { 126 + drawOfferedByOpponent = false; 127 + }, 128 + 106 129 reset() { 107 130 chess = new Chess(); 108 131 myColor = 'white'; ··· 112 135 blackDid = undefined; 113 136 status = 'waiting'; 114 137 pendingPromotion = null; 138 + drawOfferedByMe = false; 139 + drawOfferedByOpponent = false; 115 140 storedResult = null; 116 141 }, 117 142 };
+1 -1
src/lib/types.ts
··· 14 14 black?: string; 15 15 status: 'waiting' | 'active' | 'completed' | 'abandoned'; 16 16 result?: '1-0' | '0-1' | '1/2-1/2'; 17 - resultReason?: 'checkmate' | 'resignation' | 'draw_agreement' | 'stalemate' | 'insufficient' | 'repetition' | 'fifty_moves'; 17 + resultReason?: 'checkmate' | 'resignation' | 'agreement' | 'stalemate' | 'insufficient' | 'repetition' | 'fifty_moves'; 18 18 parentGameUri?: string; 19 19 drawOffered?: boolean; 20 20 timeControl?: TimeControl;
+85 -11
src/routes/game/[did]/[rkey]/+page.svelte
··· 203 203 } 204 204 205 205 async function reconcileCompletion(opponentRecord: GameRecord, opponentColor: 'white' | 'black') { 206 + // Draw by agreement: trust if our record shows we offered a draw 207 + if (opponentRecord.result === '1/2-1/2' && opponentRecord.resultReason === 'agreement') { 208 + if (auth.agent && myRkey) { 209 + game.setStatus('completed'); 210 + game.setResult('1/2-1/2', 'agreement'); 211 + const finalPgn = makePgn(game.chess, game.whiteDid, game.blackDid); 212 + await updateGame(auth.agent, myRkey, { 213 + pgn: finalPgn, 214 + status: 'completed', 215 + result: '1/2-1/2', 216 + resultReason: 'agreement', 217 + drawOffered: false, 218 + }); 219 + } 220 + return; 221 + } 222 + 206 223 const verified = verifyOpponentResult( 207 224 opponentRecord as unknown as Record<string, unknown>, 208 225 opponentColor ··· 282 299 if (pgn) { 283 300 game.applyOpponentMove(pgn); 284 301 } 302 + 303 + // Detect draw offer from opponent 304 + if (record.drawOffered) { 305 + game.receiveDrawOffer(); 306 + } else { 307 + game.clearDrawOffers(); 308 + } 309 + 285 310 const status = record.status as string; 286 311 if (status === 'completed') { 287 - const opponentColor = game.myColor === 'white' ? 'black' : 'white'; 288 - const verified = verifyOpponentResult(record, opponentColor); 289 - if (verified && auth.agent && myRkey) { 312 + const result = record.result as string; 313 + const reason = record.resultReason as string; 314 + if (result === '1/2-1/2' && reason === 'agreement' && game.drawOfferedByMe) { 315 + // Opponent accepted our draw offer -- sync our record 316 + game.setResult('1/2-1/2', 'agreement'); 290 317 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 - }); 318 + if (auth.agent && myRkey) { 319 + const finalPgn = pgn || makePgn(game.chess, game.whiteDid, game.blackDid); 320 + await updateGame(auth.agent, myRkey, { 321 + pgn: finalPgn, 322 + status: 'completed', 323 + result: '1/2-1/2', 324 + resultReason: 'agreement', 325 + drawOffered: false, 326 + }); 327 + } 328 + } else { 329 + const opponentColor = game.myColor === 'white' ? 'black' : 'white'; 330 + const verified = verifyOpponentResult(record, opponentColor); 331 + if (verified && auth.agent && myRkey) { 332 + game.setStatus('completed'); 333 + game.setResult(verified.result as '1-0' | '0-1' | '1/2-1/2', verified.resultReason); 334 + const finalPgn = pgn || makePgn(game.chess, game.whiteDid, game.blackDid); 335 + await updateGame(auth.agent, myRkey, { 336 + pgn: finalPgn, 337 + status: 'completed', 338 + result: verified.result, 339 + resultReason: verified.resultReason, 340 + }); 341 + } 299 342 } 300 343 } 301 344 }, ··· 390 433 status: game.result ? 'completed' : 'active', 391 434 result: game.result?.result, 392 435 resultReason: game.result?.reason as any, 436 + drawOffered: false, 393 437 }); 438 + game.clearDrawOffers(); 394 439 lastPersistedPgn = pgn; 395 440 moveError = ''; 396 441 if (game.result) { ··· 423 468 424 469 async function handleDrawOffer() { 425 470 if (!auth.agent || !auth.did || !myRkey) return; 471 + game.offerDraw(); 426 472 await updateGame(auth.agent, myRkey, { drawOffered: true }); 473 + } 474 + 475 + async function handleDrawAccept() { 476 + if (!auth.agent || !auth.did || !myRkey) return; 477 + game.setStatus('completed'); 478 + game.setResult('1/2-1/2', 'agreement'); 479 + const pgn = makePgn(game.chess, game.whiteDid, game.blackDid); 480 + await updateGame(auth.agent, myRkey, { 481 + pgn, 482 + status: 'completed', 483 + result: '1/2-1/2', 484 + resultReason: 'agreement', 485 + }); 486 + } 487 + 488 + function handleDrawDecline() { 489 + game.clearOpponentDrawOffer(); 490 + } 491 + 492 + async function handleDrawRetract() { 493 + if (!auth.agent || !myRkey) return; 494 + game.clearDrawOffers(); 495 + await updateGame(auth.agent, myRkey, { drawOffered: false }); 427 496 } 428 497 429 498 function flipBoard() { ··· 650 719 {#if !isSpectator} 651 720 <GameControls 652 721 gameOver={game.result !== null || game.status === 'completed'} 722 + drawOfferedByMe={game.drawOfferedByMe} 723 + drawOfferedByOpponent={game.drawOfferedByOpponent} 653 724 onresign={handleResign} 654 725 ondrawoffer={handleDrawOffer} 726 + ondrawretract={handleDrawRetract} 727 + ondrawaccept={handleDrawAccept} 728 + ondrawdecline={handleDrawDecline} 655 729 /> 656 730 {/if} 657 731