Compare commits

...

22 Commits

Author SHA1 Message Date
claude (duplicate_chess) 294a786336 docs: session handoff — deployed under chess.sethpc.xyz/duplicate/ 2026-05-19 18:14:45 -04:00
claude (duplicate_chess) cab556ede4 docs: record live deploy under chess.sethpc.xyz/duplicate/
CLAUDE.md: phase moves from "local sandbox" to deployed; adds the
operations recipe (tar pipe to /var/www/duplicate-chess on Caddy CT 600).
DECISIONS.md: supersedes the "Deployment deferred" entry with the
chosen architecture — Caddy handle_path serving the static build under
the chess.sethpc.xyz origin, rather than a separate subdomain or a
Fastify static mount.
2026-05-19 18:11:53 -04:00
claude (duplicate_chess) a58139345d fix: page title (was leftover Vite scaffold 'vite-tmp') 2026-05-19 18:09:58 -04:00
claude (duplicate_chess) 8039a8b364 build: set vite base to /duplicate/ for sub-path hosting
Hosted under chess.sethpc.xyz/duplicate/ as a third game-mode card on
blind_chess. With base='/', Vite emitted absolute /assets/... URLs that
would fall through the Caddy /duplicate/* handler and 404 against the
blind_chess Fastify backend. Setting base='/duplicate/' keeps every asset
fetch inside the static handler.
2026-05-19 18:07:23 -04:00
claude (duplicate_chess) d2adf7f321 docs: session handoff — duplicate chess v1 built and merged
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:04:16 -04:00
claude (duplicate_chess) 5047ad7786 docs: update CLAUDE.md — v1 implemented and merged
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:01:26 -04:00
claude (duplicate_chess) fae9f8dce4 docs: correct Task 4 test move data in the plan
The Task 4 test had East's and West's moves swapped relative to the
PLAYER_BOARDS mapping (E plays NE/SE, W plays NW/SW). Caught and fixed in
the shipped code during execution; this brings the plan document in line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:19:47 -04:00
claude (duplicate_chess) 5db04109a2 fix: real project README and save-file version validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 01:18:35 -04:00
claude (duplicate_chess) ead4839df4 feat(ui): assemble the duplicate chess sandbox app
Wire Compass, Panel, and PromotionDialog into App.svelte; apply dark-theme
base styles in app.css. Build passes, 26 tests pass, smoke render confirmed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 01:13:00 -04:00
claude (duplicate_chess) bedb5a0f80 feat(ui): promotion dialog 2026-05-19 01:10:08 -04:00
claude (duplicate_chess) 51615debd0 feat(ui): Panel component — turn, move log, legend, controls 2026-05-19 01:08:41 -04:00
claude (duplicate_chess) be05ee5617 feat(ui): Compass component — pinwheel layout, click-to-move wiring 2026-05-19 01:06:39 -04:00
claude (duplicate_chess) 059177c635 feat(ui): Board component — rotated grid, coloured pieces, highlights, ghosts 2026-05-19 01:04:39 -04:00
claude (duplicate_chess) 3931805b6f feat(ui): reactive game store wrapping the engine 2026-05-19 01:02:31 -04:00
claude (duplicate_chess) 3f9891a97c test(engine): scripted full-game integration test 2026-05-19 00:59:49 -04:00
claude (duplicate_chess) 763769f997 feat(engine): coordinate notation and JSON save/load 2026-05-19 00:58:12 -04:00
claude (duplicate_chess) 4278f2d19e feat(engine): endgame detection with provisional rules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 00:56:38 -04:00
claude (duplicate_chess) 4e876b3197 feat(engine): ghost derivation from cross-board piece comparison 2026-05-19 00:53:51 -04:00
claude (duplicate_chess) 7473cc69b3 feat(engine): synchronized-move intersection and selection highlight 2026-05-19 00:50:51 -04:00
claude (duplicate_chess) 88f1da9f70 feat(engine): DuplicateGame core — boards, history, undo, draw clocks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 00:46:38 -04:00
claude (duplicate_chess) c1751751af feat(engine): board/player constant maps and shared types 2026-05-19 00:43:23 -04:00
claude (duplicate_chess) bd24159f37 chore: scaffold Vite + Svelte 5 + TS project with chess.js and vitest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 00:40:03 -04:00
43 changed files with 3841 additions and 13 deletions
@@ -0,0 +1,209 @@
# Handoff: Duplicate Chess v1 — built, tested, merged to main
## Session Metadata
- Created: 2026-05-19 06:01:41
- Project: /home/claude/bin/duplicate_chess
- Branch: main (all work merged and pushed to `git.sethpc.xyz/Seth/duplicate_chess`)
- Session duration: one long session — brainstorm → spec → plan → full 14-task implementation → merge.
### Recent Commits (for context)
- 5047ad7 docs: update CLAUDE.md — v1 implemented and merged
- fae9f8d docs: correct Task 4 test move data in the plan
- 5db0410 fix: real project README and save-file version validation
- ead4839 feat(ui): assemble the duplicate chess sandbox app
- bedb5a0 feat(ui): promotion dialog
- Full implementation range: `9611c0a` (scaffold + spec) → `5047ad7`. 18 commits.
## Handoff Chain
- **Continues from**: None (first handoff — this is a brand-new project).
- **Supersedes**: None.
## Current State Summary
`duplicate_chess` is a **brand-new project created this session** — a local browser
sandbox for "duplicate chess", a four-player chess variant invented by Andrew
Freiberg (Seth's father; also the inventor behind the sibling `blind_chess`
project). The session ran the full superpowers pipeline: brainstorming → design
spec → implementation plan → subagent-driven execution of all 14 plan tasks → final
review → merge. **v1 is code-complete, all 27 engine tests pass, the build and
typecheck are clean, and it is merged to `main` and pushed.** The one thing not
done: a human interactive browser test (clicking through a real game). The app
mounts and renders correctly (verified via a headless smoke render).
## Codebase Understanding
### Architecture Overview
Single Vite + Svelte 5 + TypeScript app, **no server** (duplicate chess is
perfect-information, so everything runs client-side — this is the key difference
from `blind_chess`, which needs a server as its trusted view boundary).
- **Engine** (`src/engine/`, pure TypeScript, DOM-free, vitest-tested): four
`chess.js` games (NW/NE/SW/SE). A player's legal moves = the **intersection** of
the moves legal on their two boards, keyed by `(from,to,promotion)`. Ghost
immobility, the synchronized-checkmate definition, and en-passant/castling
divergence all fall out of the intersection — no special-case code.
- **UI** (`src/lib/`): a reactive store wraps the engine; the compass renders the
four boards as a 45°-rotated pinwheel; the triple-highlight (green = playable on
both boards, grey = legal on one only) is the teaching feature.
### Critical Files
| File | Purpose | Relevance |
|------|---------|-----------|
| `docs/superpowers/specs/2026-05-19-duplicate-chess-design.md` | The full design spec | Read first — variant rules, engine model, provisional rules |
| `docs/superpowers/plans/2026-05-19-duplicate-chess-sandbox.md` | The 14-task implementation plan | What was built, task by task |
| `src/engine/legality.ts` | `legalSyncedMoves` + `selectionHighlight` | The intersection — the heart of the variant |
| `src/engine/game.ts` | `DuplicateGame` — 4 chess.js, history, undo, draw clocks | The single source of truth for game state |
| `src/engine/ghosts.ts` | Ghost derivation by cross-board comparison | |
| `src/engine/endgame.ts` | checkmate/stalemate/threefold/fifty-move; **PROVISIONAL rules** | Andrew can revise the provisional rulings — grep `PROVISIONAL` |
| `src/lib/stores/game.svelte.ts` | Reactive store wrapping the engine | `#game` is plain (non-reactive); `view` is the `$state` snapshot |
| `src/lib/Compass.svelte` | The four-board pinwheel + click-to-move wiring | |
### Key Patterns Discovered
- **The engine is DOM-free and the single source of truth.** The UI never computes
legality; it calls the engine and renders the result.
- **Store reactivity:** `chess.js` objects must NOT be wrapped in a Svelte `$state`
proxy. The store keeps `DuplicateGame` in a plain private `#game` field and
exposes a plain-data `view` snapshot in `$state`, rebuilt after every change.
- **The pinwheel rotations** (NW 225°, NE 135°, SW 315°, SE 45°) put each player's
army on the board edge facing their seat. Confirmed against Andrew's sketch.
- **Tests reach real positions** via `playSymmetric` (test-helpers.ts): when all
four players move symmetrically the four boards stay identical, so each board is
an ordinary chess game — that is how the checkmate/stalemate/threefold tests
reach real terminal positions.
## Work Completed
### Tasks Finished
- [x] All 14 tasks of the implementation plan, executed via subagent-driven
development (fresh implementer subagent per task + a combined spec/quality review
per task + a final whole-implementation review by an opus reviewer).
- [x] Engine: `types, boards, game, legality, ghosts, endgame, notation` + an
integration test. 27 vitest tests, all passing.
- [x] UI: the reactive store + `Board`, `Compass`, `Panel`, `PromotionDialog`,
`App` components.
- [x] Two post-review fixes: a real project README, and save-file `version`
validation in `deserialize`.
- [x] Merged `build-sandbox``main`, pushed, feature branch deleted.
### Files Modified
The whole project was created this session. See `git log` on `main`. New trees:
`src/engine/` (7 modules + 6 test files), `src/lib/` (store + 4 components),
`src/App.svelte`, `src/app.css`, plus the Vite scaffold and project docs.
### Decisions Made
All recorded in `DECISIONS.md`. Key ones: local sandbox first (not networked);
single Vite app, no server; engine = 4× chess.js + intersection; compass UI as a
pinwheel of diamonds; coordinate notation; provisional endgame rules picked by
Claude and marked `PROVISIONAL`. One decision surfaced during the build and is NOT
yet in DECISIONS.md — see "Blockers/Open Questions".
## Pending Work
## Immediate Next Steps
1. **Manual interactive browser test.** Run `pnpm install && pnpm dev`, open the
URL, and play a real game: click a piece on a glowing board → confirm the
green/grey triple-highlight → click a green square → move applies to both that
player's boards → turn advances. Verify ghosts appear after a one-sided capture,
promotion dialog fires, undo / Prev / Next / Live work, Save downloads JSON and
Load restores it. The engine is well-tested; the UI interaction is verified only
by `svelte-check` + a headless smoke render so far.
2. **Decide the scrubbing semantics** (see Open Questions) and reconcile spec §4.3.
3. (Optional) The remaining minor follow-ups below, if they matter.
### Blockers/Open Questions
- [ ] **Scrub semantics — spec vs shipped code disagree.** Spec §4.3 says "making a
new move while scrubbed truncates history." The shipped code instead makes
scrubbing **view-only** (you must click "● Live" before moving). The final review
flagged this; the view-only behaviour is arguably cleaner. Seth to confirm which
to keep; then update spec §4.3 (or the code) to match.
- [ ] **The provisional endgame rules** (spec §6) are Claude's defaults, not
Andrew's rulings — double-board-mate = two winners, any stalemate ends the game
all-draw, threefold/50-move tracked on the whole system. Andrew should confirm.
### Deferred Items
- `deserialize` trusts the `player` field in a save file rather than recomputing it
from turn order — a corrupt/hand-edited save could desync. App-written saves are
always consistent, so this is robustness-only. Fix: have `DuplicateGame`'s
constructor ignore `entry.player` and use the turn-order default.
- Move log has no round-number column (within spec, but a nicety).
- Spec §4.3 names a `replayTo(n)` primitive; the code uses
`new DuplicateGame(history.slice(0,n))` instead — functionally equivalent,
cosmetic naming mismatch only.
- Networking / AI / position editor — explicitly out of v1 scope (spec §7).
## Context for Resuming Agent
## Important Context
- **The project is DONE for v1 and merged to `main`.** There is nothing half-built.
A resuming agent's job is the manual browser test (#1 above) and then deciding
whether to ship/deploy or extend.
- **`blind_chess` is the sibling project** (`~/bin/blind_chess`) — same inventor,
same homelab conventions, similar shared-engine + view-filter shape. The original
inventor conversation that defines duplicate chess is
`~/bin/blind_chess/USERFILES/4-person-chess.txt`, and Andrew's compass sketch is
`~/bin/blind_chess/USERFILES/4personchess.png`.
- **Provisional rules** are isolated in `src/engine/endgame.ts` and commented
`PROVISIONAL (spec §6)` — grep for it to find every spot a future ruling lands.
- v1 is **local only** — no deploy. Hosting the static `pnpm build` output behind
Caddy is a trivial later option (it is just static files), not done.
### Assumptions Made
- The interactive browser test passing is assumed but unverified — the headless
smoke render confirmed the app mounts and renders all four boards with no console
errors, but no clicks were exercised.
- 27 is the expected test count (all in `src/engine/`; the UI has no test harness
by design — `svelte-check` + manual, same as `blind_chess`).
### Potential Gotchas
- `pnpm test` uses `--passWithNoTests` (vitest 4.x exits 1 on no test files) — a
deliberate scaffold choice; harmless now that tests exist.
- A `.secrets.baseline` file exists for the global `detect-secrets` pre-commit hook
(it flags `pnpm-lock.yaml` SHA-512 integrity hashes as false positives).
- `svelte-check` reports **5 warnings** — all pre-existing Vite-template `tsconfig`
warnings (deprecated `moduleResolution`, missing `composite`). 0 errors. The
warnings are not defects; ignore them or fix the template tsconfig if desired.
- The brainstorming visual-companion mockups for the compass live under
`~/bin/blind_chess/.superpowers/brainstorm/.../content/` (`layout-v6.html` is the
approved layout) — they are in the `blind_chess` repo, not this one.
## Environment State
### Tools/Services Used
- pnpm workspace tooling (Node 22, pnpm 10). `gitea` CLI for push.
- Subagent-driven development for the build (sonnet implementers/reviewers, an opus
final reviewer).
### Active Processes
- None. No dev server left running.
### Environment Variables
- None added or required.
## Related Resources
- Spec: `docs/superpowers/specs/2026-05-19-duplicate-chess-design.md`
- Plan: `docs/superpowers/plans/2026-05-19-duplicate-chess-sandbox.md`
- `DECISIONS.md`, `IDEA.md`
- Repo: https://git.sethpc.xyz/Seth/duplicate_chess (`main` at `5047ad7`)
- Inventor conversation + sketch: `~/bin/blind_chess/USERFILES/4-person-chess.txt`,
`~/bin/blind_chess/USERFILES/4personchess.png`
---
**Security Reminder**: No credentials or secrets are included in this handoff.
@@ -0,0 +1,272 @@
# Handoff: Duplicate Chess deployed under chess.sethpc.xyz/duplicate/
## Session Metadata
- Created: 2026-05-19 18:12:12
- Project: /home/claude/bin/duplicate_chess
- Branch: main (all work committed + pushed)
- Session duration: ~30 minutes (after a fresh-session handoff load + a course
correction — the user's "deploy the new game mode" originally read like a
blind_chess deploy; clarified mid-session to mean duplicate_chess as a 3rd
game-mode card on chess.sethpc.xyz).
### Recent Commits (for context)
- `cab556e` docs: record live deploy under chess.sethpc.xyz/duplicate/
- `a581393` fix: page title (was leftover Vite scaffold 'vite-tmp')
- `8039a8b` build: set vite base to /duplicate/ for sub-path hosting
- `d2adf7f` docs: session handoff — duplicate chess v1 built and merged
- `5047ad7` docs: update CLAUDE.md — v1 implemented and merged
Companion change in the sibling repo: `blind_chess @ fef6dcf`
("feat(client): third landing card linking to duplicate chess") + `33a7cef`
(docs note in blind_chess CLAUDE.md).
## Handoff Chain
- **Continues from**: [2026-05-19-060141-duplicate-chess-v1-built.md](./2026-05-19-060141-duplicate-chess-v1-built.md)
— v1 was code-complete and merged but local-only.
- **Supersedes**: None.
## Current State Summary
`duplicate_chess` v1 is **live at https://chess.sethpc.xyz/duplicate/** as of
2026-05-19. It is served as a static sub-app from `/var/www/duplicate-chess/` on
Caddy CT 600 (192.168.0.185), via a `handle_path /duplicate/*` block inside the
existing `chess.sethpc.xyz` Caddy site. Vite is built with `base: '/duplicate/'`
so every asset URL carries the `/duplicate/` prefix and stays inside the static
handler instead of falling through to the blind_chess Fastify backend on CT 690.
In the sibling `blind_chess` repo, the landing page (`Landing.svelte`) now shows
a 3rd card — "Duplicate Chess (under development)" — beneath the existing
friend/AI cards, linking to `/duplicate/`. That client was rebuilt and rsynced
to CT 690; `blind-chess.service` was restarted to pick up the new bundle.
End-to-end curl evidence:
- `https://chess.sethpc.xyz/duplicate/` → 200, served `index.html` (title now
`duplicate chess`, no longer `vite-tmp`).
- `https://chess.sethpc.xyz/duplicate/assets/index-Cu-6IMcr.js` → 200, 87103 b.
- `https://chess.sethpc.xyz/duplicate/assets/index-p72E9BrX.css` → 200, 4291 b.
- `https://chess.sethpc.xyz/` → 200, blind_chess landing; the JS bundle contains
the strings "Duplicate Chess", "under development", "/duplicate/" and the new
CSS classes `.card-link`, `.badge`, `.open-cue`.
- `https://chess.sethpc.xyz/api/health``{"ok":true,"activeGames":0,"uptime":...}`.
**Not yet verified by a human in a real browser** — Andrew (Seth's dad, the
inventor) is the intended remote tester. v1 interactive testing was already
pending from the prior handoff.
## Codebase Understanding
### Architecture Overview
Three independent deploy units share the `chess.sethpc.xyz` origin:
```
chess.sethpc.xyz {
encode gzip zstd
handle_path /duplicate/* { # ← new, this session
root * /var/www/duplicate-chess # CT 600 (Caddy)
try_files {path} /index.html
file_server
}
handle {
reverse_proxy 192.168.0.245:3000 # CT 690 (blind_chess Fastify)
}
}
```
`handle_path` strips the `/duplicate` prefix before serving, so the on-disk
layout under `/var/www/duplicate-chess/` is at the root (`/index.html`,
`/assets/...`, `/favicon.svg`, `/icons.svg`). With `vite base='/duplicate/'`,
in-bundle asset URLs are `/duplicate/assets/...` — they re-enter the
`handle_path`, get stripped, and resolve. Symmetric and audit-friendly.
### Critical Files
| File | Purpose | Relevance |
|------|---------|-----------|
| `vite.config.ts` | Sets `base: '/duplicate/'` | Required for sub-path hosting; default `/` would 404 on assets |
| `index.html` | Page title fix — `<title>duplicate chess</title>` | Was `vite-tmp` from the Vite scaffold |
| `CLAUDE.md` | Operations + Live URL | Resume-time reference |
| `DECISIONS.md` | Architectural decision recorded (supersedes the "deferred" entry) | The "why" for sub-path over subdomain or Fastify static mount |
| Sibling: `blind_chess/packages/client/src/lib/Landing.svelte` | 3rd card + `.card-link`/`.badge`/`.open-cue` CSS | The visible entry point on chess.sethpc.xyz |
| Caddy: `/etc/caddy/Caddyfile` (lines ~11121124) on CT 600 | `handle_path /duplicate/*` sub-block | Backup at `/etc/caddy/Caddyfile.bak.duplicate-chess-1779228542` |
### Key Patterns Discovered
- **Vite `base` is build-time**, not runtime. It is baked into `index.html` and
bundled asset URLs. Switching it requires a rebuild.
- **CT 600 has no rsync.** Used `tar -cf - . | ssh ... 'cd ... && tar -xf -'`
to push the dist. Could `apt install rsync` later for ergonomics.
- **Caddy `handle_path` vs `handle`**: `handle_path` strips the matched prefix
before downstream directives; `handle` does not. With `base='/duplicate/'` in
Vite, both forms work, but `handle_path` lets the on-disk layout live at the
root of its own dir — cleaner than mirroring the prefix in the filesystem.
## Work Completed
### Tasks Finished
- [x] `vite.config.ts` set to `base: '/duplicate/'`, rebuilt, committed.
- [x] `index.html` title fixed from `vite-tmp` to `duplicate chess`, rebuilt.
- [x] blind_chess Landing.svelte: added 3rd "Duplicate Chess (under development)"
card with `.card-link`, `.badge`, `.open-cue` styles. Built, typechecked
clean (0 errors / 0 warnings), committed, pushed.
- [x] Provisioned `/var/www/duplicate-chess/` on CT 600; tar-piped both builds.
- [x] Backed up Caddyfile, added `handle_path /duplicate/*` sub-block via an
idempotent Python `text.replace` script (failed if the old block was
missing or the new block was already present). `caddy validate`
`Valid configuration`. `systemctl reload caddy` → active.
- [x] Rsynced blind_chess client dist to `/opt/blind-chess/client/dist/` on
CT 690, chowned to `blindchess:blindchess`, restarted the service. The
restart dropped 1 in-memory game (per pre-accepted MVP policy).
- [x] Documentation:
- `~/bin/CLAUDE.md` projects table updated with the live URL.
- `duplicate_chess/CLAUDE.md` got the Operations section + new phase line.
- `duplicate_chess/DECISIONS.md` "deployment deferred" entry superseded.
- `blind_chess/CLAUDE.md` deploy line notes the `/duplicate/*` Caddy block.
### Files Modified
| File | Changes | Rationale |
|------|---------|-----------|
| `vite.config.ts` | `base: '/duplicate/'` + 3-line comment | Sub-path hosting |
| `index.html` | title → `duplicate chess` | Vite scaffold leftover |
| `CLAUDE.md` | live URL, Operations section | Resume-time reference |
| `DECISIONS.md` | supersede the deferred-deploy decision | Architectural record |
| `~/bin/CLAUDE.md` | projects-table row updated | Loaded every session |
| `blind_chess/packages/client/src/lib/Landing.svelte` | 3rd card + CSS | Entry point |
| `blind_chess/CLAUDE.md` | deploy line mentions /duplicate/* | Resume-time reference |
| `/etc/caddy/Caddyfile` on CT 600 | added `handle_path /duplicate/*` block | Routing |
### Decisions Made
| Decision | Options Considered | Rationale |
|----------|-------------------|-----------|
| Serve duplicate as `chess.sethpc.xyz/duplicate/` sub-path | (a) separate subdomain like `duplicate.sethpc.xyz` (b) Fastify static mount on CT 690 | Sub-path keeps the user's mental model "one site, three modes"; Caddy file_server is the right tool; isolation from blind_chess server means redeploys don't drop blind games. |
| Built with Vite `base: '/duplicate/'` | (a) `base: '/'` with Caddy stripping prefix + serving | Building with the right base is simpler and survives any matcher refactor. |
| 3rd landing card as a plain `<a href="/duplicate/">` (not a button calling an API) | (a) iframe inside the SPA (b) source-merge duplicate's components into blind_chess client | Plain anchor is honest about the architecture, no cross-origin/iframe pitfalls; source-merge is excessive for "under development". |
| Landing card visibly tagged "under development" | (a) silent/equivalent | Sets expectations for Andrew; signals it's not at parity with blind/vanilla. |
## Pending Work
## Immediate Next Steps
1. **Send Andrew the URL: https://chess.sethpc.xyz/duplicate/** — and the
blind_chess landing https://chess.sethpc.xyz/ where he can see both as
"modes". Wait for his feedback on the duplicate sandbox UX.
2. **Manual browser test** of both the new 3rd landing card AND duplicate's
interactive flow (still pending from the prior handoff): play a real game,
click a piece, confirm green/grey triple-highlight, move applies to both of
the player's boards, etc.
3. (If Andrew's testing surfaces issues) The known v1 open items in the prior
handoff still apply — scrub semantics, provisional endgame rules. Andrew's
ruling matters for those.
### Blockers/Open Questions
- [ ] **Was the right model chosen for "under development" framing?** Seth said
"incorporate as a 3rd game mode 'under development'." I interpreted that
as a 3rd top-level card on the existing two-card landing (since "mode"
currently means a radio inside each card, and duplicate is 4-player so
can't fit that radio). The card is a plain link out to `/duplicate/`,
not embedded. If Seth wanted an iframe or a deeper integration, redo.
- [ ] All open items from the prior handoff remain open (scrub semantics, the
PROVISIONAL endgame rules, the trust-on-`player` deserialize, …).
### Deferred Items
- **`apt install rsync` on CT 600** — would let future deploys use `rsync`
instead of `tar -cf - | ssh tar -xf -`. The tar idiom worked but is uglier.
- **Caddyfile cleanup** — `caddy validate` warned about pre-existing
`Unnecessary header_up X-Forwarded-For/Host` in *other* blocks (not ours)
and an `unformatted` notice. Not introduced this session; safe to ignore.
- **Visual polish** on the 3rd landing card — only verified via curl + JS/CSS
string grep; not yet eyeballed in a real browser.
- **Open the duplicate sub-app from a deeper path** like `/duplicate/foo`
Caddy's `try_files {path} /index.html` falls back to index.html for any
unmatched path, so any future client-side routing works without changes.
## Context for Resuming Agent
## Important Context
- **Two repos were touched.** `duplicate_chess` got the vite base + title +
docs. `blind_chess` got the new landing card. Both pushed to `main` on
`git.sethpc.xyz/Seth/...`.
- **Two services were touched** at deploy time:
- Caddy on CT 600 (192.168.0.185) — reloaded after Caddyfile edit (graceful).
- blind-chess on CT 690 (192.168.0.245) — full restart, dropped 1 in-memory
game per pre-accepted MVP policy.
- **`chess.local` (the LAN-only blind_chess instance on VDJ-RIG) was NOT
updated** this session. It still serves the previous blind_chess client (no
3rd landing card) and has no `/duplicate/` route. The Caddy edit was on
CT 600 only. If Seth wants parity on chess.local, redeploy the blind_chess
client there and decide whether duplicate is reachable from the LAN at all
(no Caddy block currently exists for `chess.local/duplicate/*`).
- **The phrase "new game mode" caused initial confusion.** I (mistakenly)
spent the opening few tool calls trying to figure out what new thing in
`blind_chess` should be deployed before asking — none of the recent
blind_chess commits described a new mode. Seth clarified the target was
`duplicate_chess`. Future sessions: if a deploy ask seems to mismatch the
current repo's state, look at the sibling `duplicate_chess` repo too.
### Assumptions Made
- That "3rd game mode under development" meant a third top-level landing card,
not an in-blind_chess radio addition (duplicate is 4-player — wouldn't fit
the existing 2-player radio without a much bigger rework).
- That a plain anchor link to `/duplicate/` was the right shape (vs iframe).
- That dropping the 1 active in-memory blind_chess game on restart was OK
(matches the pre-accepted MVP policy in DECISIONS.md).
### Potential Gotchas
- **`vite-tmp` was the page title** at first deploy (`a581393` fixed it, but
there was a ~2-minute window where it shipped). If Andrew tests during a
redeploy window, mention it.
- **Caddy `handle_path` strips the prefix** — anyone reading `/var/www/duplicate-chess/`
layout might expect a `duplicate/` subdir; there isn't one (files live at
the root of that dir).
- **Files in `/var/www/duplicate-chess/` are owned by uid/gid 1001**
(the `claude` uid on steel141, passed through via tar), readable to all.
Worked because Caddy needs only read; chown to `caddy:caddy` or `root:root`
would be tidier.
- **`localhost` on VDJ-RIG (where chess.local lives) hits a Caddy 502** —
documented in the prior handoff. Doesn't affect this deploy (we touched CT
600's Caddy, not the rig's).
## Environment State
### Tools/Services Used
- `gitea` CLI for pushes (both repos).
- `ssh root@192.168.0.185` (Caddy CT 600) — Caddyfile edit + Caddy reload.
- `ssh root@192.168.0.245` (CT 690) — client dist rsync + systemctl restart.
- `tar -cf - | ssh ... tar -xf -` for content transfer (no rsync on CT 600).
- `pnpm` (workspace in blind_chess, single-pkg in duplicate_chess).
### Active Processes
- `caddy.service` on CT 600 — reloaded.
- `blind-chess.service` on CT 690 — restarted (1 active game dropped).
- No dev servers left running locally.
### Environment Variables
- None added or required.
## Related Resources
- Caddyfile backup: `/etc/caddy/Caddyfile.bak.duplicate-chess-1779228542` on CT 600.
- Local backups: `duplicate_chess/.backup/vite.config.ts.*.bak` and
`blind_chess/packages/client/src/lib/.backup/Landing.svelte.*.bak`.
- Live URLs:
- https://chess.sethpc.xyz/ — blind_chess landing (3rd card visible).
- https://chess.sethpc.xyz/duplicate/ — duplicate_chess sandbox.
- https://chess.sethpc.xyz/api/health — blind_chess API health.
- Sibling-repo companion commit: `blind_chess @ fef6dcf` (landing card)
+ `33a7cef` (CLAUDE.md doc note).
---
**Security Reminder**: No credentials or secrets in this handoff.
+26 -2
View File
@@ -1,7 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
node_modules/ node_modules/
dist
dist/ dist/
.superpowers/ dist-ssr
.DS_Store
*.local *.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Project-specific
.superpowers/
*.tsbuildinfo *.tsbuildinfo
GITEA_API.md GITEA_API.md
+865
View File
@@ -0,0 +1,865 @@
{
"version": "1.5.0",
"plugins_used": [
{
"name": "ArtifactoryDetector"
},
{
"name": "AWSKeyDetector"
},
{
"name": "AzureStorageKeyDetector"
},
{
"name": "Base64HighEntropyString",
"limit": 4.5
},
{
"name": "BasicAuthDetector"
},
{
"name": "CloudantDetector"
},
{
"name": "DiscordBotTokenDetector"
},
{
"name": "GitHubTokenDetector"
},
{
"name": "GitLabTokenDetector"
},
{
"name": "HexHighEntropyString",
"limit": 3.0
},
{
"name": "IbmCloudIamDetector"
},
{
"name": "IbmCosHmacDetector"
},
{
"name": "IPPublicDetector"
},
{
"name": "JwtTokenDetector"
},
{
"name": "KeywordDetector",
"keyword_exclude": ""
},
{
"name": "MailchimpDetector"
},
{
"name": "NpmDetector"
},
{
"name": "OpenAIDetector"
},
{
"name": "PrivateKeyDetector"
},
{
"name": "PypiTokenDetector"
},
{
"name": "SendGridDetector"
},
{
"name": "SlackDetector"
},
{
"name": "SoftlayerDetector"
},
{
"name": "SquareOAuthDetector"
},
{
"name": "StripeDetector"
},
{
"name": "TelegramBotTokenDetector"
},
{
"name": "TwilioKeyDetector"
}
],
"filters_used": [
{
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
},
{
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
"min_level": 2
},
{
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
},
{
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
},
{
"path": "detect_secrets.filters.heuristic.is_lock_file"
},
{
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
},
{
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
},
{
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
},
{
"path": "detect_secrets.filters.heuristic.is_sequential_string"
},
{
"path": "detect_secrets.filters.heuristic.is_swagger_file"
},
{
"path": "detect_secrets.filters.heuristic.is_templated_secret"
}
],
"results": {
"pnpm-lock.yaml": [
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "88dcf5e48940431d207eb6beeda3f28b52b9dcd6",
"is_verified": false,
"line_number": 43
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "9b1aeb3238bb5411805279e20afb4fb96fa7490d",
"is_verified": false,
"line_number": 46
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "e8cc5935afdfd3788eae945e3db3f42e2c3acee6",
"is_verified": false,
"line_number": 49
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "8733ba82d33885a92ca3009eb6ba61e505a1ee9a",
"is_verified": false,
"line_number": 52
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "3e3522c83d724aa4133ee6bb6d627e4390be31a5",
"is_verified": false,
"line_number": 55
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "9d1b9dbec9f048e048f4dd38591947ca7deff5dd",
"is_verified": false,
"line_number": 58
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "f60c00cacddf514ad090fbde7005d8721adc0b89",
"is_verified": false,
"line_number": 62
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "b0e9cbf320ebe74507bcdaae3f0b40740459a092",
"is_verified": false,
"line_number": 65
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "f274e993f5da6ef04f9ab21f5152b7f16f86fc45",
"is_verified": false,
"line_number": 68
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "63ec2819c4e19e52c1d683e5e3e2e6ce0b6cac93",
"is_verified": false,
"line_number": 74
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "907c22434e5fe282d088f338a9e6155fb51ce241",
"is_verified": false,
"line_number": 77
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "e694aeb21a36497b6eb9bc56f884bd4c69ac5add",
"is_verified": false,
"line_number": 83
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "4d2585f3339105214d7685879b53a916f5aea9fa",
"is_verified": false,
"line_number": 89
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "f1833f6e7490c05a1a76205a4bdafb80e88e04e6",
"is_verified": false,
"line_number": 95
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "cacf9ecad80e93e92e88556952a24dedeae071ae",
"is_verified": false,
"line_number": 101
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "525571cc69c07ea83a7f575ab13f8174a1279317",
"is_verified": false,
"line_number": 107
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "be823e3374be22151f74deb53ad9b52ba62e1794",
"is_verified": false,
"line_number": 114
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "f057529a66a11ba122564fc9c36ccefe29df6f1a",
"is_verified": false,
"line_number": 121
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "2e065f3f711d422e5ef5abdcd9c255dea44113f7",
"is_verified": false,
"line_number": 128
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "04eddfeea8ec2c47eac1fdc1f6033f88d175e4cf",
"is_verified": false,
"line_number": 135
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "de1fe5a83dde4beebb24a15f2ebf259aa1adb023",
"is_verified": false,
"line_number": 142
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "aaa92f5d670cc00ba757939cebeda4c34436c037",
"is_verified": false,
"line_number": 149
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "2cd4187cd1381a5ff2776451a79449d69a7790ec",
"is_verified": false,
"line_number": 155
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "a427b668faefb136b19160d59226685364b3a9e5",
"is_verified": false,
"line_number": 160
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "007e6990046ea7fce8af0547e80941651f78eb17",
"is_verified": false,
"line_number": 166
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "b0da61692236cf0f51dfca9bea5b058e6db2a173",
"is_verified": false,
"line_number": 172
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "1e9ff9fee5ad181d3f63618e0267e8ad42826e83",
"is_verified": false,
"line_number": 175
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "0e08e31e43853476fc57d10643d8c871082c08b3",
"is_verified": false,
"line_number": 178
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "de37fe98add6f4734b9251d9b8c3e9d0772f2a7a",
"is_verified": false,
"line_number": 183
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "ae55501077a6c44a2607882f75ee062a47bc2a6a",
"is_verified": false,
"line_number": 190
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "2a99232e74af9898db82959893e9c64814b15714",
"is_verified": false,
"line_number": 193
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "7739c7a36e8b5c9759887d54c3d3a252d017cb71",
"is_verified": false,
"line_number": 196
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "d4dda0583372eb865e22ea00ae160ceb315aa0df",
"is_verified": false,
"line_number": 199
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "fa52615bd39e56c7366b834e0c9b496bbb96f043",
"is_verified": false,
"line_number": 202
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "2ff8e8e4076d74fa172588e8f030832fc1d1577f",
"is_verified": false,
"line_number": 205
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "5f411c7e6073b67aca1c9043e3e952322dc21d46",
"is_verified": false,
"line_number": 208
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "4ea248d159b60f6d7fef97d151e4684cb2bd1b7c",
"is_verified": false,
"line_number": 211
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "40efaa4408aa7a51f20a5dafc886853a090bdf7f",
"is_verified": false,
"line_number": 214
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "bbd4d55c127ca96a683a41840e2cf89a50b2d879",
"is_verified": false,
"line_number": 225
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "ca76999d735911c431e5bdd1fea97ba4933bc187",
"is_verified": false,
"line_number": 228
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "902751d36d478d198a9171b2692aea0bcee1d728",
"is_verified": false,
"line_number": 231
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "f9e756c256be01a72d3b154b30203f0e3bd4c554",
"is_verified": false,
"line_number": 234
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "b510709833d6f2ff1ea90f5cfa4b514c6c56b2ce",
"is_verified": false,
"line_number": 237
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "b2c03df0ed8f8caded3abab58c3b2e3b645dbb28",
"is_verified": false,
"line_number": 240
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "5bb90e7e3d7847438b155c9fb564dfd279383855",
"is_verified": false,
"line_number": 245
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "1c504c77e2b9f1ea0e1770e65673fd7f8b3f4c8f",
"is_verified": false,
"line_number": 249
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "4a6acdb47bbd22c85e10baf5921ddbb3c09a787b",
"is_verified": false,
"line_number": 253
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "bc23715592b7602434c2776230d19f43fc479898",
"is_verified": false,
"line_number": 257
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "8095985919187b5be33de4b775c7a5e7562aafee",
"is_verified": false,
"line_number": 261
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "8ff61e34bccd3c9f85c8a24db9723d984fd74314",
"is_verified": false,
"line_number": 264
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "5974b42acc3c558a08c734a45a10785534b5d382",
"is_verified": false,
"line_number": 268
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "08c8ee0987f57add8cd4071488caef8ec431336a",
"is_verified": false,
"line_number": 272
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "c0c7aeed5e5a888b2aa3cdb739955fd27d25335e",
"is_verified": false,
"line_number": 275
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "bef68af5091abf3bd912b28fd2f8b438426dd79d",
"is_verified": false,
"line_number": 279
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "deb1c562595f81e98efdd28daf90161dc8ab8013",
"is_verified": false,
"line_number": 283
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "d3bc9e85ef73bc81be3c9767f7c26c7600f357c0",
"is_verified": false,
"line_number": 286
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "4a1a266072e5f401e1aa928a9bf6d94bfeab4806",
"is_verified": false,
"line_number": 289
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "5522dfea7968ce0fbca87245aaf099a6211d7e23",
"is_verified": false,
"line_number": 292
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "763f4272f5426f2cb52dd90f1f6a490a4ce61418",
"is_verified": false,
"line_number": 300
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "16d0c877fca994fd1c43bd7f131be967bf4f87b6",
"is_verified": false,
"line_number": 303
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "16fa3bfaeef78e32910c87baf9060e51490d09ad",
"is_verified": false,
"line_number": 307
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "d950cd8ef510429f57fb282e4a437321ca86158c",
"is_verified": false,
"line_number": 316
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "aea3a2f0dd7f8e8655ed2e6cf823ce09d0d537a4",
"is_verified": false,
"line_number": 321
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "ecca8c8ee1920fbd6387220767bdecf3635a3741",
"is_verified": false,
"line_number": 324
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "b48b7cde42a2442c78c359b776b516d8127006c6",
"is_verified": false,
"line_number": 330
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "2fb4be4f309d24f5064ac5496b8c7f59df0bf966",
"is_verified": false,
"line_number": 336
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "b6acf38d09475b54b197ff0574abc4a7e86c3543",
"is_verified": false,
"line_number": 342
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "405ac79c3832190ec6bd61bce50e9390a026af72",
"is_verified": false,
"line_number": 348
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "5f3d1367a38ac4e76290f44b6dc26d3555bbabf5",
"is_verified": false,
"line_number": 354
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "d86912fe91c497e31a4344f459a68586ce096966",
"is_verified": false,
"line_number": 361
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "1509ab36ebf61526ec42f40016db32b0925e96bc",
"is_verified": false,
"line_number": 368
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "2139b734b30d5642cf104ea814b455bf3f08ca4c",
"is_verified": false,
"line_number": 375
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "ef73b9a0b786d2e003921cc9967a0a7cd6ae1913",
"is_verified": false,
"line_number": 382
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "2ffc317e2ecc11d98e3e05bf565c6575c4cbfd51",
"is_verified": false,
"line_number": 388
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "432730c731e24c084f98525d5e133e358dc47b2f",
"is_verified": false,
"line_number": 394
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "3e2f7beb6f6a2204eb1ca56ae8ca7e9602ad13f3",
"is_verified": false,
"line_number": 398
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "1e2228f0d8e0fc2bc3b8740a8ff3d287006b9a38",
"is_verified": false,
"line_number": 401
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "1229dc2178fd4b24f6dcb957dbdc4dd4f592e26f",
"is_verified": false,
"line_number": 404
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "5b6b494f771ff69c35ad46e17ec1f7da6eb8696e",
"is_verified": false,
"line_number": 408
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "fd987a3583a86961bbb3db9e742c93a837cd5bf6",
"is_verified": false,
"line_number": 413
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "9235bcf425e49c5749d9f52ed67b13717efda795",
"is_verified": false,
"line_number": 416
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "4439cc784aca22e5cdf33ed6656f5b8b99eaa44d",
"is_verified": false,
"line_number": 419
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "84cbfec871cf2b178ab90fcc1f442fa369e99a02",
"is_verified": false,
"line_number": 422
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "84a22bebcaaf61d613e397ca696c152d80aedf67",
"is_verified": false,
"line_number": 426
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "36149fc78b01f35bca00b832a55a4ea4f21995ae",
"is_verified": false,
"line_number": 430
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "ea0a3fae2d1694876f309429bfe231935a1e70f4",
"is_verified": false,
"line_number": 434
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "7e42e0804bd25c31e53b2f0871fa2b3551dc66d9",
"is_verified": false,
"line_number": 439
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "33e224ae0061fa6dc855702e88cad5e948136009",
"is_verified": false,
"line_number": 443
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "336d02c1e7195c5237197b5884304099ab15c6ba",
"is_verified": false,
"line_number": 446
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "b015b77432bd6d255596af7b2f552d8e8c91a7f7",
"is_verified": false,
"line_number": 450
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "c8fac131170b7bd907b0771e52111a64e940b2a5",
"is_verified": false,
"line_number": 453
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "befe96270355093c9f7fb4836d62f862ced86d2a",
"is_verified": false,
"line_number": 456
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "31277df63022917906a65ae6542742dc60d23b54",
"is_verified": false,
"line_number": 464
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "2fbe8ab30e5355ea9d7538aec4d6c869562a0ed8",
"is_verified": false,
"line_number": 468
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "03816f8548cdb479aadd0ed1610f90e001fef53f",
"is_verified": false,
"line_number": 471
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "bcc923e3a3bbb5c5c7c2f2b5e17bda91f8076652",
"is_verified": false,
"line_number": 475
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "f0661775df2ce05e6c49ee07d6003c0e831cda30",
"is_verified": false,
"line_number": 479
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "9bf200e3147a1d837839d6bf5b649716c1537cbb",
"is_verified": false,
"line_number": 483
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "4e65da49f5fe5560a904f41bd2519c712c48c151",
"is_verified": false,
"line_number": 486
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "2cf79101947343f62524a7ab71f313b66d694198",
"is_verified": false,
"line_number": 491
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "7bedcb999f1cafb154755766dcf512cbc27a2833",
"is_verified": false,
"line_number": 494
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "8ac9d9448e688e8f94e810f2c49fd9b728bd2041",
"is_verified": false,
"line_number": 537
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "05b85c0e6305e0bae00683ce171f23f02837787e",
"is_verified": false,
"line_number": 545
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "95b02c02e7cb902e9603042bf77d2ce3b47d7dd1",
"is_verified": false,
"line_number": 586
},
{
"type": "Base64 High Entropy String",
"filename": "pnpm-lock.yaml",
"hashed_secret": "9b0596f2945cdeab0d6fb2407275942153f87d9c",
"is_verified": false,
"line_number": 591
}
]
},
"generated_at": "2026-05-19T04:40:00Z"
}
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+30 -5
View File
@@ -27,12 +27,37 @@ inventor's own point is that it cannot be understood from prose alone.
## Current State ## Current State
- **Phase:** spec complete; implementation not started. - **Phase:** v1 **implemented, merged, and deployed** (2026-05-19) as a third
- **Repo:** `git.sethpc.xyz/Seth/duplicate_chess`. game-mode card ("under development") on `chess.sethpc.xyz`. The engine and the
- **Deploy target:** n/a — runs locally (`pnpm dev`). compass UI are complete; not yet manually browser-tested by Seth.
- **Repo:** `git.sethpc.xyz/Seth/duplicate_chess` (`main`).
- **Live URL:** https://chess.sethpc.xyz/duplicate/ — static bundle on Caddy CT 600
at `/var/www/duplicate-chess/`. Independent of the blind_chess server (CT 690);
duplicate redeploys never restart blind_chess. Vite is built with
`base: '/duplicate/'`; the Caddy `chess.sethpc.xyz` block has a
`handle_path /duplicate/*` that strips the prefix and serves static files.
- **Local dev:** `pnpm install && pnpm dev`.
- **Stack:** Vite + Svelte 5 + TypeScript, `chess.js`. Single package; engine in - **Stack:** Vite + Svelte 5 + TypeScript, `chess.js`. Single package; engine in
`src/engine/` (pure, DOM-free, vitest-tested), UI in `src/lib/`. `src/engine/` (pure, DOM-free; 27 vitest tests passing), UI in `src/lib/`.
- **Next:** implementation plan via the writing-plans skill, then build. - **Commands:** `pnpm dev` (run) · `pnpm build` · `pnpm test` (engine) ·
`pnpm exec svelte-check --tsconfig ./tsconfig.json` (typecheck).
- **Known follow-ups (not blocking):** interactive browser test still pending (a
human task — `svelte-check` + manual is the design, see spec §8); history scrubbing
is view-only whereas spec §4.3 says truncate-on-move (deviation — confirm which to
keep); `deserialize` trusts the `player` field on replay; move log has no round
numbers. See the latest handoff in `.claude/handoffs/`.
## Operations
- **Health:** `curl https://chess.sethpc.xyz/duplicate/` returns the served `index.html`.
- **Redeploy** (after a `pnpm build`):
```
cd dist && tar -cf - . | ssh root@192.168.0.185 \
'cd /var/www/duplicate-chess && rm -rf assets favicon.svg icons.svg index.html && tar -xf -'
```
No Caddy reload needed for content changes; `file_server` serves live from disk.
- **Caddy config:** the `chess.sethpc.xyz` block in `/etc/caddy/Caddyfile` on CT 600
(192.168.0.185). The `handle_path /duplicate/*` sub-block governs this app.
## Conventions ## Conventions
+1 -1
View File
@@ -27,5 +27,5 @@ Format: `YYYY-MM-DD: <decision> — <why>`
2026-05-19: AI opponents — deferred; the sandbox is operator-driven. 2026-05-19: AI opponents — deferred; the sandbox is operator-driven.
2026-05-19: Free position editor — rejected for v1; play-from-start + history scrubbing keeps every shown position reachable by legal play, preserving the "every board is real chess" invariant. 2026-05-19: Free position editor — rejected for v1; play-from-start + history scrubbing keeps every shown position reachable by legal play, preserving the "every board is real chess" invariant.
2026-05-19: Counter-rotating pieces upright on the diamonds — rejected; pieces rotate with their board so each player's army faces their seat (the point of the compass). 2026-05-19: Counter-rotating pieces upright on the diamonds — rejected; pieces rotate with their board so each player's army faces their seat (the point of the compass).
2026-05-19: Deployment behind Caddy — deferred; v1 runs locally, the static build can be hosted later trivially. 2026-05-19: Deployment behind Caddy — deferred; v1 runs locally, the static build can be hosted later trivially. **Superseded 2026-05-19 (later same day):** deployed under `chess.sethpc.xyz/duplicate/` as a 3rd "under development" card on the blind_chess landing. Vite built with `base: '/duplicate/'`; Caddy `handle_path /duplicate/*` strips the prefix and serves `/var/www/duplicate-chess/` on CT 600. Chosen over a separate subdomain so Andrew sees both games from one URL, and over a Fastify static mount so duplicate redeploys never restart blind_chess.
2026-05-19: A separate "dual view" of the on-move player's two boards — rejected; the compass is the whole UI, with the triple-highlight happening directly on it. 2026-05-19: A separate "dual view" of the on-move player's two boards — rejected; the compass is the whole UI, with the triple-highlight happening directly on it.
+30
View File
@@ -0,0 +1,30 @@
# Duplicate Chess
A local, single-operator browser sandbox for **duplicate chess** — a four-player
chess variant invented by Andrew Freiberg. Four players (North, South, East, West)
and four boards (NW, NE, SW, SE); each player controls one colour on two boards and
must play the identical move on both. A captured piece leaves a frozen "ghost" twin
on the player's other board.
This tool puts all four boards on one screen as a rotated "compass", enforces the
synchronized-move coupling, renders ghosts, shows the move-legality intersection,
and detects the endgame.
## Develop
```
pnpm install
pnpm dev # run the dev server
pnpm build # production build
pnpm test # run the engine test suite (vitest)
```
`pnpm exec svelte-check --tsconfig ./tsconfig.json` typechecks the Svelte/TS code.
## Design
See `docs/superpowers/specs/2026-05-19-duplicate-chess-design.md` for the full design,
and `docs/superpowers/plans/2026-05-19-duplicate-chess-sandbox.md` for the build plan.
This is v1 — a local sandbox. Networked multiplayer, AI opponents, and a free
position editor are explicitly out of scope (see the spec, §7).
@@ -496,12 +496,12 @@ describe('legalSyncedMoves', () => {
}); });
it('excludes a move legal on only one of the player\'s boards', () => { it('excludes a move legal on only one of the player\'s boards', () => {
// N e2e4, S e2e4, E e7e5, W d7d5 -> NW black has pe5, NE black has pd5. // N e2e4, S e2e4, E d7d5, W e7e5 -> NW black has pe5 (W's), NE black has pd5 (E's).
const g = new DuplicateGame([ const g = new DuplicateGame([
{ player: 'N', from: 'e2', to: 'e4' }, { player: 'N', from: 'e2', to: 'e4' },
{ player: 'S', from: 'e2', to: 'e4' }, { player: 'S', from: 'e2', to: 'e4' },
{ player: 'E', from: 'e7', to: 'e5' }, { player: 'E', from: 'd7', to: 'd5' },
{ player: 'W', from: 'd7', to: 'd5' }, { player: 'W', from: 'e7', to: 'e5' },
]); ]);
expect(g.currentPlayer).toBe('N'); expect(g.currentPlayer).toBe('N');
const keys = legalSyncedMoves(g).map((m) => `${m.from}${m.to}`); const keys = legalSyncedMoves(g).map((m) => `${m.from}${m.to}`);
@@ -525,8 +525,8 @@ describe('selectionHighlight', () => {
const g = new DuplicateGame([ const g = new DuplicateGame([
{ player: 'N', from: 'e2', to: 'e4' }, { player: 'N', from: 'e2', to: 'e4' },
{ player: 'S', from: 'e2', to: 'e4' }, { player: 'S', from: 'e2', to: 'e4' },
{ player: 'E', from: 'e7', to: 'e5' }, { player: 'E', from: 'd7', to: 'd5' },
{ player: 'W', from: 'd7', to: 'd5' }, { player: 'W', from: 'e7', to: 'e5' },
]); ]);
const h = selectionHighlight(g, 'e4'); // North's e4 pawn const h = selectionHighlight(g, 'e4'); // North's e4 pawn
expect(h.boardA).toBe('NW'); expect(h.boardA).toBe('NW');
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>duplicate chess</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "duplicate-chess",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tsconfig/svelte": "^5.0.8",
"@types/node": "^24.12.3",
"svelte": "^5.55.5",
"svelte-check": "^4.4.8",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vitest": "^4.1.6"
},
"dependencies": {
"chess.js": "^1.4.0"
}
}
+1025
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import Compass from './lib/Compass.svelte';
import Panel from './lib/Panel.svelte';
import PromotionDialog from './lib/PromotionDialog.svelte';
</script>
<header>
<h1>Duplicate Chess</h1>
<p>Local sandbox — operator drives all four players. Click a piece on the
glowing boards to see its synchronized-legal moves.</p>
</header>
<main>
<Compass />
<Panel />
</main>
<PromotionDialog />
<style>
header { padding: 14px 22px; border-bottom: 1px solid #333845; }
header h1 { margin: 0; font-size: 17px; }
header p { margin: 4px 0 0; font-size: 12px; color: #9aa0aa; }
main { display: flex; gap: 22px; padding: 20px 22px; align-items: flex-start; }
</style>
+8
View File
@@ -0,0 +1,8 @@
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0;
background: #15171c;
color: #e6e8ec;
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { BOARD_IDS, PLAYERS, PLAYER_BOARDS, PLAYER_COLOR, BOARD_PLAYERS, BOARD_ROTATION } from './boards';
describe('boards constants', () => {
it('lists four boards and four players in turn order', () => {
expect(BOARD_IDS).toEqual(['NW', 'NE', 'SW', 'SE']);
expect(PLAYERS).toEqual(['N', 'S', 'E', 'W']);
});
it('each player controls exactly two boards', () => {
for (const p of PLAYERS) expect(PLAYER_BOARDS[p]).toHaveLength(2);
expect(PLAYER_BOARDS.N).toEqual(['NW', 'NE']);
expect(PLAYER_BOARDS.W).toEqual(['NW', 'SW']);
});
it('board players are consistent with player boards', () => {
for (const b of BOARD_IDS) {
const { w, b: black } = BOARD_PLAYERS[b];
expect(PLAYER_BOARDS[w]).toContain(b);
expect(PLAYER_BOARDS[black]).toContain(b);
expect(PLAYER_COLOR[w]).toBe('w');
expect(PLAYER_COLOR[black]).toBe('b');
}
});
it('has a rotation for every board', () => {
expect(BOARD_ROTATION).toEqual({ NW: 225, NE: 135, SW: 315, SE: 45 });
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { BoardId, Player, Color } from './types';
export const BOARD_IDS: BoardId[] = ['NW', 'NE', 'SW', 'SE'];
/** Turn order. */
export const PLAYERS: Player[] = ['N', 'S', 'E', 'W'];
/** The two boards each player controls (order is stable: [boardA, boardB]). */
export const PLAYER_BOARDS: Record<Player, [BoardId, BoardId]> = {
N: ['NW', 'NE'],
S: ['SW', 'SE'],
E: ['NE', 'SE'],
W: ['NW', 'SW'],
};
/** The colour each player plays on both their boards. */
export const PLAYER_COLOR: Record<Player, Color> = {
N: 'w', S: 'w', E: 'b', W: 'b',
};
/** The white and black player of each board. */
export const BOARD_PLAYERS: Record<BoardId, { w: Player; b: Player }> = {
NW: { w: 'N', b: 'W' },
NE: { w: 'N', b: 'E' },
SW: { w: 'S', b: 'W' },
SE: { w: 'S', b: 'E' },
};
/** Compass rotation in degrees for rendering each board (see spec §5.1). */
export const BOARD_ROTATION: Record<BoardId, number> = {
NW: 225, NE: 135, SW: 315, SE: 45,
};
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { DuplicateGame } from './game';
import { playSymmetric } from './test-helpers';
import { evaluateStatus } from './endgame';
describe('evaluateStatus', () => {
it('reports an ongoing game at the start', () => {
const s = evaluateStatus(new DuplicateGame());
expect(s.state).toBe('playing');
expect(s.checks).toEqual([]);
});
it('detects a double-board checkmate (Fool\'s mate, played symmetrically)', () => {
const g = new DuplicateGame();
playSymmetric(g, [
[{ from: 'f2', to: 'f3' }, { from: 'e7', to: 'e5' }],
[{ from: 'g2', to: 'g4' }, { from: 'd8', to: 'h4' }],
]);
expect(g.currentPlayer).toBe('N'); // North (White) is mated
const s = evaluateStatus(g);
expect(s.state).toBe('checkmate');
expect(s.checks.sort()).toEqual(['NE', 'NW']);
expect(s.result).toEqual({ N: 'loss', S: 'draw', E: 'win', W: 'win' });
});
it('detects threefold repetition of the whole system', () => {
const g = new DuplicateGame();
const cycle: Array<[{ from: string; to: string }, { from: string; to: string }]> = [
[{ from: 'g1', to: 'f3' }, { from: 'g8', to: 'f6' }],
[{ from: 'f3', to: 'g1' }, { from: 'f6', to: 'g8' }],
];
playSymmetric(g, cycle); // back to start (occurrence 2)
playSymmetric(g, cycle); // back to start (occurrence 3)
const s = evaluateStatus(g);
expect(s.state).toBe('draw');
expect(s.reason).toBe('threefold');
expect(s.result).toEqual({ N: 'draw', S: 'draw', E: 'draw', W: 'draw' });
});
it('detects a stalemate as an all-draw game end (provisional rule)', () => {
const g = new DuplicateGame();
// The known fastest stalemate, played symmetrically on all four boards.
playSymmetric(g, [
[{ from: 'e2', to: 'e3' }, { from: 'a7', to: 'a5' }],
[{ from: 'd1', to: 'h5' }, { from: 'a8', to: 'a6' }],
[{ from: 'h5', to: 'a5' }, { from: 'h7', to: 'h5' }],
[{ from: 'a5', to: 'c7' }, { from: 'a6', to: 'h6' }],
[{ from: 'h2', to: 'h4' }, { from: 'f7', to: 'f6' }],
[{ from: 'c7', to: 'd7' }, { from: 'e8', to: 'f7' }],
[{ from: 'd7', to: 'b7' }, { from: 'd8', to: 'd3' }],
[{ from: 'b7', to: 'b8' }, { from: 'd3', to: 'h7' }],
[{ from: 'b8', to: 'c8' }, { from: 'f7', to: 'g6' }],
[{ from: 'c8', to: 'e6' }], // no black reply — Black is stalemated
]);
expect(g.currentPlayer).toBe('E'); // a Black player, with no move
const s = evaluateStatus(g);
expect(s.state).toBe('stalemate');
expect(s.reason).toBe('stalemate');
expect(s.result).toEqual({ N: 'draw', S: 'draw', E: 'draw', W: 'draw' });
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { DuplicateGame } from './game';
import type { GameStatus, GameResult, BoardId } from './types';
import { PLAYERS, PLAYER_BOARDS, BOARD_PLAYERS } from './boards';
import { legalSyncedMoves } from './legality';
/** PROVISIONAL (spec §6): the 50-move rule fires after this many rounds. */
const FIFTY_MOVE_ROUNDS = 50;
const FIFTY_MOVE_PLIES = FIFTY_MOVE_ROUNDS * 4;
function allDraw(): GameResult {
return { N: 'draw', S: 'draw', E: 'draw', W: 'draw' };
}
/** Evaluate the game from the perspective of the player to move. */
export function evaluateStatus(game: DuplicateGame): GameStatus {
const player = game.currentPlayer;
const [a, b] = PLAYER_BOARDS[player];
const checks: BoardId[] = [];
if (game.boards[a].inCheck()) checks.push(a);
if (game.boards[b].inCheck()) checks.push(b);
const synced = legalSyncedMoves(game);
if (synced.length === 0) {
if (checks.length > 0) {
// Checkmate. PROVISIONAL (spec §6): every opponent delivering a check wins.
const winners = checks.map((board) =>
BOARD_PLAYERS[board].w === player
? BOARD_PLAYERS[board].b
: BOARD_PLAYERS[board].w,
);
const result = {} as GameResult;
for (const p of PLAYERS) {
result[p] = p === player ? 'loss' : winners.includes(p) ? 'win' : 'draw';
}
return { state: 'checkmate', result, checks };
}
// PROVISIONAL (spec §6): a no-synchronized-move stalemate ends the game, all draw.
return { state: 'stalemate', result: allDraw(), reason: 'stalemate', checks };
}
if (game.repetitionCount() >= 3) {
return { state: 'draw', result: allDraw(), reason: 'threefold', checks };
}
if (game.pliesSinceProgress >= FIFTY_MOVE_PLIES) {
return { state: 'draw', result: allDraw(), reason: 'fifty-move', checks };
}
return { state: 'playing', checks };
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { DuplicateGame } from './game';
const START = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR';
function placement(fen: string) {
return fen.split(' ')[0];
}
describe('DuplicateGame', () => {
it('starts with four boards in the standard position, North to move', () => {
const g = new DuplicateGame();
expect(g.ply).toBe(0);
expect(g.currentPlayer).toBe('N');
for (const id of ['NW', 'NE', 'SW', 'SE'] as const) {
expect(placement(g.boards[id].fen())).toBe(START);
}
});
it("applies a synchronized move to the current player's two boards only", () => {
const g = new DuplicateGame();
g.applyMove({ from: 'e2', to: 'e4' }); // North
expect(g.ply).toBe(1);
expect(g.currentPlayer).toBe('S');
expect(placement(g.boards.NW.fen())).toContain('4P3'); // pawn advanced
expect(placement(g.boards.NE.fen())).toContain('4P3');
expect(placement(g.boards.SW.fen())).toBe(START); // untouched
expect(placement(g.boards.SE.fen())).toBe(START);
});
it('cycles the current player N -> S -> E -> W -> N', () => {
const g = new DuplicateGame();
g.applyMove({ from: 'e2', to: 'e4' }); // N
g.applyMove({ from: 'e2', to: 'e4' }); // S
g.applyMove({ from: 'e7', to: 'e5' }); // E
g.applyMove({ from: 'e7', to: 'e5' }); // W
expect(g.currentPlayer).toBe('N');
expect(g.ply).toBe(4);
});
it("throws on a move not legal on both of the player's boards", () => {
const g = new DuplicateGame();
expect(() => g.applyMove({ from: 'e2', to: 'e5' })).toThrow();
});
it('undo removes the last move and restores the position', () => {
const g = new DuplicateGame();
g.applyMove({ from: 'e2', to: 'e4' });
g.undo();
expect(g.ply).toBe(0);
expect(g.currentPlayer).toBe('N');
expect(placement(g.boards.NW.fen())).toBe(START);
});
it('rebuilds from a history array passed to the constructor', () => {
const g = new DuplicateGame([
{ player: 'N', from: 'e2', to: 'e4' },
{ player: 'S', from: 'e2', to: 'e4' },
]);
expect(g.ply).toBe(2);
expect(g.currentPlayer).toBe('E');
});
it('resets the progress clock on a pawn move or capture', () => {
const g = new DuplicateGame();
g.applyMove({ from: 'e2', to: 'e4' }); // pawn move -> clock stays 0
expect(g.pliesSinceProgress).toBe(0);
g.applyMove({ from: 'e2', to: 'e4' }); // pawn move
expect(g.pliesSinceProgress).toBe(0);
g.applyMove({ from: 'g8', to: 'f6' }); // knight move -> clock increments
expect(g.pliesSinceProgress).toBe(1);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { Chess } from 'chess.js';
import type { BoardId, Player, SyncMove, HistoryEntry } from './types';
import { BOARD_IDS, PLAYERS, PLAYER_BOARDS } from './boards';
/** A chess.js move result has these fields we rely on. */
interface ChessMoveResult {
piece: string;
captured?: string;
}
export class DuplicateGame {
readonly boards: Record<BoardId, Chess>;
readonly history: HistoryEntry[] = [];
/** Plies since the last capture or pawn move on any board (for the 50-move rule). */
pliesSinceProgress = 0;
/** Repetition keys of the whole 4-board system, one per position incl. the start. */
readonly repetitionKeys: string[] = [];
constructor(history: HistoryEntry[] = []) {
this.boards = {
NW: new Chess(), NE: new Chess(), SW: new Chess(), SE: new Chess(),
};
this.repetitionKeys.push(this.systemKey());
for (const entry of history) this.applyMove(entry, entry.player);
}
get ply(): number {
return this.history.length;
}
get currentPlayer(): Player {
return PLAYERS[this.history.length % 4];
}
/** Whether `move` is a legal chess move on `board` in the current position. */
isLegalOnBoard(board: BoardId, move: SyncMove): boolean {
return this.boards[board].moves({ verbose: true }).some(
(m) =>
m.from === move.from &&
m.to === move.to &&
(m.promotion ?? undefined) === (move.promotion ?? undefined),
);
}
/** Apply one synchronized move to a player's two boards. Throws if illegal on either. */
applyMove(move: SyncMove, player: Player = this.currentPlayer): void {
const [a, b] = PLAYER_BOARDS[player];
if (!this.isLegalOnBoard(a, move) || !this.isLegalOnBoard(b, move)) {
throw new Error(
`Illegal synchronized move ${move.from}${move.to} for ${player}`,
);
}
const ra = this.boards[a].move(move) as unknown as ChessMoveResult;
const rb = this.boards[b].move(move) as unknown as ChessMoveResult;
this.history.push({ ...move, player });
const progress = isProgress(ra) || isProgress(rb);
this.pliesSinceProgress = progress ? 0 : this.pliesSinceProgress + 1;
this.repetitionKeys.push(this.systemKey());
}
/** Remove the last move by replaying the truncated history. */
undo(): void {
if (this.history.length === 0) return;
const replay = this.history.slice(0, -1);
for (const id of BOARD_IDS) this.boards[id].reset();
this.history.length = 0;
this.pliesSinceProgress = 0;
this.repetitionKeys.length = 0;
this.repetitionKeys.push(this.systemKey());
for (const e of replay) this.applyMove(e, e.player);
}
/** A repetition key for the whole system: each board's placement+castling+ep, plus the side to move. */
systemKey(): string {
return (
BOARD_IDS.map((id) => {
const parts = this.boards[id].fen().split(' ');
return `${parts[0]}${parts[2]}${parts[3]}`;
}).join('|') + ':' + this.currentPlayer
);
}
/** How many times the current system position has occurred. */
repetitionCount(): number {
const current = this.repetitionKeys[this.repetitionKeys.length - 1];
return this.repetitionKeys.filter((k) => k === current).length;
}
}
function isProgress(result: ChessMoveResult): boolean {
return result.piece === 'p' || result.captured != null;
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { DuplicateGame } from './game';
import { playSymmetric } from './test-helpers';
import { ghosts } from './ghosts';
describe('ghosts', () => {
it('reports no ghosts at the start', () => {
expect(ghosts(new DuplicateGame())).toEqual([]);
});
it('forms a ghost when a piece is captured on one board but not its twin', () => {
const g = new DuplicateGame();
// Symmetric opening so all four boards stay identical...
playSymmetric(g, [
[{ from: 'e2', to: 'e4' }, { from: 'e7', to: 'e5' }],
[{ from: 'g1', to: 'f3' }, { from: 'b8', to: 'c6' }],
]);
// ...then North & South each play Nxe5 (capturing the e5 pawn),
// and East captures that knight only on its boards (NE, SE).
g.applyMove({ from: 'f3', to: 'e5' }); // N: Nf3xe5 on NW, NE
g.applyMove({ from: 'f3', to: 'e5' }); // S: Nf3xe5 on SW, SE
g.applyMove({ from: 'c6', to: 'e5' }); // E: Nc6xe5 on NE, SE — captures the white knight
// North's knight on e5 survives on NW but was captured on NE -> NW/e5 is a ghost.
// South's knight on e5 survives on SW but was captured on SE -> SW/e5 is a ghost.
const result = ghosts(g).sort((x, y) => (x.board < y.board ? -1 : 1));
expect(result).toEqual([
{ board: 'NW', square: 'e5' },
{ board: 'SW', square: 'e5' },
]);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { DuplicateGame } from './game';
import type { GhostMarker, BoardId, Color, Square } from './types';
import { PLAYERS, PLAYER_BOARDS, PLAYER_COLOR } from './boards';
/** Squares occupied by a piece of `color` on `board`. */
function colorSquares(game: DuplicateGame, board: BoardId, color: Color): Set<Square> {
const set = new Set<Square>();
for (const row of game.boards[board].board()) {
for (const cell of row) {
if (cell && cell.color === color) set.add(cell.square);
}
}
return set;
}
/**
* Ghosts across all four players. A player's non-ghost pieces always occupy
* identical squares on both their boards (they move in lockstep), so a piece is
* a ghost iff the player's other board has no same-colour piece on that square.
*/
export function ghosts(game: DuplicateGame): GhostMarker[] {
const markers: GhostMarker[] = [];
for (const player of PLAYERS) {
const [a, b] = PLAYER_BOARDS[player];
const color = PLAYER_COLOR[player];
const sqA = colorSquares(game, a, color);
const sqB = colorSquares(game, b, color);
for (const sq of sqA) if (!sqB.has(sq)) markers.push({ board: a, square: sq });
for (const sq of sqB) if (!sqA.has(sq)) markers.push({ board: b, square: sq });
}
return markers;
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { DuplicateGame } from './game';
import { playSymmetric } from './test-helpers';
import { legalSyncedMoves } from './legality';
import { ghosts } from './ghosts';
import { evaluateStatus } from './endgame';
import { serialize, deserialize } from './notation';
describe('integration: a scripted game played to checkmate', () => {
it('plays Fool\'s mate, stays consistent throughout, and ends correctly', () => {
const g = new DuplicateGame();
// The game is live and ongoing until the mate.
expect(evaluateStatus(g).state).toBe('playing');
expect(legalSyncedMoves(g).length).toBeGreaterThan(0);
playSymmetric(g, [
[{ from: 'f2', to: 'f3' }, { from: 'e7', to: 'e5' }],
[{ from: 'g2', to: 'g4' }, { from: 'd8', to: 'h4' }],
]);
// No captures occurred, so there are no ghosts.
expect(ghosts(g)).toEqual([]);
// North is checkmated.
const status = evaluateStatus(g);
expect(status.state).toBe('checkmate');
expect(status.result?.N).toBe('loss');
expect(legalSyncedMoves(g)).toEqual([]);
// The game round-trips through save/load and reproduces the same outcome.
const restored = new DuplicateGame(deserialize(serialize(g.history)));
expect(evaluateStatus(restored).state).toBe('checkmate');
expect(restored.history).toEqual(g.history);
});
it('undo from the mated position restores a playable game', () => {
const g = new DuplicateGame();
playSymmetric(g, [
[{ from: 'f2', to: 'f3' }, { from: 'e7', to: 'e5' }],
[{ from: 'g2', to: 'g4' }, { from: 'd8', to: 'h4' }],
]);
g.undo(); // take back W's d8h4
expect(evaluateStatus(g).state).toBe('playing');
expect(g.currentPlayer).toBe('W');
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { DuplicateGame } from './game';
import { legalSyncedMoves, selectionHighlight } from './legality';
describe('legalSyncedMoves', () => {
it('returns all 20 white opening moves when a player\'s two boards are identical', () => {
const g = new DuplicateGame();
expect(legalSyncedMoves(g)).toHaveLength(20);
});
it('excludes a move legal on only one of the player\'s boards', () => {
// N e2e4, S e2e4, W e7e5, E d7d5 -> NW black has pe5, NE black has pd5.
const g = new DuplicateGame([
{ player: 'N', from: 'e2', to: 'e4' },
{ player: 'S', from: 'e2', to: 'e4' },
{ player: 'E', from: 'd7', to: 'd5' },
{ player: 'W', from: 'e7', to: 'e5' },
]);
expect(g.currentPlayer).toBe('N');
const keys = legalSyncedMoves(g).map((m) => `${m.from}${m.to}`);
// e4-d5 is a capture on NE but illegal on NW (d5 empty there): not synced.
expect(keys).not.toContain('e4d5');
// e4-e5 is legal on NE but blocked on NW (black pawn on e5): not synced.
expect(keys).not.toContain('e4e5');
});
});
describe('selectionHighlight', () => {
it('marks every destination playable when the two boards agree', () => {
const g = new DuplicateGame();
const h = selectionHighlight(g, 'e2');
expect(h.playable.sort()).toEqual(['e3', 'e4']);
expect(h.onlyA).toEqual([]);
expect(h.onlyB).toEqual([]);
});
it('splits destinations into playable vs board-local-only on divergence', () => {
const g = new DuplicateGame([
{ player: 'N', from: 'e2', to: 'e4' },
{ player: 'S', from: 'e2', to: 'e4' },
{ player: 'E', from: 'd7', to: 'd5' },
{ player: 'W', from: 'e7', to: 'e5' },
]);
const h = selectionHighlight(g, 'e4'); // North's e4 pawn
expect(h.boardA).toBe('NW');
expect(h.boardB).toBe('NE');
expect(h.playable).toEqual([]); // nothing legal on both
expect(h.onlyA).toEqual([]); // e4 is blocked on NW
expect(h.onlyB.sort()).toEqual(['d5', 'e5']); // capture + advance on NE only
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { DuplicateGame } from './game';
import type { SyncMove, Square, BoardId, PromotionPiece } from './types';
import { PLAYER_BOARDS } from './boards';
function key(m: { from: string; to: string; promotion?: string }): string {
return `${m.from}${m.to}${m.promotion ?? ''}`;
}
/** Every synchronized-legal move for the player to move (the intersection). */
export function legalSyncedMoves(game: DuplicateGame): SyncMove[] {
const [a, b] = PLAYER_BOARDS[game.currentPlayer];
const movesA = game.boards[a].moves({ verbose: true });
const keysB = new Set(game.boards[b].moves({ verbose: true }).map(key));
const seen = new Set<string>();
const result: SyncMove[] = [];
for (const m of movesA) {
const k = key(m);
if (keysB.has(k) && !seen.has(k)) {
seen.add(k);
result.push({
from: m.from,
to: m.to,
promotion: (m.promotion as PromotionPiece) || undefined,
});
}
}
return result;
}
export interface SelectionHighlight {
/** The current player's first board. */
boardA: BoardId;
/** The current player's second board. */
boardB: BoardId;
/** Destinations legal on BOTH boards (actually playable). */
playable: Square[];
/** Destinations legal on board A only. */
onlyA: Square[];
/** Destinations legal on board B only. */
onlyB: Square[];
}
/** Triple-highlight data for the current player's piece grabbed at `from`. */
export function selectionHighlight(
game: DuplicateGame,
from: Square,
): SelectionHighlight {
const [a, b] = PLAYER_BOARDS[game.currentPlayer];
const destA = new Set(
game.boards[a].moves({ verbose: true }).filter((m) => m.from === from).map((m) => m.to),
);
const destB = new Set(
game.boards[b].moves({ verbose: true }).filter((m) => m.from === from).map((m) => m.to),
);
const playable: Square[] = [];
const onlyA: Square[] = [];
const onlyB: Square[] = [];
for (const sq of destA) (destB.has(sq) ? playable : onlyA).push(sq);
for (const sq of destB) if (!destA.has(sq)) onlyB.push(sq);
return { boardA: a, boardB: b, playable, onlyA, onlyB };
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { toCoordinate, serialize, deserialize } from './notation';
import type { HistoryEntry } from './types';
describe('notation', () => {
it('renders a move as a coordinate token', () => {
expect(toCoordinate({ from: 'e2', to: 'e4' })).toBe('e2e4');
expect(toCoordinate({ from: 'e7', to: 'e8', promotion: 'q' })).toBe('e7e8q');
});
it('round-trips a game through serialize/deserialize', () => {
const history: HistoryEntry[] = [
{ player: 'N', from: 'e2', to: 'e4' },
{ player: 'S', from: 'e2', to: 'e4' },
];
const restored = deserialize(serialize(history));
expect(restored).toEqual(history);
});
it('rejects a file that is not a duplicate-chess save', () => {
expect(() => deserialize('{"variant":"chess","version":1,"moves":[]}')).toThrow();
});
it('rejects an unsupported save version', () => {
expect(() => deserialize('{"variant":"duplicate-chess","version":99,"moves":[]}')).toThrow();
});
});
+28
View File
@@ -0,0 +1,28 @@
import type { SyncMove, HistoryEntry } from './types';
/** Coordinate notation, e.g. "e2e4" or "e7e8q". */
export function toCoordinate(move: SyncMove): string {
return `${move.from}${move.to}${move.promotion ?? ''}`;
}
export interface SavedGame {
variant: 'duplicate-chess';
version: 1;
moves: HistoryEntry[];
}
export function serialize(history: HistoryEntry[]): string {
const data: SavedGame = { variant: 'duplicate-chess', version: 1, moves: history };
return JSON.stringify(data, null, 2);
}
export function deserialize(json: string): HistoryEntry[] {
const data = JSON.parse(json) as Partial<SavedGame>;
if (data.variant !== 'duplicate-chess' || !Array.isArray(data.moves)) {
throw new Error('Not a duplicate-chess save file');
}
if (data.version !== 1) {
throw new Error('Unsupported save version');
}
return data.moves;
}
+23
View File
@@ -0,0 +1,23 @@
import type { DuplicateGame } from './game';
import type { SyncMove } from './types';
/**
* Apply a list of [whiteMove, blackMove?] underlying ply-pairs symmetrically:
* North then South play the white move, East then West play the black move.
* While every move is symmetric all four boards stay identical, so each board
* behaves as an ordinary chess game — useful for reaching ordinary checkmate /
* stalemate / repetition positions. Omit blackMove for a final unanswered white move.
*/
export function playSymmetric(
game: DuplicateGame,
pairs: Array<[SyncMove, SyncMove?]>,
): void {
for (const [white, black] of pairs) {
game.applyMove(white); // N
game.applyMove(white); // S
if (black) {
game.applyMove(black); // E
game.applyMove(black); // W
}
}
}
+35
View File
@@ -0,0 +1,35 @@
export type BoardId = 'NW' | 'NE' | 'SW' | 'SE';
export type Player = 'N' | 'S' | 'E' | 'W';
export type Color = 'w' | 'b';
export type Square = string;
export type PromotionPiece = 'q' | 'r' | 'b' | 'n';
export interface SyncMove {
from: Square;
to: Square;
promotion?: PromotionPiece;
}
export interface HistoryEntry extends SyncMove {
player: Player;
}
export interface GhostMarker {
board: BoardId;
square: Square;
}
export type PlayerResult = 'win' | 'draw' | 'loss';
export type GameResult = Record<Player, PlayerResult>;
export type GameState = 'playing' | 'checkmate' | 'stalemate' | 'draw';
export type DrawReason = 'stalemate' | 'threefold' | 'fifty-move' | 'manual';
export interface GameStatus {
state: GameState;
/** Present when state !== 'playing'. */
result?: GameResult;
/** Present for a draw/stalemate. */
reason?: DrawReason;
/** Boards on which the player to move is currently in check. */
checks: BoardId[];
}
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import type { BoardId, Player, Square } from '../engine/types';
import type { SelectionHighlight } from '../engine/legality';
import { BOARD_ROTATION, BOARD_PLAYERS } from '../engine/boards';
interface Props {
id: BoardId;
fen: string;
/** Player colours, e.g. { N:'#4a90d9', ... }. */
colors: Record<Player, string>;
ghosts: Square[];
/** Highlight for this board, or null if no piece is grabbed / not active. */
highlight: { playable: Square[]; local: Square[]; selected: Square | null } | null;
active: boolean;
onSquare: (square: Square) => void;
}
let { id, fen, colors, ghosts, highlight, active, onSquare }: Props = $props();
const FILES = 'abcdefgh';
const GLYPH: Record<string, string> = {
k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟',
};
interface Cell { square: Square; piece: { glyph: string; color: string } | null; }
let cells = $derived.by<Cell[]>(() => {
const placement = fen.split(' ')[0];
const white = colors[BOARD_PLAYERS[id].w];
const black = colors[BOARD_PLAYERS[id].b];
const map: Record<string, { glyph: string; color: string }> = {};
placement.split('/').forEach((row, ri) => {
const rank = 8 - ri;
let file = 0;
for (const ch of row) {
if (/\d/.test(ch)) { file += Number(ch); continue; }
const isWhite = ch === ch.toUpperCase();
map[`${FILES[file]}${rank}`] = {
glyph: GLYPH[ch.toLowerCase()],
color: isWhite ? white : black,
};
file += 1;
}
});
const out: Cell[] = [];
for (let rank = 8; rank >= 1; rank--) {
for (let f = 0; f < 8; f++) {
const square = `${FILES[f]}${rank}`;
out.push({ square, piece: map[square] ?? null });
}
}
return out;
});
function classes(cell: Cell, index: number): string {
const dark = (index + Math.floor(index / 8)) % 2 === 1;
const hl = highlight;
const list = ['sq', dark ? 'dark' : 'light'];
if (ghosts.includes(cell.square)) list.push('ghost-sq');
if (hl?.playable.includes(cell.square)) list.push(cell.piece ? 'play occ' : 'play');
if (hl?.local.includes(cell.square)) list.push('local');
if (hl?.selected === cell.square) list.push('selected');
return list.join(' ');
}
</script>
<div class="board" class:active style="--rot:{BOARD_ROTATION[id]}deg">
{#each cells as cell, i (cell.square)}
<button class={classes(cell, i)} onclick={() => onSquare(cell.square)} aria-label={cell.square}>
{#if cell.piece}
<span class="pc" class:ghost={ghosts.includes(cell.square)}
style="color:{cell.piece.color}">{cell.piece.glyph}</span>
{/if}
</button>
{/each}
</div>
<style>
.board {
display: grid;
grid-template-columns: repeat(8, var(--sq, 34px));
grid-template-rows: repeat(8, var(--sq, 34px));
transform: rotate(var(--rot));
border: 1px solid #20232b;
border-radius: 3px;
}
.board.active { box-shadow: 0 0 0 3px var(--glow, #4a90d9), 0 0 20px 2px var(--glow, #4a90d9); }
.sq {
position: relative; padding: 0; border: 0; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.sq.light { background: #cabf9f; }
.sq.dark { background: #7d6f55; }
.pc {
font-size: calc(var(--sq, 34px) * 0.76); line-height: 1;
text-shadow: -1px -1px 0 #15171c, 1px -1px 0 #15171c,
-1px 1px 0 #15171c, 1px 1px 0 #15171c;
}
.pc.ghost { opacity: 0.42; }
.ghost-sq { outline: 2px dashed #888; outline-offset: -2px; }
.sq.play::after, .sq.local::after {
content: ''; position: absolute; border-radius: 50%;
width: 32%; height: 32%;
}
.sq.play::after { background: #46c24f; box-shadow: 0 0 7px #46c24f; }
.sq.play.occ::after {
width: 84%; height: 84%; background: transparent;
border: 3px solid #46c24f; box-shadow: 0 0 7px #46c24f;
}
.sq.local::after { background: transparent; border: 2px dashed #9aa0aa; }
.sq.selected { outline: 3px solid #3fd9d9; outline-offset: -3px; }
</style>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import Board from './Board.svelte';
import { gameStore } from './stores/game.svelte';
import { PLAYER_BOARDS } from '../engine/boards';
import type { BoardId, Player, Square } from '../engine/types';
const COLORS: Record<Player, string> = {
N: '#4a90d9', S: '#d6483f', E: '#9d5bd2', W: '#e08a3a',
};
// Board centre positions inside the 744x744 compass (see spec §5.1).
const POS: Record<BoardId, { left: number; top: number }> = {
NW: { left: 200, top: 200 }, NE: { left: 544, top: 200 },
SW: { left: 200, top: 544 }, SE: { left: 544, top: 544 },
};
const BOARD_IDS: BoardId[] = ['NW', 'NE', 'SW', 'SE'];
let view = $derived(gameStore.view);
let active = $derived(gameStore.activeBoards);
/** Ghost squares for a given board. */
function ghostsFor(id: BoardId): Square[] {
return view.ghosts.filter((g) => g.board === id).map((g) => g.square);
}
/** Highlight payload for a given board, or null. */
function highlightFor(id: BoardId) {
const h = gameStore.highlight;
if (h === null) return null;
if (id !== h.boardA && id !== h.boardB) return null;
const local = id === h.boardA ? h.onlyA : h.onlyB;
const selectedHere = active.includes(id) ? gameStore.selected : null;
return { playable: h.playable, local, selected: selectedHere };
}
function handleSquare(id: BoardId, square: Square): void {
if (gameStore.isScrubbing) return;
if (!active.includes(id)) return; // only the player-to-move's boards are interactive
if (gameStore.selected === null) {
gameStore.select(square);
} else if (gameStore.highlight?.playable.includes(square)) {
gameStore.commitTo(square);
} else {
gameStore.select(square); // re-grab or cancel
}
}
</script>
<div class="compass" style="--glow:{COLORS[view.currentPlayer]}">
{#each BOARD_IDS as id (id)}
<div class="slot" style="left:{POS[id].left}px; top:{POS[id].top}px;">
<Board
{id}
fen={view.fen[id]}
colors={COLORS}
ghosts={ghostsFor(id)}
highlight={highlightFor(id)}
active={active.includes(id)}
onSquare={(sq) => handleSquare(id, sq)}
/>
</div>
{/each}
<div class="plabel" class:on={view.currentPlayer === 'N'}
style="left:372px; top:74px; background:{COLORS.N}">NORTH</div>
<div class="plabel" class:on={view.currentPlayer === 'S'}
style="left:372px; top:670px; background:{COLORS.S}">SOUTH</div>
<div class="plabel vert" class:on={view.currentPlayer === 'E'}
style="left:670px; top:372px; background:{COLORS.E}">EAST</div>
<div class="plabel vert" class:on={view.currentPlayer === 'W'}
style="left:74px; top:372px; background:{COLORS.W}">WEST</div>
</div>
<style>
.compass { position: relative; width: 744px; height: 744px; flex: none; }
.slot { position: absolute; transform: translate(-50%, -50%); }
.plabel {
position: absolute; transform: translate(-50%, -50%);
color: #fff; font-size: 12px; font-weight: 700; letter-spacing: 0.09em;
padding: 6px 12px; border-radius: 7px; white-space: nowrap;
}
.plabel.vert { writing-mode: vertical-rl; }
.plabel.on { box-shadow: 0 0 14px currentColor; }
</style>
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
import { gameStore } from './stores/game.svelte';
import { toCoordinate } from '../engine/notation';
import { PLAYERS } from '../engine/boards';
import type { Player } from '../engine/types';
const COLORS: Record<Player, string> = {
N: '#4a90d9', S: '#d6483f', E: '#9d5bd2', W: '#e08a3a',
};
const NAME: Record<Player, string> = { N: 'North', S: 'South', E: 'East', W: 'West' };
let view = $derived(gameStore.view);
/** Move log grouped into rounds of four (N,S,E,W). */
let rounds = $derived.by(() => {
const out: string[][] = [];
view.history.forEach((entry, i) => {
const r = Math.floor(i / 4);
(out[r] ??= [])[i % 4] = toCoordinate(entry);
});
return out;
});
let statusText = $derived.by(() => {
const s = view.status;
if (s.state === 'playing') return `${NAME[view.currentPlayer]} to move`;
if (s.state === 'checkmate') {
const winners = PLAYERS.filter((p) => s.result?.[p] === 'win').map((p) => NAME[p]);
return `Checkmate — ${NAME[view.currentPlayer]} loses; ${winners.join(' & ')} win`;
}
if (s.state === 'stalemate') return 'Stalemate — all draw';
return `Draw (${s.reason})`;
});
let fileInput: HTMLInputElement;
function onFile(e: Event): void {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) gameStore.load(file).catch((err) => alert(String(err)));
}
</script>
<aside class="panel">
<section class="card">
<div class="turn">
<span class="dot" style="background:{COLORS[view.currentPlayer]}"></span>
{statusText}
</div>
<div class="sub">Ghosts on board: {view.ghosts.length} · Checks: {view.status.checks.length}</div>
</section>
<section class="card">
<h2>Move log</h2>
<table>
<thead><tr>
{#each PLAYERS as p}<th style="color:{COLORS[p]}">{NAME[p]}</th>{/each}
</tr></thead>
<tbody>
{#each rounds as round, r (r)}
<tr>
{#each [0, 1, 2, 3] as c}<td>{round[c] ?? ''}</td>{/each}
</tr>
{/each}
</tbody>
</table>
</section>
<section class="card">
<h2>Legend</h2>
<div class="legend">
<div><span class="ring play"></span> Playable — legal on both boards</div>
<div><span class="ring local"></span> Legal on that board only</div>
<div><span class="ring ghost"></span> Ghost — twin captured, frozen</div>
</div>
</section>
<section class="card controls">
<button onclick={() => gameStore.newGame()}>New game</button>
<button onclick={() => gameStore.undo()} disabled={view.ply === 0}>Undo</button>
<button onclick={() => gameStore.scrubTo((gameStore.scrubPly ?? view.ply) - 1)}
disabled={view.ply === 0}>◀ Prev</button>
<button onclick={() => gameStore.scrubTo((gameStore.scrubPly ?? view.ply) + 1)}
disabled={!gameStore.isScrubbing}>Next ▶</button>
<button onclick={() => gameStore.scrubTo(null)} disabled={!gameStore.isScrubbing}>● Live</button>
<button onclick={() => gameStore.declareDraw()}>Declare draw</button>
<button onclick={() => gameStore.save()}>Save</button>
<button onclick={() => fileInput.click()}>Load</button>
<input type="file" accept="application/json" bind:this={fileInput}
onchange={onFile} style="display:none" />
</section>
</aside>
<style>
.panel { width: 290px; display: flex; flex-direction: column; gap: 13px; }
.card {
background: #1d2027; border: 1px solid #333845;
border-radius: 9px; padding: 13px 15px;
}
.card h2 {
margin: 0 0 8px; font-size: 11px; letter-spacing: 0.07em;
text-transform: uppercase; color: #9aa0aa;
}
.turn { display: flex; align-items: center; gap: 9px; font-weight: 600; }
.dot { width: 13px; height: 13px; border-radius: 50%; }
.sub { font-size: 12px; color: #9aa0aa; margin-top: 6px; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 3px 5px; text-align: left; }
th { font-size: 10px; text-transform: uppercase; }
td { font-family: ui-monospace, Menlo, monospace; color: #cdd2da; }
.legend { display: flex; flex-direction: column; gap: 7px; font-size: 12px; }
.legend div { display: flex; align-items: center; gap: 8px; }
.ring { width: 14px; height: 14px; border-radius: 50%; flex: none; }
.ring.play { background: #46c24f; }
.ring.local { border: 2px dashed #9aa0aa; }
.ring.ghost { border: 2px dashed #888; }
.controls { display: flex; flex-wrap: wrap; gap: 6px; }
.controls button {
background: #262b34; color: #e6e8ec; border: 1px solid #333845;
border-radius: 5px; padding: 5px 9px; font-size: 12px; cursor: pointer;
}
.controls button:disabled { opacity: 0.4; cursor: default; }
</style>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import { gameStore } from './stores/game.svelte';
import type { PromotionPiece } from '../engine/types';
const PIECES: { code: PromotionPiece; glyph: string }[] = [
{ code: 'q', glyph: '♛' }, { code: 'r', glyph: '♜' },
{ code: 'b', glyph: '♝' }, { code: 'n', glyph: '♞' },
];
let pending = $derived(gameStore.pendingPromotion);
</script>
{#if pending}
<div class="backdrop" onclick={() => gameStore.cancelPromotion()}
role="presentation">
<div class="dialog" onclick={(e) => e.stopPropagation()} role="presentation">
<h3>Promote pawn ({pending.from}{pending.to})</h3>
<div class="row">
{#each PIECES as p}
<button onclick={() => gameStore.choosePromotion(p.code)}>{p.glyph}</button>
{/each}
</div>
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6);
display: flex; align-items: center; justify-content: center; z-index: 50;
}
.dialog {
background: #1d2027; border: 1px solid #333845;
border-radius: 10px; padding: 18px 22px;
}
.dialog h3 { margin: 0 0 12px; font-size: 14px; }
.row { display: flex; gap: 10px; }
.row button {
font-size: 38px; line-height: 1; width: 60px; height: 60px;
background: #262b34; color: #e6e8ec;
border: 1px solid #333845; border-radius: 8px; cursor: pointer;
}
.row button:hover { border-color: #46c24f; }
</style>
+164
View File
@@ -0,0 +1,164 @@
import { DuplicateGame } from '../../engine/game';
import { legalSyncedMoves, selectionHighlight, type SelectionHighlight } from '../../engine/legality';
import { ghosts } from '../../engine/ghosts';
import { evaluateStatus } from '../../engine/endgame';
import { serialize, deserialize } from '../../engine/notation';
import { PLAYER_BOARDS } from '../../engine/boards';
import type {
BoardId, Player, Square, SyncMove, HistoryEntry, GhostMarker, GameStatus,
} from '../../engine/types';
/** A plain, reactivity-friendly snapshot of everything the UI renders. */
export interface GameView {
/** Piece-placement FEN field per board. */
fen: Record<BoardId, string>;
currentPlayer: Player;
ply: number;
ghosts: GhostMarker[];
status: GameStatus;
history: HistoryEntry[];
}
function buildView(game: DuplicateGame): GameView {
return {
fen: {
NW: game.boards.NW.fen(),
NE: game.boards.NE.fen(),
SW: game.boards.SW.fen(),
SE: game.boards.SE.fen(),
},
currentPlayer: game.currentPlayer,
ply: game.ply,
ghosts: ghosts(game),
status: evaluateStatus(game),
history: [...game.history],
};
}
class GameStore {
/** The authoritative live game — deliberately NOT a $state proxy. */
#game = new DuplicateGame();
/** Snapshot the UI renders. While scrubbing it reflects a past ply. */
view = $state<GameView>(buildView(this.#game));
/** The grabbed square, or null. */
selected = $state<Square | null>(null);
/** Triple-highlight for the grabbed piece, or null. */
highlight = $state<SelectionHighlight | null>(null);
/** A pawn move awaiting a promotion choice, or null. */
pendingPromotion = $state<{ from: Square; to: Square } | null>(null);
/** Ply currently being viewed; null means the live position. */
scrubPly = $state<number | null>(null);
get isScrubbing(): boolean {
return this.scrubPly !== null;
}
/** Which boards belong to the player to move (for the turn glow). */
get activeBoards(): [BoardId, BoardId] {
return PLAYER_BOARDS[this.#game.currentPlayer];
}
/** Grab a piece: must be the current player's turn and a live (non-scrub) view. */
select(square: Square): void {
if (this.isScrubbing) return;
if (this.selected === square) { this.clearSelection(); return; }
this.selected = square;
this.highlight = selectionHighlight(this.#game, square);
}
clearSelection(): void {
this.selected = null;
this.highlight = null;
}
/** Attempt to play the grabbed piece to `to`. Opens the promotion dialog if needed. */
commitTo(to: Square): void {
const from = this.selected;
if (from === null || this.highlight === null) return;
if (!this.highlight.playable.includes(to)) return; // not a synchronized-legal square
const moves = legalSyncedMoves(this.#game).filter((m) => m.from === from && m.to === to);
if (moves.length === 0) return;
if (moves.some((m) => m.promotion)) {
this.pendingPromotion = { from, to };
return;
}
this.#apply(moves[0]);
}
/** Finish a promotion started by commitTo. */
choosePromotion(piece: SyncMove['promotion']): void {
if (this.pendingPromotion === null) return;
this.#apply({ ...this.pendingPromotion, promotion: piece });
this.pendingPromotion = null;
}
cancelPromotion(): void {
this.pendingPromotion = null;
}
#apply(move: SyncMove): void {
this.#game.applyMove(move);
this.clearSelection();
this.scrubPly = null;
this.view = buildView(this.#game);
}
undo(): void {
this.#game.undo();
this.clearSelection();
this.scrubPly = null;
this.view = buildView(this.#game);
}
newGame(): void {
this.#game = new DuplicateGame();
this.clearSelection();
this.pendingPromotion = null;
this.scrubPly = null;
this.view = buildView(this.#game);
}
/** Scrub the move history; null returns to the live position. */
scrubTo(ply: number | null): void {
this.clearSelection();
if (ply === null || ply >= this.#game.ply) {
this.scrubPly = null;
this.view = buildView(this.#game);
return;
}
this.scrubPly = ply;
this.view = buildView(new DuplicateGame(this.#game.history.slice(0, ply)));
}
/** Manually declare a draw (provisional: insufficient material is not auto-detected). */
declareDraw(): void {
this.view = {
...this.view,
status: { state: 'draw', reason: 'manual', checks: [],
result: { N: 'draw', S: 'draw', E: 'draw', W: 'draw' } },
};
}
save(): void {
const blob = new Blob([serialize(this.#game.history)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `duplicate-chess-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
async load(file: File): Promise<void> {
const history = deserialize(await file.text());
this.#game = new DuplicateGame(history);
this.clearSelection();
this.pendingPromotion = null;
this.scrubPly = null;
this.view = buildView(this.#game);
}
}
export const gameStore = new GameStore();
+9
View File
@@ -0,0 +1,9 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app
+2
View File
@@ -0,0 +1,2 @@
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"module": "esnext",
"types": ["svelte", "vite/client"],
"noEmit": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"moduleDetection": "force"
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vite.dev/config/
export default defineConfig({
// Hosted as a sub-app under chess.sethpc.xyz/duplicate/ alongside blind_chess.
// Setting `base` makes Vite emit asset URLs with the /duplicate/ prefix so they
// stay inside the Caddy static handler instead of falling through to blind_chess.
base: '/duplicate/',
plugins: [svelte()],
})
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { environment: 'node', include: ['src/**/*.test.ts'] },
});