diff --git a/CALL_TO_PLAY_FIXES_PLAN.md b/CALL_TO_PLAY_FIXES_PLAN.md new file mode 100644 index 0000000..6c72457 --- /dev/null +++ b/CALL_TO_PLAY_FIXES_PLAN.md @@ -0,0 +1,152 @@ +# Finish Call to Play’s replication seam + +## Summary + +Keep the existing architecture: immutable events, deterministic reduction, live delivery, and handshake history are appropriate for a LAN party. Do not replace it with owner-authoritative state, consensus, persistent storage, or cryptographic peer identities. + +The focused redesign is the delivery/merge/store seam: + +- Merge histories atomically. +- Acknowledge live deliveries. +- Preserve complete histories while users can still see a call. +- Make terminal outcomes understandable. +- Never report an action as accepted when it was immediately discarded. + +## User-visible lifecycle + +| State | Meaning | Visibility | Available actions | +|---|---|---:|---| +| Open / Ready | Call is still coordinating players | Until deadline | Existing participation, chat, creator controls | +| Time’s up | Deadline elapsed without a Start or Cancel | 5 minutes | Creator may Start, Add time, or Cancel; history remains complete | +| Running | Creator emitted Start; this is a successful outcome | 15 minutes after Start | Read-only card, roster, and complete chat | +| Cancelled | Creator emitted Cancel | 15 minutes after Cancel | Read-only card, roster, and complete chat | +| Retired | Display period ended | Hidden | None | + +“Time’s up” and “Running” are meaningfully different: a timed-out call is unresolved and recoverable, while Running is a final success receipt. Deadline passage alone never means the game started. + +Running and Cancelled rows remain in the ticker and overlay, sorted after actionable calls. They do not increase the top-bar badge. Their chat history remains readable, but all composers and controls are disabled. + +## Interface and invariant changes + +- Raise `PROTOCOL_VERSION` from 6 to 7; no compatibility path. +- Add `Response::CallToPlayAck` with outcomes equivalent to: + - `Applied` + - `Duplicate` + - `NeedHandshake` + - `NeedHistory` + - `Obsolete` + - `Rejected(reason)` +- Replace per-event insertion with one atomic `merge_batch(events, now)` operation returning retained UI events, duplicate/obsolete counts, and missing-history information. +- Change frontend nomination state to represent `running` and `cancelled`, with a `terminalAt` timestamp. +- Change `addTime` to receive the current effective deadline and calculate `max(now, deadline) + duration`. +- Preserve these invariants: + - Every visible call has its entire event and chat history. + - Handshake batches are evaluated as a whole, regardless of event order. + - Missing-root actions are not retained alone; they request history. + - Event IDs correspond only to retained events. + - Terminal tombstones prevent stale histories from resurrecting finished calls. + - Local acceptance is immediate user success; remote delivery is acknowledged and healed asynchronously. + +## Commit sequence + +1. `refactor(call-to-play): merge histories atomically` + + - Validate and deduplicate the complete incoming batch before changing the store. + - Treat an existing ID with different contents as a conflict and reject the batch. + - Evaluate compaction once after all batch events are present, fixing the quadratic handshake path. + - Commit the candidate store only when capacity and validation succeed. + - Rebuild event IDs from retained events instead of preserving every historical ID. + - Return `NeedHistory` without storing an action when neither the store nor batch contains its Create event. + - Permit a full `Create + AddTime` history to revive a call atomically. + - Mark an event as applied only when it survives compaction; obsolete events are neither broadcast nor emitted to the UI. + - Keep the 4,096-event safety cap for unresolved histories, but always permit Start and Cancel. Recently terminal histories and compact tombstones must not prevent new active calls. + - Preserve full open/ready/Time’s-up histories; never evict individual chat or participant events. + +2. `fix(call-to-play): acknowledge live replication` + + - Turn the live Call to Play request into a request/response exchange returning `CallToPlayAck`. + - Remove source-IP-versus-advertised-IP equality checks. + - Under the selected trusted-LAN model, require: + - the envelope peer ID to exist in the known peer roster; + - every live event’s actor ID to match that envelope peer ID; + - local peer-core publication to continue stamping its own actor ID. + - Return `NeedHandshake` for unknown peers and `NeedHistory` for missing call roots. + - On transport failure, malformed response, `NeedHandshake`, or `NeedHistory`, perform one full Hello/HelloAck resync. + - Treat Applied and Duplicate as delivered, Obsolete as finished, and Rejected as a logged non-retriable error. + - Keep publication locally successful without waiting for every peer, so an offline machine cannot block a LAN-party action. + - Document that shared TLS plus stable peer IDs prevent accidental identity mixing but are not hostile-peer authentication. + +3. `feat(call-to-play): retain terminal outcomes` + + - Preserve complete Running and Cancelled histories in backend snapshots for 15 minutes so late joiners receive the card, roster, and chat. + - After 15 minutes, compact each terminal call to its Start or Cancel tombstone for the remainder of the peer session. + - Continue deleting unresolved Time’s-up histories after their separate five-minute recovery window, including their event IDs. + - Derive and render Running and Cancelled frontend states instead of immediately removing them. + - Show their ticker/card status, retain unknown-game degraded rendering, sort them last, and exclude them from the badge. + - Prune the frontend’s raw event map after the corresponding display window so a long GUI session does not accumulate invisible history. + - Update the feature specification and architecture documentation with these lifecycle and clock-skew assumptions. + +4. `fix(call-to-play): extend from the current deadline` + + - Calculate extensions as `max(Date.now(), nomination.deadline) + five minutes`. + - Preserve the existing behavior that an overdue call gets five minutes from now. + - Ensure extending a call that became Ready early adds time instead of shortening its remaining deadline. + - Keep terminal Running and Cancelled calls non-extendable. + +5. `fix(call-to-play): explain peer startup state` + + - Replace the game-folder advice shown while `actorId` is unavailable with: “Call to Play is still connecting to the LAN. Try again in a moment.” + - Do not mark transport unavailable for store-level errors. + - Surface distinct messages for: + - expired/obsolete call; + - full active history; + - peer startup; + - unexpected update failure. + +## Test and acceptance plan + +- Store unit tests: + - `Create + AddTime` succeeds in every input order. + - An expired receiver returns NeedHistory for orphan AddTime, then revives after receiving full history. + - IDs removed with expired histories do not block revival. + - Stale events against terminal tombstones remain obsolete. + - Accepted results contain only retained events. + - Invalid/conflicting batches leave the store unchanged. + - Batch merge compacts once rather than once per event. + - Start and Cancel remain possible at the active-history cap. + - Visible calls always retain every chat event. + +- Transport tests: + - A known peer is accepted when transport and advertised IPs differ. + - Unknown peer IDs and mismatched actor IDs receive explicit acknowledgements. + - NeedHistory, NeedHandshake, and lost acknowledgements trigger idempotent handshake healing. + - Duplicate delivery is harmless. + +- Frontend tests: + - Ready, Time’s up, Running, Cancelled, and retired transitions. + - Full terminal history remains for 15 minutes and then disappears. + - Terminal calls appear in ticker/overlay but not the badge. + - Terminal controls and chat composer are disabled. + - Both Add-time deadline cases. + - Correct startup and store-error messages. + +- Peer CLI: + - Keep S48 as the active-call/full-history late-join acceptance test. + - Add S49 covering a terminal call whose roster and chat are reconstructed by a late joiner. + - Fix any snapshot waiting through the direct reply path rather than observing unrelated generations. + +- Final verification: + - `just fmt` + - `just clippy` + - `just test` + - `just frontend-test` + - `just build` + - `just peer-cli-tests S48 S49` + - `git diff --check` + +## Assumptions + +- The LAN is cooperative; deliberate peer-ID impersonation is outside scope. +- Wall clocks are assumed reasonably close. Atomic history revival tolerates boundary skew but does not attempt clock synchronization. +- Call to Play state remains transient and disappears when the peer process/session ends. +- The existing `FABLE_5_FINDINGS.md` commit remains untouched; all implementation work is added as focused forward commits.