From ca46008fe8d9880b356b591d78a6c828ea51b5c5 Mon Sep 17 00:00:00 2001 From: Scott Hadfield Date: Wed, 1 Apr 2026 15:06:24 -0700 Subject: [PATCH] Improve draw workflow --- src/lib/components/GameControls.svelte | 36 ++++++++- src/lib/stores/game.svelte.ts | 25 ++++++ src/lib/types.ts | 2 +- src/routes/game/[did]/[rkey]/+page.svelte | 96 ++++++++++++++++++++--- 4 files changed, 143 insertions(+), 16 deletions(-) diff --git a/src/lib/components/GameControls.svelte b/src/lib/components/GameControls.svelte index 3b83999..009e949 100644 --- a/src/lib/components/GameControls.svelte +++ b/src/lib/components/GameControls.svelte @@ -2,13 +2,41 @@ type Props = { onresign?: () => void; ondrawoffer?: () => void; + ondrawretract?: () => void; + ondrawaccept?: () => void; + ondrawdecline?: () => void; gameOver?: boolean; + drawOfferedByMe?: boolean; + drawOfferedByOpponent?: boolean; }; - let { onresign, ondrawoffer, gameOver = false }: Props = $props(); + let { + onresign, ondrawoffer, ondrawretract, ondrawaccept, ondrawdecline, + gameOver = false, drawOfferedByMe = false, drawOfferedByOpponent = false, + }: Props = $props(); {#if !gameOver} + {#if drawOfferedByOpponent} +
+

Your opponent offers a draw

+
+ + +
+
+ {/if} +
{/if} diff --git a/src/lib/stores/game.svelte.ts b/src/lib/stores/game.svelte.ts index bfd59c0..c1c0b67 100644 --- a/src/lib/stores/game.svelte.ts +++ b/src/lib/stores/game.svelte.ts @@ -13,6 +13,8 @@ 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 drawOfferedByMe = $state(false); +let drawOfferedByOpponent = $state(false); let storedResult: { result: '1-0' | '0-1' | '1/2-1/2'; reason: string } | null = $state(null); export const game = { @@ -33,6 +35,8 @@ export const game = { get whiteDid() { return whiteDid; }, get blackDid() { return blackDid; }, get pendingPromotion() { return pendingPromotion; }, + get drawOfferedByMe() { return drawOfferedByMe; }, + get drawOfferedByOpponent() { return drawOfferedByOpponent; }, init(options: { pgn?: string; @@ -54,6 +58,8 @@ export const game = { blackDid = options.blackDid; status = options.status ?? 'active'; pendingPromotion = null; + drawOfferedByMe = false; + drawOfferedByOpponent = false; storedResult = null; }, @@ -103,6 +109,23 @@ export const game = { storedResult = { result, reason }; }, + offerDraw() { + drawOfferedByMe = true; + }, + + receiveDrawOffer() { + drawOfferedByOpponent = true; + }, + + clearDrawOffers() { + drawOfferedByMe = false; + drawOfferedByOpponent = false; + }, + + clearOpponentDrawOffer() { + drawOfferedByOpponent = false; + }, + reset() { chess = new Chess(); myColor = 'white'; @@ -112,6 +135,8 @@ export const game = { blackDid = undefined; status = 'waiting'; pendingPromotion = null; + drawOfferedByMe = false; + drawOfferedByOpponent = false; storedResult = null; }, }; diff --git a/src/lib/types.ts b/src/lib/types.ts index b2be2be..e61d08e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -14,7 +14,7 @@ export interface GameRecord { black?: string; status: 'waiting' | 'active' | 'completed' | 'abandoned'; result?: '1-0' | '0-1' | '1/2-1/2'; - resultReason?: 'checkmate' | 'resignation' | 'draw_agreement' | 'stalemate' | 'insufficient' | 'repetition' | 'fifty_moves'; + resultReason?: 'checkmate' | 'resignation' | 'agreement' | 'stalemate' | 'insufficient' | 'repetition' | 'fifty_moves'; parentGameUri?: string; drawOffered?: boolean; timeControl?: TimeControl; diff --git a/src/routes/game/[did]/[rkey]/+page.svelte b/src/routes/game/[did]/[rkey]/+page.svelte index 965b726..56dc0b2 100644 --- a/src/routes/game/[did]/[rkey]/+page.svelte +++ b/src/routes/game/[did]/[rkey]/+page.svelte @@ -203,6 +203,23 @@ } async function reconcileCompletion(opponentRecord: GameRecord, opponentColor: 'white' | 'black') { + // Draw by agreement: trust if our record shows we offered a draw + if (opponentRecord.result === '1/2-1/2' && opponentRecord.resultReason === 'agreement') { + if (auth.agent && myRkey) { + game.setStatus('completed'); + game.setResult('1/2-1/2', 'agreement'); + const finalPgn = makePgn(game.chess, game.whiteDid, game.blackDid); + await updateGame(auth.agent, myRkey, { + pgn: finalPgn, + status: 'completed', + result: '1/2-1/2', + resultReason: 'agreement', + drawOffered: false, + }); + } + return; + } + const verified = verifyOpponentResult( opponentRecord as unknown as Record, opponentColor @@ -282,20 +299,46 @@ if (pgn) { game.applyOpponentMove(pgn); } + + // Detect draw offer from opponent + if (record.drawOffered) { + game.receiveDrawOffer(); + } else { + game.clearDrawOffers(); + } + const status = record.status as string; if (status === 'completed') { - const opponentColor = game.myColor === 'white' ? 'black' : 'white'; - const verified = verifyOpponentResult(record, opponentColor); - if (verified && auth.agent && myRkey) { + const result = record.result as string; + const reason = record.resultReason as string; + if (result === '1/2-1/2' && reason === 'agreement' && game.drawOfferedByMe) { + // Opponent accepted our draw offer -- sync our record + game.setResult('1/2-1/2', 'agreement'); 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, - }); + if (auth.agent && myRkey) { + const finalPgn = pgn || makePgn(game.chess, game.whiteDid, game.blackDid); + await updateGame(auth.agent, myRkey, { + pgn: finalPgn, + status: 'completed', + result: '1/2-1/2', + resultReason: 'agreement', + drawOffered: false, + }); + } + } else { + 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, + }); + } } } }, @@ -390,7 +433,9 @@ status: game.result ? 'completed' : 'active', result: game.result?.result, resultReason: game.result?.reason as any, + drawOffered: false, }); + game.clearDrawOffers(); lastPersistedPgn = pgn; moveError = ''; if (game.result) { @@ -423,9 +468,33 @@ async function handleDrawOffer() { if (!auth.agent || !auth.did || !myRkey) return; + game.offerDraw(); await updateGame(auth.agent, myRkey, { drawOffered: true }); } + async function handleDrawAccept() { + if (!auth.agent || !auth.did || !myRkey) return; + game.setStatus('completed'); + game.setResult('1/2-1/2', 'agreement'); + const pgn = makePgn(game.chess, game.whiteDid, game.blackDid); + await updateGame(auth.agent, myRkey, { + pgn, + status: 'completed', + result: '1/2-1/2', + resultReason: 'agreement', + }); + } + + function handleDrawDecline() { + game.clearOpponentDrawOffer(); + } + + async function handleDrawRetract() { + if (!auth.agent || !myRkey) return; + game.clearDrawOffers(); + await updateGame(auth.agent, myRkey, { drawOffered: false }); + } + function flipBoard() { spectatorOrientation = spectatorOrientation === 'white' ? 'black' : 'white'; } @@ -650,8 +719,13 @@ {#if !isSpectator} {/if} -- 2.51.2