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:
parent
980d567354
commit
929ee6da81
1 changed files with 344 additions and 0 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue