Chess on the ATmosphere checkmate.blue
chess
18
fork

Configure Feed

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

Add game result sharing, rematch notifications, and bug fixes

Phase 5: editable share-to-Bluesky textarea with grapheme counting
after game completion. Rematch now navigates correctly by tracking
route param changes, and the opponent receives a live rematch
notification via Jetstream. Fixed stale drawOffered on resignation,
draw acceptance, and abandonment.

+219 -30
+2 -1
.gitignore
··· 8 8 /.svelte-kit 9 9 /build 10 10 11 - # OS 11 + # OS / Editors 12 12 .DS_Store 13 13 Thumbs.db 14 + .vscode 14 15 15 16 # Env 16 17 .env
-6
.vscode/extensions.json
··· 1 - { 2 - "recommendations": [ 3 - "svelte.svelte-vscode", 4 - "bradlc.vscode-tailwindcss" 5 - ] 6 - }
-5
.vscode/settings.json
··· 1 - { 2 - "files.associations": { 3 - "*.css": "tailwindcss" 4 - } 5 - }
+53
src/lib/bluesky.ts
··· 101 101 ): string { 102 102 return `I'm challenging @${opponentHandle} to a game of chess on checkmate.blue!\n\n${gameUrl}`; 103 103 } 104 + 105 + /** Compose the default text for a game result post. */ 106 + export function composeGameResultPost( 107 + myColor: 'white' | 'black', 108 + result: { result: '1-0' | '0-1' | '1/2-1/2'; reason: string }, 109 + opponentHandle: string, 110 + moveCount: number, 111 + gameUrl: string, 112 + ): string { 113 + const opponent = `@${opponentHandle}`; 114 + const moveSuffix = moveCount > 10 ? ` in ${moveCount} moves` : ''; 115 + 116 + const iWin = 117 + (result.result === '1-0' && myColor === 'white') || 118 + (result.result === '0-1' && myColor === 'black'); 119 + const isDraw = result.result === '1/2-1/2'; 120 + 121 + let text: string; 122 + if (iWin && result.reason === 'checkmate') { 123 + text = `Checkmate! I won against ${opponent} on checkmate.blue${moveSuffix}`; 124 + } else if (iWin && result.reason === 'resignation') { 125 + text = `Victory! ${opponent} resigned our game on checkmate.blue${moveSuffix}`; 126 + } else if (iWin) { 127 + text = `I won against ${opponent} on checkmate.blue${moveSuffix}`; 128 + } else if (isDraw) { 129 + const drawReasons: Record<string, string> = { 130 + stalemate: 'Stalemate', 131 + agreement: 'Draw by agreement', 132 + repetition: 'Draw by repetition', 133 + fifty_moves: 'Draw by 50-move rule', 134 + insufficient: 'Draw by insufficient material', 135 + }; 136 + const label = drawReasons[result.reason] ?? 'Draw'; 137 + text = `${label} with ${opponent} on checkmate.blue${moveSuffix}`; 138 + } else { 139 + // Loss 140 + if (result.reason === 'checkmate') { 141 + text = `Got checkmated by ${opponent} on checkmate.blue -- good game!${moveSuffix}`; 142 + } else { 143 + text = `Good game against ${opponent} on checkmate.blue${moveSuffix}`; 144 + } 145 + } 146 + 147 + return `${text}\n\n${gameUrl}`; 148 + } 149 + 150 + /** Count graphemes accurately (handles emoji, combining characters). */ 151 + export function graphemeCount(text: string): number { 152 + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); 153 + let count = 0; 154 + for (const _ of segmenter.segment(text)) count++; 155 + return count; 156 + }
+9 -4
src/lib/jetstream.ts
··· 19 19 agent?: Agent; 20 20 initialCursor?: number; 21 21 onGameUpdate: (record: Record<string, unknown>) => void; 22 + onGameCreate?: (record: Record<string, unknown>, rkey: string, did: string) => void; 22 23 onConnectionChange?: (connected: boolean) => void; 23 24 }; 24 25 ··· 73 74 if (data.kind !== 'commit') return; 74 75 const evt = data as JetstreamEvent; 75 76 if ( 76 - (evt.commit.operation === 'update' || evt.commit.operation === 'create') && 77 77 evt.commit.collection === COLLECTIONS.game && 78 78 evt.commit.record 79 79 ) { 80 - this.options.onGameUpdate(evt.commit.record); 81 - if (!this.opponentRkey) { 82 - this.opponentRkey = evt.commit.rkey; 80 + if (evt.commit.operation === 'create') { 81 + if (!this.opponentRkey) { 82 + this.opponentRkey = evt.commit.rkey; 83 + } 84 + this.options.onGameCreate?.(evt.commit.record, evt.commit.rkey, evt.did); 85 + this.options.onGameUpdate(evt.commit.record); 86 + } else if (evt.commit.operation === 'update') { 87 + this.options.onGameUpdate(evt.commit.record); 83 88 } 84 89 } 85 90 };
+155 -14
src/routes/game/[did]/[rkey]/+page.svelte
··· 19 19 import type { GameRecord } from '$lib/types'; 20 20 import { JetstreamConnection } from '$lib/jetstream'; 21 21 import { resolveIdentity } from '$lib/microcosm'; 22 - import { composeChallengePost, postToBluesky } from '$lib/bluesky'; 22 + import { composeChallengePost, composeGameResultPost, postToBluesky, graphemeCount } from '$lib/bluesky'; 23 23 import LoginButton from '$lib/components/LoginButton.svelte'; 24 24 import type { PieceSymbol } from 'chess.js'; 25 25 ··· 30 30 let loading = $state(true); 31 31 let error = $state(''); 32 32 let moveError = $state(''); 33 - let loaded = $state(false); 34 33 let isSpectator = $state(false); 35 34 let spectatorOrientation: 'white' | 'black' = $state('white'); 36 35 let jsConnections: JetstreamConnection[] = []; ··· 39 38 let gameLastActivity: string | undefined = $state(undefined); 40 39 let rematchCreating = $state(false); 41 40 let analyzingOnLichess = $state(false); 41 + let shareText = $state(''); 42 + let sharing = $state(false); 43 + let shared = $state(false); 44 + let shareError = $state(''); 45 + let dismissShare = $state(false); 46 + let rematchOffer: { did: string; rkey: string } | null = $state(null); 47 + let rematchDismissed = $state(false); 42 48 43 49 /** 44 50 * Verify an opponent's claimed game result. Returns a trusted ··· 75 81 return () => jsConnections.forEach(c => c.destroy()); 76 82 }); 77 83 78 - // Load game once auth init completes (whether logged in or not) 84 + // Load game once auth init completes, and reload on route param changes (rematch) 79 85 $effect(() => { 80 - if (!auth.isInitializing && !loaded) { 81 - loaded = true; 82 - loadGame(); 83 - } 86 + const _did = ownerDid; 87 + const _rkey = rkey; 88 + if (auth.isInitializing) return; 89 + 90 + destroyConnections(); 91 + loading = true; 92 + error = ''; 93 + moveError = ''; 94 + shareText = ''; 95 + shared = false; 96 + dismissShare = false; 97 + shareError = ''; 98 + rematchCreating = false; 99 + rematchOffer = null; 100 + rematchDismissed = false; 101 + loadGame(); 84 102 }); 85 103 86 104 async function loadGame() { ··· 319 337 opponentDid, 320 338 agent: auth.agent ?? undefined, 321 339 onGameUpdate: async (record) => { 340 + // Ignore updates once game is over (only listen for creates via onGameCreate) 341 + if (game.status === 'completed') return; 342 + 322 343 const pgn = record.pgn as string; 323 344 if (pgn && game.applyOpponentMove(pgn)) { 324 345 sound.play('notify'); ··· 367 388 } 368 389 } 369 390 }, 391 + onGameCreate: (record, newRkey, did) => { 392 + // Detect rematch: opponent created a new game where we're a player 393 + if (game.status !== 'completed') return; 394 + const white = record.white as string; 395 + const black = record.black as string; 396 + const status = record.status as string; 397 + if (status !== 'waiting') return; 398 + if (white !== auth.did && black !== auth.did) return; 399 + sound.play('notify'); 400 + rematchOffer = { did, rkey: newRkey }; 401 + }, 370 402 onConnectionChange: (isConnected) => { 371 403 connected = isConnected; 372 404 }, ··· 495 527 const result = game.myColor === 'white' ? '0-1' : '1-0'; 496 528 game.setResult(result, 'resignation'); 497 529 game.setStatus('completed'); 530 + game.clearDrawOffers(); 498 531 await updateGame(auth.agent, myRkey, { 499 532 status: 'completed', 500 533 result: result as any, 501 534 resultReason: 'resignation', 535 + drawOffered: false, 502 536 }); 503 537 } 504 538 ··· 512 546 if (!auth.agent || !auth.did || !myRkey) return; 513 547 game.setStatus('completed'); 514 548 game.setResult('1/2-1/2', 'agreement'); 549 + game.clearDrawOffers(); 515 550 const pgn = makePgn(game.chess, game.whiteDid, game.blackDid); 516 551 await updateGame(auth.agent, myRkey, { 517 552 pgn, 518 553 status: 'completed', 519 554 result: '1/2-1/2', 520 555 resultReason: 'agreement', 556 + drawOffered: false, 521 557 }); 522 558 } 523 559 ··· 534 570 async function handleRematch() { 535 571 if (!auth.agent || !auth.did) return; 536 572 rematchCreating = true; 537 - const opponentDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; 538 - const result = await createGame(auth.agent, { 539 - white: opponentDid, 540 - black: auth.did, 541 - status: 'waiting', 542 - }); 543 - goto(`/game/${auth.did}/${result.rkey}`); 573 + try { 574 + const opponentDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; 575 + const result = await createGame(auth.agent, { 576 + white: opponentDid, 577 + black: auth.did, 578 + status: 'waiting', 579 + }); 580 + goto(`/game/${auth.did}/${result.rkey}`); 581 + } catch (e) { 582 + console.error('[handleRematch] failed:', e); 583 + rematchCreating = false; 584 + } 544 585 } 545 586 546 587 function downloadPgn() { ··· 584 625 const result = game.myColor === 'white' ? '1-0' : '0-1'; 585 626 game.setStatus('completed'); 586 627 game.setResult(result as '1-0' | '0-1', 'abandonment'); 628 + game.clearDrawOffers(); 587 629 sound.play('gameEnd'); 588 630 await updateGame(auth.agent, myRkey, { 589 631 status: 'completed', 590 632 result, 591 633 resultReason: 'abandonment', 634 + drawOffered: false, 592 635 }); 593 636 } 594 637 638 + $effect(() => { 639 + if (game.result && !isSpectator && !shareText) { 640 + shareText = composeGameResultPost( 641 + game.myColor, 642 + game.result, 643 + opponentHandle ?? 'opponent', 644 + game.moveCount, 645 + gameUrl(), 646 + ); 647 + } 648 + }); 649 + 650 + async function shareResult() { 651 + if (!auth.agent || !shareText.trim()) return; 652 + sharing = true; 653 + shareError = ''; 654 + 655 + try { 656 + const opDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; 657 + const handles = new Map<string, string>(); 658 + if (opDid && opponentHandle) { 659 + handles.set(opponentHandle, opDid); 660 + } 661 + 662 + await postToBluesky( 663 + auth.agent, 664 + shareText, 665 + handles, 666 + gameUrl(), 667 + `${game.whiteHandle ?? 'White'} vs ${game.blackHandle ?? 'Black'}`, 668 + `${game.result!.result} by ${game.result!.reason}`, 669 + ); 670 + shared = true; 671 + } catch (e) { 672 + shareError = e instanceof Error ? e.message : 'Failed to post'; 673 + } finally { 674 + sharing = false; 675 + } 676 + } 677 + 595 678 function flipBoard() { 596 679 spectatorOrientation = spectatorOrientation === 'white' ? 'black' : 'white'; 597 680 } ··· 859 942 </div> 860 943 {/if} 861 944 </div> 945 + 946 + {#if rematchOffer && !rematchDismissed} 947 + <div role="alert" class="w-full max-w-md rounded-lg border border-accent-blue bg-bg-secondary p-4 text-center"> 948 + <p class="text-sm font-semibold">{opponentHandle ?? 'Your opponent'} wants a rematch!</p> 949 + <div class="mt-3 flex justify-center gap-2"> 950 + <a 951 + href="/game/{rematchOffer.did}/{rematchOffer.rkey}" 952 + class="rounded-lg bg-accent-blue px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-accent-blue-hover" 953 + > 954 + Play 955 + </a> 956 + <button 957 + onclick={() => rematchDismissed = true} 958 + class="rounded-lg border border-border px-4 py-1.5 text-sm text-text-secondary transition-colors hover:text-text-primary" 959 + > 960 + Dismiss 961 + </button> 962 + </div> 963 + </div> 964 + {/if} 965 + 966 + {#if !isSpectator && !dismissShare} 967 + {#if shared} 968 + <p class="text-sm text-success">Shared!</p> 969 + {:else} 970 + {@const count = graphemeCount(shareText)} 971 + {@const overLimit = count > 300} 972 + <div class="w-full max-w-md"> 973 + <textarea 974 + bind:value={shareText} 975 + rows="3" 976 + aria-label="Share result to Bluesky" 977 + class="w-full rounded-lg border border-border bg-bg-secondary px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:border-accent-blue focus:ring-2 focus:ring-accent-blue/50" 978 + ></textarea> 979 + <div class="mt-2 flex items-center justify-between"> 980 + <div class="flex gap-2"> 981 + <button 982 + onclick={shareResult} 983 + disabled={sharing || overLimit || !shareText.trim()} 984 + class="rounded-lg bg-accent-blue px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-accent-blue-hover disabled:opacity-50" 985 + > 986 + {sharing ? 'Posting...' : 'Share to Bluesky'} 987 + </button> 988 + <button 989 + onclick={() => dismissShare = true} 990 + class="rounded-lg border border-border px-4 py-1.5 text-sm text-text-secondary transition-colors hover:text-text-primary" 991 + > 992 + Dismiss 993 + </button> 994 + </div> 995 + <span class="text-xs {overLimit ? 'text-danger' : 'text-text-secondary'}">{count}/300</span> 996 + </div> 997 + {#if shareError} 998 + <p class="mt-2 text-sm text-danger">{shareError}</p> 999 + {/if} 1000 + </div> 1001 + {/if} 1002 + {/if} 862 1003 {:else if game.isInCheck && game.status === 'active'} 863 1004 <div role="alert" class="rounded-lg border border-warning bg-warning/10 px-4 py-2 text-center text-sm font-semibold text-warning"> 864 1005 Check!