133 lines
7.0 KiB
TypeScript
133 lines
7.0 KiB
TypeScript
import { voiceChoices } from './battle-cards';
|
|
import { coalitionBattleBeneficiary, type CoalitionBattlePreparationContext } from './coalition-battle-preparation';
|
|
|
|
export type NativeBattlePower = 'voice' | 'prescience';
|
|
export type BattlePowerClaim =
|
|
| { kind: 'voice'; use: boolean }
|
|
| { kind: 'voiceCommand'; cardType: string; must: boolean }
|
|
| { kind: 'prescience'; use: boolean }
|
|
| { kind: 'prescienceField'; field: 'leader' | 'weapon' | 'defense' | 'dial' };
|
|
export type BattlePowerDeclaration =
|
|
| { power: 'voice'; use: false }
|
|
| { power: 'voice'; use: true; cardType: string; must: boolean }
|
|
| { power: 'prescience'; use: false }
|
|
| { power: 'prescience'; use: true; field: 'leader' | 'weapon' | 'defense' | 'dial' };
|
|
export type NativeBattlePowerOpportunity = {
|
|
event: string; territory: string; power: NativeBattlePower; owner: string;
|
|
beneficiary: string; target: string; ready: boolean;
|
|
};
|
|
export type NativeBattlePowerGame = Omit<CoalitionBattlePreparationContext, 'players' | 'status'> & {
|
|
status: string;
|
|
phase: number;
|
|
players: readonly { id: string; faction: string; ally?: string | null }[];
|
|
battle?: {
|
|
event?: string | null;
|
|
territory: string;
|
|
attacker: string;
|
|
defender: string;
|
|
revealed: boolean;
|
|
prepared?: boolean;
|
|
preparation?: { kind: string; owner: string; beneficiary: string; blocked?: string } | null;
|
|
} | null;
|
|
response?: unknown;
|
|
decision?: unknown;
|
|
phaseOpening?: unknown;
|
|
truthtrance?: unknown;
|
|
pendingTreacheryDiscard?: unknown;
|
|
};
|
|
|
|
export class BattlePowerQuestionError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'BattlePowerQuestionError';
|
|
}
|
|
}
|
|
function check(value: unknown, message: string): asserts value {
|
|
if (!value) throw new BattlePowerQuestionError(message);
|
|
}
|
|
function object(value: unknown): value is Record<string, unknown> {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
const identifier = (value: unknown): value is string =>
|
|
typeof value === 'string' && value.length > 0 && value === value.trim();
|
|
const keys = (value: Record<string, unknown>, expected: string): boolean =>
|
|
Object.keys(value).sort().join(',') === expected;
|
|
const cardType = (value: unknown): value is string => typeof value === 'string' && voiceChoices(true).includes(value);
|
|
const field = (value: unknown): value is 'leader' | 'weapon' | 'defense' | 'dial' =>
|
|
value === 'leader' || value === 'weapon' || value === 'defense' || value === 'dial';
|
|
|
|
/** Declaration/request predicates only; never a successful effect or inspected value. */
|
|
export function parseBattlePowerClaim(value: unknown): BattlePowerClaim {
|
|
check(object(value), 'Choose one native Voice or Prescience declaration predicate.');
|
|
if (value.kind === 'voice' || value.kind === 'prescience') {
|
|
check(keys(value, 'kind,use') && typeof value.use === 'boolean', 'Choose actual use or explicit Decline of this native power.');
|
|
return { kind: value.kind, use: value.use };
|
|
}
|
|
if (value.kind === 'voiceCommand') {
|
|
check(keys(value, 'cardType,kind,must') && cardType(value.cardType) && typeof value.must === 'boolean',
|
|
'Choose one native Voice category and require or forbid command.');
|
|
return { kind: 'voiceCommand', cardType: value.cardType, must: value.must };
|
|
}
|
|
check(value.kind === 'prescienceField' && keys(value, 'field,kind') && field(value.field),
|
|
'Choose only the native Prescience field requested, without an answer value.');
|
|
return { kind: 'prescienceField', field: value.field };
|
|
}
|
|
|
|
export function battlePowerOf(claim: BattlePowerClaim): NativeBattlePower {
|
|
const parsed = parseBattlePowerClaim(claim);
|
|
return parsed.kind === 'voice' || parsed.kind === 'voiceCommand' ? 'voice' : 'prescience';
|
|
}
|
|
|
|
function validateDeclaration(value: unknown): asserts value is BattlePowerDeclaration {
|
|
check(object(value) && (value.power === 'voice' || value.power === 'prescience') && typeof value.use === 'boolean',
|
|
'Retain an actual native battle-power declaration.');
|
|
check(!value.use ? keys(value, 'power,use') : value.power === 'voice'
|
|
? keys(value, 'cardType,must,power,use') && cardType(value.cardType) && typeof value.must === 'boolean'
|
|
: keys(value, 'field,power,use') && field(value.field),
|
|
'Retain only the declared native command or requested field, or explicit Decline.');
|
|
}
|
|
|
|
export function battlePowerMatches(claim: BattlePowerClaim, declaration: BattlePowerDeclaration): boolean {
|
|
const parsed = parseBattlePowerClaim(claim);
|
|
validateDeclaration(declaration);
|
|
if (battlePowerOf(parsed) !== declaration.power) return false;
|
|
if (parsed.kind === 'voice' || parsed.kind === 'prescience') return parsed.use === declaration.use;
|
|
if (!declaration.use) return false;
|
|
return parsed.kind === 'voiceCommand' ? declaration.power === 'voice' &&
|
|
parsed.cardType === declaration.cardType && parsed.must === declaration.must :
|
|
declaration.power === 'prescience' && parsed.field === declaration.field;
|
|
}
|
|
|
|
/** Public native owner/side/order only. Command legality, No-Field restrictions
|
|
* and inspected hidden values remain with the real engine quotation. */
|
|
export function nativeBattlePowerOpportunity(game: NativeBattlePowerGame, actor: string,
|
|
power: NativeBattlePower): NativeBattlePowerOpportunity | null {
|
|
if (!identifier(actor) || (power !== 'voice' && power !== 'prescience') || game.status !== 'playing' || game.phase !== 6) return null;
|
|
const battle = game.battle, preparation = battle?.preparation;
|
|
if (!battle || battle.revealed || !identifier(battle.event) || !identifier(battle.territory) ||
|
|
!preparation || battle.attacker === battle.defender || !Array.isArray(game.players) ||
|
|
game.players.some(player => !identifier(player.id)) || new Set(game.players.map(player => player.id)).size !== game.players.length ||
|
|
!game.players.some(player => player.id === battle.attacker) || !game.players.some(player => player.id === battle.defender)) return null;
|
|
const side = (kind: NativeBattlePower) => {
|
|
const owners = game.players.filter(player => player.faction === (kind === 'voice' ? 'beneGesserit' : 'atreides'));
|
|
if (owners.length !== 1) return null;
|
|
const owner = owners[0];
|
|
const beneficiary = coalitionBattleBeneficiary(game, owner.id, battle.attacker, battle.defender, true);
|
|
return beneficiary ? { owner: owner.id, beneficiary,
|
|
target: battle.attacker === beneficiary ? battle.defender : battle.attacker } : null;
|
|
};
|
|
const native = side(power);
|
|
if (!native || native.owner !== actor) return null;
|
|
const current = preparation.kind === power;
|
|
if (current) {
|
|
if (preparation.owner !== native.owner || preparation.beneficiary !== native.beneficiary) return null;
|
|
} else {
|
|
if (power !== 'prescience' || preparation.kind !== 'voice') return null;
|
|
const voice = side('voice');
|
|
if (!voice || preparation.owner !== voice.owner || preparation.beneficiary !== voice.beneficiary) return null;
|
|
}
|
|
return { event: battle.event, territory: battle.territory, power, ...native,
|
|
ready: current && !game.response && !game.decision && !game.phaseOpening &&
|
|
!game.truthtrance && !game.pendingTreacheryDiscard };
|
|
}
|