140 lines
6.9 KiB
TypeScript
140 lines
6.9 KiB
TypeScript
import type { Game } from './engine';
|
||
|
||
/** Early questions use the native pre-resolution Storm or pre-draw Spice
|
||
* window. Other phases retain their existing quiet-interaction checks. */
|
||
export function futureBattleLeaderTiming(g: Pick<Game, 'phase' | 'stormPending' | 'stormResolution' | 'spiceSequence' |
|
||
'spiceWindow' | 'spiceResolution' | 'nexus' | 'summonedWorm' | 'wormRides'>): boolean {
|
||
if (g.phase === 0) return Number.isInteger(g.stormPending) && !g.stormResolution;
|
||
if (g.phase === 1) return !g.spiceSequence && !g.spiceWindow && !g.spiceResolution &&
|
||
!g.nexus && !g.summonedWorm && !g.wormRides.length;
|
||
return [2, 3, 4, 5].includes(g.phase);
|
||
}
|
||
|
||
/** A conditional commitment for the respondent's first later native battle.
|
||
* Waiting does not require a battle to occur. The engine owns plan legality,
|
||
* answer feasibility and the decision that an obligation became impossible. */
|
||
export type FutureBattleLeaderReceipt = {
|
||
turn: number;
|
||
asker: string;
|
||
player: string;
|
||
leader: string;
|
||
answer: boolean;
|
||
battle?: string;
|
||
status: 'waiting' | 'bound' | 'settled' | 'expired' | 'released';
|
||
};
|
||
|
||
export type FutureBattleLeaderSource = {
|
||
turn: number;
|
||
event: string;
|
||
attacker: string;
|
||
defender: string;
|
||
native: boolean;
|
||
plansOpen: boolean;
|
||
};
|
||
|
||
export class FutureBattleLeaderError extends Error {}
|
||
|
||
function check(condition: unknown, message: string): asserts condition {
|
||
if (!condition) throw new FutureBattleLeaderError(message);
|
||
}
|
||
|
||
function validId(value: unknown): value is string {
|
||
return typeof value === 'string' && value.length <= 256 && value.trim() === value && value.length > 0;
|
||
}
|
||
|
||
function validTurn(value: unknown): value is number {
|
||
return Number.isSafeInteger(value) && (value as number) > 0;
|
||
}
|
||
|
||
function validateReceipt(receipt: FutureBattleLeaderReceipt): void {
|
||
check(!!receipt && typeof receipt === 'object' && !Array.isArray(receipt), 'Invalid future battle receipt.');
|
||
check(validTurn(receipt.turn) && validId(receipt.asker) && validId(receipt.player) &&
|
||
receipt.asker !== receipt.player && validId(receipt.leader) && typeof receipt.answer === 'boolean',
|
||
'Invalid future battle question.');
|
||
check(['waiting', 'bound', 'settled', 'expired', 'released'].includes(receipt.status),
|
||
'Invalid future battle status.');
|
||
check(receipt.battle === undefined || validId(receipt.battle), 'Invalid future battle identity.');
|
||
check(receipt.status !== 'waiting' && receipt.status !== 'expired' || receipt.battle === undefined,
|
||
'A waiting or expired question cannot name a battle.');
|
||
check(receipt.status !== 'bound' && receipt.status !== 'settled' || receipt.battle !== undefined,
|
||
'A bound or settled question must name its battle.');
|
||
}
|
||
|
||
/** Caller supplies the respondent's public, native leader identities; no hand is inspected. */
|
||
export function createFutureBattleLeader(
|
||
question: Pick<FutureBattleLeaderReceipt, 'turn' | 'asker' | 'player' | 'leader' | 'answer'>,
|
||
nativeLeaders: readonly string[],
|
||
): FutureBattleLeaderReceipt {
|
||
check(!!question && typeof question === 'object', 'Invalid future battle question.');
|
||
const receipt: FutureBattleLeaderReceipt = { turn: question.turn, asker: question.asker,
|
||
player: question.player, leader: question.leader, answer: question.answer, status: 'waiting' };
|
||
validateReceipt(receipt);
|
||
check(Array.isArray(nativeLeaders) && nativeLeaders.every(validId) &&
|
||
new Set(nativeLeaders).size === nativeLeaders.length && nativeLeaders.includes(receipt.leader),
|
||
'Choose one of the respondent’s native leader identities.');
|
||
return receipt;
|
||
}
|
||
|
||
/** Observe a real battle; unrelated battles and nonmatching turns leave it waiting. */
|
||
export function bindFutureBattleLeader(
|
||
receipt: FutureBattleLeaderReceipt,
|
||
source: FutureBattleLeaderSource,
|
||
): FutureBattleLeaderReceipt {
|
||
validateReceipt(receipt);
|
||
check(!!source && typeof source === 'object' && validTurn(source.turn) && validId(source.event) &&
|
||
validId(source.attacker) && validId(source.defender) && source.attacker !== source.defender &&
|
||
typeof source.native === 'boolean' && typeof source.plansOpen === 'boolean',
|
||
'Invalid observed battle.');
|
||
if (receipt.status !== 'waiting' || receipt.turn !== source.turn || !source.native ||
|
||
(receipt.player !== source.attacker && receipt.player !== source.defender))
|
||
return receipt;
|
||
check(source.plansOpen, 'The respondent’s first qualifying battle is already past plan preparation.');
|
||
return { ...receipt, battle: source.event, status: 'bound' };
|
||
}
|
||
|
||
/** Only the actual revealed respondent plan can settle the linked receipt. */
|
||
export function settleFutureBattleLeader(
|
||
receipt: FutureBattleLeaderReceipt,
|
||
observed: { turn: number; battle: string; player: string; revealed: boolean; leader: string | null },
|
||
): FutureBattleLeaderReceipt {
|
||
validateReceipt(receipt);
|
||
check(!!observed && typeof observed === 'object' && validTurn(observed.turn) && validId(observed.battle) &&
|
||
validId(observed.player) && typeof observed.revealed === 'boolean' &&
|
||
(observed.leader === null || validId(observed.leader)), 'Invalid revealed battle plan.');
|
||
check(receipt.turn === observed.turn && receipt.battle === observed.battle && receipt.player === observed.player,
|
||
'The revealed plan does not belong to this question.');
|
||
check((receipt.status === 'bound' || receipt.status === 'settled') && observed.revealed,
|
||
'Only an actual revealed bound plan can settle this question.');
|
||
check((observed.leader === receipt.leader) === receipt.answer,
|
||
'The revealed plan contradicts the active future battle answer.');
|
||
if (receipt.status === 'settled') return receipt;
|
||
return { ...receipt, status: 'settled' };
|
||
}
|
||
|
||
/** The caller must independently establish impossibility before invoking this. */
|
||
export function releaseFutureBattleLeader(
|
||
receipt: FutureBattleLeaderReceipt,
|
||
source: { turn: number; battle?: string },
|
||
): FutureBattleLeaderReceipt {
|
||
validateReceipt(receipt);
|
||
check(!!source && typeof source === 'object' && validTurn(source.turn) &&
|
||
(source.battle === undefined || validId(source.battle)) && source.turn === receipt.turn &&
|
||
source.battle === receipt.battle, 'The release does not belong to this question.');
|
||
if (receipt.status === 'released') return receipt;
|
||
check(receipt.status === 'waiting' || receipt.status === 'bound', 'This question cannot be released.');
|
||
return { ...receipt, status: 'released' };
|
||
}
|
||
|
||
/** Only unbound questions expire when their original Battle phase has ended. */
|
||
export function expireFutureBattleLeader(
|
||
receipt: FutureBattleLeaderReceipt,
|
||
source: { turn: number; battlePhaseEnded: boolean },
|
||
): FutureBattleLeaderReceipt {
|
||
validateReceipt(receipt);
|
||
check(!!source && typeof source === 'object' && validTurn(source.turn) &&
|
||
typeof source.battlePhaseEnded === 'boolean' && source.turn === receipt.turn,
|
||
'The phase end does not belong to this question.');
|
||
if (!source.battlePhaseEnded || receipt.status !== 'waiting') return receipt;
|
||
return { ...receipt, status: 'expired' };
|
||
}
|