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 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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue