From 3557aa7cb4f448460fd5d98e86ebab78742fbee1 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 19 Apr 2026 08:43:40 -0600 Subject: [PATCH] docs(adr): T2 polish architecture decisions --- docs/adr/modifier-profiles.md | 83 +++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/docs/adr/modifier-profiles.md b/docs/adr/modifier-profiles.md index f50c897..176ea47 100644 --- a/docs/adr/modifier-profiles.md +++ b/docs/adr/modifier-profiles.md @@ -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.