diff --git a/lexicons/blue.checkmate.challenge.json b/lexicons/blue.checkmate.challenge.json index aad00e1..1fb5117 100644 --- a/lexicons/blue.checkmate.challenge.json +++ b/lexicons/blue.checkmate.challenge.json @@ -28,6 +28,11 @@ "type": "string", "knownValues": ["open", "accepted", "expired", "cancelled"], "description": "Current challenge status" + }, + "challengerColor": { + "type": "string", + "knownValues": ["white", "black"], + "description": "Color the challenger will play" } } } diff --git a/src/lib/atproto.ts b/src/lib/atproto.ts index 7977853..9517937 100644 --- a/src/lib/atproto.ts +++ b/src/lib/atproto.ts @@ -28,7 +28,7 @@ async function getPublicAgent(did: string): Promise { export async function createGame( agent: Agent, - options: { white: string; black?: string; status?: GameRecord['status']; parentGameUri?: string } + options: { white?: string; black?: string; status?: GameRecord['status']; parentGameUri?: string } ): Promise<{ uri: string; rkey: string }> { const record: GameRecord = { $type: 'blue.checkmate.game', @@ -102,6 +102,49 @@ export async function getGame( } } +export async function getGamePublic( + did: string, + rkey: string +): Promise { + try { + const publicAgent = await getPublicAgent(did); + const response = await publicAgent.com.atproto.repo.getRecord({ + repo: did, + collection: COLLECTIONS.game, + rkey, + }); + return response.data.value as unknown as GameRecord; + } catch (e) { + console.error('getGamePublic failed:', did, rkey, e); + return null; + } +} + +export async function findGameRecordByParentPublic( + did: string, + parentGameUri: string +): Promise<{ rkey: string; record: GameRecord } | null> { + try { + const publicAgent = await getPublicAgent(did); + const response = await publicAgent.com.atproto.repo.listRecords({ + repo: did, + collection: COLLECTIONS.game, + limit: 100, + }); + + for (const rec of response.data.records) { + const value = rec.value as unknown as GameRecord; + if (value.parentGameUri === parentGameUri) { + return { rkey: rec.uri.split('/').pop()!, record: value }; + } + } + return null; + } catch (e) { + console.error('findGameRecordByParentPublic failed:', did, e); + return null; + } +} + export async function createChallenge( agent: Agent, options: { opponent?: string } diff --git a/src/lib/bluesky.ts b/src/lib/bluesky.ts new file mode 100644 index 0000000..c89fd56 --- /dev/null +++ b/src/lib/bluesky.ts @@ -0,0 +1,103 @@ +import type { Agent } from '@atproto/api'; + +interface Facet { + index: { byteStart: number; byteEnd: number }; + features: Array< + | { $type: 'app.bsky.richtext.facet#mention'; did: string } + | { $type: 'app.bsky.richtext.facet#link'; uri: string } + >; +} + +const encoder = new TextEncoder(); + +/** Compute the byte offset of a substring within a string (UTF-8). */ +function byteOffset(text: string, charIndex: number): number { + return encoder.encode(text.slice(0, charIndex)).byteLength; +} + +/** Build facets for @mentions and URLs in post text. */ +export function buildFacets( + text: string, + knownHandles: Map, +): Facet[] { + const facets: Facet[] = []; + + // Detect @mentions + const mentionRe = /(^|[\s(])@([a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,}))/g; + let match; + while ((match = mentionRe.exec(text)) !== null) { + const handle = match[2]; + const did = knownHandles.get(handle); + if (!did) continue; + + const mentionStart = match.index + match[1].length; + const mentionText = `@${handle}`; + facets.push({ + index: { + byteStart: byteOffset(text, mentionStart), + byteEnd: byteOffset(text, mentionStart + mentionText.length), + }, + features: [{ $type: 'app.bsky.richtext.facet#mention', did }], + }); + } + + // Detect URLs + const urlRe = /https?:\/\/[^\s)]+/g; + while ((match = urlRe.exec(text)) !== null) { + facets.push({ + index: { + byteStart: byteOffset(text, match.index), + byteEnd: byteOffset(text, match.index + match[0].length), + }, + features: [{ $type: 'app.bsky.richtext.facet#link', uri: match[0] }], + }); + } + + return facets; +} + +/** Post a Bluesky post with auto-detected facets for mentions and links. */ +export async function postToBluesky( + agent: Agent, + text: string, + knownHandles: Map, + embedUrl?: string, + embedTitle?: string, + embedDescription?: string, +): Promise<{ uri: string; cid: string }> { + const facets = buildFacets(text, knownHandles); + + const record: Record = { + $type: 'app.bsky.feed.post', + text, + facets, + createdAt: new Date().toISOString(), + }; + + if (embedUrl) { + record.embed = { + $type: 'app.bsky.embed.external', + external: { + uri: embedUrl, + title: embedTitle ?? 'checkmate.blue', + description: embedDescription ?? 'Chess on the Atmosphere', + }, + }; + } + + const response = await agent.com.atproto.repo.createRecord({ + repo: agent.assertDid, + collection: 'app.bsky.feed.post', + record, + }); + + return { uri: response.data.uri, cid: response.data.cid }; +} + +/** Compose the default text for a challenge post. */ +export function composeChallengePost( + opponentHandle: string, + gameUrl: string, +): string { + return `I'm challenging @${opponentHandle} to a game of chess on checkmate.blue!\n\n${gameUrl}`; +} diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index 465a929..e9a1534 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -3,6 +3,7 @@ import { Chessground } from '@lichess-org/chessground'; import type { Api as CgApi } from '@lichess-org/chessground/api'; import type { Config as CgConfig } from '@lichess-org/chessground/config'; + import type { Key } from '@lichess-org/chessground/types'; import type { Dests } from '$lib/game-logic'; type Props = { @@ -10,7 +11,7 @@ orientation?: 'white' | 'black'; turnColor?: 'white' | 'black'; dests?: Dests; - lastMove?: [string, string]; + lastMove?: [Key, Key]; viewOnly?: boolean; onmove?: (orig: string, dest: string) => void; }; @@ -42,12 +43,12 @@ fen, orientation, turnColor, - lastMove: lastMove as [string, string] | undefined, + lastMove, viewOnly, movable: { free: false, color: viewOnly ? undefined : orientation, - dests: dests as Map | undefined, + dests, showDests: true, }, events: { diff --git a/src/lib/game-logic.ts b/src/lib/game-logic.ts index da7cb72..7c66fb5 100644 --- a/src/lib/game-logic.ts +++ b/src/lib/game-logic.ts @@ -1,13 +1,14 @@ import { Chess, SQUARES, type Square, type PieceSymbol } from 'chess.js'; +import type { Key } from '@lichess-org/chessground/types'; -export type Dests = Map; +export type Dests = Map; export function toDests(chess: Chess): Dests { const dests: Dests = new Map(); for (const s of SQUARES) { const moves = chess.moves({ square: s, verbose: true }); if (moves.length) { - dests.set(s, moves.map((m) => m.to)); + dests.set(s as Key, moves.map((m) => m.to as Key)); } } return dests; @@ -32,11 +33,11 @@ export function turnColor(chess: Chess): 'white' | 'black' { return chess.turn() === 'w' ? 'white' : 'black'; } -export function lastMoveSquares(chess: Chess): [string, string] | undefined { +export function lastMoveSquares(chess: Chess): [Key, Key] | undefined { const history = chess.history({ verbose: true }); if (history.length === 0) return undefined; const last = history[history.length - 1]; - return [last.from, last.to]; + return [last.from as Key, last.to as Key]; } export function gameResult(chess: Chess): { diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 8d05555..890d9b3 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -6,13 +6,13 @@ const SCOPE = 'atproto transition:generic'; const isProd = typeof window !== 'undefined' && window.location.hostname === 'checkmate.blue'; const prodMetadata = { - client_id: 'https://checkmate.blue/oauth/client-metadata.json' as const, + client_id: 'https://checkmate.blue/oauth/client-metadata.json', client_name: 'checkmate.blue', - client_uri: 'https://checkmate.blue' as const, - redirect_uris: ['https://checkmate.blue/oauth/callback'] as const, + client_uri: 'https://checkmate.blue', + redirect_uris: ['https://checkmate.blue/oauth/callback'] as [string], scope: SCOPE, - grant_types: ['authorization_code', 'refresh_token'] as const, - response_types: ['code'] as const, + grant_types: ['authorization_code', 'refresh_token'] as ['authorization_code', 'refresh_token'], + response_types: ['code'] as ['code'], token_endpoint_auth_method: 'none' as const, application_type: 'web' as const, dpop_bound_access_tokens: true, diff --git a/src/lib/types.ts b/src/lib/types.ts index 87844e0..b2be2be 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -28,6 +28,7 @@ export interface ChallengeRecord { opponent?: string; gameUri?: string; status: 'open' | 'accepted' | 'expired' | 'cancelled'; + challengerColor?: 'white' | 'black'; } export type PlayerColor = 'white' | 'black'; diff --git a/src/routes/challenge/[did]/[rkey]/+page.svelte b/src/routes/challenge/[did]/[rkey]/+page.svelte index 933b094..f2b8af0 100644 --- a/src/routes/challenge/[did]/[rkey]/+page.svelte +++ b/src/routes/challenge/[did]/[rkey]/+page.svelte @@ -7,8 +7,8 @@ import { resolveIdentity } from '$lib/microcosm'; import type { ChallengeRecord } from '$lib/types'; - const challengerDid = $derived($page.params.did); - const rkey = $derived($page.params.rkey); + const challengerDid = $derived($page.params.did!); + const rkey = $derived($page.params.rkey!); let challenge: ChallengeRecord | null = $state(null); let challengerHandle = $state(''); diff --git a/src/routes/game/[did]/[rkey]/+page.svelte b/src/routes/game/[did]/[rkey]/+page.svelte index 6541912..0d8d7e6 100644 --- a/src/routes/game/[did]/[rkey]/+page.svelte +++ b/src/routes/game/[did]/[rkey]/+page.svelte @@ -8,198 +8,261 @@ import PromotionModal from '$lib/components/PromotionModal.svelte'; import { game } from '$lib/stores/game.svelte'; import { auth } from '$lib/stores/auth.svelte'; - import { getGame, updateGame, createGame, findGameRecordByParent } from '$lib/atproto'; + import { + getGame, getGamePublic, updateGame, createGame, + findGameRecordByParent, findGameRecordByParentPublic, + } from '$lib/atproto'; import { makePgn } from '$lib/game-logic'; import { JetstreamConnection } from '$lib/jetstream'; import { resolveIdentity } from '$lib/microcosm'; + import { composeChallengePost, postToBluesky } from '$lib/bluesky'; import LoginButton from '$lib/components/LoginButton.svelte'; import type { PieceSymbol } from 'chess.js'; - const ownerDid = $derived($page.params.did); - const rkey = $derived($page.params.rkey); + const ownerDid = $derived($page.params.did!); + const rkey = $derived($page.params.rkey!); let myRkey: string | undefined = $state(undefined); let loading = $state(true); let error = $state(''); let moveError = $state(''); let loaded = $state(false); - let jsConnection: JetstreamConnection | null = null; + let isSpectator = $state(false); + let spectatorOrientation: 'white' | 'black' = $state('white'); + let jsConnections: JetstreamConnection[] = []; let connected = $state(false); let lastPersistedPgn = ''; onMount(() => { - return () => jsConnection?.destroy(); + return () => jsConnections.forEach(c => c.destroy()); }); - // Load game once auth is ready + // Load game once auth init completes (whether logged in or not) $effect(() => { if (!auth.isInitializing && !loaded) { - if (auth.isLoggedIn) { - loaded = true; - loadGame(); - } else { - loading = false; - } + loaded = true; + loadGame(); } }); async function loadGame() { - if (!auth.agent) { - loading = false; - return; - } + // Read the canonical record -- use authenticated agent if available, public otherwise + const record = auth.agent + ? await getGame(auth.agent, ownerDid, rkey) + : await getGamePublic(ownerDid, rkey); - const record = await getGame(auth.agent, ownerDid, rkey); if (!record) { error = 'Game not found'; loading = false; return; } - const isWhite = record.white === auth.did; - const isBlack = record.black === auth.did; - const isOwner = ownerDid === auth.did; + const isWhite = auth.did ? record.white === auth.did : false; + const isBlack = auth.did ? record.black === auth.did : false; + const isOwner = auth.did ? ownerDid === auth.did : false; + const emptySlot = !record.white ? 'white' : !record.black ? 'black' : null; + + // Can this user join? Only if logged in, game is waiting, and there's an open slot + let canJoin = auth.isLoggedIn && record.status === 'waiting' && emptySlot !== null && !isWhite && !isBlack; + + // Check-before-join (Option A): re-fetch to narrow the race window + if (canJoin) { + const fresh = auth.agent + ? await getGame(auth.agent, ownerDid, rkey) + : await getGamePublic(ownerDid, rkey); + if (fresh) { + const freshEmpty = !fresh.white ? 'white' : !fresh.black ? 'black' : null; + if (!freshEmpty || fresh.status !== 'waiting') { + canJoin = false; + } + } + } - // Determine my color let myColor: 'white' | 'black'; if (isWhite) { myColor = 'white'; } else if (isBlack) { myColor = 'black'; - } else if (record.status === 'waiting' && !record.black) { - // Joining as black - myColor = 'black'; + } else if (canJoin && emptySlot) { + myColor = emptySlot; } else { - // Spectator -- view only myColor = 'white'; } + const isParticipant = isWhite || isBlack || canJoin; + isSpectator = !isParticipant; + game.init({ pgn: record.pgn || undefined, myColor, whiteDid: record.white, blackDid: record.black, - status: record.status === 'waiting' && myColor === 'white' ? 'waiting' : record.status, + status: record.status === 'waiting' && isOwner ? 'waiting' : record.status, }); - // Find the paired (Black's) record and reconcile state. - // Each player's record is one move behind 50% of the time, - // so we read both and use the longer PGN. + // Reconcile state by reading both records const parentUri = `at://${ownerDid}/blue.checkmate.game/${rkey}`; - let blackDid = record.black; - if (isOwner) { - // I'm White -- find Black's record for latest state + if (isSpectator) { + // Spectator: read both records for most up-to-date PGN + await reconcileSpectator(record, parentUri); + connectJetstreamSpectator(record.white, record.black); + } else if (isOwner) { + const opponentDid = myColor === 'white' ? record.black : record.white; myRkey = rkey; - if (blackDid) { - const blackResult = await findGameRecordByParent(auth.agent, blackDid, parentUri); - if (blackResult?.record.pgn) { - game.applyOpponentMove(blackResult.record.pgn); + if (opponentDid) { + const opponentResult = await findGameRecordByParent(auth.agent!, opponentDid, parentUri); + if (opponentResult?.record.pgn) { + game.applyOpponentMove(opponentResult.record.pgn); } } - } else if (myColor === 'black') { - // I'm Black -- find my own record (also has potentially newer PGN) - const myResult = await findGameRecordByParent(auth.agent, auth.did!, parentUri); + } else if (isParticipant) { + const myResult = await findGameRecordByParent(auth.agent!, auth.did!, parentUri); if (myResult) { myRkey = myResult.rkey; if (myResult.record.pgn) { game.applyOpponentMove(myResult.record.pgn); } } else { - await joinAsBlack(record); + await joinGame(record, myColor); } } lastPersistedPgn = game.pgn; - // Resolve display handles (fire-and-forget, UI updates reactively) resolvePlayerHandles(record.white, record.black); - // Connect Jetstream for opponent moves - if (myColor === 'white') { - if (blackDid && game.status === 'active') { - connectJetstream(blackDid); - } else { + // Jetstream for participants + if (isParticipant && !isSpectator) { + const opponentDid = myColor === 'white' ? record.black : record.white; + if (opponentDid && game.status === 'active') { + connectJetstream(opponentDid); + } else if (isOwner) { waitForOpponent(); } - } else if (myColor === 'black' && record.white) { - connectJetstream(record.white); } loading = false; } + async function reconcileSpectator(record: any, parentUri: string) { + // Find the non-owner's child record and use the longer PGN + const nonOwnerDid = record.white === ownerDid ? record.black : record.white; + if (!nonOwnerDid) return; + + const childResult = await findGameRecordByParentPublic(nonOwnerDid, parentUri); + if (childResult?.record.pgn) { + game.applyOpponentMove(childResult.record.pgn); + } + } + function waitForOpponent() { - // Listen to Jetstream for any blue.checkmate.game creates that reference our DID. - // Without a DID filter this subscribes to the full game collection firehose. - // This is an architectural limitation of the no-server design: we can't know - // the opponent's DID until they join. The callback filters to relevant events. - jsConnection?.destroy(); - jsConnection = new JetstreamConnection({ + destroyConnections(); + const js = new JetstreamConnection({ opponentDid: '', agent: auth.agent ?? undefined, onGameUpdate: async (record) => { const white = record.white as string; const black = record.black as string; - // Someone created a game record where we're white - if (white === auth.did && black && black !== auth.did) { - // Found our opponent! + const weAreWhite = white === auth.did && black && black !== auth.did; + const weAreBlack = black === auth.did && white && white !== auth.did; + if (weAreWhite || weAreBlack) { + const myColor = weAreWhite ? 'white' as const : 'black' as const; + const opponentDid = weAreWhite ? black : white; + game.setStatus('active'); game.init({ pgn: (record.pgn as string) || undefined, - myColor: 'white' as const, + myColor, whiteDid: white, blackDid: black, status: 'active' as const, }); - // Persist Black's DID and active status to our record so it survives reload if (auth.agent && myRkey) { - await updateGame(auth.agent, myRkey, { black, status: 'active' }); + const updates: Record = { status: 'active' }; + if (weAreWhite) updates.black = black; + else updates.white = white; + await updateGame(auth.agent, myRkey, updates); } - // Resolve opponent handle and reconnect Jetstream resolvePlayerHandles(white, black); - connectJetstream(black); + connectJetstream(opponentDid); } }, onConnectionChange: (isConnected) => { connected = isConnected; }, }); - jsConnection.connect(); + jsConnections = [js]; + js.connect(); } function connectJetstream(opponentDid: string) { - jsConnection?.destroy(); - jsConnection = new JetstreamConnection({ + destroyConnections(); + const js = new JetstreamConnection({ opponentDid, agent: auth.agent ?? undefined, onGameUpdate: (record) => { const pgn = record.pgn as string; if (pgn) { - const applied = game.applyOpponentMove(pgn); - console.log('[game] opponent update:', applied ? 'new move applied' : 'no new moves'); - - // Check if opponent resigned or game ended - const status = record.status as string; - if (status === 'completed') { - game.setStatus('completed'); - } + game.applyOpponentMove(pgn); + } + const status = record.status as string; + if (status === 'completed') { + game.setStatus('completed'); } }, onConnectionChange: (isConnected) => { connected = isConnected; }, }); - jsConnection.connect(); + jsConnections = [js]; + js.connect(); } - async function joinAsBlack(record: any) { + function connectJetstreamSpectator(whiteDid?: string, blackDid?: string) { + destroyConnections(); + const dids = [whiteDid, blackDid].filter(Boolean) as string[]; + let connectedCount = 0; + + const conns = dids.map(did => { + const js = new JetstreamConnection({ + opponentDid: did, + onGameUpdate: (record) => { + const pgn = record.pgn as string; + if (pgn) { + game.applyOpponentMove(pgn); + } + const status = record.status as string; + if (status === 'completed') { + game.setStatus('completed'); + } + }, + onConnectionChange: (isConnected) => { + connectedCount += isConnected ? 1 : -1; + connected = connectedCount > 0; + }, + }); + js.connect(); + return js; + }); + jsConnections = conns; + } + + function destroyConnections() { + jsConnections.forEach(c => c.destroy()); + jsConnections = []; + } + + async function joinGame(record: any, myColor: 'white' | 'black') { if (!auth.agent || !auth.did) return; const parentUri = `at://${ownerDid}/blue.checkmate.game/${rkey}`; + const white = myColor === 'white' ? auth.did : record.white; + const black = myColor === 'black' ? auth.did : record.black; + const result = await createGame(auth.agent, { - white: record.white, - black: auth.did, + white, + black, status: 'active', parentGameUri: parentUri, }); @@ -272,16 +335,64 @@ await updateGame(auth.agent, myRkey, { drawOffered: true }); } + function flipBoard() { + spectatorOrientation = spectatorOrientation === 'white' ? 'black' : 'white'; + } + let copied = $state(false); + let posting = $state(false); + let posted = $state(false); + let postError = $state(''); + let dmCopied = $state(false); + + function gameUrl(): string { + return `${window.location.origin}/game/${ownerDid}/${rkey}`; + } function copyGameLink() { - const url = `${window.location.origin}/game/${ownerDid}/${rkey}`; - navigator.clipboard.writeText(url); + navigator.clipboard.writeText(gameUrl()); copied = true; setTimeout(() => copied = false, 2000); } + function sendViaDm() { + navigator.clipboard.writeText(gameUrl()); + dmCopied = true; + setTimeout(() => dmCopied = false, 4000); + window.open('https://bsky.app/messages', '_blank'); + } + + async function postChallengeToBluesky() { + if (!auth.agent || !opponentHandle || posted) return; + posting = true; + postError = ''; + + const opponentDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; + const text = composeChallengePost(opponentHandle, gameUrl()); + const handles = new Map(); + if (opponentDid && opponentHandle) { + handles.set(opponentHandle, opponentDid); + } + + try { + await postToBluesky( + auth.agent, + text, + handles, + gameUrl(), + 'Chess Challenge on checkmate.blue', + `${auth.handle} wants to play chess!`, + ); + posted = true; + } catch (e) { + postError = e instanceof Error ? e.message : 'Failed to post'; + } finally { + posting = false; + } + } + const isWaiting = $derived(game.status === 'waiting'); + const boardOrientation = $derived(isSpectator ? spectatorOrientation : game.myColor); const opponentHandle = $derived( game.myColor === 'white' ? game.blackHandle : game.whiteHandle @@ -295,23 +406,14 @@

Loading game...

-{:else if !auth.isLoggedIn} -
-
-

You've been challenged!

-

Sign in with your Bluesky account to play.

-
-
- -
-
{:else if error} -
+

{error}

+ Go home
{:else}
- {#if isWaiting} + {#if isWaiting && !isSpectator}

Waiting for opponent

Share this link to invite someone:

@@ -319,7 +421,7 @@
+ {#if opponentHandle && opponentHandle !== 'Waiting...'} +
+
+ {#if posted} + Posted! + {:else} + + {/if} + +
+ {#if postError} +

{postError}

+ {/if} +
+ {/if}
{/if} - + {#if isSpectator && isWaiting} +
+ Waiting for game to start +
+ {/if} + + - + + + {#if isSpectator} +
+ + Spectating + + +
+ {/if} + + {#if !isSpectator && !auth.isLoggedIn} +
+

Sign in to play

+ +
+ {/if} {#if game.result}
@@ -367,23 +528,23 @@
{/if} - + {#if !isSpectator} + + {/if} - {#if auth.isLoggedIn} -
- - {connected ? 'Connected' : 'Reconnecting...'} -
- {/if} +
+ + {connected ? 'Live' : 'Reconnecting...'} +
- {#if game.pendingPromotion} + {#if game.pendingPromotion && !isSpectator} {/if} {/if} diff --git a/src/routes/play/+page.svelte b/src/routes/play/+page.svelte index e720c25..5936c2a 100644 --- a/src/routes/play/+page.svelte +++ b/src/routes/play/+page.svelte @@ -5,16 +5,27 @@ import { resolveIdentity } from '$lib/microcosm'; let opponentHandle = $state(''); + let colorChoice: 'white' | 'black' | 'random' = $state('white'); let isCreating = $state(false); let error = $state(''); + function resolveColors(myDid: string, opponentDid?: string): { white?: string; black?: string } { + const choice = colorChoice === 'random' + ? (Math.random() < 0.5 ? 'white' : 'black') + : colorChoice; + + if (choice === 'white') { + return { white: myDid, black: opponentDid }; + } + return { white: opponentDid, black: myDid }; + } + async function handleCreateGame() { if (!auth.agent || !auth.did) return; isCreating = true; error = ''; try { - // Resolve opponent if specified let opponentDid: string | undefined; if (opponentHandle.trim()) { const profile = await resolveIdentity(opponentHandle.trim()); @@ -26,25 +37,28 @@ opponentDid = profile.did; } - // Create the game record (status: waiting) + const { white, black } = resolveColors(auth.did, opponentDid); + const gameResult = await createGame(auth.agent, { - white: auth.did, - black: opponentDid, - status: opponentDid ? 'waiting' : 'waiting', + white, + black, + status: 'waiting', }); - // Create a challenge record pointing to the game + const challengerColor: 'white' | 'black' = colorChoice === 'random' + ? (white === auth.did ? 'white' : 'black') + : colorChoice; + const challengeResult = await createChallenge(auth.agent, { opponent: opponentDid, }); - // Update challenge with game URI await updateChallenge(auth.agent, challengeResult.rkey, { gameUri: gameResult.uri, status: 'open', + challengerColor, }); - // Navigate to the game page goto(`/game/${auth.did}/${gameResult.rkey}`); } catch (e) { error = e instanceof Error ? e.message : 'Failed to create game'; @@ -71,6 +85,25 @@ 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" /> +
+ Play as + {#each [['white', 'White'], ['black', 'Black'], ['random', 'Random']] as [value, label]} + + {/each} +
+