Chess on the ATmosphere checkmate.blue
chess
13
fork

Configure Feed

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

Add challenge invite flow, spectator mode, and color selection

- Post challenge to Bluesky with @mention facet and game link
- Send via DM option (copies link, opens bsky.app/messages)
- Color selection on /play (White, Black, Random)
- Game page handles owner-as-either-color (decoupled from record ownership)
- Spectator mode: non-participants see read-only board with flip button
- Unauthenticated users can view any game via public PDS reads
- Dual Jetstream connections for spectators (one per player)
- Check-before-join guard to reduce race condition on open games
- Public agent helpers (getGamePublic, findGameRecordByParentPublic)
- challengerColor field added to challenge lexicon
- Fixed all pre-existing type errors (oauth, Board, route params)
- 9 new tests for bluesky facet construction

authored by

Scott Hadfield and committed by tangled.org fa6f9542 b4f3377e

+605 -133
+5
lexicons/blue.checkmate.challenge.json
··· 28 28 "type": "string", 29 29 "knownValues": ["open", "accepted", "expired", "cancelled"], 30 30 "description": "Current challenge status" 31 + }, 32 + "challengerColor": { 33 + "type": "string", 34 + "knownValues": ["white", "black"], 35 + "description": "Color the challenger will play" 31 36 } 32 37 } 33 38 }
+44 -1
src/lib/atproto.ts
··· 28 28 29 29 export async function createGame( 30 30 agent: Agent, 31 - options: { white: string; black?: string; status?: GameRecord['status']; parentGameUri?: string } 31 + options: { white?: string; black?: string; status?: GameRecord['status']; parentGameUri?: string } 32 32 ): Promise<{ uri: string; rkey: string }> { 33 33 const record: GameRecord = { 34 34 $type: 'blue.checkmate.game', ··· 98 98 return response.data.value as unknown as GameRecord; 99 99 } catch (e) { 100 100 console.error('getGame failed:', did, rkey, e); 101 + return null; 102 + } 103 + } 104 + 105 + export async function getGamePublic( 106 + did: string, 107 + rkey: string 108 + ): Promise<GameRecord | null> { 109 + try { 110 + const publicAgent = await getPublicAgent(did); 111 + const response = await publicAgent.com.atproto.repo.getRecord({ 112 + repo: did, 113 + collection: COLLECTIONS.game, 114 + rkey, 115 + }); 116 + return response.data.value as unknown as GameRecord; 117 + } catch (e) { 118 + console.error('getGamePublic failed:', did, rkey, e); 119 + return null; 120 + } 121 + } 122 + 123 + export async function findGameRecordByParentPublic( 124 + did: string, 125 + parentGameUri: string 126 + ): Promise<{ rkey: string; record: GameRecord } | null> { 127 + try { 128 + const publicAgent = await getPublicAgent(did); 129 + const response = await publicAgent.com.atproto.repo.listRecords({ 130 + repo: did, 131 + collection: COLLECTIONS.game, 132 + limit: 100, 133 + }); 134 + 135 + for (const rec of response.data.records) { 136 + const value = rec.value as unknown as GameRecord; 137 + if (value.parentGameUri === parentGameUri) { 138 + return { rkey: rec.uri.split('/').pop()!, record: value }; 139 + } 140 + } 141 + return null; 142 + } catch (e) { 143 + console.error('findGameRecordByParentPublic failed:', did, e); 101 144 return null; 102 145 } 103 146 }
+103
src/lib/bluesky.ts
··· 1 + import type { Agent } from '@atproto/api'; 2 + 3 + interface Facet { 4 + index: { byteStart: number; byteEnd: number }; 5 + features: Array< 6 + | { $type: 'app.bsky.richtext.facet#mention'; did: string } 7 + | { $type: 'app.bsky.richtext.facet#link'; uri: string } 8 + >; 9 + } 10 + 11 + const encoder = new TextEncoder(); 12 + 13 + /** Compute the byte offset of a substring within a string (UTF-8). */ 14 + function byteOffset(text: string, charIndex: number): number { 15 + return encoder.encode(text.slice(0, charIndex)).byteLength; 16 + } 17 + 18 + /** Build facets for @mentions and URLs in post text. */ 19 + export function buildFacets( 20 + text: string, 21 + knownHandles: Map<string, string>, 22 + ): Facet[] { 23 + const facets: Facet[] = []; 24 + 25 + // Detect @mentions 26 + const mentionRe = /(^|[\s(])@([a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,}))/g; 27 + let match; 28 + while ((match = mentionRe.exec(text)) !== null) { 29 + const handle = match[2]; 30 + const did = knownHandles.get(handle); 31 + if (!did) continue; 32 + 33 + const mentionStart = match.index + match[1].length; 34 + const mentionText = `@${handle}`; 35 + facets.push({ 36 + index: { 37 + byteStart: byteOffset(text, mentionStart), 38 + byteEnd: byteOffset(text, mentionStart + mentionText.length), 39 + }, 40 + features: [{ $type: 'app.bsky.richtext.facet#mention', did }], 41 + }); 42 + } 43 + 44 + // Detect URLs 45 + const urlRe = /https?:\/\/[^\s)]+/g; 46 + while ((match = urlRe.exec(text)) !== null) { 47 + facets.push({ 48 + index: { 49 + byteStart: byteOffset(text, match.index), 50 + byteEnd: byteOffset(text, match.index + match[0].length), 51 + }, 52 + features: [{ $type: 'app.bsky.richtext.facet#link', uri: match[0] }], 53 + }); 54 + } 55 + 56 + return facets; 57 + } 58 + 59 + /** Post a Bluesky post with auto-detected facets for mentions and links. */ 60 + export async function postToBluesky( 61 + agent: Agent, 62 + text: string, 63 + knownHandles: Map<string, string>, 64 + embedUrl?: string, 65 + embedTitle?: string, 66 + embedDescription?: string, 67 + ): Promise<{ uri: string; cid: string }> { 68 + const facets = buildFacets(text, knownHandles); 69 + 70 + const record: Record<string, unknown> = { 71 + $type: 'app.bsky.feed.post', 72 + text, 73 + facets, 74 + createdAt: new Date().toISOString(), 75 + }; 76 + 77 + if (embedUrl) { 78 + record.embed = { 79 + $type: 'app.bsky.embed.external', 80 + external: { 81 + uri: embedUrl, 82 + title: embedTitle ?? 'checkmate.blue', 83 + description: embedDescription ?? 'Chess on the Atmosphere', 84 + }, 85 + }; 86 + } 87 + 88 + const response = await agent.com.atproto.repo.createRecord({ 89 + repo: agent.assertDid, 90 + collection: 'app.bsky.feed.post', 91 + record, 92 + }); 93 + 94 + return { uri: response.data.uri, cid: response.data.cid }; 95 + } 96 + 97 + /** Compose the default text for a challenge post. */ 98 + export function composeChallengePost( 99 + opponentHandle: string, 100 + gameUrl: string, 101 + ): string { 102 + return `I'm challenging @${opponentHandle} to a game of chess on checkmate.blue!\n\n${gameUrl}`; 103 + }
+4 -3
src/lib/components/Board.svelte
··· 3 3 import { Chessground } from '@lichess-org/chessground'; 4 4 import type { Api as CgApi } from '@lichess-org/chessground/api'; 5 5 import type { Config as CgConfig } from '@lichess-org/chessground/config'; 6 + import type { Key } from '@lichess-org/chessground/types'; 6 7 import type { Dests } from '$lib/game-logic'; 7 8 8 9 type Props = { ··· 10 11 orientation?: 'white' | 'black'; 11 12 turnColor?: 'white' | 'black'; 12 13 dests?: Dests; 13 - lastMove?: [string, string]; 14 + lastMove?: [Key, Key]; 14 15 viewOnly?: boolean; 15 16 onmove?: (orig: string, dest: string) => void; 16 17 }; ··· 42 43 fen, 43 44 orientation, 44 45 turnColor, 45 - lastMove: lastMove as [string, string] | undefined, 46 + lastMove, 46 47 viewOnly, 47 48 movable: { 48 49 free: false, 49 50 color: viewOnly ? undefined : orientation, 50 - dests: dests as Map<string, string[]> | undefined, 51 + dests, 51 52 showDests: true, 52 53 }, 53 54 events: {
+5 -4
src/lib/game-logic.ts
··· 1 1 import { Chess, SQUARES, type Square, type PieceSymbol } from 'chess.js'; 2 + import type { Key } from '@lichess-org/chessground/types'; 2 3 3 - export type Dests = Map<string, string[]>; 4 + export type Dests = Map<Key, Key[]>; 4 5 5 6 export function toDests(chess: Chess): Dests { 6 7 const dests: Dests = new Map(); 7 8 for (const s of SQUARES) { 8 9 const moves = chess.moves({ square: s, verbose: true }); 9 10 if (moves.length) { 10 - dests.set(s, moves.map((m) => m.to)); 11 + dests.set(s as Key, moves.map((m) => m.to as Key)); 11 12 } 12 13 } 13 14 return dests; ··· 32 33 return chess.turn() === 'w' ? 'white' : 'black'; 33 34 } 34 35 35 - export function lastMoveSquares(chess: Chess): [string, string] | undefined { 36 + export function lastMoveSquares(chess: Chess): [Key, Key] | undefined { 36 37 const history = chess.history({ verbose: true }); 37 38 if (history.length === 0) return undefined; 38 39 const last = history[history.length - 1]; 39 - return [last.from, last.to]; 40 + return [last.from as Key, last.to as Key]; 40 41 } 41 42 42 43 export function gameResult(chess: Chess): {
+5 -5
src/lib/oauth.ts
··· 6 6 const isProd = typeof window !== 'undefined' && window.location.hostname === 'checkmate.blue'; 7 7 8 8 const prodMetadata = { 9 - client_id: 'https://checkmate.blue/oauth/client-metadata.json' as const, 9 + client_id: 'https://checkmate.blue/oauth/client-metadata.json', 10 10 client_name: 'checkmate.blue', 11 - client_uri: 'https://checkmate.blue' as const, 12 - redirect_uris: ['https://checkmate.blue/oauth/callback'] as const, 11 + client_uri: 'https://checkmate.blue', 12 + redirect_uris: ['https://checkmate.blue/oauth/callback'] as [string], 13 13 scope: SCOPE, 14 - grant_types: ['authorization_code', 'refresh_token'] as const, 15 - response_types: ['code'] as const, 14 + grant_types: ['authorization_code', 'refresh_token'] as ['authorization_code', 'refresh_token'], 15 + response_types: ['code'] as ['code'], 16 16 token_endpoint_auth_method: 'none' as const, 17 17 application_type: 'web' as const, 18 18 dpop_bound_access_tokens: true,
+1
src/lib/types.ts
··· 28 28 opponent?: string; 29 29 gameUri?: string; 30 30 status: 'open' | 'accepted' | 'expired' | 'cancelled'; 31 + challengerColor?: 'white' | 'black'; 31 32 } 32 33 33 34 export type PlayerColor = 'white' | 'black';
+2 -2
src/routes/challenge/[did]/[rkey]/+page.svelte
··· 7 7 import { resolveIdentity } from '$lib/microcosm'; 8 8 import type { ChallengeRecord } from '$lib/types'; 9 9 10 - const challengerDid = $derived($page.params.did); 11 - const rkey = $derived($page.params.rkey); 10 + const challengerDid = $derived($page.params.did!); 11 + const rkey = $derived($page.params.rkey!); 12 12 13 13 let challenge: ChallengeRecord | null = $state(null); 14 14 let challengerHandle = $state('');
+270 -109
src/routes/game/[did]/[rkey]/+page.svelte
··· 8 8 import PromotionModal from '$lib/components/PromotionModal.svelte'; 9 9 import { game } from '$lib/stores/game.svelte'; 10 10 import { auth } from '$lib/stores/auth.svelte'; 11 - import { getGame, updateGame, createGame, findGameRecordByParent } from '$lib/atproto'; 11 + import { 12 + getGame, getGamePublic, updateGame, createGame, 13 + findGameRecordByParent, findGameRecordByParentPublic, 14 + } from '$lib/atproto'; 12 15 import { makePgn } from '$lib/game-logic'; 13 16 import { JetstreamConnection } from '$lib/jetstream'; 14 17 import { resolveIdentity } from '$lib/microcosm'; 18 + import { composeChallengePost, postToBluesky } from '$lib/bluesky'; 15 19 import LoginButton from '$lib/components/LoginButton.svelte'; 16 20 import type { PieceSymbol } from 'chess.js'; 17 21 18 - const ownerDid = $derived($page.params.did); 19 - const rkey = $derived($page.params.rkey); 22 + const ownerDid = $derived($page.params.did!); 23 + const rkey = $derived($page.params.rkey!); 20 24 21 25 let myRkey: string | undefined = $state(undefined); 22 26 let loading = $state(true); 23 27 let error = $state(''); 24 28 let moveError = $state(''); 25 29 let loaded = $state(false); 26 - let jsConnection: JetstreamConnection | null = null; 30 + let isSpectator = $state(false); 31 + let spectatorOrientation: 'white' | 'black' = $state('white'); 32 + let jsConnections: JetstreamConnection[] = []; 27 33 let connected = $state(false); 28 34 let lastPersistedPgn = ''; 29 35 30 36 onMount(() => { 31 - return () => jsConnection?.destroy(); 37 + return () => jsConnections.forEach(c => c.destroy()); 32 38 }); 33 39 34 - // Load game once auth is ready 40 + // Load game once auth init completes (whether logged in or not) 35 41 $effect(() => { 36 42 if (!auth.isInitializing && !loaded) { 37 - if (auth.isLoggedIn) { 38 - loaded = true; 39 - loadGame(); 40 - } else { 41 - loading = false; 42 - } 43 + loaded = true; 44 + loadGame(); 43 45 } 44 46 }); 45 47 46 48 async function loadGame() { 47 - if (!auth.agent) { 48 - loading = false; 49 - return; 50 - } 49 + // Read the canonical record -- use authenticated agent if available, public otherwise 50 + const record = auth.agent 51 + ? await getGame(auth.agent, ownerDid, rkey) 52 + : await getGamePublic(ownerDid, rkey); 51 53 52 - const record = await getGame(auth.agent, ownerDid, rkey); 53 54 if (!record) { 54 55 error = 'Game not found'; 55 56 loading = false; 56 57 return; 57 58 } 58 59 59 - const isWhite = record.white === auth.did; 60 - const isBlack = record.black === auth.did; 61 - const isOwner = ownerDid === auth.did; 60 + const isWhite = auth.did ? record.white === auth.did : false; 61 + const isBlack = auth.did ? record.black === auth.did : false; 62 + const isOwner = auth.did ? ownerDid === auth.did : false; 63 + const emptySlot = !record.white ? 'white' : !record.black ? 'black' : null; 64 + 65 + // Can this user join? Only if logged in, game is waiting, and there's an open slot 66 + let canJoin = auth.isLoggedIn && record.status === 'waiting' && emptySlot !== null && !isWhite && !isBlack; 67 + 68 + // Check-before-join (Option A): re-fetch to narrow the race window 69 + if (canJoin) { 70 + const fresh = auth.agent 71 + ? await getGame(auth.agent, ownerDid, rkey) 72 + : await getGamePublic(ownerDid, rkey); 73 + if (fresh) { 74 + const freshEmpty = !fresh.white ? 'white' : !fresh.black ? 'black' : null; 75 + if (!freshEmpty || fresh.status !== 'waiting') { 76 + canJoin = false; 77 + } 78 + } 79 + } 62 80 63 - // Determine my color 64 81 let myColor: 'white' | 'black'; 65 82 if (isWhite) { 66 83 myColor = 'white'; 67 84 } else if (isBlack) { 68 85 myColor = 'black'; 69 - } else if (record.status === 'waiting' && !record.black) { 70 - // Joining as black 71 - myColor = 'black'; 86 + } else if (canJoin && emptySlot) { 87 + myColor = emptySlot; 72 88 } else { 73 - // Spectator -- view only 74 89 myColor = 'white'; 75 90 } 76 91 92 + const isParticipant = isWhite || isBlack || canJoin; 93 + isSpectator = !isParticipant; 94 + 77 95 game.init({ 78 96 pgn: record.pgn || undefined, 79 97 myColor, 80 98 whiteDid: record.white, 81 99 blackDid: record.black, 82 - status: record.status === 'waiting' && myColor === 'white' ? 'waiting' : record.status, 100 + status: record.status === 'waiting' && isOwner ? 'waiting' : record.status, 83 101 }); 84 102 85 - // Find the paired (Black's) record and reconcile state. 86 - // Each player's record is one move behind 50% of the time, 87 - // so we read both and use the longer PGN. 103 + // Reconcile state by reading both records 88 104 const parentUri = `at://${ownerDid}/blue.checkmate.game/${rkey}`; 89 - let blackDid = record.black; 90 105 91 - if (isOwner) { 92 - // I'm White -- find Black's record for latest state 106 + if (isSpectator) { 107 + // Spectator: read both records for most up-to-date PGN 108 + await reconcileSpectator(record, parentUri); 109 + connectJetstreamSpectator(record.white, record.black); 110 + } else if (isOwner) { 111 + const opponentDid = myColor === 'white' ? record.black : record.white; 93 112 myRkey = rkey; 94 - if (blackDid) { 95 - const blackResult = await findGameRecordByParent(auth.agent, blackDid, parentUri); 96 - if (blackResult?.record.pgn) { 97 - game.applyOpponentMove(blackResult.record.pgn); 113 + if (opponentDid) { 114 + const opponentResult = await findGameRecordByParent(auth.agent!, opponentDid, parentUri); 115 + if (opponentResult?.record.pgn) { 116 + game.applyOpponentMove(opponentResult.record.pgn); 98 117 } 99 118 } 100 - } else if (myColor === 'black') { 101 - // I'm Black -- find my own record (also has potentially newer PGN) 102 - const myResult = await findGameRecordByParent(auth.agent, auth.did!, parentUri); 119 + } else if (isParticipant) { 120 + const myResult = await findGameRecordByParent(auth.agent!, auth.did!, parentUri); 103 121 if (myResult) { 104 122 myRkey = myResult.rkey; 105 123 if (myResult.record.pgn) { 106 124 game.applyOpponentMove(myResult.record.pgn); 107 125 } 108 126 } else { 109 - await joinAsBlack(record); 127 + await joinGame(record, myColor); 110 128 } 111 129 } 112 130 113 131 lastPersistedPgn = game.pgn; 114 132 115 - // Resolve display handles (fire-and-forget, UI updates reactively) 116 133 resolvePlayerHandles(record.white, record.black); 117 134 118 - // Connect Jetstream for opponent moves 119 - if (myColor === 'white') { 120 - if (blackDid && game.status === 'active') { 121 - connectJetstream(blackDid); 122 - } else { 135 + // Jetstream for participants 136 + if (isParticipant && !isSpectator) { 137 + const opponentDid = myColor === 'white' ? record.black : record.white; 138 + if (opponentDid && game.status === 'active') { 139 + connectJetstream(opponentDid); 140 + } else if (isOwner) { 123 141 waitForOpponent(); 124 142 } 125 - } else if (myColor === 'black' && record.white) { 126 - connectJetstream(record.white); 127 143 } 128 144 129 145 loading = false; 130 146 } 131 147 148 + async function reconcileSpectator(record: any, parentUri: string) { 149 + // Find the non-owner's child record and use the longer PGN 150 + const nonOwnerDid = record.white === ownerDid ? record.black : record.white; 151 + if (!nonOwnerDid) return; 152 + 153 + const childResult = await findGameRecordByParentPublic(nonOwnerDid, parentUri); 154 + if (childResult?.record.pgn) { 155 + game.applyOpponentMove(childResult.record.pgn); 156 + } 157 + } 158 + 132 159 function waitForOpponent() { 133 - // Listen to Jetstream for any blue.checkmate.game creates that reference our DID. 134 - // Without a DID filter this subscribes to the full game collection firehose. 135 - // This is an architectural limitation of the no-server design: we can't know 136 - // the opponent's DID until they join. The callback filters to relevant events. 137 - jsConnection?.destroy(); 138 - jsConnection = new JetstreamConnection({ 160 + destroyConnections(); 161 + const js = new JetstreamConnection({ 139 162 opponentDid: '', 140 163 agent: auth.agent ?? undefined, 141 164 onGameUpdate: async (record) => { 142 165 const white = record.white as string; 143 166 const black = record.black as string; 144 - // Someone created a game record where we're white 145 - if (white === auth.did && black && black !== auth.did) { 146 - // Found our opponent! 167 + const weAreWhite = white === auth.did && black && black !== auth.did; 168 + const weAreBlack = black === auth.did && white && white !== auth.did; 169 + if (weAreWhite || weAreBlack) { 170 + const myColor = weAreWhite ? 'white' as const : 'black' as const; 171 + const opponentDid = weAreWhite ? black : white; 172 + 147 173 game.setStatus('active'); 148 174 game.init({ 149 175 pgn: (record.pgn as string) || undefined, 150 - myColor: 'white' as const, 176 + myColor, 151 177 whiteDid: white, 152 178 blackDid: black, 153 179 status: 'active' as const, 154 180 }); 155 - // Persist Black's DID and active status to our record so it survives reload 156 181 if (auth.agent && myRkey) { 157 - await updateGame(auth.agent, myRkey, { black, status: 'active' }); 182 + const updates: Record<string, unknown> = { status: 'active' }; 183 + if (weAreWhite) updates.black = black; 184 + else updates.white = white; 185 + await updateGame(auth.agent, myRkey, updates); 158 186 } 159 - // Resolve opponent handle and reconnect Jetstream 160 187 resolvePlayerHandles(white, black); 161 - connectJetstream(black); 188 + connectJetstream(opponentDid); 162 189 } 163 190 }, 164 191 onConnectionChange: (isConnected) => { 165 192 connected = isConnected; 166 193 }, 167 194 }); 168 - jsConnection.connect(); 195 + jsConnections = [js]; 196 + js.connect(); 169 197 } 170 198 171 199 function connectJetstream(opponentDid: string) { 172 - jsConnection?.destroy(); 173 - jsConnection = new JetstreamConnection({ 200 + destroyConnections(); 201 + const js = new JetstreamConnection({ 174 202 opponentDid, 175 203 agent: auth.agent ?? undefined, 176 204 onGameUpdate: (record) => { 177 205 const pgn = record.pgn as string; 178 206 if (pgn) { 179 - const applied = game.applyOpponentMove(pgn); 180 - console.log('[game] opponent update:', applied ? 'new move applied' : 'no new moves'); 207 + game.applyOpponentMove(pgn); 208 + } 209 + const status = record.status as string; 210 + if (status === 'completed') { 211 + game.setStatus('completed'); 212 + } 213 + }, 214 + onConnectionChange: (isConnected) => { 215 + connected = isConnected; 216 + }, 217 + }); 218 + jsConnections = [js]; 219 + js.connect(); 220 + } 221 + 222 + function connectJetstreamSpectator(whiteDid?: string, blackDid?: string) { 223 + destroyConnections(); 224 + const dids = [whiteDid, blackDid].filter(Boolean) as string[]; 225 + let connectedCount = 0; 181 226 182 - // Check if opponent resigned or game ended 227 + const conns = dids.map(did => { 228 + const js = new JetstreamConnection({ 229 + opponentDid: did, 230 + onGameUpdate: (record) => { 231 + const pgn = record.pgn as string; 232 + if (pgn) { 233 + game.applyOpponentMove(pgn); 234 + } 183 235 const status = record.status as string; 184 236 if (status === 'completed') { 185 237 game.setStatus('completed'); 186 238 } 187 - } 188 - }, 189 - onConnectionChange: (isConnected) => { 190 - connected = isConnected; 191 - }, 239 + }, 240 + onConnectionChange: (isConnected) => { 241 + connectedCount += isConnected ? 1 : -1; 242 + connected = connectedCount > 0; 243 + }, 244 + }); 245 + js.connect(); 246 + return js; 192 247 }); 193 - jsConnection.connect(); 248 + jsConnections = conns; 249 + } 250 + 251 + function destroyConnections() { 252 + jsConnections.forEach(c => c.destroy()); 253 + jsConnections = []; 194 254 } 195 255 196 - async function joinAsBlack(record: any) { 256 + async function joinGame(record: any, myColor: 'white' | 'black') { 197 257 if (!auth.agent || !auth.did) return; 198 258 199 259 const parentUri = `at://${ownerDid}/blue.checkmate.game/${rkey}`; 260 + const white = myColor === 'white' ? auth.did : record.white; 261 + const black = myColor === 'black' ? auth.did : record.black; 262 + 200 263 const result = await createGame(auth.agent, { 201 - white: record.white, 202 - black: auth.did, 264 + white, 265 + black, 203 266 status: 'active', 204 267 parentGameUri: parentUri, 205 268 }); ··· 272 335 await updateGame(auth.agent, myRkey, { drawOffered: true }); 273 336 } 274 337 338 + function flipBoard() { 339 + spectatorOrientation = spectatorOrientation === 'white' ? 'black' : 'white'; 340 + } 341 + 275 342 let copied = $state(false); 343 + let posting = $state(false); 344 + let posted = $state(false); 345 + let postError = $state(''); 346 + let dmCopied = $state(false); 347 + 348 + function gameUrl(): string { 349 + return `${window.location.origin}/game/${ownerDid}/${rkey}`; 350 + } 276 351 277 352 function copyGameLink() { 278 - const url = `${window.location.origin}/game/${ownerDid}/${rkey}`; 279 - navigator.clipboard.writeText(url); 353 + navigator.clipboard.writeText(gameUrl()); 280 354 copied = true; 281 355 setTimeout(() => copied = false, 2000); 282 356 } 283 357 358 + function sendViaDm() { 359 + navigator.clipboard.writeText(gameUrl()); 360 + dmCopied = true; 361 + setTimeout(() => dmCopied = false, 4000); 362 + window.open('https://bsky.app/messages', '_blank'); 363 + } 364 + 365 + async function postChallengeToBluesky() { 366 + if (!auth.agent || !opponentHandle || posted) return; 367 + posting = true; 368 + postError = ''; 369 + 370 + const opponentDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; 371 + const text = composeChallengePost(opponentHandle, gameUrl()); 372 + const handles = new Map<string, string>(); 373 + if (opponentDid && opponentHandle) { 374 + handles.set(opponentHandle, opponentDid); 375 + } 376 + 377 + try { 378 + await postToBluesky( 379 + auth.agent, 380 + text, 381 + handles, 382 + gameUrl(), 383 + 'Chess Challenge on checkmate.blue', 384 + `${auth.handle} wants to play chess!`, 385 + ); 386 + posted = true; 387 + } catch (e) { 388 + postError = e instanceof Error ? e.message : 'Failed to post'; 389 + } finally { 390 + posting = false; 391 + } 392 + } 393 + 284 394 const isWaiting = $derived(game.status === 'waiting'); 395 + const boardOrientation = $derived(isSpectator ? spectatorOrientation : game.myColor); 285 396 286 397 const opponentHandle = $derived( 287 398 game.myColor === 'white' ? game.blackHandle : game.whiteHandle ··· 295 406 <div class="flex min-h-screen items-center justify-center"> 296 407 <p class="text-text-secondary">Loading game...</p> 297 408 </div> 298 - {:else if !auth.isLoggedIn} 299 - <div class="flex min-h-screen flex-col items-center justify-center gap-6 p-4"> 300 - <div class="text-center"> 301 - <h1 class="text-2xl font-bold">You've been challenged!</h1> 302 - <p class="mt-2 text-text-secondary">Sign in with your Bluesky account to play.</p> 303 - </div> 304 - <div class="w-full max-w-sm"> 305 - <LoginButton /> 306 - </div> 307 - </div> 308 409 {:else if error} 309 - <div class="flex min-h-screen items-center justify-center"> 410 + <div class="flex min-h-screen flex-col items-center justify-center gap-4"> 310 411 <p class="text-danger">{error}</p> 412 + <a href="/" class="text-accent-blue hover:underline">Go home</a> 311 413 </div> 312 414 {:else} 313 415 <div class="flex min-h-screen flex-col items-center justify-center gap-4 p-4"> 314 - {#if isWaiting} 416 + {#if isWaiting && !isSpectator} 315 417 <div class="w-full max-w-md rounded-lg border border-border bg-bg-secondary p-6 text-center"> 316 418 <p class="text-lg font-semibold">Waiting for opponent</p> 317 419 <p class="mt-2 text-sm text-text-secondary">Share this link to invite someone:</p> ··· 319 421 <input 320 422 type="text" 321 423 readonly 322 - value="{window.location.origin}/game/{ownerDid}/{rkey}" 424 + value={gameUrl()} 323 425 class="flex-1 rounded-lg border border-border bg-bg-primary px-3 py-2 font-mono text-xs text-text-primary" 324 426 /> 325 427 <button ··· 329 431 {copied ? 'Copied!' : 'Copy'} 330 432 </button> 331 433 </div> 434 + {#if opponentHandle && opponentHandle !== 'Waiting...'} 435 + <div class="mt-4 flex flex-col items-center gap-2"> 436 + <div class="flex gap-2"> 437 + {#if posted} 438 + <span class="rounded-lg border border-success px-4 py-2 text-sm text-success">Posted!</span> 439 + {:else} 440 + <button 441 + onclick={postChallengeToBluesky} 442 + disabled={posting} 443 + class="rounded-lg border border-accent-blue px-4 py-2 text-sm font-semibold text-accent-blue transition-colors hover:bg-accent-blue hover:text-white disabled:opacity-50" 444 + > 445 + {posting ? 'Posting...' : 'Post to Bluesky'} 446 + </button> 447 + {/if} 448 + <button 449 + onclick={sendViaDm} 450 + class="rounded-lg border border-border px-4 py-2 text-sm text-text-secondary transition-colors hover:border-accent-blue hover:text-text-primary" 451 + > 452 + {dmCopied ? 'Link copied! Paste in DM' : 'Send via DM'} 453 + </button> 454 + </div> 455 + {#if postError} 456 + <p class="text-sm text-danger">{postError}</p> 457 + {/if} 458 + </div> 459 + {/if} 332 460 </div> 333 461 {/if} 334 462 335 - <PlayerBar handle={opponentHandle ?? 'Waiting...'} isActive={!game.isMyTurn && game.status === 'active'} /> 463 + {#if isSpectator && isWaiting} 464 + <div class="rounded-lg bg-bg-secondary px-4 py-2 text-sm text-text-secondary"> 465 + Waiting for game to start 466 + </div> 467 + {/if} 468 + 469 + <PlayerBar 470 + handle={boardOrientation === 'white' ? (game.blackHandle ?? 'Black') : (game.whiteHandle ?? 'White')} 471 + isActive={boardOrientation === 'white' ? game.turnColor === 'black' : game.turnColor === 'white'} 472 + /> 336 473 337 474 <Board 338 475 fen={game.fen} 339 - orientation={game.myColor} 476 + orientation={boardOrientation} 340 477 turnColor={game.turnColor} 341 - dests={game.isMyTurn ? game.dests : undefined} 478 + dests={!isSpectator && game.isMyTurn ? game.dests : undefined} 342 479 lastMove={game.lastMove} 343 - viewOnly={game.status === 'completed'} 480 + viewOnly={isSpectator || game.status === 'completed'} 344 481 onmove={handleMove} 345 482 /> 346 483 347 - <PlayerBar handle={myHandle ?? 'You'} isActive={game.isMyTurn} /> 484 + <PlayerBar 485 + handle={boardOrientation === 'white' ? (game.whiteHandle ?? 'White') : (game.blackHandle ?? 'Black')} 486 + isActive={boardOrientation === 'white' ? game.turnColor === 'white' : game.turnColor === 'black'} 487 + /> 488 + 489 + {#if isSpectator} 490 + <div class="flex items-center gap-3"> 491 + <span class="rounded-lg bg-bg-secondary px-3 py-1 text-sm text-text-secondary"> 492 + Spectating 493 + </span> 494 + <button 495 + onclick={flipBoard} 496 + class="rounded-lg border border-border px-3 py-1 text-sm text-text-secondary transition-colors hover:text-text-primary" 497 + > 498 + Flip board 499 + </button> 500 + </div> 501 + {/if} 502 + 503 + {#if !isSpectator && !auth.isLoggedIn} 504 + <div class="w-full max-w-sm rounded-lg border border-border bg-bg-secondary p-4 text-center"> 505 + <p class="mb-3 text-sm text-text-secondary">Sign in to play</p> 506 + <LoginButton /> 507 + </div> 508 + {/if} 348 509 349 510 {#if game.result} 350 511 <div class="rounded-lg bg-bg-secondary p-4 text-center"> ··· 367 528 </div> 368 529 {/if} 369 530 370 - <GameControls 371 - gameOver={game.result !== null || game.status === 'completed'} 372 - onresign={handleResign} 373 - ondrawoffer={handleDrawOffer} 374 - /> 531 + {#if !isSpectator} 532 + <GameControls 533 + gameOver={game.result !== null || game.status === 'completed'} 534 + onresign={handleResign} 535 + ondrawoffer={handleDrawOffer} 536 + /> 537 + {/if} 375 538 376 539 <MoveList chess={game.chess} /> 377 540 378 - {#if auth.isLoggedIn} 379 - <div class="flex items-center gap-2 text-xs text-text-secondary"> 380 - <span class="inline-block h-2 w-2 rounded-full" class:bg-success={connected} class:bg-danger={!connected}></span> 381 - {connected ? 'Connected' : 'Reconnecting...'} 382 - </div> 383 - {/if} 541 + <div class="flex items-center gap-2 text-xs text-text-secondary"> 542 + <span class="inline-block h-2 w-2 rounded-full" class:bg-success={connected} class:bg-danger={!connected}></span> 543 + {connected ? 'Live' : 'Reconnecting...'} 544 + </div> 384 545 </div> 385 546 386 - {#if game.pendingPromotion} 547 + {#if game.pendingPromotion && !isSpectator} 387 548 <PromotionModal color={game.myColor} onselect={handlePromotion} /> 388 549 {/if} 389 550 {/if}
+41 -8
src/routes/play/+page.svelte
··· 5 5 import { resolveIdentity } from '$lib/microcosm'; 6 6 7 7 let opponentHandle = $state(''); 8 + let colorChoice: 'white' | 'black' | 'random' = $state('white'); 8 9 let isCreating = $state(false); 9 10 let error = $state(''); 10 11 12 + function resolveColors(myDid: string, opponentDid?: string): { white?: string; black?: string } { 13 + const choice = colorChoice === 'random' 14 + ? (Math.random() < 0.5 ? 'white' : 'black') 15 + : colorChoice; 16 + 17 + if (choice === 'white') { 18 + return { white: myDid, black: opponentDid }; 19 + } 20 + return { white: opponentDid, black: myDid }; 21 + } 22 + 11 23 async function handleCreateGame() { 12 24 if (!auth.agent || !auth.did) return; 13 25 isCreating = true; 14 26 error = ''; 15 27 16 28 try { 17 - // Resolve opponent if specified 18 29 let opponentDid: string | undefined; 19 30 if (opponentHandle.trim()) { 20 31 const profile = await resolveIdentity(opponentHandle.trim()); ··· 26 37 opponentDid = profile.did; 27 38 } 28 39 29 - // Create the game record (status: waiting) 40 + const { white, black } = resolveColors(auth.did, opponentDid); 41 + 30 42 const gameResult = await createGame(auth.agent, { 31 - white: auth.did, 32 - black: opponentDid, 33 - status: opponentDid ? 'waiting' : 'waiting', 43 + white, 44 + black, 45 + status: 'waiting', 34 46 }); 35 47 36 - // Create a challenge record pointing to the game 48 + const challengerColor: 'white' | 'black' = colorChoice === 'random' 49 + ? (white === auth.did ? 'white' : 'black') 50 + : colorChoice; 51 + 37 52 const challengeResult = await createChallenge(auth.agent, { 38 53 opponent: opponentDid, 39 54 }); 40 55 41 - // Update challenge with game URI 42 56 await updateChallenge(auth.agent, challengeResult.rkey, { 43 57 gameUri: gameResult.uri, 44 58 status: 'open', 59 + challengerColor, 45 60 }); 46 61 47 - // Navigate to the game page 48 62 goto(`/game/${auth.did}/${gameResult.rkey}`); 49 63 } catch (e) { 50 64 error = e instanceof Error ? e.message : 'Failed to create game'; ··· 70 84 placeholder="Opponent handle (optional)" 71 85 class="rounded-lg border border-border bg-bg-secondary px-4 py-2 text-text-primary placeholder:text-text-secondary focus:border-accent-blue focus:outline-none" 72 86 /> 87 + 88 + <fieldset class="flex gap-2"> 89 + <legend class="mb-2 text-sm text-text-secondary">Play as</legend> 90 + {#each [['white', 'White'], ['black', 'Black'], ['random', 'Random']] as [value, label]} 91 + <label 92 + class="flex-1 cursor-pointer rounded-lg border px-3 py-2 text-center text-sm transition-colors {colorChoice === value ? 'border-accent-blue bg-accent-blue/10 text-accent-blue' : 'border-border text-text-secondary hover:text-text-primary'}" 93 + > 94 + <input 95 + type="radio" 96 + name="color" 97 + {value} 98 + checked={colorChoice === value} 99 + onchange={() => colorChoice = value as typeof colorChoice} 100 + class="sr-only" 101 + /> 102 + {label} 103 + </label> 104 + {/each} 105 + </fieldset> 73 106 74 107 <button 75 108 type="submit"
+1 -1
src/routes/profile/[handle]/+page.svelte
··· 3 3 import { onMount } from 'svelte'; 4 4 import { resolveIdentity, findGamesForPlayer, type ConstellationLink, type SlingshotProfile } from '$lib/microcosm'; 5 5 6 - const handle = $derived($page.params.handle); 6 + const handle = $derived($page.params.handle!); 7 7 8 8 let profile: SlingshotProfile | null = $state(null); 9 9 let games: ConstellationLink[] = $state([]);
+124
tests/lib/bluesky.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { buildFacets, composeChallengePost } from '$lib/bluesky'; 3 + 4 + const encoder = new TextEncoder(); 5 + 6 + describe('buildFacets', () => { 7 + it('detects a mention with a known handle', () => { 8 + const text = 'Hello @alice.bsky.social!'; 9 + const handles = new Map([['alice.bsky.social', 'did:plc:alice']]); 10 + const facets = buildFacets(text, handles); 11 + 12 + expect(facets).toHaveLength(1); 13 + expect(facets[0].features[0]).toEqual({ 14 + $type: 'app.bsky.richtext.facet#mention', 15 + did: 'did:plc:alice', 16 + }); 17 + 18 + const byteStart = facets[0].index.byteStart; 19 + const byteEnd = facets[0].index.byteEnd; 20 + const slice = encoder.encode(text).slice(byteStart, byteEnd); 21 + expect(new TextDecoder().decode(slice)).toBe('@alice.bsky.social'); 22 + }); 23 + 24 + it('skips mentions for unknown handles', () => { 25 + const text = 'Hello @unknown.handle.com!'; 26 + const handles = new Map<string, string>(); 27 + const facets = buildFacets(text, handles); 28 + 29 + const mentions = facets.filter(f => 30 + f.features.some(feat => '$type' in feat && feat.$type === 'app.bsky.richtext.facet#mention') 31 + ); 32 + expect(mentions).toHaveLength(0); 33 + }); 34 + 35 + it('detects URLs', () => { 36 + const text = 'Check out https://checkmate.blue/game/did/rkey for the game'; 37 + const facets = buildFacets(text, new Map()); 38 + 39 + expect(facets).toHaveLength(1); 40 + expect(facets[0].features[0]).toEqual({ 41 + $type: 'app.bsky.richtext.facet#link', 42 + uri: 'https://checkmate.blue/game/did/rkey', 43 + }); 44 + 45 + const byteStart = facets[0].index.byteStart; 46 + const byteEnd = facets[0].index.byteEnd; 47 + const slice = encoder.encode(text).slice(byteStart, byteEnd); 48 + expect(new TextDecoder().decode(slice)).toBe('https://checkmate.blue/game/did/rkey'); 49 + }); 50 + 51 + it('detects both mention and URL in the same text', () => { 52 + const text = "I'm challenging @bob.test to chess!\n\nhttps://checkmate.blue/game/did/rkey"; 53 + const handles = new Map([['bob.test', 'did:plc:bob']]); 54 + const facets = buildFacets(text, handles); 55 + 56 + expect(facets).toHaveLength(2); 57 + 58 + const mention = facets.find(f => 59 + f.features.some(feat => '$type' in feat && feat.$type === 'app.bsky.richtext.facet#mention') 60 + ); 61 + const link = facets.find(f => 62 + f.features.some(feat => '$type' in feat && feat.$type === 'app.bsky.richtext.facet#link') 63 + ); 64 + 65 + expect(mention).toBeDefined(); 66 + expect(link).toBeDefined(); 67 + }); 68 + 69 + it('computes correct byte offsets for unicode text before the mention', () => { 70 + // Emoji takes 4 bytes in UTF-8 71 + const text = '\u{1F600} Hello @alice.bsky.social!'; 72 + const handles = new Map([['alice.bsky.social', 'did:plc:alice']]); 73 + const facets = buildFacets(text, handles); 74 + 75 + expect(facets).toHaveLength(1); 76 + 77 + const byteStart = facets[0].index.byteStart; 78 + const byteEnd = facets[0].index.byteEnd; 79 + const bytes = encoder.encode(text); 80 + const slice = bytes.slice(byteStart, byteEnd); 81 + expect(new TextDecoder().decode(slice)).toBe('@alice.bsky.social'); 82 + }); 83 + 84 + it('handles handles with hyphens and numbers', () => { 85 + const text = 'Playing @test-user123.bsky.social today'; 86 + const handles = new Map([['test-user123.bsky.social', 'did:plc:test123']]); 87 + const facets = buildFacets(text, handles); 88 + 89 + expect(facets).toHaveLength(1); 90 + expect(facets[0].features[0]).toEqual({ 91 + $type: 'app.bsky.richtext.facet#mention', 92 + did: 'did:plc:test123', 93 + }); 94 + }); 95 + 96 + it('detects mention at start of text', () => { 97 + const text = '@alice.bsky.social check this out'; 98 + const handles = new Map([['alice.bsky.social', 'did:plc:alice']]); 99 + const facets = buildFacets(text, handles); 100 + 101 + expect(facets).toHaveLength(1); 102 + const byteStart = facets[0].index.byteStart; 103 + const byteEnd = facets[0].index.byteEnd; 104 + const slice = encoder.encode(text).slice(byteStart, byteEnd); 105 + expect(new TextDecoder().decode(slice)).toBe('@alice.bsky.social'); 106 + }); 107 + 108 + it('returns empty array for text with no mentions or URLs', () => { 109 + const text = 'Just a plain message with no links'; 110 + const facets = buildFacets(text, new Map()); 111 + expect(facets).toHaveLength(0); 112 + }); 113 + }); 114 + 115 + describe('composeChallengePost', () => { 116 + it('includes opponent handle and game URL', () => { 117 + const text = composeChallengePost( 118 + 'alice.bsky.social', 119 + 'https://checkmate.blue/game/did:plc:white/abc123', 120 + ); 121 + expect(text).toContain('@alice.bsky.social'); 122 + expect(text).toContain('https://checkmate.blue/game/did:plc:white/abc123'); 123 + }); 124 + });