docs(adr): T2 polish architecture decisions

This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 08:43:40 -06:00
commit 3557aa7cb4
No known key found for this signature in database

View file

@ -373,3 +373,86 @@ requiring no changes to the registry contract itself.
and straightforward to reason about.
---
---
## T2-ADR-1: Turn-boundary queue
### Decision
Hot-swap updates are enqueued server-side (`room.pendingProfile`) and applied at the next turn boundary (after `applyMove()` succeeds), not immediately on receipt.
### Rationale
T1 shipped an immediate-apply simplification (profile swap took effect the moment the server received `modifier-profile.update`). The retrospective noted this was acceptable because WS messages serialize per-socket — a client can't interleave its own move and swap. But:
- The **opponent** can still have a move in-flight when the swap lands, causing move validation to run against a profile they didn't agree to.
- Observers/spectators (future T3+ concern) see inconsistent ordering.
- Determinism goal: the game state at turn N is fully determined by {profile at turn N, moves 1..N}.
Enqueuing eliminates the race.
### Semantics
- On `modifier-profile.update` (or on `consent=approve` per T2-ADR-2): server validates shape + stores in `room.pendingProfile`, acks sender with `modifier-profile.queued`.
- After the next move applies successfully: server runs `validateProfile(pending, layout, session)`. If valid, `reconcileProfileSwap(session, room.profile, pending, layout)`; broadcast `modifier-profile.updated`; clear pending.
- If invalid at apply time: NACK to original sender with specific error code, clear pending, no broadcast.
- **Last-write-wins**: rapid successive updates replace the pending slot. Only the most recent pending fires on the next boundary.
### Rejected Alternatives
- Per-socket move-boundary lock — forces sender to wait synchronously for their own next move before the swap is acked. Poor UX; WS doesn't guarantee reply ordering anyway.
- Queue per sender — multiple pending profiles per room, applied in order. Nondeterministic interaction if both players queue updates simultaneously. Last-write-wins is simpler and sufficient.
---
## T2-ADR-2: Two-player consent
### Decision
In multiplayer rooms, a profile swap requires both players' agreement. Host sends `modifier-profile.propose`; opponent reviews and sends `modifier-profile.consent` with approve/reject. Approve promotes the proposal to the pending-queue (T2-ADR-1). Reject clears and notifies the host. 60s timeout → auto-reject.
### Rationale
T1 granted host unilateral authority to change rules mid-game. This is an unfair advantage model; chess variants are a social contract. Both players must opt-in to rule changes.
Solo mode and single-player "vs. bot" contexts bypass consent — the sole participant is trivially the sole consenter. `modifier-profile.update` remains valid in solo mode as a shortcut.
### Semantics
- Host → server: `{type: "modifier-profile.propose", roomCode, candidate: ModifierProfile}`.
- Server validates shape + stores `room.proposalState = {profile, proposedBy, proposedAt, timeoutHandle}`.
- Server → opponent: `{type: "modifier-profile.proposal-pending", profile, expiresAt}`.
- Opponent → server: `{type: "modifier-profile.consent", roomCode, decision: "approve" | "reject"}`.
- On approve: clear proposalState, promote to `room.pendingProfile` (T2-ADR-1), server → host: `modifier-profile.consent-received`.
- On reject or 60s timeout: clear proposalState, server → both: `modifier-profile.rejected`.
- Only the non-proposer can consent. Self-consent is rejected.
- Stale proposals (after a newer `propose` replaces them): old timeout cancelled, old state overwritten.
### Rejected Alternatives
- Majority-vote model for 3+ player scenarios — not applicable (chess is 2-player).
- Silent auto-approve with opt-out grace period — violates the social-contract principle.
- Host-only with explicit "I agree to changes" checkbox at game start — inflexible; players may change their minds after seeing how a rule plays out.
---
## T2-ADR-3: Editor undo/redo
### Decision
The Modifier Profile Editor maintains a client-side snapshot stack of working-profile states. Every meaningful user action (add/delete/edit modifier, change bound layout) pushes a snapshot. Cmd/Ctrl+Z undoes, Cmd/Ctrl+Shift+Z redoes. Stack capped at 50 snapshots (oldest dropped). History cleared on Save or Cancel.
### Rationale
Users compose profiles iteratively; mistakes are common (added wrong modifier, wrong color, wrong square). Manual deletion is destructive and loses intermediate states. Undo/redo is standard for structured editors.
Capped at 50 snapshots to bound memory. Cleared on Save because the saved state is the new baseline (undoing past Save would revert persisted data, which is surprising).
### Semantics
- Editor state: `{history: ModifierProfile[], historyIndex: number, clipboard: Modifier[]}`.
- `currentProfile = history[historyIndex]`.
- Every mutation: `pushSnapshot(newProfile)`:
1. Truncate history forward of `historyIndex` (redo branches lost).
2. Append newProfile.
3. If `history.length > 50`, drop the oldest entry and decrement historyIndex.
4. Set `historyIndex = history.length - 1`.
- `undo()`: `historyIndex = max(0, historyIndex - 1)`.
- `redo()`: `historyIndex = min(history.length - 1, historyIndex + 1)`.
- Toolbar buttons disabled at stack ends.
- Save or Cancel: `setHistory([currentProfile]); setHistoryIndex(0)` — fresh single-entry stack for a new session.
### Rejected Alternatives
- Infinite history — memory unbounded. 50 is generous for a single editing session.
- Per-modifier undo (like per-field undo in some editors) — over-granular for structured data edits.
- Persist history to localStorage — adds complexity for marginal benefit; users expect editor state to reset between sessions.