diff --git a/packages/server/src/broadcast.ts b/packages/server/src/broadcast.ts index 0e42fe5..3b676ff 100644 --- a/packages/server/src/broadcast.ts +++ b/packages/server/src/broadcast.ts @@ -32,6 +32,9 @@ import { type ErrorCode, type Fact as WireFact, type GameMovePayload, + type ModifierProfileConsentPayload, + type ModifierProfileProposePayload, + type ModifierProfileRejectReason, type ModifierProfileUpdatePayload, type PresetActivation, type RoomCreatePayload, @@ -264,6 +267,12 @@ export function handleMessage( case "modifier-profile.update": handleModifierProfileUpdate(ws, msg.payload); break; + case "modifier-profile.propose": + handleModifierProfilePropose(ws, msg.payload); + break; + case "modifier-profile.consent": + handleModifierProfileConsent(ws, msg.payload); + break; case "room.created": case "room.joined": case "game.state": @@ -272,6 +281,9 @@ export function handleMessage( case "game.presets": case "modifier-profile.updated": case "modifier-profile.queued": + case "modifier-profile.proposal-pending": + case "modifier-profile.rejected": + case "modifier-profile.consent-received": case "error": sendTo( ws, @@ -1028,6 +1040,338 @@ function findSocketByToken( // slot without going through `applyPendingProfile`. export { clearPendingProfile }; +// --------------------------------------------------------------------------- +// T2-ADR-2: Two-player consent handlers +// --------------------------------------------------------------------------- +// +// `modifier-profile.update` (T2) is the host-unilateral path. It remains +// valid in all room configurations for solo mode and as an administrative +// shortcut. `modifier-profile.propose` + `modifier-profile.consent` (T3) +// is the socially-negotiated path for multiplayer: either player may +// propose; the opponent must approve; on approve the profile enters the +// T2 queue and applies at the next turn boundary via the existing +// `applyPendingProfileIfAny` drain. + +/** Consent window. 60s matches the reconnect grace so a player briefly + * disconnected mid-decision doesn't auto-miss their window. */ +const CONSENT_TIMEOUT_MS = 60_000; + +/** + * Cancel and clear a room's current proposal. Callers emit any + * wire traffic themselves (rejected/superseded broadcast or + * silence on approve). Safe when no proposal is present. + */ +function clearProposalState(room: { + proposalState?: { timeoutHandle: ReturnType }; +}): void { + if (room.proposalState === undefined) return; + clearTimeout(room.proposalState.timeoutHandle); + delete room.proposalState; +} + +/** + * Broadcast `modifier-profile.rejected` to every connected socket + * in the room. Used from all three termination paths (opponent + * vote, timeout, supersession). + */ +function broadcastProfileRejected( + roomCode: string, + reason: ModifierProfileRejectReason, +): void { + broadcastToRoom( + roomCode, + envelope("modifier-profile.rejected", { + roomCode, + reason, + }), + ); +} + +/** + * Handle a `modifier-profile.propose` request. + * + * Requires 2 filled player slots (solo rooms are directed to + * `modifier-profile.update` instead). Either player may propose. + * A second propose while one is pending supersedes the first. + * Same early-rejection gates as `update`: roomCode mismatch, + * unknown room, game-over, stale version, profile invalid. + * + * On acceptance: installs `room.proposalState` with a 60s timer, + * sends `proposal-pending` to the opponent only, and acks the + * proposer with the T2 `modifier-profile.queued` shape (reused + * so client receipt handlers remain uniform). + */ +function handleModifierProfilePropose( + ws: ServerWebSocket, + payload: ModifierProfileProposePayload, +): void { + const { roomCode, token } = ws.data; + if (roomCode === undefined || token === undefined) { + sendTo( + ws, + errorMessage("BAD_TOKEN", "not authenticated into a room", false), + ); + return; + } + + if (payload.roomCode !== roomCode) { + sendTo( + ws, + errorMessage( + "BAD_TOKEN", + "profile propose roomCode does not match authenticated room", + false, + ), + ); + return; + } + + const room = roomRegistry.getRoom(roomCode); + if (!room) { + sendTo( + ws, + errorMessage( + "ROOM_NOT_FOUND", + `profile propose: room ${roomCode} not found`, + false, + ), + ); + return; + } + + // Multiplayer gate. Check player slot count, not live + // connections — a reconnecting player in grace still counts. + if (room.players.size < 2) { + sendTo( + ws, + errorMessage( + "INVALID_MESSAGE", + "profile propose requires 2 players; use modifier-profile.update in solo mode", + false, + ), + ); + return; + } + + const proposer = room.players.get(token); + if (!proposer) { + sendTo(ws, errorMessage("BAD_TOKEN", "unknown token for room", false)); + return; + } + + const session = sessionRegistry.get(roomCode); + if (!session) { + sendTo( + ws, + errorMessage( + "INVALID_MESSAGE", + "internal error: missing game session", + true, + ), + ); + ws.close(); + return; + } + + if (session.getGameOver() !== null) { + sendTo( + ws, + errorMessage("GAME_OVER", "cannot propose on a finished game", false), + ); + return; + } + + if (payload.version !== session.getProfileVersion()) { + sendTo( + ws, + errorMessage( + "MODIFIER_PROFILE_INVALID", + `profile version mismatch: client=${String(payload.version)}, server=${String(session.getProfileVersion())}`, + false, + ), + ); + return; + } + + const candidate = asChessProfile(payload.candidate); + const check = validateProfile(candidate, room.layout); + if (!check.valid) { + const first = check.errors[0]!; + sendTo( + ws, + errorMessage( + mapProfileValidationCode(first.code), + first.message, + false, + ), + ); + return; + } + + // Supersede any in-flight proposal. Broadcast BEFORE installing + // the new state so wire ordering is: + // old rejected(superseded) -> new proposal-pending + if (room.proposalState !== undefined) { + clearProposalState(room); + broadcastProfileRejected(roomCode, "superseded"); + } + + const proposedAt = Date.now(); + const expiresAt = proposedAt + CONSENT_TIMEOUT_MS; + // Handle comparison guards against a stale timer firing after + // supersession/consent has already cleared state. + const timeoutHandle = setTimeout(() => { + const liveRoom = roomRegistry.getRoom(roomCode); + if (!liveRoom || liveRoom.proposalState === undefined) return; + if (liveRoom.proposalState.timeoutHandle !== timeoutHandle) return; + clearProposalState(liveRoom); + broadcastProfileRejected(roomCode, "timeout"); + }, CONSENT_TIMEOUT_MS); + + room.proposalState = { + profile: candidate, + proposedBy: proposer.color, + proposedByToken: token, + proposedAt, + expiresAt, + timeoutHandle, + }; + + // Opponent-only broadcast. If opponent socket is in grace + // window the message is dropped; the timer still fires at 60s. + for (const p of room.players.values()) { + if (p.token === token) continue; + const opponentSocket = findSocketByToken(p.token); + if (opponentSocket !== undefined) { + sendTo( + opponentSocket, + envelope("modifier-profile.proposal-pending", { + roomCode, + profile: candidate, + expiresAt, + proposer: proposer.color, + }), + ); + } + } + + // Proposer ack reuses the T2 queued shape so client-side + // receipt handlers are uniform across update vs. propose. + sendTo( + ws, + envelope("modifier-profile.queued", { + roomCode, + pendingVersion: session.getProfileVersion() + 1, + }), + ); + + logger + .child({ clientId: ws.data.clientId, roomCode }) + .info("modifier-profile.propose"); +} + +/** + * Handle opponent's approve/reject on a pending proposal. The + * proposer never sends this for their own proposal — self-consent + * is caught as `INVALID_MESSAGE`. + * + * On approve: promote candidate into the T2 queue + * (`setPendingProfile`), send `consent-received` to proposer only. + * + * On reject: broadcast `rejected` with reason `"rejected"`. + */ +function handleModifierProfileConsent( + ws: ServerWebSocket, + payload: ModifierProfileConsentPayload, +): void { + const { roomCode, token } = ws.data; + if (roomCode === undefined || token === undefined) { + sendTo( + ws, + errorMessage("BAD_TOKEN", "not authenticated into a room", false), + ); + return; + } + + if (payload.roomCode !== roomCode) { + sendTo( + ws, + errorMessage( + "BAD_TOKEN", + "profile consent roomCode does not match authenticated room", + false, + ), + ); + return; + } + + const room = roomRegistry.getRoom(roomCode); + if (!room) { + sendTo( + ws, + errorMessage( + "ROOM_NOT_FOUND", + `profile consent: room ${roomCode} not found`, + false, + ), + ); + return; + } + + const proposal = room.proposalState; + if (proposal === undefined) { + sendTo( + ws, + errorMessage( + "INVALID_MESSAGE", + "no pending profile proposal to consent to", + false, + ), + ); + return; + } + + if (token === proposal.proposedByToken) { + sendTo( + ws, + errorMessage( + "INVALID_MESSAGE", + "self-consent is not permitted; the opponent must decide", + false, + ), + ); + return; + } + + clearTimeout(proposal.timeoutHandle); + + if (payload.decision === "approve") { + const candidate = proposal.profile; + const proposerToken = proposal.proposedByToken; + delete room.proposalState; + setPendingProfile(room, candidate, proposerToken); + + const proposerSocket = findSocketByToken(proposerToken); + if (proposerSocket !== undefined) { + sendTo( + proposerSocket, + envelope("modifier-profile.consent-received", { roomCode }), + ); + } + logger + .child({ clientId: ws.data.clientId, roomCode }) + .info("modifier-profile.consent (approve)"); + return; + } + + // Reject. + delete room.proposalState; + broadcastProfileRejected(roomCode, "rejected"); + logger + .child({ clientId: ws.data.clientId, roomCode }) + .info("modifier-profile.consent (reject)"); +} + function handleSetPresets( ws: ServerWebSocket, payload: RoomSetPresetsPayload,