CLose story 1.2

This commit is contained in:
2026-05-21 23:08:34 +02:00
commit 110b295a7b
75 changed files with 16065 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
/**
* Socket message contract.
*
* Two message directions:
* Intent (GM → all): scrying-pool.visibility.set { opId, userId, targetState }
* Echo (all ← GM): scrying-pool.visibility.updated { opId, userId, state, revision }
*
* Validated at both send and receive. Payload ≥ 4096 bytes → throw before emit.
*
* @module contracts/socket-message
*/
/** @typedef {'scrying-pool.visibility.set'|'scrying-pool.visibility.updated'|'scrying-pool.preset.apply'|'scrying-pool.preset.applied'} SocketEventName */
/**
* @typedef {Object} SocketIntentPayload
* @property {string} opId - Unique operation ID (non-empty string).
* @property {string} userId - Target participant userId (non-empty string).
* @property {string} targetState - Desired VisibilityState.
*/
/**
* @typedef {Object} SocketEchoPayload
* @property {string} opId - Matches the originating intent opId.
* @property {string} userId - Affected participant userId.
* @property {string} state - Authoritative VisibilityState after this operation.
* @property {number} revision - Monotonically increasing revision counter.
*/
/**
* @typedef {Object} SocketMessage
* @property {SocketEventName} event - Socket event name.
* @property {SocketIntentPayload|SocketEchoPayload} payload - Message payload.
*/
export const SOCKET_EVENTS = Object.freeze({
VISIBILITY_SET: "scrying-pool.visibility.set",
VISIBILITY_UPDATED: "scrying-pool.visibility.updated",
PRESET_APPLY: "scrying-pool.preset.apply",
PRESET_APPLIED: "scrying-pool.preset.applied",
});
export const MAX_PAYLOAD_BYTES = 4096;
/**
* Creates a socket intent message (GM → all clients).
* @param {string} opId - Unique operation ID.
* @param {string} userId - Target participant userId.
* @param {string} targetState - Desired VisibilityState.
* @returns {SocketMessage}
*/
export function createSocketIntentMessage(opId, userId, targetState) {
return {
event: SOCKET_EVENTS.VISIBILITY_SET,
payload: { opId, userId, targetState },
};
}
/**
* Creates a socket echo message (authoritative broadcast to all clients).
* @param {string} opId - Originating intent opId.
* @param {string} userId - Affected participant userId.
* @param {string} state - Authoritative VisibilityState.
* @param {number} revision - Revision counter.
* @returns {SocketMessage}
*/
export function createSocketEchoMessage(opId, userId, state, revision) {
return {
event: SOCKET_EVENTS.VISIBILITY_UPDATED,
payload: { opId, userId, state, revision },
};
}
/**
* Validates a SocketMessage DTO. Throws TypeError on any violation.
* @param {unknown} data - Value to validate.
* @returns {SocketMessage} The validated message.
* @throws {TypeError} If data fails validation.
*/
export function isValidSocketMessage(data) {
if (data === null || typeof data !== "object") {
throw new TypeError("SocketMessage: must be an object");
}
const obj = /** @type {Record<string, unknown>} */ (data);
const { event, payload, ...rest } = obj;
if (Object.keys(rest).length > 0) {
throw new TypeError(`SocketMessage: unknown keys: ${Object.keys(rest).join(", ")}`);
}
if (!Object.values(SOCKET_EVENTS).includes(/** @type {any} */ (event))) {
throw new TypeError(`SocketMessage: unknown event "${event}"`);
}
if (payload === null || typeof payload !== "object") {
throw new TypeError("SocketMessage: payload must be an object");
}
const p = /** @type {Record<string, unknown>} */ (payload);
// Validate intent payload
if (event === SOCKET_EVENTS.VISIBILITY_SET) {
const { opId, userId, targetState, ...payloadRest } = p;
if (Object.keys(payloadRest).length > 0) {
throw new TypeError(`SocketMessage intent: unknown payload keys: ${Object.keys(payloadRest).join(", ")}`);
}
if (typeof opId !== "string" || opId.length === 0) {
throw new TypeError("SocketMessage: opId must be a non-empty string");
}
if (typeof userId !== "string" || userId.length === 0) {
throw new TypeError("SocketMessage: userId must be a non-empty string");
}
if (typeof targetState !== "string" || targetState.length === 0) {
throw new TypeError("SocketMessage: targetState must be a non-empty string");
}
}
// Validate echo payload
if (event === SOCKET_EVENTS.VISIBILITY_UPDATED) {
const { opId, userId, state, revision, ...payloadRest } = p;
if (Object.keys(payloadRest).length > 0) {
throw new TypeError(`SocketMessage echo: unknown payload keys: ${Object.keys(payloadRest).join(", ")}`);
}
if (typeof opId !== "string" || opId.length === 0) {
throw new TypeError("SocketMessage: opId must be a non-empty string");
}
if (typeof userId !== "string" || userId.length === 0) {
throw new TypeError("SocketMessage: userId must be a non-empty string");
}
if (typeof state !== "string" || state.length === 0) {
throw new TypeError("SocketMessage: state must be a non-empty string");
}
if (typeof revision !== "number" || !Number.isFinite(revision) || revision < 0) {
throw new TypeError("SocketMessage: revision must be a finite non-negative number");
}
}
return /** @type {SocketMessage} */ (data);
}