97 lines
5.3 KiB
TypeScript
97 lines
5.3 KiB
TypeScript
/** A future choice names only the actor's original native dial opportunity.
|
||
* The accepted value stays in the normal private storm dial until reveal. */
|
||
export type StormDialSource = { turn: number; player: string };
|
||
export type StormDialClaim = {
|
||
kind: 'stormDial'; source: string; value: number;
|
||
/** Older saved claims omit this field and retain exact-value meaning. */
|
||
compare?: 'eq' | 'gte' | 'lte';
|
||
};
|
||
export type StormDialOpportunity = StormDialSource & { source: string; min: number; max: number };
|
||
export type StormDialGame = {
|
||
status: string;
|
||
advanced: boolean;
|
||
phase: number;
|
||
turn: number;
|
||
players: readonly { id: string }[];
|
||
stormDialers: readonly string[];
|
||
stormPending: number | null;
|
||
/** Public view: identities that have sealed, never their unrevealed values. */
|
||
stormSubmitted?: readonly string[];
|
||
/** Server state: only the presence of a property is inspected. */
|
||
stormDials?: Readonly<Record<string, unknown>>;
|
||
};
|
||
|
||
export class StormDialQuestionError extends Error {
|
||
constructor(message: string) {
|
||
super(message);
|
||
this.name = 'StormDialQuestionError';
|
||
}
|
||
}
|
||
function check(value: unknown, message: string): asserts value {
|
||
if (!value) throw new StormDialQuestionError(message);
|
||
}
|
||
const identifier = (value: unknown): value is string =>
|
||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||
const positive = (value: unknown): value is number =>
|
||
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||
const object = (value: unknown): value is Record<string, unknown> =>
|
||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||
|
||
/** Public canonical source, still decodable after the original dial is sealed. */
|
||
export function parseStormDialSource(source: unknown): StormDialSource | null {
|
||
if (!identifier(source)) return null;
|
||
let tuple: unknown;
|
||
try { tuple = JSON.parse(source); } catch { return null; }
|
||
if (!Array.isArray(tuple) || tuple.length !== 3 || tuple[0] !== 'stormDial' ||
|
||
!positive(tuple[1]) || !identifier(tuple[2]) || JSON.stringify(tuple) !== source) return null;
|
||
return { turn: tuple[1], player: tuple[2] };
|
||
}
|
||
|
||
/** Parse a future native choice within that turn's real dial range. */
|
||
export function parseStormDialClaim(value: unknown): StormDialClaim {
|
||
check(object(value) &&
|
||
(Object.keys(value).sort().join(',') === 'kind,source,value' ||
|
||
Object.keys(value).sort().join(',') === 'compare,kind,source,value') &&
|
||
value.kind === 'stormDial' &&
|
||
(!Object.hasOwn(value, 'compare') || value.compare === 'eq' || value.compare === 'gte' || value.compare === 'lte'),
|
||
'Choose one original storm-dial opportunity and value comparison.');
|
||
const source = parseStormDialSource(value.source);
|
||
check(source && typeof value.value === 'number' && Number.isSafeInteger(value.value) &&
|
||
value.value >= (source.turn === 1 ? 0 : 1) && value.value <= (source.turn === 1 ? 20 : 3),
|
||
'Choose a value within the original turn’s native storm-dial range.');
|
||
return { kind: 'stormDial', source: value.source as string, value: value.value,
|
||
...(Object.hasOwn(value, 'compare') ? { compare: value.compare as 'eq' | 'gte' | 'lte' } : {}) };
|
||
}
|
||
|
||
/** Compare the actual accepted native submission, never an invented default. */
|
||
export function stormDialMatches(claim: StormDialClaim, actual: unknown): boolean {
|
||
const parsed = parseStormDialClaim(claim), source = parseStormDialSource(parsed.source)!;
|
||
check(typeof actual === 'number' && Number.isSafeInteger(actual) &&
|
||
actual >= (source.turn === 1 ? 0 : 1) && actual <= (source.turn === 1 ? 20 : 3),
|
||
'Retain the actual native storm-dial submission.');
|
||
return parsed.compare === 'gte' ? actual >= parsed.value :
|
||
parsed.compare === 'lte' ? actual <= parsed.value : actual === parsed.value;
|
||
}
|
||
|
||
/** Public eligibility only. Reads whether a value is sealed, never that value.
|
||
* Original phase/card/response gates and projected owner actions remain native. */
|
||
export function nativeStormDialOpportunity(game: StormDialGame, player: string,
|
||
expectedSource?: string): StormDialOpportunity | null {
|
||
if (game.status !== 'playing' || game.advanced !== false || game.phase !== 0 ||
|
||
!positive(game.turn) || game.stormPending !== null || !identifier(player) ||
|
||
!Array.isArray(game.players) || !game.players.length ||
|
||
game.players.some(row => !row || !identifier(row.id)) ||
|
||
new Set(game.players.map(row => row.id)).size !== game.players.length ||
|
||
!Array.isArray(game.stormDialers) || !game.stormDialers.length ||
|
||
!game.stormDialers.every(id => identifier(id) && game.players.some(row => row.id === id)) ||
|
||
new Set(game.stormDialers).size !== game.stormDialers.length || !game.stormDialers.includes(player)) return null;
|
||
if (game.stormSubmitted !== undefined) {
|
||
if (!Array.isArray(game.stormSubmitted) || !game.stormSubmitted.every(id =>
|
||
identifier(id) && game.stormDialers.includes(id)) ||
|
||
new Set(game.stormSubmitted).size !== game.stormSubmitted.length || game.stormSubmitted.includes(player)) return null;
|
||
} else if (!object(game.stormDials) || Object.hasOwn(game.stormDials, player)) return null;
|
||
const source = JSON.stringify(['stormDial', game.turn, player]);
|
||
if (expectedSource !== undefined && expectedSource !== source) return null;
|
||
return { source, turn: game.turn, player, min: game.turn === 1 ? 0 : 1, max: game.turn === 1 ? 20 : 3 };
|
||
}
|