docs: design spec for table-fidelity feature batch
Three features requested by Andrew Freiberg (physical-game player) and refined by Seth, bringing digital blind chess closer to the physical table: 1. Moderator announces every move and attempted move to both players (widen announcement audience to 'both'; suppress bot retry churn). 2. Running capture tally (server-derived per-viewer protocol field). 3. Phantom opponent pieces — a client-local, drag-and-drop opponent-model overlay, blind mode only, never sent to the server. Spec only; no implementation. Phased: F1+F2 then F3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,435 @@
|
|||||||
|
# Table-Fidelity Features — Design Spec
|
||||||
|
|
||||||
|
> Three features that bring digital blind chess closer to the physical-table
|
||||||
|
> experience, requested by Andrew Freiberg (an experienced physical-game
|
||||||
|
> player) and refined by Seth.
|
||||||
|
|
||||||
|
- **Date:** 2026-05-18
|
||||||
|
- **Status:** Approved (brainstorm), pending spec review
|
||||||
|
- **Project:** blind_chess
|
||||||
|
- **Supersedes/extends:** `2026-04-28-blind-chess-design.md` (moderator vocabulary,
|
||||||
|
view filter, FSM). No prior decision is reversed; one (`audience` filtering of
|
||||||
|
move events) is deliberately widened — see Feature 1.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
From Andrew's email (the physical game uses a human moderator and three people):
|
||||||
|
|
||||||
|
1. *"Can you make it so the moderator announces all moves or attempted moves,
|
||||||
|
white and black? One of the things a player listens for is what the moderator
|
||||||
|
says when it is the other player's turn."*
|
||||||
|
2. *"A running tabulation so you can see how many pieces you have captured.
|
||||||
|
Normally when you play, you set up the opponent's pieces on your board and
|
||||||
|
remove them as you make a capture to keep track."*
|
||||||
|
|
||||||
|
And Seth's framing of the third: *"You move the other players' pieces at will to
|
||||||
|
create a model 'guess' of where their pieces are... move the opponent's pieces
|
||||||
|
anywhere you want, including off the board."*
|
||||||
|
|
||||||
|
These map to three features. They are independent in code but share one theme:
|
||||||
|
making the digital game as faithful to the physical table as Andrew's
|
||||||
|
real-world experience of it.
|
||||||
|
|
||||||
|
## Scope at a glance
|
||||||
|
|
||||||
|
| # | Feature | Where | Size |
|
||||||
|
|---|---------|-------|------|
|
||||||
|
| 1 | Moderator announces every move & attempted move, to both players | server (+ tiny client) | small |
|
||||||
|
| 2 | Running capture tally | server + client panel | small–medium |
|
||||||
|
| 3 | Phantom opponent pieces (private opponent-model overlay) | client only | medium |
|
||||||
|
|
||||||
|
**Phasing:** one spec, one plan, implemented in two increments —
|
||||||
|
*Increment 1* = Features 1 + 2 (server-centric, shippable alone);
|
||||||
|
*Increment 2* = Feature 3 (the larger client build).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 1 — Moderator announces everything, to both players
|
||||||
|
|
||||||
|
### Current behaviour
|
||||||
|
|
||||||
|
`Announcement` carries an `audience` field (`'w' | 'b' | 'both'`), filtered in
|
||||||
|
exactly two places: the server's `broadcastSinceLast` (`ws.ts`) and the
|
||||||
|
client's `ModeratorPanel`.
|
||||||
|
|
||||||
|
- **Move events** (`white_moved`, `*_moved_captured`, `*_castled_*`,
|
||||||
|
`*_promoted`) — emitted with `audience: opp`. Only the opponent of the mover
|
||||||
|
sees them.
|
||||||
|
- **Attempted-move errors** (`no_such_piece`, `no_legal_moves`, `wont_help`,
|
||||||
|
`illegal_move`) — emitted with `audience: actor`. Only the player who made
|
||||||
|
the attempt sees them.
|
||||||
|
- **State changes** (`*_in_check`, `*_checkmate`, draws) and
|
||||||
|
resign/draw/abandon — already `audience: 'both'`.
|
||||||
|
|
||||||
|
So a player does **not** hear what the moderator says about the opponent's
|
||||||
|
*failed* attempts — exactly the channel Andrew listens for at the table.
|
||||||
|
|
||||||
|
### Change
|
||||||
|
|
||||||
|
Both move events and attempted-move errors become `audience: 'both'`. After
|
||||||
|
this change **every** announcement is `'both'`; the `audience` field becomes
|
||||||
|
uniformly `'both'` but is **retained** as the moderator-channel egress-control
|
||||||
|
hook (the filtering code in `ws.ts` and `ModeratorPanel` stays — it is the
|
||||||
|
security boundary, now a pass-through).
|
||||||
|
|
||||||
|
Concretely:
|
||||||
|
|
||||||
|
- `translator.ts` `translateMove` — the six move-event `announce(...)` calls
|
||||||
|
change their audience argument from `opp` to `'both'`.
|
||||||
|
- `commit.ts` `announceWith` — `announce(text, color, ply)` becomes
|
||||||
|
`announce(text, 'both', ply)`.
|
||||||
|
|
||||||
|
No protocol change, no enum change.
|
||||||
|
|
||||||
|
### Client: labelling attempted-move lines
|
||||||
|
|
||||||
|
The four error enums carry **no colour** (`wont_help`, not `white_wont_help`).
|
||||||
|
With the announcement now shared, the panel must say *whose* attempt it was.
|
||||||
|
|
||||||
|
It can be derived for free: an attempted move only ever happens on the actor's
|
||||||
|
turn, and an error announcement's `ply` is `chess.history().length` captured
|
||||||
|
*before* the move applies. Therefore **`ply` parity is the actor**:
|
||||||
|
`ply % 2 === 0` → White attempted, odd → Black. (Move events already encode
|
||||||
|
colour in the enum text and need no parity prefix.)
|
||||||
|
|
||||||
|
The client (`ModeratorPanel` / `moderator-strings`) prefixes the four error
|
||||||
|
texts with `"White — "` / `"Black — "` derived from `ply` parity. The current
|
||||||
|
alarm-red `.err` styling on those four entries is replaced with a neutral/muted
|
||||||
|
"moderator info" style — with a shared transcript they are commentary, not
|
||||||
|
"you did something wrong".
|
||||||
|
|
||||||
|
### Bot retry suppression
|
||||||
|
|
||||||
|
The blind Casual bot searches for a legal move by retrying rejected candidates
|
||||||
|
inside one decision cycle (`BotDriver`, `RETRY_CAP = 25`). Each rejection runs
|
||||||
|
through `announceWith`, which pushes one error announcement onto
|
||||||
|
`game.announcements`. With those now `'both'`, the human opponent would see the
|
||||||
|
bot's internal search as up to 25 lines of moderator spam.
|
||||||
|
|
||||||
|
Fix, entirely within `bot/driver.ts`: in `dispatch`, on the `'announce'` retry
|
||||||
|
branch, pop the just-pushed announcement off `game.announcements` before
|
||||||
|
returning `{ kind: 'retry' }`. This is safe because:
|
||||||
|
|
||||||
|
- `announceWith` pushes exactly one announcement and the driver receives it as
|
||||||
|
`result.announcements[0]`; nothing mutates `game.announcements` in between,
|
||||||
|
so it is the last element. Pop with an identity guard
|
||||||
|
(`if (last === result.announcements[0]) pop()`).
|
||||||
|
- The whole bot decision cycle runs inside `pokeBot(game)` and completes
|
||||||
|
*before* `ws.ts` calls `broadcastSinceLast` — intermediate announcements are
|
||||||
|
removed before any broadcast ever runs.
|
||||||
|
- The bot tracks its own rejections via `attemptHistory` (explicitly passed),
|
||||||
|
not via `newAnnouncements`, so popping does not affect the bot's logic.
|
||||||
|
|
||||||
|
Only the bot's **final committed move** announcement survives and is broadcast
|
||||||
|
(that is Feature 1 working: the human hears "Black has moved"). A bot that
|
||||||
|
exhausts retries still resigns; the human sees the resignation, not the 25
|
||||||
|
fumbles.
|
||||||
|
|
||||||
|
Human probes are **not** suppressed: a person tries 1–3 pieces per turn,
|
||||||
|
naturally bounded — broadcasting those is the feature.
|
||||||
|
|
||||||
|
### Information-channel note (deliberate)
|
||||||
|
|
||||||
|
Feature 1 genuinely widens what blind mode reveals. Hearing "won't help you" on
|
||||||
|
the opponent's turn tells you they are pinned or otherwise constrained;
|
||||||
|
`no_legal_moves` tells you they touched a fully-boxed-in piece. This is the
|
||||||
|
*intended* moderator channel and it is faithful to the physical game (Andrew,
|
||||||
|
an experienced physical-game player, explicitly asked for it). It is recorded
|
||||||
|
here as a conscious, authorised reduction in blindness — **not** a leak through
|
||||||
|
an illegitimate side channel. `buildView` and `geometric.ts` (the zero-leak
|
||||||
|
core) are untouched.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- `packages/server/src/translator.ts` — audience of move events.
|
||||||
|
- `packages/server/src/commit.ts` — audience in `announceWith`.
|
||||||
|
- `packages/server/src/bot/driver.ts` — pop intermediate retry rejections.
|
||||||
|
- `packages/client/src/lib/moderator-strings.ts` /
|
||||||
|
`packages/client/src/lib/ModeratorPanel.svelte` — actor prefix via `ply`
|
||||||
|
parity; neutral styling for attempted-move lines.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Unit (`translator`): move events carry `audience: 'both'`.
|
||||||
|
- Unit (`commit-fsm`): `no_such_piece` / `no_legal_moves` / `wont_help` /
|
||||||
|
`illegal_move` carry `audience: 'both'`.
|
||||||
|
- Unit (`driver`): after a decision cycle that incurs ≥1 retry, only the final
|
||||||
|
move's announcement(s) remain in `game.announcements`; intermediate
|
||||||
|
rejections are absent.
|
||||||
|
- Integration (`ai-game-casual`): the human side receives the bot's final move
|
||||||
|
announcement and no retry-rejection announcements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 2 — Running capture tally
|
||||||
|
|
||||||
|
### What the player sees
|
||||||
|
|
||||||
|
A read-only panel beside the board:
|
||||||
|
|
||||||
|
- Primary line — **"You've captured:"** followed by glyphs of the opponent
|
||||||
|
pieces you have taken, with a count, e.g. `♟ ♟ ♞ (3)`.
|
||||||
|
- Secondary muted line — **"Lost:"** followed by glyphs of your pieces the
|
||||||
|
opponent has taken, e.g. `♙ ♗ (2)`.
|
||||||
|
|
||||||
|
(Andrew asked specifically for captures; losses are free to compute and
|
||||||
|
complete the picture. Seth may drop the "Lost" line at spec review.)
|
||||||
|
|
||||||
|
### Why this needs the server
|
||||||
|
|
||||||
|
In blind mode the capturing client cannot see what it captured — opponent
|
||||||
|
pieces are filtered out of its `BoardView`. The captured piece's *type* must
|
||||||
|
come from the server. This is the same single-piece-of-history reveal the
|
||||||
|
physical moderator gives you when you take a piece.
|
||||||
|
|
||||||
|
### Data model
|
||||||
|
|
||||||
|
`MoveRecord` already records `by: Color` and `capturedPieceType?: PieceType`
|
||||||
|
for every move (`state.ts`). The tally is a pure derivation of
|
||||||
|
`game.moveHistory`.
|
||||||
|
|
||||||
|
New shared type (`packages/shared/src/types.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type PieceTally = Partial<Record<PieceType, number>>;
|
||||||
|
```
|
||||||
|
|
||||||
|
New field on the `joined` and `update` server messages
|
||||||
|
(`packages/shared/src/protocol.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
captures: { byYou: PieceTally; byOpponent: PieceTally };
|
||||||
|
```
|
||||||
|
|
||||||
|
`captures` is sent on **every** `update` (it is tiny and keeps `update`
|
||||||
|
idempotent — replaying the latest `update` still renders correctly, matching
|
||||||
|
the existing protocol decision).
|
||||||
|
|
||||||
|
### Server computation
|
||||||
|
|
||||||
|
New module `packages/server/src/captures.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function captureTally(game, viewer): { byYou: PieceTally; byOpponent: PieceTally }
|
||||||
|
```
|
||||||
|
|
||||||
|
Iterates `game.moveHistory`; for each record with `capturedPieceType`,
|
||||||
|
increments `byYou` if `by === viewer`, else `byOpponent`. En-passant captures
|
||||||
|
are included automatically (`capturedPieceType` is `'p'`).
|
||||||
|
|
||||||
|
`ws.ts` includes `captures: captureTally(game, color)` in the `joined` payload
|
||||||
|
(`onHello`) and in `update` payloads (`sendUpdateTo`).
|
||||||
|
|
||||||
|
### Client
|
||||||
|
|
||||||
|
- `game.svelte.ts` store gains a `captures` field, set from `joined` and
|
||||||
|
`update`.
|
||||||
|
- New component `packages/client/src/lib/CaptureTally.svelte`, rendered in the
|
||||||
|
side panel of `Game.svelte` near `ModeratorPanel`. Reuses `pieces.ts`
|
||||||
|
`pieceGlyph`. `byYou` glyphs render in the opponent's colour (they are
|
||||||
|
opponent pieces); `byOpponent` glyphs render in your colour.
|
||||||
|
|
||||||
|
### Modes
|
||||||
|
|
||||||
|
Built for both modes. In vanilla it is a simple scoreboard; in blind it is the
|
||||||
|
load-bearing feature. The bot ignores `captures`; a future ReconBrain may
|
||||||
|
consume it (Phase 2, out of scope here).
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- `packages/shared/src/types.ts` — `PieceTally`.
|
||||||
|
- `packages/shared/src/protocol.ts` — `captures` on `joined` / `update`.
|
||||||
|
- `packages/server/src/captures.ts` — new, `captureTally`.
|
||||||
|
- `packages/server/src/ws.ts` — include `captures` in `joined` / `update`.
|
||||||
|
- `packages/client/src/lib/stores/game.svelte.ts` — store field.
|
||||||
|
- `packages/client/src/lib/CaptureTally.svelte` — new component.
|
||||||
|
- `packages/client/src/lib/Game.svelte` — mount the panel.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Unit (`captures`): `captureTally` returns correct per-viewer counts for a
|
||||||
|
`moveHistory` containing captures by both colours, including en passant.
|
||||||
|
- Client component verified manually (no client test harness — see Feature 3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 3 — Phantom opponent pieces
|
||||||
|
|
||||||
|
A private opponent-model overlay on the player's own board: the digital form of
|
||||||
|
"set up the opponent's pieces on your board and move/remove them to keep
|
||||||
|
track." **Blind mode only** — pointless in vanilla, where real opponent pieces
|
||||||
|
are visible.
|
||||||
|
|
||||||
|
### Behaviour (the manual model, per Seth's decision)
|
||||||
|
|
||||||
|
- **Seeded once** at game start with the opponent's 16 pieces on their standard
|
||||||
|
home squares — saves the manual initial setup.
|
||||||
|
- **Fully manual thereafter.** Drag any phantom anywhere; drag it off the board
|
||||||
|
to remove it; place fresh phantoms from an always-available palette of the
|
||||||
|
six piece types. **No count limits, no automation, no auto-removal on
|
||||||
|
capture.** Editable at any time, including the opponent's turn.
|
||||||
|
- The capture tally (Feature 2) is a **separate** read-only counter — it is
|
||||||
|
*not* coupled to the phantom layer.
|
||||||
|
- Phantoms are visually distinct from real pieces (translucent, dashed
|
||||||
|
outline) and use the opponent's colour.
|
||||||
|
- A phantom **cannot** occupy a square holding one of your real pieces (your
|
||||||
|
real pieces are known truth). Other squares are all valid — overlapping where
|
||||||
|
the opponent might really be is the entire point.
|
||||||
|
- On game-over the board reveals all real pieces; the phantom overlay and
|
||||||
|
palette are **hidden** so the reveal is clean.
|
||||||
|
|
||||||
|
### The security invariant (load-bearing)
|
||||||
|
|
||||||
|
The phantom layer is **100% client-local**. It is never serialized to the wire,
|
||||||
|
never sent to the server, never seen by the opponent. It contains zero real
|
||||||
|
opponent information by construction — it is the player's own fiction.
|
||||||
|
|
||||||
|
To make this auditable, the phantom layer gets its **own store**, separate from
|
||||||
|
the server-synced `game.svelte.ts`:
|
||||||
|
|
||||||
|
> New store `packages/client/src/lib/stores/phantoms.svelte.ts`. A reviewer
|
||||||
|
> confirms "phantoms never leak" by verifying this store is never read in any
|
||||||
|
> `send(...)` / `commit(...)` path. No `ClientMessage` variant carries phantom
|
||||||
|
> data.
|
||||||
|
|
||||||
|
`buildView` and `geometric.ts` are untouched.
|
||||||
|
|
||||||
|
### Data model
|
||||||
|
|
||||||
|
- Phantom state: `Partial<Record<Square, Piece>>` — at most one phantom per
|
||||||
|
square; `Piece.color` is always the opponent's colour. Placing on an occupied
|
||||||
|
phantom square replaces.
|
||||||
|
- Store operations: `place(sq, type)`, `move(from, to)`, `remove(sq)`,
|
||||||
|
`clear()`, `loadForGame(gameId, you)`.
|
||||||
|
- **Persistence:** `localStorage`, key `bc:phantoms:<gameId>`, value = JSON of
|
||||||
|
the phantom map. Survives reload / reconnect (important on phones).
|
||||||
|
- **Seeding:** on first load of a blind game (no `localStorage` key present),
|
||||||
|
seed the 16 opponent pieces at standard start squares for the opponent
|
||||||
|
colour (`you === 'w' ? 'b' : 'w'`) and persist immediately. The presence of
|
||||||
|
the key thereafter means "already seeded — load, do not re-seed", so a reload
|
||||||
|
never wipes the player's edits.
|
||||||
|
- On `gameStatus === 'finished'`, clear the `localStorage` key (avoids
|
||||||
|
unbounded accumulation across games).
|
||||||
|
|
||||||
|
The pure transformation logic — standard start squares for a colour,
|
||||||
|
place/move/remove on a map, (de)serialization — is extracted into a plain
|
||||||
|
(non-`.svelte`) module so it can be unit-tested. `phantoms.svelte.ts` is the
|
||||||
|
thin reactive wrapper. (The plan decides whether to stand up a `vitest` config
|
||||||
|
in the client package, which currently has none, or host the pure module in
|
||||||
|
`packages/shared`.)
|
||||||
|
|
||||||
|
### Interaction — drag-and-drop (approved option A)
|
||||||
|
|
||||||
|
Drag-and-drop via **pointer events** (`pointerdown` / `pointermove` /
|
||||||
|
`pointerup`) so it works for both mouse and touch. Real moves stay
|
||||||
|
click-to-move (the touch-move FSM is unchanged — the deferred decision against
|
||||||
|
drag-and-drop for *real* moves still stands; F3's drag is phantom-only).
|
||||||
|
|
||||||
|
- **Move a phantom:** `pointerdown` on a phantom → once the pointer moves past a
|
||||||
|
small threshold (~6 px) it is a drag; a drag image follows the pointer.
|
||||||
|
`pointerup` over another square → move the phantom there; over a square with
|
||||||
|
your real piece → invalid, snap back; outside the board → remove the phantom.
|
||||||
|
- **Place from palette:** `pointerdown` on a palette piece → drag → `pointerup`
|
||||||
|
over a board square free of your real piece → place a phantom of that type.
|
||||||
|
- **Tap vs drag:** a `pointerdown`+`pointerup` with no movement past the
|
||||||
|
threshold is **not** a phantom action — it is forwarded to the board's normal
|
||||||
|
`onSquareClick`, so you can still arm/commit a real move onto a square you
|
||||||
|
have a phantom guess on (e.g. to capture there). A drag past the threshold
|
||||||
|
`stopPropagation`s so the underlying square click does not also fire.
|
||||||
|
|
||||||
|
This isolation — tap → real move, drag → phantom — means phantom editing never
|
||||||
|
blocks the live game's move path and needs no mode toggle.
|
||||||
|
|
||||||
|
### Rendering & components
|
||||||
|
|
||||||
|
- The phantom layer is rendered **within `Board.svelte`** as an additional
|
||||||
|
styled layer in each grid cell (alignment with real squares is then free).
|
||||||
|
`Board.svelte` owns the pointer-event handling and the tap-vs-drag
|
||||||
|
disambiguation, because that decision cannot be cleanly split across
|
||||||
|
components. `Board.svelte` remains prop-driven: it receives phantom data and
|
||||||
|
`onPhantomMove` / `onPhantomPlace` / `onPhantomRemove` callbacks.
|
||||||
|
- New component `packages/client/src/lib/PhantomPalette.svelte` — the
|
||||||
|
six-type palette, rendered beside the board on desktop / below on mobile;
|
||||||
|
source of palette→board drags.
|
||||||
|
- `Game.svelte` wires the `phantoms` store to `Board` and `PhantomPalette`,
|
||||||
|
and gates the whole phantom UI on `mode === 'blind' && gameStatus ===
|
||||||
|
'active'`.
|
||||||
|
- Phantom styling lives in `app.css` / component styles (translucent, dashed).
|
||||||
|
|
||||||
|
### Out of scope for v1
|
||||||
|
|
||||||
|
Highlighting ignores phantoms — the geometric highlight stays a function of
|
||||||
|
your real pieces only. Letting bishop/rook rays stop at phantom pieces would be
|
||||||
|
information-safe (phantoms hold zero real opponent data) and is a reasonable
|
||||||
|
future enhancement, but it is not in this spec.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- New: `packages/client/src/lib/stores/phantoms.svelte.ts`,
|
||||||
|
`packages/client/src/lib/PhantomPalette.svelte`, a pure phantom-model module
|
||||||
|
(location per plan), optionally a small pointer-drag helper.
|
||||||
|
- Modified: `packages/client/src/lib/Board.svelte` (phantom layer + drag +
|
||||||
|
tap-vs-drag), `packages/client/src/lib/Game.svelte` (mount palette, wire
|
||||||
|
store, blind+active gating), `packages/client/src/app.css` (phantom styles).
|
||||||
|
- No server or shared changes for Feature 3 (unless the pure model module is
|
||||||
|
hosted in `packages/shared`).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Feature 3 is client-only and the project currently has no client test harness
|
||||||
|
(the 78 existing tests are all `shared` + `server`). The pure phantom-model
|
||||||
|
logic (seed squares, place/move/remove, (de)serialization) is unit-tested; the
|
||||||
|
drag interaction and rendering are verified manually on phone + desktop. The
|
||||||
|
plan decides the test-infra approach.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture & invariants summary
|
||||||
|
|
||||||
|
- **Feature 1** widens the moderator channel: every `Announcement` becomes
|
||||||
|
`audience: 'both'`. The field and its filtering are retained as the egress
|
||||||
|
control. This is a deliberate, authorised increase in shared information,
|
||||||
|
faithful to the physical game.
|
||||||
|
- **Feature 2** adds one server-derived, per-viewer protocol field
|
||||||
|
(`captures`). Capture *types* stay out of the `ModeratorText` enum —
|
||||||
|
announcements remain a pure event vocabulary; the tally is structured data.
|
||||||
|
- **Feature 3** adds a client-local-only layer that never reaches the wire,
|
||||||
|
isolated in its own store for auditability.
|
||||||
|
- The zero-leak core — `buildView` and `geometric.ts` — is **not touched** by
|
||||||
|
any of the three features.
|
||||||
|
|
||||||
|
## Phasing
|
||||||
|
|
||||||
|
| Increment | Contents | Independently shippable |
|
||||||
|
|-----------|----------|--------------------------|
|
||||||
|
| 1 | Features 1 + 2 | Yes — server + a read-only client panel |
|
||||||
|
| 2 | Feature 3 | Yes — client phantom layer |
|
||||||
|
|
||||||
|
One implementation plan; tasks ordered so Increment 1 completes (and can
|
||||||
|
deploy) before Increment 2 begins.
|
||||||
|
|
||||||
|
## Out of scope / explicitly rejected
|
||||||
|
|
||||||
|
- **Smart-tracker phantom model** (auto-removal on capture, promotion
|
||||||
|
bookkeeping, constrained opponent army) — rejected by Seth in favour of the
|
||||||
|
manual model.
|
||||||
|
- **Phantom layer in vanilla mode** — pointless; excluded.
|
||||||
|
- **Drag-and-drop for real moves** — still deferred (DECISIONS.md). F3's drag
|
||||||
|
is phantom-only.
|
||||||
|
- **Highlighting interacting with phantoms** — safe future enhancement, not
|
||||||
|
v1.
|
||||||
|
- **Capture tally feeding bot decisions** — bot ignores it; possible Phase-2
|
||||||
|
ReconBrain input.
|
||||||
|
- **Sending phantom state to the server / persisting it server-side** — would
|
||||||
|
break the security invariant; never.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None outstanding. Resolved during brainstorm:
|
||||||
|
|
||||||
|
- Phantom model: manual, seeded once, no automation, unlimited placement,
|
||||||
|
editable anytime (Seth).
|
||||||
|
- Phantom interaction: drag-and-drop, tap-vs-drag disambiguation, no mode
|
||||||
|
toggle (Seth — option A).
|
||||||
|
- Feature 2 "Lost" secondary line: included by default; Seth may drop it at
|
||||||
|
spec review.
|
||||||
Reference in New Issue
Block a user