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:
parent
9af78ab5e2
commit
2a903f8bd6
1 changed files with 140 additions and 0 deletions
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue