feat(server): handlers for two-player profile consent (T3)

Adds handleModifierProfilePropose and handleModifierProfileConsent
per T2-ADR-2. Propose requires 2 filled player slots; either player
may propose. Supersedes any prior pending proposal (old gets
modifier-profile.rejected reason="superseded"). 60s timeout auto-
rejects with reason="timeout". Approve promotes the candidate into
the existing T2 queue via setPendingProfile; reject broadcasts
rejected to both. Self-consent blocked.

modifier-profile.update (host-unilateral T2 path) remains valid in
all room configurations as an administrative shortcut and the solo-
mode entrypoint.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 09:23:40 -06:00
commit 929ee6da81
No known key found for this signature in database

View file

@ -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<typeof setTimeout> };
}): 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<ClientData>,
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<ClientData>,
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<ClientData>,
payload: RoomSetPresetsPayload,