766 lines
30 KiB
TypeScript
766 lines
30 KiB
TypeScript
import { parseRevivalClaim, revivalClaimText, revivalPromiseModeSupported, type RevivalClaim } from './revival-promises';
|
||
import { parseMovementClaim, movementClaimText, movementPromiseTargetAvailable, MovementClaimError, type MovementClaim } from './movement-promises';
|
||
import { shipmentAvailable } from './shipment-opportunity';
|
||
import type { ShipmentClaim } from './shipment-promises';
|
||
import {
|
||
shipmentPromiseModeSupported,
|
||
shipmentPromiseTurnAvailable,
|
||
parseShipmentClaim,
|
||
shipmentClaimText,
|
||
ShipmentClaimError,
|
||
} from './shipment-promises';
|
||
import {
|
||
parseMixedShipmentExpression,
|
||
compileMixedShipmentExpression,
|
||
mixedShipmentExpressionText,
|
||
type MixedShipmentExpression,
|
||
} from './mixed-shipment-question';
|
||
import {
|
||
parseCardCountFact,
|
||
cardCountFactMatches,
|
||
cardCountFactText,
|
||
type CardCountFact,
|
||
} from './truthtrance-card-count';
|
||
import {
|
||
parseHandInventoryFact,
|
||
handInventoryFactMatches,
|
||
handInventoryFactText,
|
||
type HandInventoryFact,
|
||
} from './truthtrance-hand-inventory';
|
||
import {
|
||
parsePlanClaim,
|
||
planClaimText,
|
||
type PlanClaim,
|
||
} from './battle-promises';
|
||
import { gameTerritories } from './board';
|
||
import { combatLocationName } from './combat-location';
|
||
import { parseForceCountFact, forceCountFactMatches, forceCountFactText, forceCountCounterError, type ForceCountFact, type ForceCountPlayer } from './truthtrance-force-count';
|
||
import type { Action, Game, Player } from './engine';
|
||
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
|
||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||
import {
|
||
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
|
||
TRUTH_FACT_SIZE_ERROR, TRUTH_FACT_GROUP_ERROR,
|
||
} from './truth-fact-expression';
|
||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||
import { canUseAsTruthtranceRole } from './shrine';
|
||
import { isKnowledgeFact, parseKnowledgeFact, knowledgeFactAnswer, knowledgeFactText, truthKnowledgeOf, type KnowledgeFact, type TruthKnowledge } from './truthtrance-knowledge';
|
||
import { parseNoFieldValueFact, noFieldValueFactMatches, noFieldValueFactText, type NoFieldValueFact } from './truthtrance-no-field-value';
|
||
import { parseFaceDancerFact, faceDancerFactMatches, faceDancerFactText, type FaceDancerFact } from './truthtrance-face-dancer';
|
||
import { parseBidClaim, bidClaimText, bidPromiseModeSupported, type BidClaim } from './bid-promises';
|
||
|
||
export type TruthFact =
|
||
| KnowledgeFact
|
||
| CardCountFact
|
||
| HandInventoryFact
|
||
| ForceCountFact
|
||
| NoFieldValueFact
|
||
| FaceDancerFact
|
||
| { kind: 'hand'; name: string }
|
||
| { kind: 'traitor'; leader: string }
|
||
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
|
||
| { kind: 'and' | 'or'; terms: TruthFact[] };
|
||
export type TruthQuestion =
|
||
| ({ kind: 'revival'; target: string } & RevivalClaim)
|
||
| ({ kind: 'bid'; target: string } & BidClaim)
|
||
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
|
||
| ({ kind: 'movement'; target: string } & MovementClaim)
|
||
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
|
||
| { kind: 'mixedShipment'; target: string; mixed: MixedShipmentExpression }
|
||
| { kind: 'battlePlan'; target: string; territory: string; claim: PlanClaim }
|
||
| { kind: 'fact'; target: string; fact: TruthFact }
|
||
| {
|
||
kind: 'freeform';
|
||
target: string;
|
||
text: string;
|
||
scope: 'fact' | 'currentTurn';
|
||
};
|
||
export type TruthAnswer = 'yes' | 'no' | 'unknown';
|
||
export type TruthQueueEntry = {
|
||
player: string;
|
||
card: string;
|
||
/** Durable authorization for a Karama committed through an occupied Shrine. */
|
||
source?: 'shrine';
|
||
};
|
||
export type TruthWindow = {
|
||
stage: 'priority' | 'ask' | 'answer' | 'unknown';
|
||
queue: TruthQueueEntry[];
|
||
passed: string[];
|
||
question: TruthQuestion | null;
|
||
};
|
||
export type TruthRecord = {
|
||
turn: number;
|
||
phase: number;
|
||
asker: string;
|
||
question: TruthQuestion;
|
||
answer: TruthAnswer;
|
||
};
|
||
export class TruthError extends Error {}
|
||
function check(value: unknown, message: string): asserts value {
|
||
if (!value) throw new TruthError(message);
|
||
}
|
||
const record = (value: unknown): Record<string, unknown> => {
|
||
check(
|
||
value && typeof value === 'object' && !Array.isArray(value),
|
||
'Choose a valid Truthtrance question.',
|
||
);
|
||
return value as Record<string, unknown>;
|
||
};
|
||
function parseFact(
|
||
g: Game,
|
||
value: unknown,
|
||
respondent: Player,
|
||
depth = 0,
|
||
budget = { remaining: TRUTH_FACT_MAX_NODES },
|
||
): TruthFact {
|
||
check(
|
||
depth <= TRUTH_FACT_MAX_DEPTH && budget.remaining-- > 0,
|
||
TRUTH_FACT_SIZE_ERROR,
|
||
);
|
||
const v = record(value);
|
||
if (v.kind === 'forceCount') {
|
||
try {
|
||
const fact = parseForceCountFact(v, gameTerritories(g).flatMap(t =>
|
||
t.sectors.map(sector => ({ territory: t.id, sector }))));
|
||
const unavailable = forceCountCounterError(respondent, fact.counter);
|
||
check(!unavailable, unavailable ?? 'Choose a recorded physical counter type.');
|
||
return fact;
|
||
} catch (error) {
|
||
throw new TruthError(error instanceof Error ? error.message : String(error));
|
||
}
|
||
}
|
||
if (v.kind === 'noFieldValue') {
|
||
try { return parseNoFieldValueFact(v); }
|
||
catch (error) { throw new TruthError(error instanceof Error ? error.message : String(error)); }
|
||
}
|
||
if (isKnowledgeFact(v)) {
|
||
try { return parseKnowledgeFact(v); }
|
||
catch (error) { throw new TruthError(error instanceof Error ? error.message : String(error)); }
|
||
}
|
||
if (v.kind === 'handCount' || v.kind === 'handInventory') {
|
||
try {
|
||
return v.kind === 'handCount'
|
||
? parseCardCountFact(v, TRUTH_CARD_NAMES)
|
||
: parseHandInventoryFact(v);
|
||
} catch (error) {
|
||
throw new TruthError(
|
||
error instanceof Error ? error.message : String(error),
|
||
);
|
||
}
|
||
}
|
||
if (v.kind === 'hand') {
|
||
check(
|
||
typeof v.name === 'string' && TRUTH_CARD_NAMES.includes(v.name),
|
||
'Choose a known treachery card name.',
|
||
);
|
||
return { kind: 'hand', name: v.name };
|
||
}
|
||
if (v.kind === 'traitor') {
|
||
check(
|
||
typeof v.leader === 'string' &&
|
||
(v.leader === CHEAP_HERO_TRAITOR ||
|
||
g.players.some((p) => p.leaders.some((l) => l.id === v.leader))),
|
||
'Choose a leader in this game.',
|
||
);
|
||
return { kind: 'traitor', leader: v.leader };
|
||
}
|
||
if (v.kind === 'faceDancer') {
|
||
try {
|
||
// Candidate identities are public printed components, independent of the
|
||
// respondent's private cards or the status of any particular Face Dancer.
|
||
return parseFaceDancerFact(v, [CHEAP_HERO_TRAITOR,
|
||
...g.players.flatMap(player => player.leaders.map(leader => leader.id))]);
|
||
} catch (error) {
|
||
throw new TruthError(error instanceof Error ? error.message : String(error));
|
||
}
|
||
}
|
||
if (v.kind === 'spice') {
|
||
check(
|
||
v.compare === 'eq' || v.compare === 'gte' || v.compare === 'lte',
|
||
'Compare currently held spice using exactly, at least, or at most.',
|
||
);
|
||
check(
|
||
typeof v.value === 'number' &&
|
||
Number.isSafeInteger(v.value) &&
|
||
v.value >= 0,
|
||
'Choose a nonnegative whole spice amount within the safe integer range.',
|
||
);
|
||
return { kind: 'spice', compare: v.compare, value: v.value };
|
||
}
|
||
check(
|
||
(v.kind === 'and' || v.kind === 'or') &&
|
||
Array.isArray(v.terms) &&
|
||
v.terms.length >= 2 &&
|
||
v.terms.length <= TRUTH_FACT_MAX_TERMS,
|
||
TRUTH_FACT_GROUP_ERROR,
|
||
);
|
||
return {
|
||
kind: v.kind,
|
||
terms: v.terms.map((term) => parseFact(g, term, respondent, depth + 1, budget)),
|
||
};
|
||
}
|
||
/** Shared battle discs are valid plan identities, but never expand the traitor inventory. */
|
||
function battleLeaderIdentities(g: Game) {
|
||
return [
|
||
...g.players.flatMap((player) => player.leaders),
|
||
...(g.dukeVidal?.leader.id === DUKE_VIDAL_ID ? [g.dukeVidal.leader] : []),
|
||
];
|
||
}
|
||
function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||
const v = record(value);
|
||
check(
|
||
typeof v.target === 'string' &&
|
||
v.target !== asker &&
|
||
g.players.some((p) => p.id === v.target),
|
||
'Ask one other player.',
|
||
);
|
||
if (v.kind === 'revival') {
|
||
check(Object.keys(v).sort().join(',') === 'counter,kind,minimum,target,turn',
|
||
'Supply the current Revival turn, physical counter type, minimum and respondent.');
|
||
const claim = parseRevivalClaim(v);
|
||
check(revivalPromiseModeSupported(g) && g.status === 'playing' && g.phase === 4 &&
|
||
claim.turn === g.turn && !g.ready.includes(v.target as string) &&
|
||
(!g.revivalOrder || g.revivalOrder.remaining.includes(v.target as string)) &&
|
||
!g.pendingRevival && !g.response && !g.decision && !g.phaseOpening &&
|
||
!g.pendingKarama && !g.pendingTreacheryDiscard,
|
||
'Choose a remaining native Revival opportunity in a supported table and finish pending decisions.');
|
||
return { kind: 'revival', target: v.target, ...claim };
|
||
}
|
||
if (v.kind === 'bid') {
|
||
check(Object.keys(v).sort().join(',') === 'kind,lot,minimum,target,turn',
|
||
'Supply the current ordinary lot, turn, bid minimum and respondent.');
|
||
const claim = parseBidClaim(v);
|
||
check(bidPromiseModeSupported(g) && g.status === 'playing' && g.phase === 3 && g.auction &&
|
||
!g.currentAuctionSale && !g.richeseAuction && !g.ixAuction && !g.response && !g.decision &&
|
||
!g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
|
||
claim.turn === g.turn && claim.lot === g.auction.index + 1,
|
||
'Choose the current ordinary bid lot in a supported table and finish pending decisions.');
|
||
return { kind: 'bid', target: v.target, ...claim };
|
||
}
|
||
if (v.kind === 'traitorCall') {
|
||
check(Object.keys(v).sort().join(',') === 'battle,kind,target,territory' &&
|
||
g.status === 'playing' && g.phase === 6 && g.battle?.revealed &&
|
||
typeof v.battle === 'string' && v.battle === g.battle.event &&
|
||
v.territory === g.battle.territory && g.battle.traitorCalls[v.target as string] === undefined,
|
||
'Choose the current revealed battle and a still-unsubmitted Traitor decision.');
|
||
return { kind: 'traitorCall', target: v.target as string, battle: v.battle, territory: v.territory as string };
|
||
}
|
||
if (v.kind === 'movement') {
|
||
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,territory',
|
||
'Supply one ground-movement destination, minimum and respondent.');
|
||
check(movementPromiseTargetAvailable(g, v.target as string),
|
||
'Ground-movement promises need a supported unused opportunity this round under Basic or Advanced rules with declared native families and pending decisions finished. Optional modules remain unfinished.');
|
||
try { return { kind: 'movement', target: v.target as string, ...parseMovementClaim(v) }; }
|
||
catch (error) { if (error instanceof MovementClaimError) throw new TruthError(error.message); throw error; }
|
||
}
|
||
if (v.kind === 'shipment' || v.kind === 'mixedShipment') {
|
||
check(
|
||
shipmentPromiseModeSupported(g, v.target) &&
|
||
shipmentPromiseTurnAvailable(g, v.target, shipmentAvailable(
|
||
g, g.players.find((p) => p.id === v.target)!,
|
||
)) &&
|
||
!g.response &&
|
||
!g.decision &&
|
||
!g.phaseOpening &&
|
||
!g.pendingShipment,
|
||
'Structured shipment promises require a supported unused shipment this turn. During Shipment and Movement the respondent must be active or still waiting. Finish pending decisions first.',
|
||
);
|
||
try {
|
||
if (v.kind === 'mixedShipment') {
|
||
check(Object.keys(v).sort().join(',') === 'kind,mixed,target',
|
||
'Supply only the mixed question and its respondent.');
|
||
const respondent = g.players.find(p => p.id === v.target)!;
|
||
return {kind:'mixedShipment',target:v.target,mixed:parseMixedShipmentExpression(v.mixed,
|
||
fact => parseFact(g,fact,respondent))};
|
||
}
|
||
return { kind: 'shipment', target: v.target, ...parseShipmentClaim(v) };
|
||
} catch (error) {
|
||
if (error instanceof ShipmentClaimError)
|
||
throw new TruthError(error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
if (v.kind === 'battlePlan') {
|
||
check(
|
||
g.phase === 6 &&
|
||
g.battle &&
|
||
[g.battle.attacker, g.battle.defender].includes(v.target),
|
||
'Ask a combatant about the current battle.',
|
||
);
|
||
return {
|
||
kind: 'battlePlan',
|
||
target: v.target,
|
||
territory: g.battle.territory,
|
||
claim: parsePlanClaim(
|
||
v.claim,
|
||
battleLeaderIdentities(g).map((leader) => leader.id),
|
||
),
|
||
};
|
||
}
|
||
if (v.kind === 'fact')
|
||
return { kind: 'fact', target: v.target, fact: parseFact(g, v.fact, g.players.find(p => p.id === v.target)!) };
|
||
check(
|
||
v.kind === 'freeform' &&
|
||
typeof v.text === 'string' &&
|
||
v.text.trim().length > 0 &&
|
||
v.text.length <= 500,
|
||
'Write a game-related yes/no question of up to 500 characters.',
|
||
);
|
||
check(
|
||
v.scope === 'fact' || v.scope === 'currentTurn',
|
||
'Commitments may only concern the current turn.',
|
||
);
|
||
return {
|
||
kind: 'freeform',
|
||
target: v.target,
|
||
text: v.text.trim(),
|
||
scope: v.scope,
|
||
};
|
||
}
|
||
/** Reparse a committed question without recomputing its answer or binding again.
|
||
* The caller supplies the unchanged parent opportunity with its controls restored. */
|
||
export function validateTruthQuestionReceipt(
|
||
g: Game,
|
||
asker: string,
|
||
question: TruthQuestion,
|
||
) {
|
||
check(
|
||
JSON.stringify(parseQuestion(g, asker, question)) ===
|
||
JSON.stringify(question),
|
||
'The completed Truthtrance question does not match its recorded opportunity.',
|
||
);
|
||
}
|
||
/** Revalidate persisted physical card receipts before any write or projection. */
|
||
export function validateSavedTruthtrance(g: Game) {
|
||
const window = g.truthtrance;
|
||
// Older timing-only fixtures may represent the instant before the first
|
||
// declaration with an empty priority queue. No card is committed there.
|
||
if (window && window.queue.length > 0) {
|
||
check(
|
||
Array.isArray(window.queue) &&
|
||
window.queue.length > 0 &&
|
||
new Set(window.queue.map((entry) => entry?.card)).size ===
|
||
window.queue.length &&
|
||
window.queue.every((entry) => {
|
||
if (
|
||
!entry ||
|
||
typeof entry.player !== 'string' ||
|
||
typeof entry.card !== 'string' ||
|
||
(entry.source !== undefined && entry.source !== 'shrine')
|
||
)
|
||
return false;
|
||
return queuedTruthCardMatches(
|
||
g.players.find((player) => player.id === entry.player)?.hand ?? [],
|
||
entry,
|
||
);
|
||
}),
|
||
'The saved Truthtrance must retain each committed physical card in its holder’s hand.',
|
||
);
|
||
}
|
||
if (window?.question?.kind === 'fact' || window?.question?.kind === 'movement' || window?.question?.kind === 'traitorCall') {
|
||
check((window.stage === 'answer' || window.stage === 'unknown') && window.queue.length > 0,
|
||
'The saved fact question must retain its asking player and answer stage.');
|
||
check(JSON.stringify(parseQuestion(g, window.queue[0].player, window.question)) === JSON.stringify(window.question),
|
||
'The saved fact question no longer matches its recorded form.');
|
||
return;
|
||
}
|
||
if (window?.question?.kind !== 'shipment' && window?.question?.kind !== 'mixedShipment') return;
|
||
check(
|
||
(window.stage === 'answer' || window.stage === 'unknown') &&
|
||
Array.isArray(window.queue) &&
|
||
window.queue.length > 0 &&
|
||
new Set(window.queue.map((entry) => entry?.card)).size ===
|
||
window.queue.length &&
|
||
window.queue.every((entry) =>
|
||
queuedTruthCardMatches(
|
||
g.players.find((p) => p.id === entry.player)?.hand ?? [],
|
||
entry,
|
||
),
|
||
),
|
||
'The saved shipment question must retain each physical Truthtrance in its holder’s hand.',
|
||
);
|
||
parseQuestion(g, window.queue[0].player, window.question);
|
||
}
|
||
|
||
/** Historical name retained for source references and focused callers. */
|
||
export const validateSavedShipmentQuestion = validateSavedTruthtrance;
|
||
|
||
/** Receipt validation uses the printed card identity, never current Shrine occupation. */
|
||
export function queuedTruthCardMatches(
|
||
hand: readonly { id: string; effect?: string }[],
|
||
entry: TruthQueueEntry,
|
||
): boolean {
|
||
return (
|
||
hand.filter(
|
||
(card) =>
|
||
card.id === entry.card &&
|
||
card.effect === (entry.source === 'shrine' ? 'karama' : 'truthtrance'),
|
||
).length === 1
|
||
);
|
||
}
|
||
/** A fact answer never returns which disjunct matched or any underlying private cards. */
|
||
export function truthFactAnswer(
|
||
p: Pick<Player, 'hand' | 'traitors' | 'traitorChoices' | 'spice' | 'noField' | 'faceDancers'> & Partial<ForceCountPlayer>,
|
||
fact: TruthFact,
|
||
knowledge?: TruthKnowledge,
|
||
): TruthAnswer {
|
||
if (isKnowledgeFact(fact)) return knowledgeFactAnswer(fact, knowledge);
|
||
if (fact.kind === 'noFieldValue')
|
||
return noFieldValueFactMatches(p.noField, fact) ? 'yes' : 'no';
|
||
if (fact.kind === 'forceCount')
|
||
return forceCountFactMatches(p as ForceCountPlayer, fact) ? 'yes' : 'no';
|
||
if (fact.kind === 'handInventory')
|
||
return handInventoryFactMatches(p.hand, fact) ? 'yes' : 'no';
|
||
if (fact.kind === 'handCount')
|
||
return cardCountFactMatches(p.hand, fact) ? 'yes' : 'no';
|
||
if (fact.kind === 'hand')
|
||
return p.hand.some((c) => c.name === fact.name) ? 'yes' : 'no';
|
||
if (fact.kind === 'traitor') {
|
||
if (p.traitorChoices.length) return 'unknown';
|
||
return p.traitors.includes(fact.leader) ? 'yes' : 'no';
|
||
}
|
||
if (fact.kind === 'faceDancer')
|
||
return faceDancerFactMatches(p, fact) ? 'yes' : 'no';
|
||
if (fact.kind === 'spice') {
|
||
// A current fact reports personal custody, not transaction-specific spending power or a future promise.
|
||
const matches =
|
||
fact.compare === 'eq'
|
||
? p.spice === fact.value
|
||
: fact.compare === 'gte'
|
||
? p.spice >= fact.value
|
||
: p.spice <= fact.value;
|
||
return matches ? 'yes' : 'no';
|
||
}
|
||
const answers = fact.terms.map((term) => truthFactAnswer(p, term, knowledge));
|
||
if (fact.kind === 'and')
|
||
return answers.includes('no')
|
||
? 'no'
|
||
: answers.includes('unknown')
|
||
? 'unknown'
|
||
: 'yes';
|
||
return answers.includes('yes')
|
||
? 'yes'
|
||
: answers.includes('unknown')
|
||
? 'unknown'
|
||
: 'no';
|
||
}
|
||
/** Resolve current clauses only for the respondent's private feasibility or definite answer. */
|
||
export function truthShipmentClaim(
|
||
g: Game,
|
||
p: Player,
|
||
question: Extract<TruthQuestion,{kind:'shipment'|'mixedShipment'}>,
|
||
): ShipmentClaim {
|
||
return question.kind === 'shipment' ? question : {
|
||
claim: compileMixedShipmentExpression(question.mixed,
|
||
fact => truthFactAnswer(p,fact,truthKnowledgeOf(g,p))),
|
||
};
|
||
}
|
||
export function truthQuestionText(
|
||
q: TruthQuestion,
|
||
leaderName: (id: string) => string,
|
||
locationName: (id: string) => string,
|
||
): string {
|
||
if (q.kind === 'freeform') return q.text;
|
||
if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`;
|
||
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
|
||
if (q.kind === 'traitorCall') return `In this revealed battle in ${locationName(q.territory)}, will you declare the opposing leader a Traitor?`;
|
||
if (q.kind === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`;
|
||
if (q.kind === 'shipment')
|
||
return `Will you ${shipmentClaimText(q)} this turn?`;
|
||
if (q.kind === 'battlePlan')
|
||
return `In this battle in ${locationName(q.territory)}, will it be true that ${planClaimText(q.claim, leaderName)}?`;
|
||
const clause = (f: TruthFact): string => {
|
||
if (isKnowledgeFact(f)) return knowledgeFactText(f);
|
||
if (f.kind === 'noFieldValue') return noFieldValueFactText(f);
|
||
if (f.kind === 'forceCount') return forceCountFactText(f);
|
||
if (f.kind === 'handInventory') return handInventoryFactText(f);
|
||
if (f.kind === 'handCount') return cardCountFactText(f);
|
||
if (f.kind === 'hand') return `you hold ${f.name}`;
|
||
if (f.kind === 'traitor') return `${leaderName(f.leader)} is your traitor`;
|
||
if (f.kind === 'faceDancer') return faceDancerFactText(f, leaderName);
|
||
if (f.kind === 'spice')
|
||
return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`;
|
||
return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`;
|
||
};
|
||
return `Is it true that ${q.kind === 'mixedShipment'
|
||
? mixedShipmentExpressionText(q.mixed,clause) : clause(q.fact)}?`;
|
||
}
|
||
/** The interrupt overlays existing state. No battle/auction/response objects are moved or reconstructed. */
|
||
export function resolveTruthAction(
|
||
g: Game,
|
||
p: Player,
|
||
a: Action,
|
||
effects: {
|
||
completeDefiniteAnswer: (g: Game) => void;
|
||
log: (g: Game, text: string) => void;
|
||
shipmentAnswers: (
|
||
g: Game,
|
||
p: Player,
|
||
claim: ShipmentClaim,
|
||
) => TruthAnswer[];
|
||
bindShipment: (
|
||
g: Game,
|
||
p: Player,
|
||
claim: ShipmentClaim,
|
||
answer: boolean,
|
||
asker: string,
|
||
mixed?: MixedShipmentExpression,
|
||
) => void;
|
||
movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[];
|
||
bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void;
|
||
revivalAnswers?: (g: Game, p: Player, claim: RevivalClaim) => TruthAnswer[];
|
||
bindRevival?: (g: Game, p: Player, claim: RevivalClaim, answer: boolean, asker: string) => void;
|
||
bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[];
|
||
bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void;
|
||
traitorAnswers?: (g: Game, p: Player) => TruthAnswer[];
|
||
bindTraitor?: (g: Game, p: Player, answer: boolean, asker: string) => void;
|
||
battleAnswers: (g: Game, p: Player, claim: PlanClaim) => TruthAnswer[];
|
||
bindBattle: (
|
||
g: Game,
|
||
p: Player,
|
||
claim: PlanClaim,
|
||
answer: boolean,
|
||
asker: string,
|
||
) => void;
|
||
cardBlock: (g: Game, p: Player, card: Player['hand'][number]) => string | null;
|
||
},
|
||
): boolean {
|
||
const selectedCard =
|
||
a.type === 'card' && typeof a.card === 'string'
|
||
? p.hand.find((card) => card.id === a.card)
|
||
: undefined;
|
||
const shrineCards = Array.isArray(a.shrineTruthtrance)
|
||
? a.shrineTruthtrance
|
||
: [];
|
||
const isPlay =
|
||
a.type === 'card' &&
|
||
!a.mode &&
|
||
!!selectedCard &&
|
||
canUseAsTruthtranceRole(g, p, selectedCard) &&
|
||
(selectedCard.effect === 'truthtrance' ||
|
||
shrineCards.includes(selectedCard.id));
|
||
if (!g.truthtrance && !isPlay && !a.type.startsWith('truth')) return false;
|
||
check(
|
||
g.status === 'playing' || g.status === 'setup',
|
||
'Truthtrance requires a started game.',
|
||
);
|
||
if (isPlay) {
|
||
check(
|
||
!g.truthtrance || g.truthtrance.stage === 'priority',
|
||
'Answer the current Truthtrance before playing another card.',
|
||
);
|
||
g.truthtrance ??= {
|
||
stage: 'priority',
|
||
queue: [],
|
||
passed: [],
|
||
question: null,
|
||
};
|
||
const w = g.truthtrance;
|
||
check(
|
||
!w.passed.includes(p.id),
|
||
'You already declared or passed in this Truthtrance priority window.',
|
||
);
|
||
const extra = a.cards ?? [];
|
||
check(
|
||
Array.isArray(extra) && extra.length <= p.hand.length,
|
||
'Choose your additional Truthtrance cards.',
|
||
);
|
||
const selected: unknown[] = [a.card, ...extra];
|
||
check(
|
||
new Set(selected).size === selected.length &&
|
||
selected.every(
|
||
(id) =>
|
||
typeof id === 'string' &&
|
||
p.hand.some(
|
||
(card) =>
|
||
card.id === id &&
|
||
canUseAsTruthtranceRole(g, p, card) &&
|
||
(card.effect === 'truthtrance' || shrineCards.includes(id)),
|
||
),
|
||
),
|
||
'Each declared Truthtrance must be a different card in your hand.',
|
||
);
|
||
const selectedCards = selected.map((id) =>
|
||
p.hand.find((card) => card.id === id),
|
||
);
|
||
const reservation = selectedCards
|
||
.map((card) => (card ? effects.cardBlock(g, p, card) : null))
|
||
.find((reason): reason is string => !!reason);
|
||
check(
|
||
!reservation,
|
||
reservation ?? 'Choose uncommitted physical cards.',
|
||
);
|
||
check(
|
||
new Set(shrineCards).size === shrineCards.length &&
|
||
shrineCards.every(
|
||
(id) =>
|
||
typeof id === 'string' &&
|
||
selected.includes(id) &&
|
||
p.hand.some((card) => card.id === id && card.effect === 'karama'),
|
||
),
|
||
'Choose only selected Karama cards for the Shrine conversion.',
|
||
);
|
||
for (const card of selected)
|
||
w.queue.push({
|
||
player: p.id,
|
||
card: card as string,
|
||
...(shrineCards.includes(card) ? { source: 'shrine' as const } : {}),
|
||
});
|
||
w.passed.push(p.id);
|
||
effects.log(
|
||
g,
|
||
`${p.name} declared Truthtrance. The table checks storm-order priority before a question is asked.`,
|
||
);
|
||
} else {
|
||
const w = g.truthtrance;
|
||
check(w, 'There is no pending Truthtrance.');
|
||
if (w.stage === 'priority') {
|
||
check(
|
||
a.type === 'truthPass' && !w.passed.includes(p.id),
|
||
'Declare Truthtrance or pass its priority window first.',
|
||
);
|
||
w.passed.push(p.id);
|
||
} else {
|
||
const current = w.queue[0];
|
||
check(current, 'No Truthtrance is awaiting resolution.');
|
||
if (a.type === 'truthAsk') {
|
||
check(
|
||
(w.stage === 'ask' || w.stage === 'unknown') &&
|
||
p.id === current.player,
|
||
'Waiting for the Truthtrance holder to ask a question.',
|
||
);
|
||
w.question = parseQuestion(g, p.id, a.question);
|
||
if (w.question.kind === 'revival')
|
||
check(effects.revivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||
'Choose a respondent with a remaining native Revival opportunity.');
|
||
if (w.question.kind === 'bid')
|
||
check(effects.bidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||
'Choose a bidder with a remaining ordinary opportunity in this lot.');
|
||
if (w.question.kind === 'traitorCall')
|
||
check(effects.traitorAnswers?.(g, g.players.find(other => other.id === w.question!.target)!).length,
|
||
'Choose an actual unsubmitted native Traitor voter.');
|
||
w.stage = 'answer';
|
||
const target = g.players.find(
|
||
(other) => other.id === w.question!.target,
|
||
)!;
|
||
effects.log(
|
||
g,
|
||
`${p.name} asks ${target.name}: ${truthQuestionText(w.question, (id) => (id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : battleLeaderIdentities(g).find((leader) => leader.id === id)!.name), id => combatLocationName(g, id))}`,
|
||
);
|
||
} else if (a.type === 'truthSave') {
|
||
check(
|
||
w.stage === 'unknown' && p.id === current.player,
|
||
'Truthtrance can be saved after an answer cannot be known.',
|
||
);
|
||
effects.log(g, `${p.name} saved Truthtrance for later.`);
|
||
nextQuestion(g);
|
||
} else {
|
||
check(
|
||
a.type === 'truthAnswer' &&
|
||
w.stage === 'answer' &&
|
||
w.question?.target === p.id,
|
||
'Waiting for the questioned player to answer Truthtrance.',
|
||
);
|
||
const q = w.question;
|
||
check(
|
||
a.answer === 'yes' || a.answer === 'no' || a.answer === 'unknown',
|
||
'Answer Yes, No, or I don’t know.',
|
||
);
|
||
if (q.kind === 'fact')
|
||
check(
|
||
a.answer === truthFactAnswer(p, q.fact, truthKnowledgeOf(g, p)),
|
||
'Answer this fact question truthfully.',
|
||
);
|
||
if (q.kind === 'shipment' || q.kind === 'mixedShipment') {
|
||
const claim = truthShipmentClaim(g,p,q);
|
||
check(
|
||
effects.shipmentAnswers(g, p, claim).includes(a.answer),
|
||
'Choose an answer consistent with your available shipment and earlier promises.',
|
||
);
|
||
if (a.answer !== 'unknown')
|
||
effects.bindShipment(g, p, claim, a.answer === 'yes', current.player,
|
||
q.kind === 'mixedShipment' ? q.mixed : undefined);
|
||
}
|
||
if (q.kind === 'movement') {
|
||
check(effects.movementAnswers && effects.bindMovement && effects.movementAnswers(g, p, q).includes(a.answer),
|
||
'Choose an answer consistent with legal ground movement and earlier promises.');
|
||
if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player);
|
||
}
|
||
if (q.kind === 'revival') {
|
||
check(effects.revivalAnswers && effects.bindRevival && effects.revivalAnswers(g, p, q).includes(a.answer),
|
||
'Choose an answer consistent with native force revivals and earlier answers.');
|
||
if (a.answer !== 'unknown') effects.bindRevival(g, p, q, a.answer === 'yes', current.player);
|
||
}
|
||
if (q.kind === 'bid') {
|
||
check(effects.bidAnswers && effects.bindBid && effects.bidAnswers(g, p, q).includes(a.answer),
|
||
'Choose an answer consistent with this lot, your legal bids and earlier answers.');
|
||
if (a.answer !== 'unknown') effects.bindBid(g, p, q, a.answer === 'yes', current.player);
|
||
}
|
||
if (q.kind === 'traitorCall') {
|
||
check(effects.traitorAnswers && effects.bindTraitor && effects.traitorAnswers(g, p).includes(a.answer),
|
||
'Choose an answer compatible with your current legal Traitor declaration and earlier answers.');
|
||
if (a.answer !== 'unknown') effects.bindTraitor(g, p, a.answer === 'yes', current.player);
|
||
}
|
||
if (q.kind === 'battlePlan') {
|
||
check(
|
||
effects.battleAnswers(g, p, q.claim).includes(a.answer),
|
||
'Choose an answer consistent with your legal plans and previous commitments.',
|
||
);
|
||
if (a.answer !== 'unknown')
|
||
effects.bindBattle(
|
||
g,
|
||
p,
|
||
q.claim,
|
||
a.answer === 'yes',
|
||
current.player,
|
||
);
|
||
}
|
||
// Freeform semantics cannot be certified by a deterministic engine. AI does not invent an answer.
|
||
if (q.kind === 'freeform' && p.bot)
|
||
check(
|
||
a.answer === 'unknown',
|
||
'AI cannot yet interpret freeform questions; ask a structured fact question.',
|
||
);
|
||
(g.truthHistory ??= []).push({
|
||
turn: g.turn,
|
||
phase: g.phase,
|
||
asker: current.player,
|
||
question: q,
|
||
answer: a.answer,
|
||
});
|
||
effects.log(
|
||
g,
|
||
`${p.name} answers Truthtrance: ${a.answer === 'unknown' ? 'I don’t know' : a.answer === 'yes' ? 'Yes' : 'No'}.`,
|
||
);
|
||
if (a.answer === 'unknown') w.stage = 'unknown';
|
||
else effects.completeDefiniteAnswer(g);
|
||
}
|
||
}
|
||
}
|
||
if (
|
||
g.truthtrance?.stage === 'priority' &&
|
||
g.truthtrance.passed.length === g.players.length
|
||
) {
|
||
const order = [
|
||
...g.order,
|
||
...g.players
|
||
.map((other) => other.id)
|
||
.filter((id) => !g.order.includes(id)),
|
||
];
|
||
g.truthtrance.queue.sort(
|
||
(a, b) => order.indexOf(a.player) - order.indexOf(b.player),
|
||
);
|
||
g.truthtrance.stage = 'ask';
|
||
}
|
||
return true;
|
||
}
|
||
function nextQuestion(g: Game) {
|
||
const w = g.truthtrance!;
|
||
w.queue.shift();
|
||
if (!w.queue.length) g.truthtrance = null;
|
||
else {
|
||
w.stage = 'ask';
|
||
w.question = null;
|
||
}
|
||
}
|