Files
Dune/game/truthtrance.ts
T
Quintine 49586efd2f Support waiting-turn Basic ground-movement promises
Allow unused respondents in the current Movement queue to answer against their current owned board and resources. Isolate foreign declarations, prices, flights and searches in detached forecasting without future income/discard assumptions. Preserve native per-player Hajr history. Other players cannot fulfill or close the promise with their own moves/end. Ordinary executable guidance waits until the real owned turn.

Keep an actual already-paid Box decision actionable while waiting. Enumerate its full entitled choice, prioritizing movement cards without excluding ordinary cards that preserve an existing route; pre-payment known-discard rules remain unchanged. Preserve the original event, fee, private inspection and source continuation. Check deferred Emperor gift and Spiritual Advisor costs on their allowed outcome before another player responds. Localize native pending-movement deferral to the actual mover so another waiting owner cannot hide voluntary Box overspending. Minimal profiles may decline optional accompaniment under a positive promise.

Verify 18 files125/125, types/lint and four original-action guidance/paid-choice/table-rendering programmes. Original source19 reverses the unopened classic deal; native44 earns Box1/Hajr1 and genuinely spends Richese Truth, with Guild's actual later question supplying a second ordinary paid candidate. Eight existing-profile runs make32 accepted proposals plus12 separate neutral acknowledgements. Controlled pact/gift, BG losses, original Ornithopter acquisition and native Fremen/Box cases disclose their staging. Paired affordable/unaffordable interruptions preserve exact frames and prevent voluntary route loss.

The production build passes at the initial freeze. Only the regression gains its affordable counterpart afterward; source identity proves every application/build/dependency and other file unchanged, while final tests/types/runtime run again. Final checkpoint/reference prose overlays are separately reviewed. Earlier phases/later rounds, remaining preparations, No-Field/modules, full modes, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation.
2026-10-24 15:31:17 +11:00

678 lines
24 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 { 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 { 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';
export type TruthFact =
| KnowledgeFact
| CardCountFact
| HandInventoryFact
| ForceCountFact
| NoFieldValueFact
| { 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: '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: 16 },
): TruthFact {
check(
depth <= 4 && budget.remaining-- > 0,
'Truthtrance supports at most 16 clauses and four levels of grouping.',
);
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 === '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 <= 8,
'Combine two to eight fact clauses with AND or OR.',
);
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 === '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 require an active or still-waiting Basic turn this round, with pending decisions finished and no optional modules.');
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') {
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'> & 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 === '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 === '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 === '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;
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);
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 === '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;
}
}