From 2a903f8bd69f88e33e0f7ededf980584560ac818 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 19 Apr 2026 09:18:52 -0600 Subject: [PATCH] feat(server): protocol schemas for two-player consent (T3) Adds 5 new wire messages (T2-ADR-2): - client\u2192server: modifier-profile.propose, modifier-profile.consent - server\u2192client: modifier-profile.proposal-pending, modifier-profile.rejected, modifier-profile.consent-received All additive \u2014 no existing message shape changes. Wired into ClientMessageSchema, ServerMessageSchema, AnyMessageSchema, and KNOWN_MESSAGE_TYPES. Handlers follow in the next commit. --- packages/server/src/protocol.ts | 140 ++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index b0e2631..a849d00 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -348,6 +348,111 @@ export type ModifierProfileQueuedPayload = z.infer< typeof ModifierProfileQueuedPayloadSchema >; +// --------------------------------------------------------------------------- +// T2-ADR-2: Two-player consent flow for profile swaps +// --------------------------------------------------------------------------- +// +// Multiplayer rooms (2 filled player slots) require opponent opt-in before +// a profile swap can land. The `modifier-profile.update` path (T2) remains +// available as a host-unilateral shortcut in solo mode and as an +// administrative escape hatch — it is NOT removed. The consent flow layers +// on top: +// +// 1. Any player sends `modifier-profile.propose` with a candidate. +// 2. Server validates, stashes `Room.proposalState`, and forwards +// `modifier-profile.proposal-pending` to the OPPONENT only. +// 3. Opponent sends `modifier-profile.consent` with approve/reject. +// 4. Approve → profile enters the T2 queue and applies at the next +// turn boundary; proposer gets `modifier-profile.consent-received`. +// 5. Reject/timeout/superseded → both get `modifier-profile.rejected`. + +/** + * Client → server: propose a profile swap for opponent consent. + * Only valid in multiplayer rooms (2 filled slots); in solo mode + * clients should use `modifier-profile.update` directly. + * + * `version` is the `profileVersion` the client believed the room + * was at — stale versions rejected with `MODIFIER_PROFILE_INVALID`. + */ +export const ModifierProfileProposePayloadSchema = z.object({ + roomCode: RoomCodeSchema, + candidate: ModifierProfileSchema, + version: z.number().int().min(0), +}); +export type ModifierProfileProposePayload = z.infer< + typeof ModifierProfileProposePayloadSchema +>; + +/** + * Server → opponent only: a profile swap has been proposed and + * awaits consent. `expiresAt` is a unix-ms deadline after which + * the server auto-rejects with reason `"timeout"`. The proposing + * player does NOT receive this message; they get a + * `modifier-profile.queued` ack instead (shape reused from T2 so + * client receipt handlers remain uniform). + */ +export const ModifierProfileProposalPendingPayloadSchema = z.object({ + roomCode: RoomCodeSchema, + profile: ModifierProfileSchema, + /** Unix-ms deadline. */ + expiresAt: z.number().int().positive(), + /** Color of the proposing player (for UI prompt copy). */ + proposer: ColorSchema, +}); +export type ModifierProfileProposalPendingPayload = z.infer< + typeof ModifierProfileProposalPendingPayloadSchema +>; + +/** + * Client → server (opponent only): approve or reject a pending + * proposal. Self-consent — the proposer trying to consent to + * their own proposal — is rejected with `INVALID_MESSAGE`. + */ +export const ModifierProfileConsentPayloadSchema = z.object({ + roomCode: RoomCodeSchema, + decision: z.enum(["approve", "reject"]), +}); +export type ModifierProfileConsentPayload = z.infer< + typeof ModifierProfileConsentPayloadSchema +>; + +/** Why a proposal ended without being promoted to the pending queue. */ +export const ModifierProfileRejectReasonSchema = z.enum([ + "rejected", + "timeout", + "superseded", +]); +export type ModifierProfileRejectReason = z.infer< + typeof ModifierProfileRejectReasonSchema +>; + +/** + * Server → both players: the current proposal will NOT be applied. + * Broadcast after opponent rejection, 60s timeout, or supersession + * by a newer proposal. UIs clear any "proposal-pending" banner on + * receipt. + */ +export const ModifierProfileRejectedPayloadSchema = z.object({ + roomCode: RoomCodeSchema, + reason: ModifierProfileRejectReasonSchema, +}); +export type ModifierProfileRejectedPayload = z.infer< + typeof ModifierProfileRejectedPayloadSchema +>; + +/** + * Server → proposer only: opponent approved; proposal has been + * promoted to the T2 pending queue and will apply at the next + * turn boundary via the regular `modifier-profile.updated` + * broadcast. Proposer's UI closes the "awaiting consent" state. + */ +export const ModifierProfileConsentReceivedPayloadSchema = z.object({ + roomCode: RoomCodeSchema, +}); +export type ModifierProfileConsentReceivedPayload = z.infer< + typeof ModifierProfileConsentReceivedPayloadSchema +>; + export const RoomJoinPayloadSchema = z.object({ code: RoomCodeSchema, }); @@ -513,6 +618,14 @@ export const ModifierProfileUpdateMessageSchema = msg( "modifier-profile.update", ModifierProfileUpdatePayloadSchema, ); +export const ModifierProfileProposeMessageSchema = msg( + "modifier-profile.propose", + ModifierProfileProposePayloadSchema, +); +export const ModifierProfileConsentMessageSchema = msg( + "modifier-profile.consent", + ModifierProfileConsentPayloadSchema, +); export const ClientMessageSchema = z.discriminatedUnion("type", [ RoomCreateMessageSchema, @@ -521,6 +634,8 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [ GameMoveMessageSchema, RoomSetPresetsMessageSchema, ModifierProfileUpdateMessageSchema, + ModifierProfileProposeMessageSchema, + ModifierProfileConsentMessageSchema, ]); export type ClientMessage = z.infer; @@ -547,6 +662,18 @@ export const ModifierProfileQueuedMessageSchema = msg( "modifier-profile.queued", ModifierProfileQueuedPayloadSchema, ); +export const ModifierProfileProposalPendingMessageSchema = msg( + "modifier-profile.proposal-pending", + ModifierProfileProposalPendingPayloadSchema, +); +export const ModifierProfileRejectedMessageSchema = msg( + "modifier-profile.rejected", + ModifierProfileRejectedPayloadSchema, +); +export const ModifierProfileConsentReceivedMessageSchema = msg( + "modifier-profile.consent-received", + ModifierProfileConsentReceivedPayloadSchema, +); export const ErrorMessageSchema = msg("error", ErrorPayloadSchema); export const ServerMessageSchema = z.discriminatedUnion("type", [ @@ -558,6 +685,9 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [ GamePresetsMessageSchema, ModifierProfileUpdatedMessageSchema, ModifierProfileQueuedMessageSchema, + ModifierProfileProposalPendingMessageSchema, + ModifierProfileRejectedMessageSchema, + ModifierProfileConsentReceivedMessageSchema, ErrorMessageSchema, ]); export type ServerMessage = z.infer; @@ -569,6 +699,8 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [ GameMoveMessageSchema, RoomSetPresetsMessageSchema, ModifierProfileUpdateMessageSchema, + ModifierProfileProposeMessageSchema, + ModifierProfileConsentMessageSchema, RoomCreatedMessageSchema, RoomJoinedMessageSchema, GameStateMessageSchema, @@ -577,6 +709,9 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [ GamePresetsMessageSchema, ModifierProfileUpdatedMessageSchema, ModifierProfileQueuedMessageSchema, + ModifierProfileProposalPendingMessageSchema, + ModifierProfileRejectedMessageSchema, + ModifierProfileConsentReceivedMessageSchema, ErrorMessageSchema, ]); export type AnyMessage = z.infer; @@ -588,6 +723,8 @@ export const KNOWN_MESSAGE_TYPES = [ "game.move", "room.setPresets", "modifier-profile.update", + "modifier-profile.propose", + "modifier-profile.consent", "room.created", "room.joined", "game.state", @@ -596,6 +733,9 @@ export const KNOWN_MESSAGE_TYPES = [ "game.presets", "modifier-profile.updated", "modifier-profile.queued", + "modifier-profile.proposal-pending", + "modifier-profile.rejected", + "modifier-profile.consent-received", "error", ] as const; export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];