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 ErrorCode,
type Fact as WireFact, type Fact as WireFact,
type GameMovePayload, type GameMovePayload,
type ModifierProfileConsentPayload,
type ModifierProfileProposePayload,
type ModifierProfileRejectReason,
type ModifierProfileUpdatePayload, type ModifierProfileUpdatePayload,
type PresetActivation, type PresetActivation,
type RoomCreatePayload, type RoomCreatePayload,
@ -264,6 +267,12 @@ export function handleMessage(
case "modifier-profile.update": case "modifier-profile.update":
handleModifierProfileUpdate(ws, msg.payload); handleModifierProfileUpdate(ws, msg.payload);
break; break;
case "modifier-profile.propose":
handleModifierProfilePropose(ws, msg.payload);
break;
case "modifier-profile.consent":
handleModifierProfileConsent(ws, msg.payload);
break;
case "room.created": case "room.created":
case "room.joined": case "room.joined":
case "game.state": case "game.state":
@ -272,6 +281,9 @@ export function handleMessage(
case "game.presets": case "game.presets":
case "modifier-profile.updated": case "modifier-profile.updated":
case "modifier-profile.queued": case "modifier-profile.queued":
case "modifier-profile.proposal-pending":
case "modifier-profile.rejected":
case "modifier-profile.consent-received":
case "error": case "error":
sendTo( sendTo(
ws, ws,
@ -1028,6 +1040,338 @@ function findSocketByToken(
// slot without going through `applyPendingProfile`. // slot without going through `applyPendingProfile`.
export { clearPendingProfile }; 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( function handleSetPresets(
ws: ServerWebSocket<ClientData>, ws: ServerWebSocket<ClientData>,
payload: RoomSetPresetsPayload, payload: RoomSetPresetsPayload,