import { TRUTH_CARD_NAMES } from './truthtrance-card-names'; import type { Plan } from './engine'; import type { Card } from './cards'; import { CHEAP_HERO_TRAITOR } from './traitors'; import { BATTLE_CLAIM_MAX_NODES, BATTLE_CLAIM_MAX_DEPTH, BATTLE_CLAIM_MAX_TERMS, BATTLE_CLAIM_INPUT_ERROR, } from './battle-claim-expression'; export type PlanClaim = | { kind: 'dial' | 'support'; compare: 'eq' | 'gte' | 'lte'; value: number } | { kind: 'weapon' | 'defense'; name: string | null } | { kind: 'leader'; leader: string | null } | { kind: 'kwisatz'; use: boolean } | { kind: 'and' | 'or'; terms: PlanClaim[] }; export type BattlePromise = { /** Optional link for new mixed questions; older pure records retain their shape. */ event?: string; player: string; asker: string; claim: PlanClaim; answer: boolean; released?: boolean; }; export class PlanClaimError extends Error {} export function parsePlanClaim( value: unknown, leaders: string[], depth = 0, budget = { remaining: BATTLE_CLAIM_MAX_NODES }, ): PlanClaim { const fail = (): never => { throw new PlanClaimError( BATTLE_CLAIM_INPUT_ERROR, ); }; if ( !value || typeof value !== 'object' || Array.isArray(value) || depth > BATTLE_CLAIM_MAX_DEPTH || budget.remaining-- <= 0 ) return fail(); const v = value as Record; if (v.kind === 'and' || v.kind === 'or') { if (!Array.isArray(v.terms) || v.terms.length < 2 || v.terms.length > BATTLE_CLAIM_MAX_TERMS) return fail(); return { kind: v.kind, terms: v.terms.map((term) => parsePlanClaim(term, leaders, depth + 1, budget), ), }; } if (v.kind === 'dial' || v.kind === 'support') { if ( !['eq', 'gte', 'lte'].includes(String(v.compare)) || typeof v.value !== 'number' || !Number.isFinite(v.value) || v.value < 0 || v.value > 40 || !Number.isInteger(v.value * (v.kind === 'dial' ? 2 : 1)) ) return fail(); return { kind: v.kind, compare: v.compare as 'eq' | 'gte' | 'lte', value: v.value, }; } if (v.kind === 'weapon' || v.kind === 'defense') { if ( v.name !== null && (typeof v.name !== 'string' || !TRUTH_CARD_NAMES.includes(v.name)) ) return fail(); return { kind: v.kind, name: v.name }; } if (v.kind === 'leader') { if ( v.leader !== null && (typeof v.leader !== 'string' || (!leaders.includes(v.leader) && v.leader !== CHEAP_HERO_TRAITOR)) ) return fail(); return { kind: v.kind, leader: v.leader }; } if (v.kind === 'kwisatz' && typeof v.use === 'boolean') return { kind: 'kwisatz', use: v.use }; return fail(); } /** Undefined means a partial candidate cannot yet determine the answer. */ export function matchesPlanClaim( claim: PlanClaim, plan: Partial, hand: readonly Card[], ): boolean | undefined { if (claim.kind === 'and' || claim.kind === 'or') { const terms = claim.terms.map((c) => matchesPlanClaim(c, plan, hand)); if (claim.kind === 'and') return terms.includes(false) ? false : terms.includes(undefined) ? undefined : true; return terms.includes(true) ? true : terms.includes(undefined) ? undefined : false; } if (claim.kind === 'kwisatz') return plan.kwisatz === undefined ? undefined : plan.kwisatz === claim.use; if (claim.kind === 'dial' || claim.kind === 'support') { const n = plan[claim.kind]; if (n === undefined) return undefined; return claim.compare === 'eq' ? n === claim.value : claim.compare === 'gte' ? n >= claim.value : n <= claim.value; } if (claim.kind === 'weapon' || claim.kind === 'defense') { const id = plan[claim.kind]; if (id === undefined) return undefined; return ( (id === null ? null : hand.find((c) => c.id === id)?.name) === claim.name ); } if (claim.kind === 'leader') { if (plan.leader === undefined) return undefined; return ( (hand.find((c) => c.id === plan.leader)?.kind === 'hero' ? CHEAP_HERO_TRAITOR : plan.leader) === claim.leader ); } } export function respectsBattlePromises( promises: readonly BattlePromise[], player: string, plan: Partial, hand: readonly Card[], ): boolean { return promises .filter((p) => p.player === player && !p.released) .every((p) => { const match = matchesPlanClaim(p.claim, plan, hand); return match === undefined || match === p.answer; }); } export function planClaimText( c: PlanClaim, leaderName: (id: string) => string, ): string { if (c.kind === 'and' || c.kind === 'or') return `(${c.terms.map((term) => planClaimText(term, leaderName)).join(c.kind === 'and' ? ' AND ' : ' OR ')})`; if (c.kind === 'dial' || c.kind === 'support') return `your ${c.kind === 'dial' ? 'dial' : 'spice support'} is ${c.compare === 'eq' ? 'exactly' : c.compare === 'gte' ? 'at least' : 'at most'} ${c.value}`; if (c.kind === 'weapon' || c.kind === 'defense') return c.name === null ? `your ${c.kind} slot is empty` : `you play ${c.name} in your ${c.kind} slot`; if (c.kind === 'leader') return c.leader === null ? 'you use no leader or hero' : `you use ${c.leader === CHEAP_HERO_TRAITOR ? 'a Cheap Hero / Heroine' : leaderName(c.leader)}`; if (c.kind === 'kwisatz') return `you ${c.use ? 'use' : 'do not use'} the Kwisatz Haderach`; throw new PlanClaimError('Unknown battle condition.'); }