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
|
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({
|
export const RoomJoinPayloadSchema = z.object({
|
||||||
code: RoomCodeSchema,
|
code: RoomCodeSchema,
|
||||||
});
|
});
|
||||||
|
|
@ -513,6 +618,14 @@ export const ModifierProfileUpdateMessageSchema = msg(
|
||||||
"modifier-profile.update",
|
"modifier-profile.update",
|
||||||
ModifierProfileUpdatePayloadSchema,
|
ModifierProfileUpdatePayloadSchema,
|
||||||
);
|
);
|
||||||
|
export const ModifierProfileProposeMessageSchema = msg(
|
||||||
|
"modifier-profile.propose",
|
||||||
|
ModifierProfileProposePayloadSchema,
|
||||||
|
);
|
||||||
|
export const ModifierProfileConsentMessageSchema = msg(
|
||||||
|
"modifier-profile.consent",
|
||||||
|
ModifierProfileConsentPayloadSchema,
|
||||||
|
);
|
||||||
|
|
||||||
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||||
RoomCreateMessageSchema,
|
RoomCreateMessageSchema,
|
||||||
|
|
@ -521,6 +634,8 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||||
GameMoveMessageSchema,
|
GameMoveMessageSchema,
|
||||||
RoomSetPresetsMessageSchema,
|
RoomSetPresetsMessageSchema,
|
||||||
ModifierProfileUpdateMessageSchema,
|
ModifierProfileUpdateMessageSchema,
|
||||||
|
ModifierProfileProposeMessageSchema,
|
||||||
|
ModifierProfileConsentMessageSchema,
|
||||||
]);
|
]);
|
||||||
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
||||||
|
|
||||||
|
|
@ -547,6 +662,18 @@ export const ModifierProfileQueuedMessageSchema = msg(
|
||||||
"modifier-profile.queued",
|
"modifier-profile.queued",
|
||||||
ModifierProfileQueuedPayloadSchema,
|
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 ErrorMessageSchema = msg("error", ErrorPayloadSchema);
|
||||||
|
|
||||||
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||||
|
|
@ -558,6 +685,9 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||||
GamePresetsMessageSchema,
|
GamePresetsMessageSchema,
|
||||||
ModifierProfileUpdatedMessageSchema,
|
ModifierProfileUpdatedMessageSchema,
|
||||||
ModifierProfileQueuedMessageSchema,
|
ModifierProfileQueuedMessageSchema,
|
||||||
|
ModifierProfileProposalPendingMessageSchema,
|
||||||
|
ModifierProfileRejectedMessageSchema,
|
||||||
|
ModifierProfileConsentReceivedMessageSchema,
|
||||||
ErrorMessageSchema,
|
ErrorMessageSchema,
|
||||||
]);
|
]);
|
||||||
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
||||||
|
|
@ -569,6 +699,8 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
|
||||||
GameMoveMessageSchema,
|
GameMoveMessageSchema,
|
||||||
RoomSetPresetsMessageSchema,
|
RoomSetPresetsMessageSchema,
|
||||||
ModifierProfileUpdateMessageSchema,
|
ModifierProfileUpdateMessageSchema,
|
||||||
|
ModifierProfileProposeMessageSchema,
|
||||||
|
ModifierProfileConsentMessageSchema,
|
||||||
RoomCreatedMessageSchema,
|
RoomCreatedMessageSchema,
|
||||||
RoomJoinedMessageSchema,
|
RoomJoinedMessageSchema,
|
||||||
GameStateMessageSchema,
|
GameStateMessageSchema,
|
||||||
|
|
@ -577,6 +709,9 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
|
||||||
GamePresetsMessageSchema,
|
GamePresetsMessageSchema,
|
||||||
ModifierProfileUpdatedMessageSchema,
|
ModifierProfileUpdatedMessageSchema,
|
||||||
ModifierProfileQueuedMessageSchema,
|
ModifierProfileQueuedMessageSchema,
|
||||||
|
ModifierProfileProposalPendingMessageSchema,
|
||||||
|
ModifierProfileRejectedMessageSchema,
|
||||||
|
ModifierProfileConsentReceivedMessageSchema,
|
||||||
ErrorMessageSchema,
|
ErrorMessageSchema,
|
||||||
]);
|
]);
|
||||||
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
|
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
|
||||||
|
|
@ -588,6 +723,8 @@ export const KNOWN_MESSAGE_TYPES = [
|
||||||
"game.move",
|
"game.move",
|
||||||
"room.setPresets",
|
"room.setPresets",
|
||||||
"modifier-profile.update",
|
"modifier-profile.update",
|
||||||
|
"modifier-profile.propose",
|
||||||
|
"modifier-profile.consent",
|
||||||
"room.created",
|
"room.created",
|
||||||
"room.joined",
|
"room.joined",
|
||||||
"game.state",
|
"game.state",
|
||||||
|
|
@ -596,6 +733,9 @@ export const KNOWN_MESSAGE_TYPES = [
|
||||||
"game.presets",
|
"game.presets",
|
||||||
"modifier-profile.updated",
|
"modifier-profile.updated",
|
||||||
"modifier-profile.queued",
|
"modifier-profile.queued",
|
||||||
|
"modifier-profile.proposal-pending",
|
||||||
|
"modifier-profile.rejected",
|
||||||
|
"modifier-profile.consent-received",
|
||||||
"error",
|
"error",
|
||||||
] as const;
|
] as const;
|
||||||
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];
|
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue