From c9d760874a73ee58b45091041506dbdc37023b94 Mon Sep 17 00:00:00 2001 From: Scott Hadfield Date: Wed, 1 Apr 2026 20:11:31 -0700 Subject: [PATCH] Add Phase 6 polish: sounds, rematch, PGN export, abandon detection, PWA - Sound effects for moves, captures, game end, and opponent notifications - Rematch button after game completion (swaps colors) - PGN download and Lichess analysis integration - Abandoned game detection with 7-day inactivity threshold - PWA manifest with theme color - Draw offer state restored on page reload - Fix waitForOpponent false activation when creator is black - Lexicon: add lastMoveAt field and abandonment result reason - Add project roadmap, spec, and implementation plans --- ROADMAP.md | 234 +++++++ SPEC.md | 750 ++++++++++++++++++++++ lexicons/blue.checkmate.game.json | 7 +- plans/e2e-test-plan.md | 166 +++++ plans/phase-1-challenge-invite-flow.md | 70 ++ plans/phase-2-shareability-branding.md | 212 ++++++ plans/phase-3-spectator-mode.md | 61 ++ plans/phase-4-homepage-game-discovery.md | 291 +++++++++ plans/phase-5-game-result-sharing.md | 231 +++++++ plans/phase-6-polish.md | 313 +++++++++ plans/phase-7-open-challenge-board.md | 98 +++ src/app.html | 2 + src/lib/stores/sound.svelte.ts | 34 + src/lib/types.ts | 3 +- src/routes/game/[did]/[rkey]/+page.svelte | 182 +++++- static/manifest.json | 15 + static/sounds/capture.mp3 | Bin 0 -> 3432 bytes static/sounds/move.mp3 | Bin 0 -> 2547 bytes static/sounds/notify.mp3 | Bin 0 -> 8011 bytes 19 files changed, 2659 insertions(+), 10 deletions(-) create mode 100644 ROADMAP.md create mode 100644 SPEC.md create mode 100644 plans/e2e-test-plan.md create mode 100644 plans/phase-1-challenge-invite-flow.md create mode 100644 plans/phase-2-shareability-branding.md create mode 100644 plans/phase-3-spectator-mode.md create mode 100644 plans/phase-4-homepage-game-discovery.md create mode 100644 plans/phase-5-game-result-sharing.md create mode 100644 plans/phase-6-polish.md create mode 100644 plans/phase-7-open-challenge-board.md create mode 100644 src/lib/stores/sound.svelte.ts create mode 100644 static/manifest.json create mode 100644 static/sounds/capture.mp3 create mode 100644 static/sounds/move.mp3 create mode 100644 static/sounds/notify.mp3 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..3011702 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,234 @@ +# checkmate.blue Roadmap + +## Phase 1: Challenge & Invite Flow -- DONE + +Implemented on branch `phase-1-challenge-invite-flow`. + +- **1a. Post-Based Challenges** -- "Post to Bluesky" button on the game waiting screen with @mention facets and game link. +- **1b. DM-Based Challenges** -- "Send via DM" button copies game link to clipboard and opens bsky.app/messages. Full API-driven DMs deferred (requires `transition:chat.bsky` scope). +- **1c. Color Selection** -- White/Black/Random picker on `/play`. Game page logic fully decoupled from record ownership. +- **Check-before-join guard** -- re-fetches game record before joining to reduce race conditions on shared links. + +See `plans/phase-1-challenge-invite-flow.md` for full details. + +--- + +## Phase 3: Spectator Mode -- DONE + +Pulled forward and implemented alongside Phase 1. + +- **Read-only game view** for non-participants with flip-board button. +- **Unauthenticated viewing** -- no login required to watch a game. +- **Dual Jetstream connections** -- spectators receive live updates from both players. +- **Game-full fallback** -- extra visitors become spectators, not errors. + +See `plans/phase-3-spectator-mode.md` for full details. + +--- + +## Phase 2: Shareability & Branding -- NEXT + +Priority: **High**. Links shared on social platforms currently show bare URLs. A recognizable brand and rich link previews drive organic growth. + +### 2a. Logo + +FontAwesome chess knight icon in AT Protocol blue (`#0085FF`). + +**Usage:** +- Favicon (SVG) +- Navbar icon (32x32) +- OG image fallback (centered on dark background) +- PWA icon (192x192, 512x512) + +**Implementation:** +- Create an SVG from the FA `chess-knight` glyph, colored `#0085FF`. +- Generate PNG variants at required sizes for PWA manifest and Apple touch icon. +- Replace the current placeholder favicon. + +### 2b. Open Graph Meta Tags + +Rich link previews when sharing game/challenge URLs on Bluesky, Twitter, Discord, etc. + +**Problem:** The app is a pure SPA (SSR disabled, static adapter with `fallback: index.html`). Every route serves the same HTML shell, so crawlers see the same generic meta tags regardless of URL. + +**Options (in order of preference):** + +1. **Cloudflare Worker in front of static site** -- intercept requests from known bot user agents, fetch game/challenge data from the PDS, return a minimal HTML page with correct `og:title`, `og:description`, `og:image` tags. All other requests pass through to the static SPA. + +2. **OG image generation service** -- a separate edge function that renders a board position as a PNG. URL pattern: `checkmate.blue/og/{did}/{rkey}.png`. The Cloudflare Worker references these in the `og:image` tag. + +3. **Static fallback only** -- use the same generic card (logo + "checkmate.blue - Chess on the Atmosphere") for all links. Easiest but least compelling. + +**Recommended approach:** Start with option 3 (generic card using the logo) and add the Cloudflare Worker (option 1) as a fast follow. The OG image generation (option 2) is nice-to-have. + +**Meta tags for different routes:** + +| Route | og:title | og:description | +|-------|----------|---------------| +| `/` | checkmate.blue | Chess on the Atmosphere | +| `/game/{did}/{rkey}` | Chess Game - {white} vs {black} | {status} - {move count} moves | +| `/challenge/{did}/{rkey}` | Chess Challenge from {handle} | {handle} wants to play chess | +| `/profile/{handle}` | {handle} on checkmate.blue | Chess profile | + +--- + +## Phase 4: Homepage & Game Discovery + +Priority: **Medium**. Makes the platform feel alive and gives new visitors something to see. + +### 4a. Active Games List + +Show ongoing games on the homepage, sorted by most recent activity. + +**Deduplication:** Only show White's records (canonical). Black's records have `parentGameUri` set, so filter those out. A game record without `parentGameUri` and with `status: active` is a canonical active game. + +**Implementation:** + +- Query approach TBD -- depends on what Constellation supports for global queries. Options: + - Query Constellation for all `blue.checkmate.game` records globally (if supported). + - Maintain a known-players list and query each player's games (doesn't scale). + - Use the Jetstream firehose to build a client-side index of recent games (complex, ephemeral). + - Add a lightweight indexing endpoint (breaks the "no server" constraint). +- Filter to `status: active` and `parentGameUri` absent (White's record only). +- Sort by last activity. **Requires a `lastMoveAt` field on the lexicon** -- PGN parsing for sort order is too expensive for a list view. + +**Lexicon change:** +``` +"lastMoveAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp of the most recent move" +} +``` + +This field gets updated on every `putRecord` call when a move is made. + +- Display each game as a card: White handle vs Black handle, move count, last activity time, link to spectate. + +### 4b. Completed Games Feed + +Below active games, show recently completed games with results. + +- Same query approach as active games, filtered to `status: completed`. +- Show result (1-0, 0-1, draw), result reason, player handles. +- Links to view the final position. + +--- + +## Phase 5: Game Result Sharing + +Priority: **Medium**. The viral loop. Players share their wins (and losses) to Bluesky. + +**Implementation:** + +- After a game ends (checkmate, resignation, draw), show a "Share to Bluesky" button on the game page. +- Create a `app.bsky.feed.post` record with: + - Text describing the result: "Checkmate! I beat @{opponent} on checkmate.blue" / "Good game -- @{opponent} got me this time" / "Draw with @{opponent} on checkmate.blue" + - Mention facet for the opponent + - Link facet to the game URL + - Embed with game link card (benefits from Phase 2 OG tags) +- Include move count and result reason in the post text for flavor. +- This is always opt-in. Show a pre-filled post that the player can edit before posting. + +**Note:** `src/lib/bluesky.ts` (created in Phase 1) already has `buildFacets()` and `postToBluesky()` which can be reused. The main new work is `composeGameResultPost()` and the editable textarea UI on the game page. + +--- + +## Phase 6: Polish + +Priority: **Lower**. Quality-of-life improvements that make gameplay feel complete. + +### 6a. Sound Effects + +- Move sound (piece placement) +- Capture sound +- Check sound +- Game over sound (checkmate, draw) +- Opponent move notification sound + +Use small audio files (MP3/OGG). Lichess sounds are BSD-licensed and could be used directly. + +### 6b. Rematch Button + +After a game ends, show a "Rematch" button that creates a new game with the same opponent, colors swapped. + +- Creates a new `blue.checkmate.game` record with White/Black reversed. +- The rematch is essentially a new challenge to the same opponent, auto-linked. +- Could use the challenge record to track the rematch offer, or just create the game directly and show the link. + +### 6c. PGN Export & Analysis + +- "Download PGN" button on completed (or in-progress) games. +- "Analyze on Lichess" button that opens the Lichess analysis board with the game's PGN. +- Lichess analysis URL: `https://lichess.org/analysis/pgn/{url-encoded-pgn}` + +### 6d. Abandoned Game Detection + +- If a game has been inactive for a configurable period (e.g., 7 days), show an "Abandon" option to the waiting player. +- The abandoning player writes `status: abandoned` to their record. +- Could also auto-mark games as abandoned based on `lastMoveAt` (requires the lexicon change from Phase 4). +- No server-side enforcement -- this is a convention. A bot (separate project) could handle automated cleanup. + +### 6e. Mobile PWA + +- Add a `manifest.json` with app name, icons (from Phase 2a logo), theme color. +- Add a service worker for offline shell caching. +- The static SvelteKit build is already most of the way there. + +--- + +## Phase 7: Open Challenge Board (Deferred) + +Priority: **Lower**. Allow players to browse and accept open challenges from anyone, not just targeted invites. + +- Add a public list of open challenges to the homepage via Constellation queries. +- Requires investigation into whether Constellation supports global collection queries (not just DID-targeted). +- Display open challenges with challenger handle, creation time. +- Filter out expired challenges client-side (e.g., older than 24h). +- Auto-expiry is a convention (UI hides old challenges), not enforced server-side. + +--- + +## Separate Project: checkmate.blue Bot + +**Not part of this project.** Tracked here for reference. + +A Jetstream listener service that watches `blue.checkmate.game` records and posts game results from a `@checkmate.blue` bot account. Separate repo, separate deployment, runs as a persistent process (not a static site). + +Responsibilities: +- Watch for `status: completed` game records on Jetstream. +- Post game results to the bot's Bluesky feed. +- Potentially: abandoned game detection, global game indexing, rating calculation. + +--- + +## Lexicon Changes Summary + +Changes needed across the remaining roadmap: + +| Field | Lexicon | Phase | Status | +|-------|---------|-------|--------| +| `challengerColor` | `blue.checkmate.challenge` | 1c | DONE | +| `lastMoveAt` | `blue.checkmate.game` | 4a | Pending | + +--- + +## Dependency Graph + +``` +Phase 1 (Challenge Invite Flow) ── DONE +Phase 3 (Spectator Mode) ── DONE + +Phase 2a (Logo) ─────────────────── prerequisite for 2b +Phase 2b (OG Tags) ─────────────── benefits 5 + +Phase 4a (Active Games List) ────── requires lexicon change (lastMoveAt) +Phase 4b (Completed Games Feed) ── same query infrastructure as 4a + +Phase 5 (Game Result Sharing) ──── benefits from 2b (OG tags make shared links look good) + reuses bluesky.ts from Phase 1 + +Phase 6 (Polish) ───────────────── independent, can be interleaved anywhere + +Phase 7 (Open Challenge Board) ─── deferred, depends on Constellation global query support +``` diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..f017f10 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,750 @@ +# checkmate.blue — Technical Specification v2 + +## Overview + +**checkmate.blue** is a federated chess platform built entirely on AT Protocol. Players authenticate with their Bluesky identity, play real-time 1v1 chess with game state stored as atproto records, and receive move updates via Jetstream. There is no application database and no server-side game logic — the browser is the app, the PDS is the database, and the protocol is the infrastructure. + +**Target:** Working PoC in 2 days. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Browser │ +│ │ +│ ┌──────────────┐ ┌─────────────────────────┐ │ +│ │ SvelteKit │ │ @atproto/oauth-client │ │ +│ │ UI + Pages │ │ -browser │ │ +│ │ │ │ (IndexedDB sessions) │ │ +│ └──────────────┘ └─────────────────────────┘ │ +│ │ │ │ +│ ┌──────────────┐ ┌─────────────────────────┐ │ +│ │ chessground │ │ chess.js │ │ +│ │ (board UI) │ │ (validation + PGN) │ │ +│ └──────────────┘ └─────────────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌───────────────────────────────────────────┐ │ +│ │ atproto Agent (authenticated) │ │ +│ │ • putRecord → player's PDS (write moves) │ │ +│ │ • getRecord → opponent's PDS (read game) │ │ +│ └───────────────────────────────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌─────────────────────────┐ │ +│ │ Jetstream │ │ Constellation / │ │ +│ │ (WebSocket) │ │ Slingshot │ │ +│ │ opponent │ │ (game discovery, │ │ +│ │ move events │ │ handle resolution) │ │ +│ └──────────────┘ └─────────────────────────┘ │ +└─────────────────────────────────────────────────┘ + │ │ + ▼ ▼ + ┌───────────┐ ┌─────────────────────┐ + │ Jetstream │ │ Player's PDS │ + │ (Bluesky) │ │ (e.g. bsky.social) │ + └───────────┘ └─────────────────────┘ +``` + +**There is no application server, no database, no WebSocket server to run.** The SvelteKit app can be deployed as a static site. The only server-side requirement is hosting the OAuth client-metadata.json endpoint, which SvelteKit handles as a static route. + +--- + +## Stack + +| Layer | Technology | +|-------|-----------| +| Framework | SvelteKit (static adapter or minimal node adapter) | +| Chess logic | `chess.js` (client-side validation, PGN generation) | +| Board UI | `chessground` (Lichess BSD-licensed board) | +| Auth | `@atproto/oauth-client-browser` (sessions in IndexedDB) | +| AT Protocol | `@atproto/api` (read/write records to PDS) | +| Real-time | Jetstream WebSocket (listen for opponent's moves) | +| Queries | Constellation (game discovery, backlinks) | +| Identity | Slingshot (handle ↔ DID resolution, record cache) | +| Styling | Tailwind CSS | +| Domain | `checkmate.blue` | + +### Key dependencies + +```json +{ + "dependencies": { + "@atproto/api": "latest", + "@atproto/oauth-client-browser": "latest", + "chess.js": "latest", + "chessground": "latest", + "svelte": "latest", + "@sveltejs/kit": "latest" + }, + "devDependencies": { + "@sveltejs/adapter-static": "latest", + "autoprefixer": "latest", + "postcss": "latest", + "tailwindcss": "latest", + "typescript": "latest", + "vite": "latest" + } +} +``` + +--- + +## AT Protocol Lexicons + +Namespace: `blue.checkmate.*` + +### Game Record — `blue.checkmate.game` + +A single record represents an entire game. Both players maintain their own copy -- White's record is the "primary" and Black's record includes a `parentGameUri` pointing back to it. Each record is updated with each move via `putRecord`. + +```json +{ + "lexicon": 1, + "id": "blue.checkmate.game", + "defs": { + "timeControl": { + "type": "object", + "description": "Time control settings for the game (not yet implemented)", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "knownValues": ["untimed", "correspondence", "clock"] + }, + "initialSeconds": { + "type": "integer", + "description": "Starting time per player in seconds" + }, + "incrementSeconds": { + "type": "integer", + "description": "Seconds added after each move (Fischer increment)" + }, + "moveTimeLimitSeconds": { + "type": "integer", + "description": "Max seconds per move for correspondence games" + } + } + }, + "main": { + "type": "record", + "key": "tid", + "description": "A chess game on checkmate.blue", + "record": { + "type": "object", + "required": ["pgn", "createdAt", "status"], + "properties": { + "pgn": { + "type": "string", + "maxLength": 100000, + "description": "PGN of the game including headers and moves so far" + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "white": { + "type": "string", + "format": "did", + "description": "DID of the white player" + }, + "black": { + "type": "string", + "format": "did", + "description": "DID of the black player" + }, + "status": { + "type": "string", + "knownValues": ["waiting", "active", "completed", "abandoned"] + }, + "result": { + "type": "string", + "knownValues": ["1-0", "0-1", "1/2-1/2"] + }, + "resultReason": { + "type": "string", + "knownValues": ["checkmate", "resignation", "draw_agreement", "stalemate", "insufficient", "repetition", "fifty_moves"] + }, + "parentGameUri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of White's game record, set on Black's copy" + }, + "drawOffered": { + "type": "boolean", + "description": "Whether a draw has been offered by the last moving player" + }, + "timeControl": { + "type": "ref", + "ref": "#timeControl" + }, + "moveTimes": { + "type": "array", + "items": { "type": "integer" }, + "description": "Seconds elapsed for each half-move, in ply order" + } + } + } + } + } +} +``` + +> **Note:** `timeControl` and `moveTimes` are defined in the lexicon but not yet implemented. All games are currently untimed. + +### Challenge Record — `blue.checkmate.challenge` + +Created when a player wants to start a game. Contains a reference to who they're challenging (or left open for anyone). When accepted, a game record is created and the challenge is updated with a reference to it. + +```json +{ + "lexicon": 1, + "id": "blue.checkmate.challenge", + "defs": { + "main": { + "type": "record", + "key": "tid", + "description": "A challenge to play chess on checkmate.blue", + "record": { + "type": "object", + "required": ["createdAt", "status"], + "properties": { + "createdAt": { + "type": "string", + "format": "datetime" + }, + "opponent": { + "type": "string", + "format": "did", + "description": "DID of the specific opponent, or omit for open challenge" + }, + "gameUri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the game record once accepted" + }, + "status": { + "type": "string", + "knownValues": ["open", "accepted", "expired", "cancelled"] + } + } + } + } + } +} +``` + +--- + +## Authentication + +Use `@atproto/oauth-client-browser` for fully client-side OAuth. Sessions are stored in the browser's IndexedDB — no server-side session management needed. + +### Setup + +```typescript +// src/lib/oauth.ts +import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; + +export const oauthClient = new BrowserOAuthClient({ + clientMetadata: { + client_id: 'https://checkmate.blue/oauth/client-metadata.json', + client_name: 'checkmate.blue', + client_uri: 'https://checkmate.blue', + redirect_uris: ['https://checkmate.blue/oauth/callback'], + scope: 'atproto transition:generic', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + application_type: 'web', + dpop_bound_access_tokens: true, + }, + handleResolver: 'https://bsky.social', +}); +``` + +For local development, use the loopback client pattern as specified in the atproto OAuth spec — `http://localhost` with any port is treated as a special development client that doesn't require a publicly accessible client-metadata endpoint. + +### Flow + +1. User clicks "Sign in with Bluesky" and enters their handle +2. `oauthClient.signIn(handle)` redirects to their PDS +3. User authorizes, PDS redirects back to `/oauth/callback` +4. `oauthClient.init()` on page load restores sessions from IndexedDB +5. Create an `Agent` from the session for all atproto operations + +```typescript +import { Agent } from '@atproto/api'; + +const session = await oauthClient.restore(did); +const agent = new Agent(session); +// agent is now authenticated — can read/write to user's PDS +``` + +### Client Metadata Endpoint + +Serve a static JSON file at `/oauth/client-metadata.json`. This must be publicly accessible and match the `client_id` URL exactly. In SvelteKit, create this as a server route or static file. + +--- + +## Game Flow + +### 1. Create Challenge + +Player A creates a challenge record in their own repo: + +```typescript +const challenge = await agent.com.atproto.repo.createRecord({ + repo: agent.session.did, + collection: 'blue.checkmate.challenge', + record: { + $type: 'blue.checkmate.challenge', + createdAt: new Date().toISOString(), + status: 'open', + // opponent: 'did:plc:bob' — optional, omit for open challenge + }, +}); +// Share the AT URI or derive a challenge URL from it +``` + +The challenge URL is constructed from the AT URI: `https://checkmate.blue/challenge/{did}/{rkey}` + +### 2. Accept Challenge + +Player B views the challenge, clicks accept. Player A's client creates the game record and updates the challenge: + +**Who creates the game record?** The challenger (Player A). Their client watches for challenge acceptance (via Jetstream or polling) and then: + +1. Creates a `blue.checkmate.game` record in their repo with both players assigned +2. Updates the challenge record with the game URI and status `accepted` + +Alternatively, for simplicity in the PoC: the accepting player (Player B) calls an XRPC endpoint or the game creation is handled by whoever loads the game page first. The simplest approach: **Player A creates the game record immediately when creating the challenge**, with status `waiting`. Player B accepting just means they open the game URL and start playing. + +```typescript +// Player A creates game + challenge together +const game = await agent.com.atproto.repo.createRecord({ + repo: agent.session.did, + collection: 'blue.checkmate.game', + record: { + $type: 'blue.checkmate.game', + pgn: '[Event "checkmate.blue"]\n[White ""]\n[Black ""]\n[Result "*"]\n\n*', + createdAt: new Date().toISOString(), + white: agent.session.did, // challenger plays white + status: 'waiting', + moveCount: 0, + }, +}); +// Share game URL: https://checkmate.blue/game/{did}/{rkey} +``` + +When Player B opens the URL, the game record is updated with their DID as black and status becomes `active`. + +### 3. Making Moves + +The current player validates the move client-side with chess.js, then writes the updated game state to the **game owner's** repo. + +**Key design decision:** The game record lives in Player A's repo. Both players need write access to update it. But Player B can't write to Player A's repo — they can only write to their own. + +**Solution: Each player writes their moves to their OWN repo as a move record, and the opponent's client reads it.** + +Revised approach — **two records, one per player**: + +When the game starts, each player creates a `blue.checkmate.game` record in their own repo, referencing the same game. Moves are written to the moving player's record. The opponent watches for updates via Jetstream. + +``` +Player A's repo: + blue.checkmate.game/{rkey-A} + { white: A, black: B, pgn: "1. e4 e5 2. Nf3", lastMoveBy: A, ... } + +Player B's repo: + blue.checkmate.game/{rkey-B} + { white: A, black: B, pgn: "1. e4 e5 2. Nf3 Nc6", lastMoveBy: B, ... } +``` + +Each player updates THEIR OWN record with the full PGN after making a move. The opponent reads the updated PGN from the other player's record to see the new move, validates it, and then writes the updated PGN (including their reply) to their own record. + +**Move validation flow:** + +1. Player A makes a move in the UI +2. Client validates with chess.js — is it legal? Is it A's turn? +3. Client updates Player A's game record with the new PGN via `putRecord` +4. Player B's client receives the update via Jetstream +5. Player B's client reads the updated PGN, validates the new move with chess.js +6. If valid, updates the local board state +7. Player B makes their move, writes to their own record +8. Player A receives via Jetstream, validates, updates board + +**Why two records?** Because atproto permissions are per-user. You can only write to your own repo. This is the natural pattern — each player maintains their own copy of the game state, and the protocol ensures both can be read by anyone. + +### 4. Game Over + +When checkmate, stalemate, or draw is detected by chess.js, the current player writes the final state with `status: completed` and the `result` field filled in. Both players' records should reflect the final state. + +### 5. Resignation / Draw + +Resign: player writes `status: completed`, `result` favoring opponent, `resultReason: resignation` to their own record. + +Draw offer: could be a field on the record (`drawOffered: true`) that the opponent reads and either accepts or ignores. On acceptance, both records get `result: 1/2-1/2`. + +--- + +## Jetstream Integration + +Jetstream delivers real-time events from the atproto firehose over WebSocket. The client connects directly to Jetstream and filters for the opponent's record updates. + +### Connection + +```typescript +// Connect to Jetstream, filtered by opponent's DID and our game collection +const jetstreamUrl = new URL('wss://jetstream2.us-east.bsky.network/subscribe'); +jetstreamUrl.searchParams.set('wantedCollections', 'blue.checkmate.game'); +jetstreamUrl.searchParams.set('wantedDids', opponentDid); + +const ws = new WebSocket(jetstreamUrl.toString()); + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.kind === 'commit' && data.commit.operation === 'update') { + // Opponent updated their game record — they made a move + const record = data.commit.record; + if (record.pgn) { + // Validate the new move with chess.js + // Update the local board + } + } +}; +``` + +### Jetstream Event Structure + +Events arrive as JSON with a `kind` field. For record updates: + +```typescript +{ + kind: 'commit', + did: 'did:plc:opponent', + commit: { + operation: 'update', // or 'create', 'delete' + collection: 'blue.checkmate.game', + rkey: 'abc123', + record: { /* the full updated record */ }, + cid: 'bafyrei...' + }, + time_us: 1234567890 +} +``` + +### Fallback Polling + +If Jetstream connection drops, fall back to polling `getRecord` every 3 seconds until WebSocket reconnects: + +```typescript +const response = await agent.com.atproto.repo.getRecord({ + repo: opponentDid, + collection: 'blue.checkmate.game', + rkey: opponentRkey, +}); +``` + +--- + +## Microcosm Integration + +### Constellation — Game Discovery + +Query Constellation to find all games a player is involved in, without maintaining a local index. + +```typescript +// Find all game records that link to a player's DID +const response = await fetch( + `https://constellation.microcosm.blue/links/all?` + + `target=${encodeURIComponent(playerDid)}` + + `&collection=blue.checkmate.game` +); +``` + +This returns all records across the network that reference the player's DID — i.e., games where they appear as `white` or `black`. + +### Slingshot — Handle Resolution & Record Cache + +Resolve DIDs to handles (and vice versa) and get fast cached access to records: + +```typescript +// Resolve a DID to handle/profile info +const response = await fetch( + `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?` + + `identifier=${encodeURIComponent(did)}` +); +``` + +Useful for displaying opponent handles/avatars without querying the PDS directly. + +--- + +## Pages & Routes + +### `/` — Landing Page + +- Logo/mascot placeholder + tagline +- "Sign in with Bluesky" button +- If authenticated: player handle, avatar, quick actions +- Active games list (fetched from Constellation or direct PDS query) + +### `/play` — Create or Browse Challenges + +- Create a new challenge (open or directed at a specific handle) +- Browse open challenges from other players (via Constellation backlinks on the player declaration records) + +### `/challenge/{did}/{rkey}` — View / Accept Challenge + +- Shows who created the challenge +- "Accept & Play" button +- On accept: creates game records, redirects to game page + +### `/game/{did}/{rkey}` — Active Game + +The main gameplay page. The URL contains the game owner's DID and record key. + +**Layout (mobile-first):** + +``` +┌──────────────────────────┐ +│ Opponent handle + avatar │ +├──────────────────────────┤ +│ │ +│ Chessground Board │ +│ (responsive, square) │ +│ │ +├──────────────────────────┤ +│ Your handle + avatar │ +├──────────────────────────┤ +│ [Resign] [Offer Draw] │ +└──────────────────────────┘ +``` + +Desktop: board centered, player info and move list in a sidebar. + +**Chessground integration:** + +```typescript +import { Chessground } from 'chessground'; +import 'chessground/assets/chessground.base.css'; +import 'chessground/assets/chessground.brown.css'; +import 'chessground/assets/chessground.cburnett.css'; + +const ground = Chessground(boardElement, { + fen: currentFen, + orientation: playerColor, + turnColor: turnColor, + movable: { + free: false, + color: playerColor, + dests: legalMoves, // computed from chess.js + }, + events: { + move: (orig, dest) => handleMove(orig, dest), + }, +}); +``` + +**Promotion handling:** When a pawn reaches the 8th rank, show a modal with piece choices (Q/R/B/N) before writing the move. + +### `/profile/{handle}` — Player Profile (stretch) + +- Display name, handle, avatar from atproto profile +- Game history via Constellation queries +- Win/loss/draw record + +### `/oauth/callback` — OAuth Redirect + +Handles the OAuth callback. The `BrowserOAuthClient` processes the redirect params and stores the session in IndexedDB. + +### `/oauth/client-metadata.json` — OAuth Client Metadata + +Static JSON endpoint required by the atproto OAuth spec. Must be publicly accessible. + +--- + +## File Structure + +``` +checkmate-blue/ +├── src/ +│ ├── lib/ +│ │ ├── oauth.ts # BrowserOAuthClient setup +│ │ ├── atproto.ts # Agent creation, record read/write helpers +│ │ ├── game-logic.ts # chess.js wrapper, move validation, PGN ops +│ │ ├── jetstream.ts # Jetstream WebSocket connection + filtering +│ │ ├── microcosm.ts # Constellation + Slingshot query helpers +│ │ ├── stores/ +│ │ │ ├── auth.ts # Current user / agent store +│ │ │ ├── game.ts # Active game state store +│ │ │ └── jetstream.ts # Jetstream connection state +│ │ ├── components/ +│ │ │ ├── Board.svelte # Chessground wrapper +│ │ │ ├── PlayerBar.svelte # Avatar + handle display +│ │ │ ├── GameControls.svelte # Resign, draw offer buttons +│ │ │ ├── PromotionModal.svelte +│ │ │ ├── MoveList.svelte # PGN move list display +│ │ │ └── LoginButton.svelte # "Sign in with Bluesky" +│ │ └── types.ts # Shared TypeScript types +│ ├── routes/ +│ │ ├── +layout.svelte # Global layout, auth init +│ │ ├── +page.svelte # Landing page +│ │ ├── play/ +│ │ │ └── +page.svelte # Create / browse challenges +│ │ ├── challenge/ +│ │ │ └── [did]/ +│ │ │ └── [rkey]/ +│ │ │ └── +page.svelte # View / accept challenge +│ │ ├── game/ +│ │ │ └── [did]/ +│ │ │ └── [rkey]/ +│ │ │ └── +page.svelte # Main game board +│ │ ├── profile/ +│ │ │ └── [handle]/ +│ │ │ └── +page.svelte # Player profile +│ │ └── oauth/ +│ │ ├── callback/ +│ │ │ └── +page.svelte # OAuth callback handler +│ │ └── client-metadata.json/ +│ │ └── +server.ts # Serve OAuth client metadata +│ └── app.css # Tailwind + chessground overrides +├── lexicons/ +│ ├── blue.checkmate.game.json +│ └── blue.checkmate.challenge.json +├── static/ +│ ├── favicon.svg +│ └── mascot-placeholder.svg +├── svelte.config.js +├── tailwind.config.ts +├── tsconfig.json +└── package.json +``` + +--- + +## Design System + +### Visual Direction + +Dark theme, blue accents. Minimal, clean, mobile-first. + +### Mascot / Logo + +Goose wearing a chess crown — inspired by ATmosphereConf's "Goodstuff Goosetopher." Art to be provided separately (permission pending from original artist). Design all layout slots to accommodate a square mascot image at 48x48 (nav), 128x128 (hero), 32x32 (favicon). + +### Color Palette + +```css +:root { + --bg-primary: #0f1419; + --bg-secondary: #1a2332; + --bg-board: #2a3a4a; + --accent-blue: #1d9bf0; + --accent-blue-hover: #1a8cd8; + --text-primary: #e7e9ea; + --text-secondary: #71767b; + --success: #00ba7c; + --danger: #f4212e; + --warning: #ffd400; + --border: #2f3336; +} +``` + +### Typography + +System sans-serif stack for body and headings. Monospace (`JetBrains Mono` or system monospace) for move notation. + +--- + +## Implementation Order (2-Day Plan) + +### Day 1: Auth + Board + Protocol Writes + +**Morning:** +1. Scaffold SvelteKit project from Bailey's atproto template or flo-bit's client-side OAuth scaffold +2. Adapt OAuth for `@atproto/oauth-client-browser` +3. Serve `client-metadata.json` endpoint +4. Test: can log in with a Bluesky account, get an authenticated agent + +**Afternoon:** +5. Integrate chessground Board component with chess.js +6. Implement `createRecord` for game creation +7. Implement `putRecord` for move writes +8. Build game page that loads game state from PDS on mount +9. Test: can create a game, make moves, see them written to PDS + +### Day 2: Real-time + Polish + +**Morning:** +10. Connect to Jetstream, filter for opponent's DID + game collection +11. When Jetstream delivers an update, validate and apply the opponent's move +12. Add fallback polling for when Jetstream disconnects +13. Test: two browsers, two accounts, can play a full game in real-time + +**Afternoon:** +14. Challenge creation and acceptance flow +15. Resign / draw offer +16. Landing page with branding and auth +17. Responsive layout polish (mobile board sizing) +18. Deploy to Linode (or static host), configure domain +19. Swap in mascot art if available + +--- + +## Edge Cases & Considerations + +- **Move validation is client-side only.** A malicious client could write illegal moves. Acceptable for the PoC. A future version could add a server-side validation proxy. +- **PGN is the source of truth.** Both players maintain their own record with the full PGN. If records diverge (e.g., due to a bug or tampering), the PGN can be diffed to find where they forked. +- **Jetstream may lag.** Events typically arrive within 1-2 seconds. If faster feedback is needed in the future, consider a lightweight signaling WebSocket alongside the protocol writes. +- **Record conflicts.** Since it's turn-based and each player only writes to their own record, there are no concurrent write conflicts. +- **PDS rate limits.** Writing one record per move is fine. A typical game is 40 moves, so 40 `putRecord` calls spread over minutes to hours. Well within any rate limit. +- **Offline / disconnect.** If a player closes their browser, the game state is on the PDS. They can reopen the game URL, read the current PGN, and continue. No session to restore. +- **OAuth token expiry.** `@atproto/oauth-client-browser` handles token refresh automatically via IndexedDB. Shorter token lifetimes (public client) are fine for gameplay sessions. + +--- + +## Deployment + +### Static Hosting (simplest) + +Since there's no server-side logic beyond serving static files and the client-metadata endpoint, the app can be deployed to: +- **Vercel** — SvelteKit has a Vercel adapter +- **Cloudflare Pages** — near-zero latency +- **GitHub Pages** — free, simple +- **Linode** — if you want full control, serve via Caddy + +The `client-metadata.json` must be served at the exact URL matching the `client_id`. If using a static adapter, ensure this route is handled. + +### Custom Domain + +Configure `checkmate.blue` DNS to point to the hosting provider. If using Caddy on Linode: + +```caddyfile +checkmate.blue { + root * /var/www/checkmate-blue + file_server + try_files {path} /index.html # SPA fallback +} +``` + +--- + +## What This Spec Does NOT Cover (v2+) + +- Server-side move validation +- ELO rating calculation +- Spectator mode +- Tournament system +- Full chess clocks / time controls +- Anti-cheat +- Custom PDS or relay +- Chat / messaging +- Sound effects +- Custom board themes / piece sets +- Move timer / timeout enforcement +- Game search / filtering beyond Constellation queries +- Bluesky post integration (sharing game results) diff --git a/lexicons/blue.checkmate.game.json b/lexicons/blue.checkmate.game.json index 4ddd6d5..677c36e 100644 --- a/lexicons/blue.checkmate.game.json +++ b/lexicons/blue.checkmate.game.json @@ -65,9 +65,14 @@ }, "resultReason": { "type": "string", - "knownValues": ["checkmate", "resignation", "draw_agreement", "stalemate", "insufficient", "repetition", "fifty_moves"], + "knownValues": ["checkmate", "resignation", "agreement", "stalemate", "insufficient", "repetition", "fifty_moves", "abandonment"], "description": "Reason for the game result" }, + "lastMoveAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp of the most recent move" + }, "parentGameUri": { "type": "string", "format": "at-uri", diff --git a/plans/e2e-test-plan.md b/plans/e2e-test-plan.md new file mode 100644 index 0000000..25bfaf7 --- /dev/null +++ b/plans/e2e-test-plan.md @@ -0,0 +1,166 @@ +# E2E Test Plan -- Phase 1 + Spectator Mode + +Manual tests for the changes on the `phase-1-challenge-invite-flow` branch. You need two Bluesky accounts and two browser windows (or one regular + one incognito). + +**Accounts:** Call them Account A and Account B. Use separate browser profiles or windows so their sessions don't conflict. + +--- + +## Test 1: Targeted Challenge (White) + +**Tests:** Game creation with color selection, opponent resolution, challenge acceptance, basic gameplay. + +1. Sign in as Account A. +2. Go to `/play`. +3. Enter Account B's handle in the opponent field. +4. Leave color selection on "White" (default). +5. Click "Challenge Player". +6. Verify you land on `/game/{A-did}/{rkey}` with the "Waiting for opponent" panel. +7. Verify the link input shows the correct game URL. +8. Verify the "Post to Bluesky" and "Send via DM" buttons are visible. +9. In the second browser, sign in as Account B. +10. Navigate to the game URL from step 6. +11. **Expected:** Account B joins as Black. The board shows from Black's perspective. The "Waiting for opponent" panel disappears on Account A's screen. +12. Make a move as Account A (White). Verify Account B's board updates within a few seconds. +13. Make a move as Account B (Black). Verify Account A's board updates. + +--- + +## Test 2: Targeted Challenge (Black) + +**Tests:** Challenger-as-black color assignment. + +1. Sign in as Account A. Go to `/play`. +2. Enter Account B's handle. +3. Select "Black" in the color selector. +4. Click "Challenge Player". +5. Verify you land on the game page. The board should show from Black's perspective (black pieces at bottom). +6. In the second browser, sign in as Account B and navigate to the game URL. +7. **Expected:** Account B joins as White. Their board shows from White's perspective. +8. Account B makes the first move (they're White). Verify Account A sees it. +9. Account A makes a move. Verify Account B sees it. + +--- + +## Test 3: Random Color + +**Tests:** Random color assignment. + +1. Sign in as Account A. Go to `/play`. +2. Enter Account B's handle. +3. Select "Random". +4. Click "Challenge Player". +5. Note which color Account A was assigned (check the board orientation). +6. Have Account B join via the game URL. +7. **Expected:** Account B gets the opposite color. Both boards orient correctly. + +--- + +## Test 4: Post Challenge to Bluesky + +**Tests:** Bluesky post creation with mention facet. + +1. Create a targeted challenge (any color) as Account A. +2. On the waiting screen, click "Post to Bluesky". +3. **Expected:** Button changes to "Posted!". +4. Open Account A's Bluesky profile (bsky.app). Verify a post exists mentioning Account B with the game link. +5. On Account B's Bluesky, verify they received a notification (mention). +6. Click the game link in the post. **Expected:** It navigates to the game page. + +--- + +## Test 5: Send via DM + +**Tests:** DM workflow (clipboard + messages page). + +1. Create a targeted challenge as Account A. +2. On the waiting screen, click "Send via DM". +3. **Expected:** Button text changes to "Link copied! Paste in DM". A new tab opens to `bsky.app/messages`. +4. Verify the game URL is in your clipboard (paste it somewhere to check). +5. In the Bluesky messages tab, find or start a conversation with Account B and paste the link. + +--- + +## Test 6: Spectator Mode (Logged In) + +**Tests:** Third-party viewing of an active game. + +1. Start a game between Account A and Account B (use a targeted challenge, have both join). +2. Make a few moves so the game is active. +3. Open a third browser window (or incognito). Sign in as a third account (or use Account A/B on a different game they're not part of). +4. Navigate to the game URL. +5. **Expected:** + - Board loads showing the current position from White's perspective. + - "Spectating" label is visible. + - "Flip board" button is visible. + - No resign/draw buttons. + - No legal move indicators when hovering pieces. + - The board is not interactive (can't drag pieces). +6. Click "Flip board". **Expected:** Board orientation toggles to Black's perspective. +7. Have one of the players make a move. **Expected:** The spectator's board updates in real-time. + +--- + +## Test 7: Spectator Mode (Not Logged In) + +**Tests:** Unauthenticated game viewing. + +1. Start an active game between Account A and Account B. +2. Open a fresh incognito window (not signed in). +3. Navigate to the game URL. +4. **Expected:** + - Board loads with the current position. No sign-in prompt blocking the view. + - "Spectating" label and "Flip board" button visible. + - "Live" connection indicator visible (Jetstream connects for spectators). + - A small "Sign in to play" section appears below the board (not blocking the view). +5. Have a player make a move. **Expected:** Spectator board updates live. + +--- + +## Test 8: Game Full -- Graceful Fallback + +**Tests:** Multiple people clicking the same game link. + +1. Account A creates an open game (no opponent handle, just click "Create Open Game"). +2. Share the game URL with Account B. +3. Account B navigates to the URL and joins (fills the empty slot). +4. Now open the same URL in a third browser/profile, signed in as a different account. +5. **Expected:** The third person sees the game in spectator mode, not an error. Both player slots are already filled. + +--- + +## Test 9: Check-Before-Join Guard + +**Tests:** Race condition mitigation when two people try to join simultaneously. + +This is hard to trigger manually but you can approximate it: + +1. Account A creates an open game. +2. Account B opens the game URL but does NOT let it finish loading (throttle network in dev tools, or just be ready). +3. Meanwhile, have Account C open the same URL and let it load fully -- Account C joins. +4. Now let Account B's page finish loading. +5. **Expected:** Account B sees the game in spectator mode (the slot was filled by C between B's two fetches). + +--- + +## Test 10: Completed Game Viewing + +**Tests:** Spectators can view finished games. + +1. Play a game to completion (checkmate, or have one player resign). +2. Verify both players see the result. +3. Open the game URL in an incognito window (not logged in). +4. **Expected:** Board shows the final position. Result is displayed (e.g., "White wins -- checkmate"). No interactive elements. "Spectating" label visible. + +--- + +## Quick Checks + +These don't need a full walkthrough but are worth verifying: + +- [ ] The color selector on `/play` visually highlights the selected option. +- [ ] "Copy" button on the waiting screen copies the correct URL and shows "Copied!" briefly. +- [ ] Connection indicator shows "Live" (green dot) when Jetstream is connected. +- [ ] The Bluesky post from Test 4 has a clickable @mention (not plain text). +- [ ] Navigating away from a game page and back doesn't create duplicate Jetstream connections (check console for `[jetstream] connecting` logs). +- [ ] An existing game where the owner was always White (pre-color-selection) still loads correctly. diff --git a/plans/phase-1-challenge-invite-flow.md b/plans/phase-1-challenge-invite-flow.md new file mode 100644 index 0000000..1735724 --- /dev/null +++ b/plans/phase-1-challenge-invite-flow.md @@ -0,0 +1,70 @@ +# Phase 1: Challenge & Invite Flow + +Status: **Implemented** (branch: `phase-1-challenge-invite-flow`) + +--- + +## What Was Implemented + +### 1a. Post-Based Challenges -- DONE + +"Post to Bluesky" button on the game waiting screen. Creates a `app.bsky.feed.post` in the challenger's repo with: +- Text mentioning the opponent: "I'm challenging @{handle} to a game of chess on checkmate.blue!" +- Mention facet with correct UTF-8 byte offsets +- Link facet for the game URL +- `app.bsky.embed.external` with title and description for the link card + +Only shows when a specific opponent is set (not for open games). Button disables after posting to prevent double-posts. + +**Files created:** +- `src/lib/bluesky.ts` -- `buildFacets()`, `postToBluesky()`, `composeChallengePost()` +- `tests/lib/bluesky.test.ts` -- 9 tests covering facet construction, byte offsets with unicode, handle edge cases + +**Files modified:** +- `src/routes/game/[did]/[rkey]/+page.svelte` -- added post button in waiting state + +### 1b. DM-Based Challenges -- DONE (simplified) + +Originally planned as a `chat.bsky.convo` API integration. Investigation revealed that `transition:generic` scope does NOT cover `chat.bsky.*` operations -- adding DM support would require the `transition:chat.bsky` scope, broadening the permission grant. + +**Implemented instead:** "Send via DM" button that copies the game link to clipboard and opens `https://bsky.app/messages` in a new tab. The user pastes the link in the conversation. Not as seamless as API-driven DMs, but works without additional OAuth scope. + +**Files modified:** +- `src/routes/game/[did]/[rkey]/+page.svelte` -- added DM button in waiting state + +### 1c. Color Selection -- DONE + +White/Black/Random segmented control on the `/play` form. Game page logic fully decoupled from record ownership: + +- Owner creates canonical record regardless of their color +- `white`/`black` fields determine color, independent of who owns the record +- Non-owner creates child record with `parentGameUri` regardless of their color +- Jetstream, record pairing, and join logic all work for either color assignment +- `createGame` now accepts optional `white` param (was required) + +**Files modified:** +- `src/routes/play/+page.svelte` -- color selector UI, `resolveColors()` helper +- `src/routes/game/[did]/[rkey]/+page.svelte` -- `loadGame()` rewritten for color-agnostic logic, `joinAsBlack` renamed to `joinGame`, `waitForOpponent` handles either color +- `src/lib/atproto.ts` -- `white` param made optional in `createGame` +- `src/lib/types.ts` -- `challengerColor` added to `ChallengeRecord` +- `lexicons/blue.checkmate.challenge.json` -- `challengerColor` field added + +### Also: Check-Before-Join Guard + +Before creating a child record to join a game, the game page re-fetches the owner's record to verify the slot is still empty. If it's been filled (race condition), the viewer falls into spectator mode instead of creating an orphaned record. + +--- + +## What Was NOT Implemented + +- **Editable post text** -- the Bluesky post uses a fixed template. An editable textarea (planned for Phase 5's game result sharing) was deferred. +- **Bluesky post text adjusting for color choice** -- the post always says "I'm challenging @handle to a game of chess." It doesn't mention which color the challenger picked. Could be added later. +- **API-driven DM sending** -- requires `transition:chat.bsky` scope. Deferred unless there's a strong reason to broaden permissions. + +--- + +## Decisions Made During Implementation + +- **OAuth scope is already minimal.** `atproto transition:generic` is the tightest scope available today. There are no collection-level scopes yet. The `transition:` prefix signals these are temporary and will be replaced by granular permissions eventually. +- **DM deep linking doesn't work.** Bluesky message URLs use conversation IDs (`bsky.app/messages/{convoId}`), not DIDs. Resolving the conversation ID requires `chat.bsky` scope. The fallback (open inbox) is acceptable. +- **Record ownership and color are now fully independent.** The game URL always uses the creator's DID/rkey. The `white`/`black` fields determine who plays what. This is a meaningful architectural change from the original design where owner=White was assumed. diff --git a/plans/phase-2-shareability-branding.md b/plans/phase-2-shareability-branding.md new file mode 100644 index 0000000..9899aa5 --- /dev/null +++ b/plans/phase-2-shareability-branding.md @@ -0,0 +1,212 @@ +# Phase 2: Shareability & Branding + +Priority: **High** + +## Problem + +The app has no logo and no social card metadata. Shared links on Bluesky, Twitter, and Discord show bare URLs with no preview. This hurts discoverability and makes the platform look unfinished. + +## Current State + +- `app.html` has a plain `checkmate.blue` and no OG meta tags. +- `+layout.svelte` sets ``. +- `static/favicon.svg` and `static/mascot-placeholder.svg` exist as placeholders. +- The app is a pure SPA: SSR disabled, static adapter with `fallback: index.html`. Every route serves the same HTML shell. + +**Key files:** +- `src/app.html` -- HTML shell +- `src/routes/+layout.svelte` -- global layout, head tags +- `static/` -- static assets + +--- + +## 2a. Logo + +### Overview + +A FontAwesome chess knight icon colored in AT Protocol blue (`#0085FF`) on the existing dark background (`#0f1419`). + +### Implementation + +**Create `static/logo.svg`:** + +Extract the SVG path from FontAwesome's `chess-knight-piece` (solid variant, FA Free). The FA Free license (CC BY 4.0 for icons) allows use with attribution. Include a comment in the SVG referencing FontAwesome. + +The SVG should be: +- Viewbox: `0 0 384 512` (standard FA dimensions for this icon) +- Fill: `#0085FF` +- No background (transparent) + +**Create `static/favicon.svg`** (replace existing): + +Same knight icon, sized for favicon use. SVG favicons scale naturally, so this can be the same file or a simplified version. + +**Create `static/og-default.png`:** + +A 1200x630 image (standard OG image dimensions) with: +- Background: `#0f1419` (the app's `--bg-primary`) +- Centered knight icon in `#0085FF` +- "checkmate.blue" text below the icon in white +- "Chess on the Atmosphere" subtitle in `#71767b` + +This can be generated once as a static asset. Use any image tool or a build script. + +**Create PNG variants for PWA (Phase 6e, but generate now):** + +- `static/icon-192.png` -- 192x192, knight on dark background +- `static/icon-512.png` -- 512x512, knight on dark background +- `static/apple-touch-icon.png` -- 180x180 + +**Update `+layout.svelte`:** + +Replace the favicon reference. Add Apple touch icon. + +```svelte + + + + +``` + +**Update navbar in `+layout.svelte`:** + +Replace the text-only "checkmate.blue" link with the logo icon + text: + +```svelte + + + checkmate.blue + +``` + +### Acceptance Criteria + +- [ ] `static/logo.svg` exists with the knight icon in `#0085FF`. +- [ ] `static/favicon.svg` replaced with the knight icon. +- [ ] `static/og-default.png` exists at 1200x630 with logo + text on dark background. +- [ ] Navbar shows the logo icon alongside the site name. +- [ ] Favicon displays correctly in browser tabs. + +### Edge Cases + +- SVG favicon support: all modern browsers support SVG favicons. No PNG fallback needed for the PoC, but the Apple touch icon covers iOS. + +--- + +## 2b. Open Graph Meta Tags + +### Overview + +Add meta tags so shared links show rich previews. Start with static/generic tags (same card for all routes), with a plan for dynamic per-route tags via a Cloudflare Worker later. + +### Implementation + +#### Step 1: Static Meta Tags (Immediate) + +**Update `src/app.html`:** + +Add OG and Twitter Card meta tags to the ``: + +```html + + + + + + + + + + +``` + +This gives every page the same generic card. Not ideal but functional. + +#### Step 2: Dynamic OG Tags via Cloudflare Worker (Fast Follow) + +**Separate deployment** -- a Cloudflare Worker sits in front of the static site and intercepts requests from known bot user agents. + +**Bot user agents to detect:** + +``` +Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, +WhatsApp, TelegramBot, Bluesky (cardyb) +``` + +**Worker logic:** + +1. Check `User-Agent` header against bot list. +2. If not a bot, pass through to the static site (origin). +3. If a bot, parse the URL path: + - `/game/{did}/{rkey}` -- fetch game record from PDS, extract player handles/status/move count, return HTML with dynamic OG tags. + - `/challenge/{did}/{rkey}` -- fetch challenge record, return HTML with challenger info. + - `/profile/{handle}` -- resolve handle, return HTML with profile info. + - All other routes -- return generic OG tags. +4. Return a minimal HTML document with just the `` containing OG tags. The body can be empty or contain a redirect meta tag -- bots only read the head. + +**Dynamic OG content per route:** + +| Route | og:title | og:description | og:image | +|-------|----------|---------------|----------| +| `/` | checkmate.blue | Chess on the Atmosphere | `/og-default.png` | +| `/game/{did}/{rkey}` | {whiteHandle} vs {blackHandle} | {status} -- {moveCount} moves played | `/og-default.png` (or dynamic board image later) | +| `/challenge/{did}/{rkey}` | Chess Challenge from {handle} | {handle} wants to play chess on checkmate.blue | `/og-default.png` | +| `/profile/{handle}` | {handle} on checkmate.blue | Chess profile on checkmate.blue | `/og-default.png` | + +**Worker needs to:** +- Resolve DIDs to handles via Slingshot or public API. +- Fetch game records via public `getRecord` (no auth needed). +- Cache responses (OG tags don't need to be real-time; 5-minute TTL is fine). + +**This is a separate deployment artifact** but can live in this repo under a `worker/` directory, or in its own repo. Decision left to implementation time. + +#### Step 3: Dynamic OG Images (Nice-to-Have) + +Generate per-game OG images showing the board position. This would be a separate service (Cloudflare Worker with `@cloudflare/pages-plugin-satori` or similar) that: + +1. Receives a request like `/og/game/{did}/{rkey}.png`. +2. Fetches the game record, extracts the FEN from the PGN. +3. Renders a chess board as an SVG/PNG with player names, move count, and status. +4. Returns the image with cache headers. + +The Cloudflare Worker from Step 2 would reference this URL in the `og:image` tag for game routes. + +**Deferred** -- the static `og-default.png` is sufficient for launch. + +### Acceptance Criteria + +#### Step 1 (Static) +- [ ] `app.html` contains OG and Twitter Card meta tags. +- [ ] Sharing any checkmate.blue URL on Bluesky/Twitter/Discord shows a card with the logo image, title, and description. + +#### Step 2 (Dynamic Worker) +- [ ] Sharing a game URL shows the player handles and game status in the card. +- [ ] Sharing a challenge URL shows the challenger's handle. +- [ ] Non-bot requests pass through to the SPA with no change in behavior. +- [ ] Worker responses are cached (5-minute TTL). + +### Edge Cases + +- Bot user agent detection is imperfect. Some bots may not be caught (they get generic tags -- acceptable). Some real users may be misidentified as bots (they get the minimal HTML -- the meta refresh or JS redirect handles this). +- Game records may not exist (deleted, invalid rkey) -- worker falls back to generic tags. +- Handle resolution may fail -- use the DID as fallback text. + +--- + +## Files to Create + +| File | Purpose | +|------|---------| +| `static/logo.svg` | Knight logo in AT Protocol blue | +| `static/og-default.png` | Default OG card image (1200x630) | +| `static/icon-192.png` | PWA icon | +| `static/icon-512.png` | PWA icon | +| `static/apple-touch-icon.png` | iOS home screen icon | + +## Files to Modify + +| File | Changes | +|------|---------| +| `src/app.html` | Add OG and Twitter Card meta tags | +| `src/routes/+layout.svelte` | Update favicon ref, add apple-touch-icon, add logo to navbar | +| `static/favicon.svg` | Replace with knight logo | diff --git a/plans/phase-3-spectator-mode.md b/plans/phase-3-spectator-mode.md new file mode 100644 index 0000000..1af1859 --- /dev/null +++ b/plans/phase-3-spectator-mode.md @@ -0,0 +1,61 @@ +# Phase 3: Spectator Mode + +Status: **Implemented** (branch: `phase-1-challenge-invite-flow`) + +Pulled forward and implemented alongside Phase 1 because spectator mode is the natural fallback when multiple people click a shared game link. + +--- + +## What Was Implemented + +### Read-Only Game View -- DONE + +Non-participants (logged in or not) see a fully functional spectator view: + +- Board rendered in view-only mode (`movable: false`, no legal move indicators) +- "Spectating" label displayed +- "Flip board" button toggles between White/Black perspective +- GameControls (resign, draw offer) hidden +- Player bars show handles with correct active-turn indicators based on board orientation +- Game result displayed for completed games + +### Unauthenticated Viewing -- DONE + +Non-logged-in users can view any game. The `$effect` that triggers `loadGame()` now fires after auth initialization completes regardless of login status. Record reads use public (unauthenticated) agents. + +**New helpers in `atproto.ts`:** +- `getGamePublic(did, rkey)` -- reads a game record without authentication +- `findGameRecordByParentPublic(did, parentUri)` -- finds child records without authentication + +### Dual Jetstream Connections -- DONE + +Spectators subscribe to both players' DIDs via two separate `JetstreamConnection` instances. The `jsConnections` array (replacing the single `jsConnection`) tracks all connections and cleans them up on navigation. + +Connection status indicator shows "Live" when at least one connection is active. + +### Game-Full Fallback -- DONE + +When a non-participant loads a game where both slots are filled, they're placed in spectator mode instead of seeing an error. This handles: +- Multiple people clicking a publicly shared link +- Someone visiting a game they're not part of +- Direct link sharing for spectating + +### Check-Before-Join -- DONE + +Before creating a child record to join, the game page re-fetches the owner's record to verify the slot is still open. If filled between the first fetch and the re-fetch, the viewer enters spectator mode. This reduces (but doesn't eliminate) orphaned records from race conditions. + +--- + +## What Was NOT Implemented + +- **Spectator count** -- would need a server component. Out of scope. +- **Partial connection indicator** -- the spec proposed a yellow "warning" state when one of two Jetstream connections drops. The implementation uses a simpler binary (connected if any connection is live). + +--- + +## Files Modified + +| File | Changes | +|------|---------| +| `src/routes/game/[did]/[rkey]/+page.svelte` | `isSpectator` state, unauthenticated loading, `reconcileSpectator()`, `connectJetstreamSpectator()`, `destroyConnections()`, flip board, spectator UI | +| `src/lib/atproto.ts` | `getGamePublic()`, `findGameRecordByParentPublic()` | diff --git a/plans/phase-4-homepage-game-discovery.md b/plans/phase-4-homepage-game-discovery.md new file mode 100644 index 0000000..f558198 --- /dev/null +++ b/plans/phase-4-homepage-game-discovery.md @@ -0,0 +1,291 @@ +# Phase 4: Homepage & Game Discovery + +Priority: **Medium** + +## Problem + +The homepage only shows the logged-in user's own games as a list of opaque rkey links. There's no way to see what's happening on the platform, no way to discover active games to watch, and the game list provides no useful information at a glance. New visitors see nothing but a login prompt. + +## Current State + +**Homepage (`src/routes/+page.svelte`):** + +- Logged in: shows "Your Games" fetched via `findGamesForPlayer(did)` from Constellation. Each game is displayed as a clickable rkey string (line 71) -- no player names, no status, no move count. +- Not logged in: shows title + login form. No content for unauthenticated visitors. + +**Constellation query (`src/lib/microcosm.ts`):** + +`findGamesForPlayer(did)` queries `constellation.microcosm.blue/links/all?target={did}&collection=blue.checkmate.game`. This returns all game records across the network that reference the given DID (games where they appear as white or black). Returns `{ uri, collection, path, target }` -- just record URIs, no record content. + +**Deduplication problem:** Each game has two records (White's and Black's). Constellation returns both for any player. The homepage currently shows all of them, leading to duplicates. + +**Key files:** +- `src/routes/+page.svelte` -- homepage +- `src/lib/microcosm.ts` -- Constellation/Slingshot helpers +- `src/lib/atproto.ts` -- record CRUD +- `src/lib/types.ts` -- record types + +--- + +## Lexicon Change: `lastMoveAt` + +Add a `lastMoveAt` field to the `blue.checkmate.game` lexicon. This is needed for sorting games by recent activity without parsing PGN. + +**In `lexicons/blue.checkmate.game.json`**, add to `properties`: + +```json +"lastMoveAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp of the most recent move" +} +``` + +**In `src/lib/types.ts`**, add to `GameRecord`: + +```typescript +lastMoveAt?: string; +``` + +**Update `writeMove` in the game page** to include `lastMoveAt`: + +```typescript +await updateGame(auth.agent, myRkey, { + pgn, + lastMoveAt: new Date().toISOString(), + status: game.result ? 'completed' : 'active', + // ... existing fields +}); +``` + +This is backward-compatible -- existing records without `lastMoveAt` still work; they just sort to the bottom or use `createdAt` as fallback. + +--- + +## 4a. User's Active Games (Improved) + +### Overview + +Replace the current bare rkey list with rich game cards showing player handles, game status, move count, and whose turn it is. + +### Implementation + +**Fetch game records, not just URIs:** + +The current flow gets URIs from Constellation, then renders them directly. To show rich info, we need to fetch the actual game records. + +```typescript +async function loadGames(did: string) { + const links = await findGamesForPlayer(did); + + // Deduplicate: only keep records where we are the owner (our DID in the URI) + // OR where there is no parentGameUri (White's canonical record). + // Since we can't filter without reading, fetch all and filter. + const gamePromises = links.map(async (link) => { + const parsed = parseAtUri(link.uri); + if (!parsed) return null; + const record = await getGamePublic(parsed.did, parsed.rkey); + if (!record) return null; + return { ...parsed, record }; + }); + + const results = await Promise.all(gamePromises); + + // Deduplicate: if we have both White's and Black's record for the same game, + // keep only the one WITHOUT parentGameUri (White's canonical record). + const canonical = results.filter((g) => g && !g.record.parentGameUri); + + // Sort: active games first, then by lastMoveAt (or createdAt fallback), descending. + games = canonical + .sort((a, b) => { + const aTime = a.record.lastMoveAt || a.record.createdAt; + const bTime = b.record.lastMoveAt || b.record.createdAt; + return bTime.localeCompare(aTime); + }); +} +``` + +**Performance concern:** Fetching N game records individually is slow. Mitigation: +- Constellation typically returns a bounded number of results. +- Use `Promise.all` for parallel fetches. +- Consider adding Slingshot caching for records if available. +- Limit to most recent 20 games initially. + +**Game card component: `src/lib/components/GameCard.svelte`** + +Displays a single game in the list: + +``` +┌──────────────────────────────────────┐ +│ whiteHandle vs blackHandle │ +│ Active -- 24 moves -- White to move │ +│ Last activity: 5 minutes ago │ +└──────────────────────────────────────┘ +``` + +Props: +- `game: { did: string, rkey: string, record: GameRecord }` + +The component resolves handles from DIDs using `resolveIdentity`. To avoid N+1 handle resolution, batch-resolve or cache results. + +**Handle caching:** + +Create a simple in-memory handle cache in `microcosm.ts`: + +```typescript +const handleCache = new Map(); + +export async function resolveHandle(did: string): Promise { + if (handleCache.has(did)) return handleCache.get(did)!; + const profile = await resolveIdentity(did); + const handle = profile?.handle ?? did; + handleCache.set(did, handle); + return handle; +} +``` + +**Status display:** +- `waiting` -- "Waiting for opponent" +- `active` -- "{turnColor} to move -- {moveCount} moves" +- `completed` -- Result + reason (e.g., "White wins by checkmate") +- `abandoned` -- "Abandoned" + +**Relative time display:** + +Use a simple relative time formatter (no library needed): + +```typescript +function timeAgo(iso: string): string { + const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (seconds < 60) return 'just now'; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; +} +``` + +### Homepage Layout Update + +```svelte +{#if auth.isLoggedIn} + + + {#if loadingGames} +

