From 17d5d3fb431392bf0efc94f5cfecbb3dce4019f6 Mon Sep 17 00:00:00 2001 From: Scott Hadfield Date: Wed, 9 Sep 2026 07:33:42 -0700 Subject: [PATCH] Sync Really Bad Chess docs and spec with the shipped implementation --- docs/really-bad-chess.md | 32 ++++----- specs/really-bad-chess.md | 137 +++++++++++++------------------------- 2 files changed, 64 insertions(+), 105 deletions(-) diff --git a/docs/really-bad-chess.md b/docs/really-bad-chess.md index f6b1895..af32347 100644 --- a/docs/really-bad-chess.md +++ b/docs/really-bad-chess.md @@ -41,25 +41,26 @@ Each side has exactly 16 pieces: 1 king + 15 generated pieces. **White:** 1. Pick a target value: random integer in [45, 68]. -2. Fill 15 piece slots to reach the target, allowing a variance of +/- 5 (actual total lands in [target - 5, target + 5], clamped to [40, 73]). +2. Fill 15 piece slots to land exactly on the target. **Black (human-vs-bot):** 1. Pick a disadvantage: random float in [0%, 13%]. 2. Black's target = floor(white's actual total * (1 - disadvantage)). -3. Fill 15 piece slots with the same +/- 5 variance. +3. Fill 15 piece slots to land exactly on that target. **Black (human-vs-human):** -Same target as white, 0% disadvantage. +Same target as white, so both sides end up with identical material. + +Every piece value is odd, so 15 pieces always sum to an odd number. An even target is nudged up by one before filling (a white target of 68 becomes 69). ### Piece selection Per side (15 pieces, excluding king): 1. White is guaranteed at least 1 queen. Black is not. -2. Each side gets 1-5 pawns (random cap chosen per side). +2. Each side picks a preferred pawn count of 1-5. Pawns beyond that are only added when nothing else can still reach the target, and never more than 8 (the second rank has eight squares). 3. Fill all 15 slots by picking random piece types, tracking the remaining budget. -4. As slots fill, constrain choices so the remaining budget can be spent across remaining slots (minimum 1 point per slot for pawns, maximum 9 for queens). -5. If the budget is overshot or can't be met, the remaining slots are filled with pawns. +4. Each pick is restricted to pieces that leave the remaining slots able to sum to exactly the remaining budget, so the total always lands on the target. ### Placement @@ -82,7 +83,7 @@ The generated position is stored as a standard FEN string: - Halfmove clock: `0` - Fullmove number: `1` -Example: `qnQbKQnr/BNNBRPPP/8/8/8/8/nnbrqppp/rnqbkbnr w - - 0 1` +Example: `qqbbkrrr/rrnbpppp/8/8/8/8/NNNNRBPP/QQNRKRBN w - - 0 1` --- @@ -102,14 +103,13 @@ Daily challenge games use the human-vs-bot material rules (white advantage of 0- ### Game record (`blue.checkmate.game`) -Two fields distinguish a Really Bad Chess game from standard chess: +One field distinguishes a Really Bad Chess game from standard chess: | Field | Type | Description | |-------|------|-------------| | `variant` | string | `"really-bad-chess"`. Omitted for standard chess. | -| `startingFen` | string (max 200 chars) | The generated FEN string. Omitted for standard chess. | -Both fields are optional for backward compatibility. A game record without `variant` or `startingFen` is a standard chess game. +The starting position is not a record field. It travels in the PGN headers (`[SetUp "1"]` / `[FEN "..."]`), which is standard PGN for games that do not begin from the normal position. A game record without `variant` is a standard chess game. ### Challenge record (`blue.checkmate.challenge`) @@ -117,16 +117,16 @@ Both fields are optional for backward compatibility. A game record without `vari |-------|------|-------------| | `variant` | string | `"really-bad-chess"`. Omitted for standard chess. | -The challenge carries the variant so the opponent knows what they're accepting. The `startingFen` lives only on the game record. +The challenge carries the variant so the opponent knows what they're accepting. ### PGN headers -When a game has a custom starting position, the PGN includes standard headers for non-standard start positions: +When a game has a custom starting position, the PGN includes the standard headers for non-standard start positions. `Variant` is `From Position` because Really Bad Chess is orthodox rules from a shuffled position, which is the name other PGN readers understand: ``` [SetUp "1"] -[FEN "qnQbKQnr/BNNBRPPP/8/8/8/8/nnbrqppp/rnqbkbnr w - - 0 1"] -[Variant "Really Bad Chess"] +[FEN "qqbbkrrr/rrnbpppp/8/8/8/8/NNNNRBPP/QQNRKRBN w - - 0 1"] +[Variant "From Position"] ``` The PGN is the source of truth for game state. Both players maintain their own game record with the full PGN. On load, both records are read and the longer PGN is used (since each record is one move behind 50% of the time). @@ -137,11 +137,11 @@ The PGN is the source of truth for game state. Both players maintain their own g When the bot receives a game with `variant: "really-bad-chess"`: -1. **Read the `startingFen`** from the game record and initialize the chess engine from that position. +1. **Read the `[FEN]` header** from the game record's PGN and initialize the chess engine from that position. 2. **Play normally** from the custom FEN. Stockfish (or any UCI engine) accepts arbitrary FEN positions via the `position fen moves ` command. 3. **Validate the FEN** if desired. The bot may reject games with invalid FENs (no king, pawns on ranks 1/8, etc.) but this is optional -- the client already validates via chess.js before writing the record. 4. **Difficulty scaling** works the same as standard chess. The bot's difficulty level (easy/medium/hard) is independent of the variant. The material asymmetry already gives the human an advantage; the bot's search depth controls the rest. -The bot does NOT generate the FEN. The client (white player) generates the FEN and writes it to the game record. The bot reads it and plays from that position. +The bot does NOT generate the FEN. The client (white player) generates the FEN and embeds it in the PGN headers of the game record. The bot reads it and plays from that position. For daily challenges, the client generates the FEN from the date seed before creating the game. The bot just plays whatever position it's given. diff --git a/specs/really-bad-chess.md b/specs/really-bad-chess.md index e3ca9f6..185a2f9 100644 --- a/specs/really-bad-chess.md +++ b/specs/really-bad-chess.md @@ -1,5 +1,7 @@ # Really Bad Chess -- Spec +Status: **Implemented** (ada5675, 07b7ab0). This spec has been updated to describe what shipped. `docs/really-bad-chess.md` is the canonical rules reference; where the two overlap, that document wins. + ## Overview A chess variant where each side gets a randomized set of pieces instead of the standard arrangement. Pieces are balanced by total material value but asymmetric between sides -- you might get 3 queens and no rooks, your opponent might get 5 knights and 2 bishops. Standard movement rules apply. Kings are mandatory, everything else is up to chance. @@ -10,17 +12,17 @@ Inspired by Zach Gage's Really Bad Chess. An earlier standalone prototype lives ### Custom Game (human vs human) -Same flow as standard chess on checkmate.blue, but with a randomized starting position. Either player can create a Really Bad Chess game via the challenge flow. Both players see the same board. The board is generated when the game is created and stored in the game record as a FEN string. Both sides get the same target material value (random in [45, 70]) for a fair match. +Same flow as standard chess on checkmate.blue, but with a randomized starting position. Either player can create a Really Bad Chess game from `/play/really-bad-chess`. Both players see the same board. The board is generated when the game is created and carried in the PGN headers of the game record. Both sides get the same target material value (random in [45, 68]) for a fair match. ### Custom Game (human vs bot) -Player challenges one of the bot accounts to a Really Bad Chess game. Player is always white with a material advantage (0-7 points over black). +Not implemented. Challenging a bot from the custom game form gives both sides equal material exactly like a human opponent. Only the daily challenge uses the asymmetric rules below. ### Daily Challenge (human vs bot) -A new random board is generated each day from a deterministic seed. Every player who plays the daily challenge gets the same board and plays as white against the bot. White has the material advantage as usual. Results can be compared: fewest moves to win, fastest time, or just whether you won or lost. +A new random board is generated each day from a deterministic seed. Every player who plays the daily challenge gets the same board and plays as white against the bot. Black is 0-13% weaker than white. Results can be compared: fewest moves to win, fastest time, or just whether you won or lost. -**Depends on:** the checkmate.blue bot being deployed and playable. +**Depends on:** the checkmate.blue bot (a separate project) being deployed and playable. The client side ships; the button targets `easy.checkmate.blue` by default, overridable with `?bot=`. --- @@ -28,7 +30,7 @@ A new random board is generated each day from a deterministic seed. Every player ### Rules -Ported from the standalone prototype with one adjustment (seeded PRNG for daily mode). +Ported from the standalone prototype with two adjustments: a seeded PRNG for daily mode, and exact-fill budgeting (the prototype's +/- 5 variance is gone). **Material values:** P=1, N=3, B=3, R=5, Q=9, K=0 (king is mandatory, not counted) @@ -38,28 +40,27 @@ The total material value accounts for ALL 15 non-king pieces. There is no separa **White (human):** 1. Pick a target: random integer in [45, 68] -2. Fill 15 piece slots to reach the target, allowing a variance of +/- 5 (actual total lands in [target - 5, target + 5], clamped to [40, 73]) +2. Fill 15 piece slots to land exactly on the target -**Black (computer/opponent):** +**Black (daily challenge):** 1. Pick a disadvantage: random percentage in [0%, 13%] 2. Black's target = floor(whiteActualTotal * (1 - disadvantage)) -3. Fill 15 piece slots with the same variance of +/- 5 +3. Fill 15 piece slots to land exactly on that target -This means black is between 0% and 13% weaker than white. At the extremes: if white has 73 points and black has 0% disadvantage, it's a fair fight. If white has 73 and black has 13% disadvantage, black gets ~63 points. +All piece values are odd, so 15 pieces always sum to an odd number. An even target is nudged up by one before filling, so white's actual total is in [45, 69] and black's is in [floor(0.87 * white), white]. -For reference, standard chess material is 39 per side (Q=9, 2R=10, 2B=6, 2N=6, 8P=8). So even the low end (~40) is stronger than a standard army. +For reference, standard chess material is 39 per side (Q=9, 2R=10, 2B=6, 2N=6, 8P=8). So even the low end is stronger than a standard army. -For human vs human custom games, both sides use the same target with 0% disadvantage. +For human vs human custom games, both sides use the same target, so their material is identical. ### Piece Generation **Per side (15 pieces + 1 king = 16 total):** 1. White is guaranteed at least 1 queen -2. Each side gets 1-5 pawns (random cap per side) +2. Each side picks a preferred pawn count of 1-5. Extra pawns are added only when no other piece can still reach the target, and never more than 8, since pawns only fit on the second rank 3. Fill all 15 slots by picking random piece types, tracking the remaining budget -4. As slots fill up, constrain choices so the remaining budget can be spent across the remaining slots (minimum 1 point per remaining slot for pawns, maximum 9 for queens) -5. The +/- 5 variance means the algorithm doesn't need to hit the target exactly -- it just needs to land within 5 points, which keeps generation simple and avoids awkward forced fills at the end +4. Each pick is restricted to pieces that leave the remaining slots able to sum to exactly the remaining budget (a memoized reachability check over slots, pawns left, and budget), so the total always lands on the target ### Placement @@ -77,7 +78,7 @@ Convert the board to a FEN string with: - Fullmove number: `1` - Active color: `w` -Example: `qnQbKQnr/BNNBRPPP/8/8/8/8/nnbrqppp/rnqbkbnr w - - 0 1` +Example: `qqbbkrrr/rrnbpppp/8/8/8/8/NNNNRBPP/QQNRKRBN w - - 0 1` ### Seeded PRNG (Daily Challenge) @@ -94,18 +95,13 @@ For custom games, use `Math.random()` (or `crypto.getRandomValues` for better en ## Lexicon Changes -**`blue.checkmate.game`** -- two new optional fields: +**`blue.checkmate.game`** -- one new optional field: ```json "variant": { "type": "string", "knownValues": ["standard", "really-bad-chess", "chess960"], "description": "Chess variant for this game. Omit for standard chess." -}, -"startingFen": { - "type": "string", - "maxLength": 200, - "description": "FEN of the starting position for non-standard variants. Omit for standard chess." } ``` @@ -119,7 +115,7 @@ For custom games, use `Math.random()` (or `crypto.getRandomValues` for better en } ``` -Both fields are omitted for standard chess games (backward compatible). The challenge record carries the variant so the opponent knows what they're accepting before viewing the game. The `startingFen` lives only on the game record since that's where both clients read it. +The field is omitted for standard chess games (backward compatible). The challenge record carries the variant so the opponent knows what they're accepting before viewing the game. The starting position is not a record field; it is carried in the PGN `[SetUp "1"]` / `[FEN "..."]` headers, which every PGN reader already understands. --- @@ -147,42 +143,13 @@ const chess = new Chess(startingFen); All subsequent move validation, game-over detection, and PGN generation work normally from any legal FEN. The PGN will include a `[FEN "..."]` header and a `[SetUp "1"]` header, which is standard PGN for games starting from non-standard positions. -### game.svelte.ts changes - -`init()` currently creates a `new Chess()` (standard position) and optionally loads a PGN. For Really Bad Chess: - -```typescript -init(options: { - pgn?: string; - startingFen?: string; // NEW - myColor?: PlayerColor; - // ... -}) { - chess = options.startingFen ? new Chess(options.startingFen) : new Chess(); - if (options.pgn) { - chess.loadPgn(options.pgn); - } - // ... -} -``` +### game.svelte.ts -If both `startingFen` and `pgn` are provided (game in progress), load the PGN -- it already contains the `[FEN]` header, so chess.js reconstructs the correct position. +`init()` is unchanged: it loads whatever PGN it is given, and a variant PGN already carries the `[FEN]` header, so chess.js reconstructs the position. The store exposes `get startingFen()` (read from the PGN headers) so the UI can tell a variant game from a standard one. -### game-logic.ts changes +### game-logic.ts -`makePgn()` needs to include the `[FEN]` and `[SetUp]` headers when a starting FEN is present: - -```typescript -export function makePgn(chess: Chess, white?: string, black?: string, startingFen?: string): string { - // existing headers... - if (startingFen) { - headers.push(['SetUp', '1']); - headers.push(['FEN', startingFen]); - headers.push(['Variant', 'Really Bad Chess']); - } - // ... -} -``` +`makeInitialPgn(fen, variant)` builds the record's first PGN: `[SetUp "1"]`, `[FEN "..."]`, and `[Variant "From Position"]` for `really-bad-chess`. `makePgn()` preserves those headers on every subsequent write because chess.js keeps loaded headers. --- @@ -190,39 +157,30 @@ export function makePgn(chess: Chess, white?: string, black?: string, startingFe ### Creating a Really Bad Chess game -1. On `/play`, add a variant selector (toggle or dropdown): "Standard" | "Really Bad Chess" -2. When "Really Bad Chess" is selected, generate the board FEN client-side before creating the game record -3. Write the game record with `variant: 'really-bad-chess'` and `startingFen: ""` -4. Everything else (challenge flow, Jetstream, spectating) works identically +1. `/play/really-bad-chess` renders `CreateGameForm` with `variant="really-bad-chess"` and `generateFen={generateRandomFen}` +2. On submit, the form generates the FEN client-side and writes the game record with `variant: 'really-bad-chess'` and `pgn: makeInitialPgn(fen, variant)` +3. When the opponent joins, their child record copies `variant` and the initial `pgn` +4. Everything else (challenge flow, Jetstream, spectating, rematch) works identically; a rematch generates a fresh board ### Loading a Really Bad Chess game -1. `loadGame()` reads the game record -2. If `record.variant === 'really-bad-chess'` and `record.startingFen`, pass the FEN to `game.init()` -3. chess.js loads the position, chessground renders it, game proceeds normally -4. PGN reconciliation between two records works the same -- the PGN contains the FEN header +1. `loadGame()` reads the game record and passes `record.pgn` to `game.init()` +2. chess.js reads the `[FEN]` header, chessground renders the position, game proceeds normally +3. PGN reconciliation between two records works the same -- both PGNs contain the FEN header ### Daily Challenge flow -1. Homepage or `/play` shows a "Daily Challenge" button (only if the bot is available) -2. Clicking it generates today's FEN from the date seed -3. Creates a game record with the bot as the opponent, `variant: 'really-bad-chess'`, `startingFen` from the seed -4. Player is always white -5. On completion, show result alongside a leaderboard or comparison (future feature) +1. `/play/really-bad-chess` shows a "Today's Daily Challenge" button +2. Clicking it resolves the bot handle (`?bot=` query param, default `easy.checkmate.blue`), generates today's FEN from the date seed, and creates a game with the bot as black and the player as white +3. On completion, show result alongside a leaderboard or comparison (future feature) --- ## UI Changes -### /play page - -Add a variant selector: - -``` -[Standard] [Really Bad Chess] -``` +### /play/really-bad-chess page -When Really Bad Chess is selected, the opponent handle input and color picker still work normally. The FEN is generated on create, not shown to the user beforehand. +A dedicated route rather than a selector on `/play`. It reuses `CreateGameForm`, so the opponent handle input and color picker work exactly as for standard games, with the daily challenge button above them. The FEN is generated on create, not shown to the user beforehand. ### Game page @@ -232,7 +190,7 @@ When Really Bad Chess is selected, the opponent handle input and color picker st ### Homepage -If the bot is deployed and the daily challenge is available, show a "Daily Challenge" card with today's date. +Not changed. The daily challenge lives on `/play/really-bad-chess`. --- @@ -257,33 +215,34 @@ Port the generation algorithm from the standalone prototype, replacing `Math.ran --- -## Files to Modify +## Files Modified | File | Change | |------|--------| -| `lexicons/blue.checkmate.game.json` | Add `variant` and `startingFen` fields | -| `src/lib/types.ts` | Add `variant` and `startingFen` to `GameRecord` | -| `src/lib/stores/game.svelte.ts` | Accept `startingFen` in `init()`, pass to Chess constructor | -| `src/lib/game-logic.ts` | `makePgn()` includes FEN/SetUp headers when applicable | -| `src/lib/atproto.ts` | Pass `variant` and `startingFen` through `createGame()` and `updateGame()` | -| `src/routes/play/+page.svelte` | Variant selector UI, generate FEN on create | -| `src/routes/game/[did]/[rkey]/+page.svelte` | Read `variant`/`startingFen` from record, pass to `game.init()` | -| `src/routes/+page.svelte` | Daily challenge card (future, depends on bot) | +| `lexicons/blue.checkmate.game.json`, `lexicons/blue.checkmate.challenge.json` | Add `variant` field | +| `src/lib/types.ts` | Add `variant` to `GameRecord` and `ChallengeRecord` | +| `src/lib/stores/game.svelte.ts` | Expose `startingFen` read from the PGN headers | +| `src/lib/game-logic.ts` | `makeInitialPgn()` builds the SetUp/FEN/Variant headers | +| `src/lib/atproto.ts` | Pass `variant` and initial `pgn` through `createGame()` and `createChallenge()` | +| `src/lib/components/CreateGameForm.svelte` | Shared form with `variant`, `generateFen`, and `dailyChallenge` props | +| `src/routes/game/[did]/[rkey]/+page.svelte` | Copy `variant`/`pgn` on join; regenerate a board on rematch | -### New file +### New files | File | Purpose | |------|---------| | `src/lib/really-bad-chess.ts` | Board generation, seeded PRNG, FEN conversion | +| `src/routes/play/really-bad-chess/+page.svelte` | Variant route with the daily challenge button | +| `tests/lib/really-bad-chess.test.ts` | Generator invariants (items 1-3 below) | --- ## Verification -1. **Board generation**: Generate 100 random boards, verify each has exactly 1 king per side (e1 and e8), exactly 15 non-king pieces per side, white material within +/- 5 of target (target in [45, 68]), black material 0-13% less than white's actual total, valid FEN syntax. +1. **Board generation**: Generate 100 random boards, verify each has exactly 1 king per side (e1 and e8), exactly 15 non-king pieces per side, at most 8 pawns per side, white material in [45, 69], black material equal to white's (human vs human) or in [floor(0.87 * white), white] (daily), valid FEN syntax. 2. **chess.js loading**: Load generated FENs into chess.js, verify `isGameOver()` returns false, verify legal moves exist for white. 3. **Daily seed**: Generate today's FEN twice, verify identical. Generate yesterday's FEN, verify different. -4. **Game creation**: Create a Really Bad Chess game, verify `variant` and `startingFen` are written to the PDS record. +4. **Game creation**: Create a Really Bad Chess game, verify `variant` and a PGN carrying `[SetUp "1"]` / `[FEN "..."]` are written to the PDS record. 5. **Game play**: Play a full game with a Really Bad Chess position -- make moves, verify chess.js validates them correctly, verify PGN includes FEN header. 6. **Spectator**: Open a Really Bad Chess game as a spectator, verify the board renders the custom position correctly. 7. **Pawn promotion**: Move a pawn to the back rank in a Really Bad Chess game, verify the promotion modal appears and works. -- 2.51.2