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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 09:18:52 -06:00
commit 2a903f8bd6
No known key found for this signature in database

View file

@ -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<typeof ClientMessageSchema>;
@ -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<typeof ServerMessageSchema>;
@ -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<typeof AnyMessageSchema>;
@ -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];