From 9af78ab5e2b3df6581ca18950843ea60d2a099d7 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 19 Apr 2026 09:17:22 -0600 Subject: [PATCH] feat(server): Room.proposalState scaffolding for T3 consent flow Adds the optional `proposalState` field on `Room` holding the in-flight two-player consent proposal per T2-ADR-2. Includes profile, proposer color + token, timestamps, and the active setTimeout handle so supersession / consent can cancel it cleanly. Pure type-only addition \u2014 no runtime behavior change; handlers land in the next commit. --- packages/server/src/rooms.ts | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 19b772e..b8dbbe9 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -64,6 +64,45 @@ export interface Room { * returns. */ pendingProposerToken?: string; + /** + * In-flight two-player consent proposal (T2-ADR-2). Populated by + * `modifier-profile.propose` and drained by + * `modifier-profile.consent` (approve → promoted to + * `pendingProfile` via the T2 queue; reject → discarded) or by + * the 60s auto-timeout. + * + * Mutually exclusive with a simultaneous proposal: a second + * `propose` while this is set supersedes the first (the first + * round-trips a `modifier-profile.rejected` with reason + * `"superseded"` before the new proposal registers). This keeps + * the state machine linear — at any instant a room has AT MOST + * one proposal awaiting consent. + */ + proposalState?: { + /** The candidate profile, already validated against the layout + * at receipt time. Stored verbatim; if consent approves, this + * object is handed to `setPendingProfile` unchanged. */ + profile: ModifierProfile; + /** Color of the proposing player. Used in the + * `proposal-pending` broadcast so the opponent UI can phrase + * "White/Black proposes…" correctly. */ + proposedBy: "white" | "black"; + /** Player token of the proposing player. Used to route the + * `consent-received` ack back on approve, and to enforce the + * self-consent guard on `consent`. Token beats socket id + * because it survives reconnect. */ + proposedByToken: string; + /** Unix-ms when the proposal was accepted. */ + proposedAt: number; + /** Unix-ms deadline (proposedAt + 60_000), mirrored to the + * opponent so the UI can animate a countdown without a + * round-trip. */ + expiresAt: number; + /** Active setTimeout handle. Cleared on any state transition + * so a stale timer can't fire after consent/supersession + * has already concluded the proposal. */ + timeoutHandle: ReturnType; + }; } export type JoinResult =