Files
Dune/game/future-battle-leader.ts
T
Quintine 0e9209531a
Verify and publish checkpoint container / container (push) Canceled after 0s
Support conditional Truthtrance before storm and spice
2026-10-28 22:10:08 +11:00

140 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' };
}