Loading games...

+ {:else} + {#if activeGames.length > 0} +
+

Your Active Games

+ {#each activeGames as game} + + {/each} +
+ {/if} + + {#if completedGames.length > 0} +
+

Completed

+ {#each completedGames.slice(0, 5) as game} + + {/each} +
+ {/if} + {/if} +{/if} +``` + +Split games into `activeGames` (status `waiting` or `active`) and `completedGames` (status `completed`). + +--- + +## 4b. Global Active Games Feed + +### Overview + +Show active games from all players on the homepage, visible to everyone (including non-logged-in visitors). This makes the platform feel alive. + +### Discovery Challenge + +Constellation queries require a target DID -- there's no "give me all records of this collection" global query. This means we can't easily discover games from unknown players. + +**Options:** + +1. **Jetstream client-side indexing:** Connect to Jetstream filtered on `blue.checkmate.game` (no DID filter) and build a local index of active games. Shows only games with activity since the page loaded. Ephemeral -- lost on refresh. + +2. **Known-players seed list:** Maintain a small list of known active players (hardcoded or fetched from a config). Query each player's games via Constellation. Doesn't scale, but works for early days when the player base is small. + +3. **Relay/indexer service (separate project):** A server-side Jetstream listener that indexes all `blue.checkmate.game` records and exposes an API. This is the proper solution but breaks the "no server" constraint for the main app. Could be bundled with the bot project. + +4. **Constellation global query:** Investigate if Constellation supports querying by collection without a target DID. If `target` is optional, `collection=blue.checkmate.game` might return all game records. + +**Recommended approach:** Start with option 1 (Jetstream client-side) for the "Live Games" section, and option 2 (seed list) for a "Recent Games" section. Plan for option 3 as part of the bot/indexer project. + +### Jetstream Live Feed Implementation + +Connect to Jetstream on the homepage with `wantedCollections=blue.checkmate.game` (no DID filter). As game updates arrive, build a local map of active games: + +```typescript +const liveGames = new Map(); + +function handleJetstreamEvent(event: JetstreamEvent) { + if (event.commit.operation === 'update' || event.commit.operation === 'create') { + const record = event.commit.record as GameRecord; + // Only track canonical records (no parentGameUri) + if (!record.parentGameUri && record.status === 'active') { + const key = `${event.did}/${event.commit.rkey}`; + liveGames.set(key, { did: event.did, rkey: event.commit.rkey, record }); + } + } +} +``` + +Display these as a "Live Games" section on the homepage. Sort by most recent update. Show a note: "Showing games with activity since you opened this page." + +**Considerations:** +- Without a DID filter, Jetstream sends all `blue.checkmate.game` events. At small scale this is fine. At large scale it could be noisy -- but by that point you'd want the indexer (option 3). +- Rate limit the UI updates (debounce or batch every second). +- Disconnect the Jetstream connection when the user navigates away from the homepage. + +--- + +## Acceptance Criteria + +### 4a (User's Games) +- [ ] Homepage shows the user's active games as rich cards with player handles, status, move count, and last activity. +- [ ] Games are deduplicated (only canonical/White's records shown). +- [ ] Games are sorted by most recent activity. +- [ ] Completed games shown in a separate section. +- [ ] Game cards link to the game page. + +### 4b (Global Feed) +- [ ] Non-logged-in visitors see a "Live Games" section showing active games discovered via Jetstream. +- [ ] Games update in real-time as moves are made. +- [ ] Only canonical records (no `parentGameUri`) are shown. +- [ ] Jetstream connection is cleaned up on navigation away from homepage. + +## Edge Cases + +- Player has no games: show "No games yet" with a prompt to create one. +- Game record fetch fails (PDS down): skip that game, don't break the list. +- Many games (>20): paginate or limit to most recent 20 with a "Show more" option. +- `lastMoveAt` not set on old records: fall back to `createdAt` for sorting. +- Constellation returns stale data (deleted records): gracefully handle 404s on individual record fetches. + +--- + +## Files to Create + +| File | Purpose | +|------|---------| +| `src/lib/components/GameCard.svelte` | Rich game card for lists | + +## Files to Modify + +| File | Changes | +|------|---------| +| `src/routes/+page.svelte` | Redesigned homepage with rich game lists, Jetstream live feed | +| `src/lib/microcosm.ts` | Add handle caching | +| `src/lib/types.ts` | Add `lastMoveAt` to `GameRecord` | +| `src/lib/atproto.ts` | Add `getGamePublic` if not already added in Phase 3 | +| `src/routes/game/[did]/[rkey]/+page.svelte` | Include `lastMoveAt` in `writeMove` calls | +| `lexicons/blue.checkmate.game.json` | Add `lastMoveAt` field | diff --git a/plans/phase-5-game-result-sharing.md b/plans/phase-5-game-result-sharing.md new file mode 100644 index 0000000..62171fb --- /dev/null +++ b/plans/phase-5-game-result-sharing.md @@ -0,0 +1,231 @@ +# Phase 5: Game Result Sharing + +Priority: **Medium** + +## Problem + +When a game ends, there's no way to share the result on Bluesky. This is the viral loop: players share their wins (and good games) to their followers, who discover checkmate.blue and start playing. + +## Current State + +**Game completion flow (`src/routes/game/[did]/[rkey]/+page.svelte`):** + +When a game ends (checkmate, stalemate, resignation, draw), the result block shows: +- "White wins" / "Black wins" / "Draw" +- The result reason (e.g., "checkmate") + +There's no sharing option. The game page shows the result and that's it. + +**Reusable infrastructure from Phase 1:** `src/lib/bluesky.ts` already provides `buildFacets()` (auto-detects @mentions and URLs with correct UTF-8 byte offsets) and `postToBluesky()` (creates a Bluesky post with facets and optional embed). This phase adds `composeGameResultPost()` and the editable textarea UI. + +**Key files:** +- `src/routes/game/[did]/[rkey]/+page.svelte` -- game page, result display +- `src/lib/bluesky.ts` -- Bluesky post helpers (created in Phase 1) +- `src/lib/stores/game.svelte.ts` -- game state, result detection +- `src/lib/game-logic.ts` -- `gameResult()` function + +--- + +## Implementation + +### Post Content + +The post should be: +- Brief and natural-sounding +- Include the result and reason +- Mention the opponent (so they see it in notifications) +- Link to the game (so followers can view it via spectator mode) + +**Template variations based on result:** + +``` +Win by checkmate: "Checkmate! I won against @{opponent} on checkmate.blue {gameUrl}" +Win by resignation: "Victory! @{opponent} resigned our game on checkmate.blue {gameUrl}" +Loss by checkmate: "Got checkmated by @{opponent} on checkmate.blue -- good game! {gameUrl}" +Loss by resignation: (player resigned -- they may not want to broadcast this. Still offer the option.) +Draw (stalemate): "Stalemate with @{opponent} on checkmate.blue {gameUrl}" +Draw (agreement): "Agreed to a draw with @{opponent} on checkmate.blue {gameUrl}" +Draw (repetition): "Draw by repetition with @{opponent} on checkmate.blue {gameUrl}" +Draw (50 moves): "Draw by 50-move rule with @{opponent} on checkmate.blue {gameUrl}" +Draw (insufficient): "Draw -- insufficient material -- with @{opponent} on checkmate.blue {gameUrl}" +``` + +**Include move count** for flavor: "...in 47 moves" appended when > 10 moves. + +### Editable Post + +Show the pre-filled text in an editable textarea so the player can customize before posting. This is important -- auto-generated text feels impersonal, and players may want to add their own commentary. + +### New Function in `src/lib/bluesky.ts` + +```typescript +export function composeGameResultPost( + myColor: 'white' | 'black', + result: { result: '1-0' | '0-1' | '1/2-1/2'; reason: string }, + opponentHandle: string, + moveCount: number, +): string +``` + +Returns the default post text. Separate from the posting logic so the text can be displayed in the textarea first. + +```typescript +export async function postToBluesky( + agent: Agent, + text: string, + mentionDid?: string, + mentionHandle?: string, + linkUrl?: string, +): Promise<{ uri: string; cid: string }> +``` + +Generalized posting function (also usable by Phase 1's challenge posts). Constructs facets for any mentions and links found in the text. + +**Facet detection:** + +Rather than building facets from parameters, scan the text for `@handle` patterns and URL patterns, then construct facets with correct byte offsets. This way the user can edit the text (move the mention around, add more text) and facets are always correct. + +```typescript +function detectFacets(text: string, knownHandles: Map): Facet[] { + // Find @mentions -- match against knownHandles map to get DIDs + // Find URLs -- https://... patterns + // Return facet array with correct byte offsets +} +``` + +The `knownHandles` map lets us resolve `@handle` to a DID for the mention facet without an API call at post time. + +### UI: Share Button on Game Page + +After the result block (lines 349-362 in the game page), add a "Share to Bluesky" section: + +```svelte +{#if game.result && !isSpectator} +
+ +
+ + {#if !shared} + + {:else} +

Shared!

+ {/if} +{/if} +``` + +**State variables:** + +```typescript +let shareText = $state(''); +let sharing = $state(false); +let shared = $state(false); +let dismissShare = $state(false); +``` + +**Initialize `shareText`** when the game result is detected: + +```typescript +$effect(() => { + if (game.result && !shareText) { + shareText = composeGameResultPost( + game.myColor, + game.result, + opponentHandle ?? 'opponent', + game.moveCount, + ); + } +}); +``` + +### Post Creation + +```typescript +async function shareResult() { + if (!auth.agent || !shareText.trim()) return; + sharing = true; + + const opponentDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; + const gameUrl = `https://checkmate.blue/game/${ownerDid}/${rkey}`; + + // Build known handles map for facet detection + const handles = new Map(); + if (opponentDid && opponentHandle) { + handles.set(opponentHandle, opponentDid); + } + + await postToBluesky(auth.agent, shareText, handles, gameUrl); + shared = true; + sharing = false; +} +``` + +### Link Card Embed + +The post should include an `app.bsky.embed.external` embed with the game URL: + +```typescript +embed: { + $type: 'app.bsky.embed.external', + external: { + uri: gameUrl, + title: `${whiteHandle} vs ${blackHandle}`, + description: `${result.result} by ${result.reason} -- ${moveCount} moves`, + }, +} +``` + +If Phase 2's OG tags are implemented, Bluesky's card fetcher will use those instead. The embed `title`/`description` are fallbacks. + +--- + +## Acceptance Criteria + +- [ ] After a game ends, a "Share to Bluesky" section appears below the result for participants. +- [ ] The share section contains an editable textarea pre-filled with a natural result message. +- [ ] The message mentions the opponent's handle. +- [ ] Clicking "Share to Bluesky" creates a post in the player's Bluesky feed. +- [ ] The post has correct facets: clickable mention for the opponent, clickable link for the game URL. +- [ ] The post includes a link card embed with game info. +- [ ] Character count is shown (Bluesky limit: 300 graphemes). +- [ ] The textarea prevents input beyond 300 characters. +- [ ] A "Dismiss" option hides the share section without posting. +- [ ] After posting, the section shows "Shared!" confirmation. +- [ ] Spectators do not see the share section. +- [ ] Errors during posting show a message and allow retry. + +## Edge Cases + +- Opponent handle not yet resolved when game ends: use DID as fallback in the default text. If handle resolves later, don't overwrite user's edits. +- Player edits the text to remove the @mention: post still works, just without the mention facet. +- Player edits the text to add additional @mentions: the `detectFacets` approach handles this if we resolve the handle. For unknown handles, skip the mention facet (it renders as plain text). +- Game ended by resignation: the resigning player may not want to share. The share section still appears (it's opt-in) but the default text is neutral. +- Text exceeds 300 graphemes: disable the post button, show count in red. Note: grapheme count != string length for emoji/unicode. Use `Intl.Segmenter` for accurate grapheme counting. + +--- + +## Files to Create + +None (Phase 1 creates `src/lib/bluesky.ts`). + +## Files to Modify + +| File | Changes | +|------|---------| +| `src/lib/bluesky.ts` | Add `composeGameResultPost`, generalize `postToBluesky` with facet detection | +| `src/routes/game/[did]/[rkey]/+page.svelte` | Add share section after result block | diff --git a/plans/phase-6-polish.md b/plans/phase-6-polish.md new file mode 100644 index 0000000..940e9c1 --- /dev/null +++ b/plans/phase-6-polish.md @@ -0,0 +1,313 @@ +# Phase 6: Polish + +Priority: **Lower** + +Quality-of-life improvements. Each sub-phase is independent and can be done in any order or interleaved with other work. + +--- + +## 6a. Sound Effects + +### Problem + +The game is silent. No audio feedback on moves, captures, or game events. This makes gameplay feel disconnected, especially when waiting for an opponent's move. + +### Implementation + +**Sound set:** + +Lichess sounds are BSD-licensed. Use the standard set: +- `move.mp3` -- piece placement +- `capture.mp3` -- capture +- `check.mp3` -- move that gives check (optional, some players find this annoying) +- `game-end.mp3` -- checkmate, stalemate, resignation, draw +- `notify.mp3` -- opponent made a move (plays when it's your turn) + +Place in `static/sounds/`. + +**Audio playback helper: `src/lib/sounds.ts`** + +```typescript +const sounds = { + move: () => new Audio('/sounds/move.mp3'), + capture: () => new Audio('/sounds/capture.mp3'), + gameEnd: () => new Audio('/sounds/game-end.mp3'), + notify: () => new Audio('/sounds/notify.mp3'), +}; + +export function playSound(type: keyof typeof sounds): void { + try { + sounds[type]().play(); + } catch { + // Browser may block autoplay; ignore silently + } +} +``` + +Preload sounds on first user interaction to avoid autoplay restrictions. + +**Integration points:** + +- `handleMove()` in the game page: after a successful move, play `move` or `capture` based on whether the move was a capture. chess.js move result includes a `captured` field. +- `applyOpponentMove()`: play `notify` when the opponent moves. +- Game over detection: play `gameEnd`. + +**Detecting captures:** The current `tryMove` and `applyMove` functions in `game-logic.ts` return a boolean. To know if a move was a capture, either: +- Change `applyMove` to return the move object (which has a `captured` field), or +- Check if a piece was on the destination square before the move. + +Simplest: change `applyMove` to return `Move | null` instead of `boolean`, and update callers. + +**Mute toggle:** Add a sound toggle in the nav or game page. Store preference in `localStorage`. + +### Acceptance Criteria + +- [ ] Piece moves play a sound. +- [ ] Captures play a distinct sound. +- [ ] Game-ending events play a sound. +- [ ] Opponent's move triggers a notification sound. +- [ ] Sound preference persists in localStorage. +- [ ] Sound toggle is accessible from the game page. +- [ ] No errors if the browser blocks autoplay. + +--- + +## 6b. Rematch Button + +### Problem + +After a game ends, starting a new game with the same opponent requires navigating to `/play`, entering their handle again, and sharing a new link. A one-click rematch with swapped colors is the expected UX. + +### Implementation + +**UI:** After game result display, add a "Rematch" button alongside the share section (Phase 5). + +```svelte +{#if game.result && !isSpectator} + + + +{/if} +``` + +**Logic:** + +```typescript +async function handleRematch() { + if (!auth.agent || !auth.did) return; + rematchCreating = true; + + // Swap colors + const opponentDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; + const newWhite = game.myColor === 'white' ? opponentDid : auth.did; + const newBlack = game.myColor === 'white' ? auth.did : opponentDid; + + const result = await createGame(auth.agent, { + white: newWhite, + black: newBlack, + status: 'waiting', + }); + + goto(`/game/${auth.did}/${result.rkey}`); +} +``` + +The rematch creates a new game in the current player's repo. The opponent needs to visit the link and join. The "Post to Bluesky" and copy-link UI on the waiting screen handles the invite (Phase 1). + +**Linking rematches:** Could add a `rematchOf` field to the game record pointing to the previous game URI. Nice for history but not essential. Defer unless there's a clear use case. + +### Acceptance Criteria + +- [ ] "Rematch" button appears after game completion for participants. +- [ ] Clicking it creates a new game with colors swapped. +- [ ] Player is redirected to the new game's waiting screen. +- [ ] The opponent can join via the same invite mechanisms (link, post, DM). + +--- + +## 6c. PGN Export & Analysis + +### Problem + +Players can't download their game's PGN or analyze it on external tools. + +### Implementation + +**Download PGN button:** + +Add to the game page (visible for any game, in progress or completed): + +```typescript +function downloadPgn() { + const pgn = makePgn(game.chess, game.whiteDid, game.blackDid); + const blob = new Blob([pgn], { type: 'application/x-chess-pgn' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `checkmate-blue-${rkey}.pgn`; + a.click(); + URL.revokeObjectURL(url); +} +``` + +**Analyze on Lichess button:** + +Lichess accepts PGN via URL for analysis. The import endpoint accepts POST requests with PGN data, but for simplicity use the paste-friendly URL approach: + +```typescript +function openLichessAnalysis() { + const pgn = encodeURIComponent(game.chess.pgn()); + window.open(`https://lichess.org/paste?pgn=${pgn}`, '_blank'); +} +``` + +Note: very long PGNs may exceed URL length limits. For games > ~100 moves, fall back to opening the Lichess import page and letting the user paste. + +Alternative: use the Lichess API to import the game: + +```typescript +const response = await fetch('https://lichess.org/api/import', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `pgn=${encodeURIComponent(game.chess.pgn())}`, +}); +const data = await response.json(); +window.open(data.url, '_blank'); +``` + +This returns a Lichess URL for the imported game with full analysis board. No Lichess auth required for public imports. + +### Acceptance Criteria + +- [ ] "Download PGN" button available on all game pages. +- [ ] Downloaded file has correct PGN content with headers. +- [ ] "Analyze on Lichess" button opens Lichess with the game loaded. +- [ ] Both buttons work for in-progress and completed games. + +--- + +## 6d. Abandoned Game Detection + +### Problem + +Games where one player stops responding have no resolution. The waiting player is stuck. + +### Implementation + +**Client-side convention:** If `lastMoveAt` (Phase 4) is older than a threshold (e.g., 7 days), show an "Abandon" option to the active player. + +```svelte +{#if canAbandon} + +{/if} +``` + +```typescript +const canAbandon = $derived(() => { + if (game.status !== 'active' || !game.isMyTurn === false) return false; + const lastMove = record?.lastMoveAt || record?.createdAt; + if (!lastMove) return false; + const daysSince = (Date.now() - new Date(lastMove).getTime()) / 86400000; + return daysSince > 7; +}); +``` + +**On abandon:** + +```typescript +async function handleAbandon() { + const result = game.myColor === 'white' ? '1-0' : '0-1'; + await updateGame(auth.agent, myRkey, { + status: 'abandoned', + result, + resultReason: 'abandonment', + }); + game.setStatus('completed'); +} +``` + +Note: `abandonment` is not in the current `resultReason` known values. Add it to the lexicon: + +```json +"knownValues": ["checkmate", "resignation", "draw_agreement", "stalemate", + "insufficient", "repetition", "fifty_moves", "abandonment"] +``` + +**No server-side enforcement.** This is a client-side convention. A malicious client could ignore it. The bot project (separate) could handle automated abandonment detection with more authority. + +### Acceptance Criteria + +- [ ] After 7 days of inactivity, the waiting player sees an "Abandon" option. +- [ ] Clicking it marks the game as abandoned with the inactive player losing. +- [ ] The threshold is measured from `lastMoveAt` or `createdAt`. +- [ ] Games in `waiting` status (opponent never joined) can also be abandoned/cancelled by the creator. + +--- + +## 6e. Mobile PWA + +### Problem + +The app works in mobile browsers but doesn't feel native. No home screen icon, no "Add to Home Screen" prompt, no offline shell. + +### Implementation + +**Create `static/manifest.json`:** + +```json +{ + "name": "checkmate.blue", + "short_name": "checkmate", + "start_url": "/", + "display": "standalone", + "background_color": "#0f1419", + "theme_color": "#0085FF", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} +``` + +**Add manifest link to `app.html`:** + +```html + + +``` + +**Service worker (optional):** SvelteKit can generate a service worker, but for a PoC the manifest alone provides the "Add to Home Screen" experience. A service worker for offline shell caching is a nice-to-have but not essential since the app requires network access for all core functionality. + +### Acceptance Criteria + +- [ ] `manifest.json` exists with correct app name, icons, colors. +- [ ] Mobile browsers show "Add to Home Screen" prompt. +- [ ] App launched from home screen uses standalone display mode (no browser chrome). +- [ ] Theme color matches the app's accent blue. + +--- + +## Files to Create + +| File | Purpose | +|------|---------| +| `src/lib/sounds.ts` | Audio playback helper | +| `static/sounds/move.mp3` | Move sound effect | +| `static/sounds/capture.mp3` | Capture sound effect | +| `static/sounds/game-end.mp3` | Game over sound | +| `static/sounds/notify.mp3` | Opponent move notification | +| `static/manifest.json` | PWA manifest | + +## Files to Modify + +| File | Changes | +|------|---------| +| `src/routes/game/[did]/[rkey]/+page.svelte` | Rematch button, PGN export buttons, abandon option, sound integration | +| `src/lib/game-logic.ts` | Change `applyMove` return type to `Move \| null` for capture detection | +| `src/lib/types.ts` | Add `abandonment` to resultReason union | +| `lexicons/blue.checkmate.game.json` | Add `abandonment` to resultReason knownValues | +| `src/app.html` | Add manifest link, theme-color meta | diff --git a/plans/phase-7-open-challenge-board.md b/plans/phase-7-open-challenge-board.md new file mode 100644 index 0000000..2410374 --- /dev/null +++ b/plans/phase-7-open-challenge-board.md @@ -0,0 +1,98 @@ +# Phase 7: Open Challenge Board + +Priority: **Deferred** + +## Problem + +Players can only challenge specific opponents by handle. There's no way to put out an open challenge for anyone to accept, and no way to browse available opponents. + +## Current State + +The `/play` page requires entering an opponent handle (or leaving it blank for an "open" game that no one can discover). Challenges exist as `blue.checkmate.challenge` records but are only accessible via direct link. + +--- + +## Prerequisite Investigation + +**Before implementing, determine whether Constellation supports global collection queries.** + +The current Constellation query pattern is: +``` +GET /links/all?target={did}&collection=blue.checkmate.game +``` + +This returns records that reference a specific DID. For an open challenge board, we need records of a collection regardless of target: +``` +GET /links/all?collection=blue.checkmate.challenge +``` + +If this is not supported, alternatives: +- Use Jetstream to build a client-side index of open challenges (ephemeral, only shows challenges created since page load). +- Build a lightweight indexer (part of the bot project) that maintains a list of open challenges. +- Use a "challenge hub" pattern where open challenges reference a well-known DID (e.g., the checkmate.blue bot account), making them discoverable via Constellation. + +--- + +## Implementation (Contingent on Discovery Mechanism) + +### Challenge Creation + +Update `/play` to allow creating open challenges (no opponent specified). The game record is created with `status: waiting` and no `black` field. The challenge record has no `opponent` field. + +This already works mechanically -- the code just doesn't distinguish between "open" and "targeted" challenges in the UI. + +### Challenge Board UI + +Add a section to the homepage (or a dedicated `/challenges` page): + +``` +┌──────────────────────────────────────────┐ +│ Open Challenges │ +│ │ +│ @alice.bsky.social -- White -- 2m ago │ +│ [Accept] │ +│ │ +│ @bob.example.com -- Random -- 5m ago │ +│ [Accept] │ +│ │ +│ (no more open challenges) │ +└──────────────────────────────────────────┘ +``` + +Each challenge shows: +- Challenger handle +- Color preference (if Phase 1c is implemented) +- Time since creation +- "Accept" button + +### Accepting an Open Challenge + +Same flow as accepting a targeted challenge: +1. Create Black's game record with `parentGameUri`. +2. Redirect to the game page. + +### Challenge Expiry + +Open challenges should expire after a reasonable time (e.g., 1 hour). Since there's no server to enforce this: +- The UI hides challenges older than the threshold. +- The challenger's client can update `status: expired` if they revisit. +- Stale challenges that someone tries to accept may result in a game the challenger never responds to -- handled by abandoned game detection (Phase 6d). + +### Acceptance Criteria + +- [ ] Players can create open challenges (no specific opponent). +- [ ] Open challenges are discoverable on the homepage or a dedicated page. +- [ ] Challenges show the creator's handle, color preference, and age. +- [ ] Any logged-in player can accept an open challenge. +- [ ] Challenges older than 1 hour are hidden. +- [ ] Accepting an open challenge follows the standard game join flow. + +--- + +## Files to Modify + +| File | Changes | +|------|---------| +| `src/routes/+page.svelte` | Add open challenges section | +| `src/routes/play/+page.svelte` | Distinguish open vs targeted challenge creation in UI | +| `src/lib/microcosm.ts` | Add challenge discovery query (depends on mechanism) | diff --git a/src/app.html b/src/app.html index 27da33b..6af45e1 100644 --- a/src/app.html +++ b/src/app.html @@ -3,6 +3,8 @@ + + checkmate.blue %sveltekit.head% diff --git a/src/lib/stores/sound.svelte.ts b/src/lib/stores/sound.svelte.ts new file mode 100644 index 0000000..76e3b34 --- /dev/null +++ b/src/lib/stores/sound.svelte.ts @@ -0,0 +1,34 @@ +let muted = $state( + typeof localStorage !== 'undefined' && localStorage.getItem('sound-muted') === 'true' +); + +const SOUNDS = { + move: '/sounds/move.mp3', + capture: '/sounds/capture.mp3', + gameEnd: '/sounds/notify.mp3', + notify: '/sounds/notify.mp3', +} as const; + +type SoundType = keyof typeof SOUNDS; +const cache = new Map(); + +export const sound = { + get muted() { return muted; }, + + toggle() { + muted = !muted; + localStorage.setItem('sound-muted', String(muted)); + }, + + play(type: SoundType) { + if (muted) return; + const path = SOUNDS[type]; + let el = cache.get(path); + if (!el) { + el = new Audio(path); + cache.set(path, el); + } + el.currentTime = 0; + el.play().catch(() => {}); + }, +}; diff --git a/src/lib/types.ts b/src/lib/types.ts index e61d08e..af23c76 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -14,7 +14,8 @@ export interface GameRecord { black?: string; status: 'waiting' | 'active' | 'completed' | 'abandoned'; result?: '1-0' | '0-1' | '1/2-1/2'; - resultReason?: 'checkmate' | 'resignation' | 'agreement' | 'stalemate' | 'insufficient' | 'repetition' | 'fifty_moves'; + resultReason?: 'checkmate' | 'resignation' | 'agreement' | 'stalemate' | 'insufficient' | 'repetition' | 'fifty_moves' | 'abandonment'; + lastMoveAt?: string; 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 56dc0b2..3d97cb8 100644 --- a/src/routes/game/[did]/[rkey]/+page.svelte +++ b/src/routes/game/[did]/[rkey]/+page.svelte @@ -1,5 +1,6 @@