Files
Dune/game/truthtrance.ts
T

766 lines
30 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 { 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;
}
}