Support Truthtrance charity commitments in Basic play

This commit is contained in:
Quintine committed 2026-10-27 19:07:38 +11:00
1 parent 6a31315c95
commit 72ff9db5e8
18 files changed
+815 -5

No files matched your search

+6
View File
@@ -1314,6 +1314,7 @@ function policyActions(g: GameView): Action[] {
? [{ type: 'decision', event: search.event, card: ordered[0].id }]
: [];
}
if (g.truthCharityGuide) return [g.truthCharityGuide.action];
if (g.bribePromiseGuide) return [g.bribePromiseGuide.action];
if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action];
if (g.revivalExpressionPromiseGuide) return [g.revivalExpressionPromiseGuide.action];
@@ -1380,6 +1381,10 @@ function policyActions(g: GameView): Action[] {
const answer = g.truthBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'charity') {
const answer = g.truthCharityAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'richeseBid') {
const answer = g.truthRicheseBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
@@ -3232,6 +3237,7 @@ function policyActions(g: GameView): Action[] {
g.phase === 2 &&
!me.charityClaimed &&
!g.charity.incomePending &&
!g.charity.truthBlocked &&
g.charity.amount > 0
)
return [{ type: 'charity' }];
+116
View File
@@ -0,0 +1,116 @@
/** A definite answer about claiming charity after the answer in the current
* Charity phase. Native eligibility and the cause of an opportunity changing
* remain authoritative in the engine. */
export type CharityPromise = {
event: string;
turn: number;
player: string;
asker: string;
answer: boolean;
fulfilled: boolean;
released: boolean;
};
export class CharityPromiseError extends Error {}
export type CharityPromiseAnswer = 'yes' | 'no';
export type CharityPromiseChange = 'voluntary' | 'external';
function checkContext(player: string, turn: number): void {
if (typeof player !== 'string' || !player.trim() || player !== player.trim() ||
!Number.isSafeInteger(turn) || turn < 1)
throw new CharityPromiseError('Retain the original player and turn for the Charity answer.');
}
/** Each physical answer owns a distinct stable receipt across continuations. */
export function validateCharityPromises(promises: readonly CharityPromise[]): void {
if (!Array.isArray(promises)) throw new CharityPromiseError('The Charity answer receipts are invalid.');
const events = new Set<string>();
for (const row of promises) {
if (!row || typeof row.event !== 'string' || !row.event.trim() || row.event !== row.event.trim() ||
events.has(row.event) || typeof row.player !== 'string' || !row.player.trim() ||
row.player !== row.player.trim() || typeof row.asker !== 'string' || !row.asker.trim() ||
row.asker !== row.asker.trim() || row.asker === row.player ||
!Number.isSafeInteger(row.turn) || row.turn < 1 || typeof row.answer !== 'boolean' ||
typeof row.fulfilled !== 'boolean' || typeof row.released !== 'boolean' || row.fulfilled && row.released)
throw new CharityPromiseError('A Charity answer lost its unique event, participants or outcome.');
events.add(row.event);
}
}
/** A No remains live through phase close, even if concurrent Basic Ready is
* later reopened. A Yes completes only when the actual claim is accepted. */
export function liveCharityPromises<T extends CharityPromise>(
promises: readonly T[], player: string, turn: number,
): T[] {
checkContext(player, turn);
validateCharityPromises(promises);
return promises.filter(row => row.player === player && row.turn === turn && !row.fulfilled && !row.released);
}
/** canClaim is the native, current opportunity, including funding and prior
* claim rules. It must not be a temporary inability while a response is open. */
export function charityPromiseAnswers(
promises: readonly CharityPromise[], player: string, turn: number, canClaim: boolean,
): CharityPromiseAnswer[] {
const live = liveCharityPromises(promises, player, turn);
return [
...(canClaim && !live.some(row => !row.answer) ? ['yes' as const] : []),
...(!live.some(row => row.answer) ? ['no' as const] : []),
];
}
/** Called only for a definite accepted answer, before the Truth card's actual
* discard. The engine reopens Basic readiness separately. */
export function bindCharityPromise(
promises: readonly CharityPromise[], player: string, asker: string, turn: number,
answer: boolean, canClaim: boolean, event: string = crypto.randomUUID(),
): CharityPromise[] {
checkContext(asker, turn);
if (asker === player || typeof answer !== 'boolean' ||
!charityPromiseAnswers(promises, player, turn, canClaim).includes(answer ? 'yes' : 'no'))
throw new CharityPromiseError('Choose a Charity answer consistent with the current opportunity and previous answers.');
const next = [...promises, { event, turn, player, asker, answer, fulfilled: false, released: false }];
validateCharityPromises(next);
return next;
}
/** Invoke after native claim validation, before the actual claim settles. */
export function guardCharityClaim<T extends CharityPromise>(
promises: readonly T[], player: string, turn: number,
): T[] {
const live = liveCharityPromises(promises, player, turn);
if (live.some(row => !row.answer))
throw new CharityPromiseError('Your Truthtrance No prevents claiming Charity this phase.');
return promises.map(row => live.includes(row) && row.answer ? { ...row, fulfilled: true } : row);
}
/** A reachable affirmative claim must happen before this player confirms Ready. */
export function guardCharityReady(
promises: readonly CharityPromise[], player: string, turn: number, canClaim: boolean,
): void {
if (canClaim && liveCharityPromises(promises, player, turn).some(row => row.answer))
throw new CharityPromiseError('Claim Charity before finishing your Truthtrance Yes.');
}
/** Reconcile only at a stable native boundary. A voluntary change may not
* sacrifice an attainable Yes; an externally impossible Yes is released. No
* resolves only when the original phase closes, never merely on Ready. */
export function reconcileCharityPromises<T extends CharityPromise>(
promises: readonly T[], player: string, turn: number,
opportunity: { phaseOpen: boolean; canClaim: boolean; change: CharityPromiseChange },
): T[] {
const live = liveCharityPromises(promises, player, turn);
if (!opportunity || typeof opportunity.phaseOpen !== 'boolean' ||
typeof opportunity.canClaim !== 'boolean' ||
(opportunity.change !== 'voluntary' && opportunity.change !== 'external'))
throw new CharityPromiseError('Retain the original Charity opportunity and the cause of its change.');
if ((!opportunity.phaseOpen || !opportunity.canClaim) && live.some(row => row.answer) &&
opportunity.change === 'voluntary')
throw new CharityPromiseError('This action would abandon your Truthtrance Charity Yes.');
return promises.map(row => {
if (!live.includes(row)) return row;
if (row.answer && (!opportunity.phaseOpen || !opportunity.canClaim)) return { ...row, released: true };
if (!row.answer && !opportunity.phaseOpen) return { ...row, fulfilled: true };
return row;
});
}
+106
View File
@@ -1,3 +1,4 @@
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder } from './sapho-truth-order';
import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid,
liveRicheseBidPromises, reconcileRicheseBidPromises, finishRicheseBidLot, validateRicheseBidPromises,
@@ -1395,6 +1396,7 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
charityPromises?: CharityPromise[];
revivalOrder?: RevivalOrder;
charityOrder?: CharityOrder;
pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[];
@@ -7096,6 +7098,7 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
`truth:${next.historyIndex}`,
),
history: g.truthHistory,
...(g.charityPromises ? { charityPromises: g.charityPromises } : {}),
battle: g.battle,
shipmentPromises: g.shipmentPromises,
movementPromises: g.movementPromises,
@@ -16146,6 +16149,88 @@ function phaseBidWitnesses(g: Game, p: Player): PhaseBidOpportunity[] {
}
return result;
}
/** The first structured Charity question covers the live concurrent Basic phase.
* A detached native action quotes the real entitlement and payer; it never
* commits a payment or opens a response at the table. */
function charityPromiseRule<T>(run: () => T): T {
try { return run(); } catch (error) {
if (error instanceof CharityPromiseError) throw new RuleError(error.message);
throw error;
}
}
function charityQuestionWindow(g: Game): boolean {
return g.status === 'playing' && !g.advanced && g.phase === 2 && !g.charityOrder &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
(!byFaction(g, 'choam') || g.choamCharity?.turn === g.turn);
}
function charityPromiseCompletion(g: Game, player: string): Action | null {
if (!charityQuestionWindow(g) || getPlayer(g,player).charityTurn === g.turn) return null;
const trial = structuredClone(g);
trial.truthtrance = null;
trial.ready = trial.ready.filter(id => id !== player);
// Evaluate physical availability independently of this answer's own guard.
delete trial.charityPromises;
const claim = (source: Game): boolean => {
try {
const result = applyActionInner(source, player, {type:'charity'}, 'shipmentPreparation');
return getPlayer(result,player).charityTurn === result.turn;
} catch (error) { if (error instanceof RuleError) return false; throw error; }
};
if (claim(trial)) return {type:'charity'};
// A real ordinary payment may make an otherwise wealthy player eligible.
// Quote only the minimum known one-payment route and never invent consent.
const actor = getPlayer(trial,player), amount = actor.spice - 1;
if (!Number.isSafeInteger(amount) || amount <= 0 || amount > uncommittedSpice(trial,actor)) return null;
for (const recipient of trial.players) {
if (recipient.id === player || recipient.id === actor.ally) continue;
const action: Action = {type:'bribe',target:recipient.id,amount};
try {
const paid = applyActionInner(trial,player,action,'shipmentPreparation');
if (claim(paid)) return action;
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
return null;
}
function charityPromiseCanClaim(g: Game, player: string): boolean {
return !!charityPromiseCompletion(g,player);
}
function charityTruthAnswers(g: Game, p: Player, turn: number): TruthAnswer[] {
if (!charityQuestionWindow(g) || turn !== g.turn || p.charityTurn === turn) return [];
if (richeseBidOtherPromises(g,p)) return ['unknown'];
return charityPromiseRule(() => charityPromiseAnswers(g.charityPromises ?? [],p.id,turn,charityPromiseCanClaim(g,p.id)));
}
function bindCharityTruth(g: Game,p: Player,turn: number,answer: boolean,asker: string): void {
requireRule(charityTruthAnswers(g,p,turn).includes(answer ? 'yes' : 'no'),
'Choose an answer compatible with your current Charity opportunity.');
g.charityPromises = charityPromiseRule(() => bindCharityPromise(g.charityPromises ?? [],p.id,asker,turn,answer,charityPromiseCanClaim(g,p.id)));
// A definite new answer reopens a concurrent Basic Ready, not a completed phase.
g.ready = g.ready.filter(id => id !== p.id);
}
function reconcileCharityTruth(g: Game,voluntary?: {actor:string;action:Action}): void {
if (!g.charityPromises?.length || g.truthtrance || g.response || g.decision || g.phaseOpening ||
g.pendingKarama || g.pendingTreacheryDiscard) return;
let promises: CharityPromise[] = g.charityPromises;
const people = new Set(promises.filter(row => !row.fulfilled && !row.released).map(row => row.player));
for (const player of people) {
const rows: CharityPromise[] = promises.filter(row => row.player === player && !row.fulfilled && !row.released);
for (const turn of new Set(rows.map(row => row.turn))) {
const open = g.status === 'playing' && !g.advanced && g.phase === 2 && g.turn === turn;
const before: CharityPromise[] = promises;
promises = charityPromiseRule(() => reconcileCharityPromises(before,player,turn,
{phaseOpen:open,canClaim:open && charityPromiseCanClaim(g,player),
change:voluntary?.actor === player ? 'voluntary' : 'external'}));
if (promises.some((row,index) => row.player === player && row.released && !before[index]?.released))
log(g, `${getPlayer(g,player).name} can no longer fulfill the Truthtrance Charity answer; it is no longer binding.`);
}
}
g.charityPromises = promises;
}
function projectedCharityTruthGuide(g: Game,player: string): {action:Action} | null {
if (g.truthtrance || !charityQuestionWindow(g) || g.ready.includes(player)) return null;
const promise = liveCharityPromises(g.charityPromises ?? [],player,g.turn).find(row => row.answer);
const action = promise && charityPromiseCompletion(g,player);
return action && promise ? {action:{...action,charityPromiseEvent:promise.event,charityPromiseTurn:promise.turn}} : null;
}
function richeseBidRule<T>(run: () => T): T {
try { return run(); } catch (error) { if (error instanceof RicheseBidPromiseError) throw new RuleError(error.message); throw error; }
}
@@ -39366,6 +39451,12 @@ export function applyAction(state: Game, id: string, action: Action): Game {
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
'This phase bid guide is stale or belongs to another auction lot.');
}
if (action.charityPromiseEvent !== undefined || action.charityPromiseTurn !== undefined) {
const guide = projectedCharityTruthGuide(state,id);
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
'This Charity promise guide is stale or belongs to another opportunity.');
}
if (action.richeseBidPromiseId !== undefined) {
const guide = projectedRicheseBidGuide(state,id);
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
@@ -39387,6 +39478,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
reconcileBidPromises(g, voluntary);
reconcilePhaseBids(g, voluntary);
reconcileRicheseBids(g, voluntary);
reconcileCharityTruth(g, voluntary);
reconcileRevivalPromises(g, voluntary);
reconcileBribePromises(g, voluntary);
reconcileGroundMovementPromises(g, promiseActor, action, state.phase);
@@ -39513,6 +39605,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
reconcileBidPromises(g, { actor: id, action });
reconcilePhaseBids(g, { actor: id, action });
reconcileRicheseBids(g, { actor: id, action });
reconcileCharityTruth(g, { actor: id, action });
reconcileRevivalPromises(g, { actor: id, action });
reconcileBribePromises(g, { actor: id, action });
reconcileAlliancePromises(g, { actor: id, action });
@@ -39732,6 +39825,7 @@ function settleAutomaticContinuations(g: Game) {
reconcileBidPromises(g);
reconcilePhaseBids(g);
reconcileRicheseBids(g);
reconcileCharityTruth(g);
reconcileRevivalPromises(g);
reconcileBribePromises(g);
reconcileGroundMovementPromises(g);
@@ -39841,6 +39935,7 @@ export function normalizeAutomaticGame(state: Game): Game {
reconcileBidPromises(g);
reconcilePhaseBids(g);
reconcileRicheseBids(g);
reconcileCharityTruth(g);
reconcileRevivalPromises(g);
reconcileBribePromises(g);
reconcileGroundMovementPromises(g);
@@ -40123,6 +40218,8 @@ function applyActionInner(
bindMixedBid: bindMixedBidTruth,
bidAnswers: bidTruthAnswers,
bindBid: bindBidTruth,
charityAnswers: charityTruthAnswers,
bindCharity: bindCharityTruth,
richeseBidAnswers: richeseBidTruthAnswers,
bindRicheseBid: bindRicheseBidTruth,
phaseBidAnswers: phaseBidTruthAnswers,
@@ -42544,6 +42641,7 @@ function applyActionInner(
p.charityTurn !== g.turn,
'Charity may be claimed only once per turn.',
);
if (g.charityPromises) g.charityPromises = charityPromiseRule(() => guardCharityClaim(g.charityPromises!,id,g.turn));
if (g.charityOrder)
g.charityOrder = charityOrderRule(() => startCharityOrder(charityOrderContext(g),g.charityOrder!,id));
p.charityTurn = g.turn;
@@ -43852,6 +43950,7 @@ function applyActionInner(
}
if (t === 'ready') {
if (g.phase === 2) {
if (g.charityPromises) charityPromiseRule(() => guardCharityReady(g.charityPromises!,id,g.turn,charityPromiseCanClaim(g,id)));
const orderBlock = nativeCharityOrderBlock(g, id);
requireRule(!orderBlock, orderBlock ?? 'Wait for your Charity turn.');
}
@@ -44129,6 +44228,11 @@ export function viewGame(state: Game, id: string, now?: number) {
}).map(player => player.id) : [],
truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null,
truthCharityTargets: charityQuestionWindow(g) ? g.players.filter(player => player.charityTurn !== g.turn).map(player => player.id) : [],
truthCharityAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'charity' ? charityTruthAnswers(g,me,g.truthtrance.question.turn) : null,
truthCharityPromises: (g.charityPromises ?? []).filter(row => row.player === id).map(row => ({...row})),
truthCharityGuide: projectedCharityTruthGuide(g,id),
truthRicheseBidTargets: richeseBidWindow(g) ? g.players.filter(player => {
const lot = g.richeseAuction!;
return lot.eligible.includes(player.id) && !lot.acted.includes(player.id);
@@ -44832,6 +44936,8 @@ export function viewGame(state: Game, id: string, now?: number) {
g.choamCharity?.turn === g.turn && g.choamCharity.canceled,
incomeHomeworld: choamPlayer ? homeworldLowBonus(g, choamPlayer.id) : 0,
orderBlocked: nativeCharityOrderBlock(g, id),
truthBlocked: liveCharityPromises(g.charityPromises ?? [],id,g.turn).some(row => !row.answer)
? 'Your Truthtrance No prevents claiming Charity this phase.' : null,
},
charityOrder: g.charityOrder ? structuredClone(g.charityOrder) : null,
moritaniAssassinate: projectedMoritaniAssassinate(g,id),
+24 -1
View File
@@ -3899,13 +3899,35 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Pure parser/budget, original Basic battle/payment/Prescience and controlled native assistance cases; wider Advanced/faction/card preparation and final deployed acceptance remain open.', evidence: ['tests/mixed-battle-question.test.ts', 'tests/mixed-action-question.test.ts', 'tests/fixture-truth-mixed-battle.ts', 'tests/truth-mixed-battle-runtime.test.ts', 'tests/truth-mixed-battle-funding.test.ts', 'tests/truth-mixed-battle-controls.test.tsx'] },
],
},
{
id: 'truthtrance-charity',
title: 'Truthtrance: Charity commitment',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Ask whether another player will claim Charity after answering during the current Basic Charity phase.',
steps: [
'Yes requires an actual native Charity claim while possible; No forbids it until the original Charity phase ends. An answer reopens that player’s concurrent Ready, but does not reopen a completed phase or count an earlier claim.',
'A private guide can suggest the exact claim or an ordinary bribe that makes the claim eligible. The bribe is paid now and received at Mentat; the claim uses the normal eligibility, payer and inflation rules.',
'A Yes remains binding while a supported claim or payment route exists. A voluntary action cannot abandon it; an external change that removes the supported route releases it. Independent future commitments may require an unknown answer until their joint planning is supported.',
'The live Basic timing and interaction with other obligations are provisional. Earlier phases, Advanced ordered Charity, other preparations and broader combinations remain unfinished.',
],
related: ['card-truthtrance', 'charity', 'truthtrance-bribe'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Native Basic claim, one-payment eligibility route, same-phase Yes/No and event-bound guide are connected; wider forecasts and combined obligations remain open.', evidence: ['game/charity-promises.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Question, recipient answers, persistent private guidance and claim/Ready reasons are connected; browser acceptance remains open.', evidence: ['components/truthtrance.tsx', 'components/truth-charity-promise.tsx', 'components/charity-claim.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Profiles use the private answer and native guided action; strategy and calibration are deferred.', evidence: ['game/bots.ts', 'tests/truth-charity-commitment-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'The FAQ source, provisional timing and bounded preparation route are recorded.', evidence: ['docs/TRUTHTRANCE_CHARITY.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused native and rendered controls cover the Basic prototype; wider combinations and deployed acceptance remain open.', evidence: ['tests/charity-promises.test.ts', 'tests/truth-charity-commitment-runtime.test.ts', 'tests/truth-charity-commitment-controls.test.tsx'] },
],
},
{
id: 'card-truthtrance',
title: 'Truthtrance',
category: 'Cards',
coverage: 'Partial',
summary:
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, named/grouped native revival, ordinary bribe-payment, current-Nexus alliance, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
'Ask another player a public yes/no question. Verified facts, supported Charity, ordinary-bid, ordinary force-revival, named/grouped native revival, ordinary bribe-payment, current-Nexus alliance, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
steps: [
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
@@ -3947,6 +3969,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-movement-expression',
'truthtrance-mixed-movement',
'truthtrance-bid',
'truthtrance-charity',
'truthtrance-revival',
'truthtrance-leader-revival',
'truthtrance-revival-expression',
+25
View File
@@ -1,3 +1,4 @@
import { liveCharityPromises } from './charity-promises';
import { createSaphoTruthOrder, passSaphoTruthOrder, SaphoTruthOrderError, type SaphoTruthOrderFrame } from './sapho-truth-order';
import { parseTravelActionClaim, type TravelClaim } from './travel-expression-promises';
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES,
@@ -87,6 +88,7 @@ export type TruthFact =
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression<MovementClaim> };
export type TruthQuestion =
| { kind: 'charity'; target: string; turn: number }
| ({ kind: 'turnActions'; target: string } & TurnActionClaim)
| ({ kind: 'travel'; target: string } & TravelClaim)
| ({ kind: 'mixedTraitorCall'; target: string } & MixedTraitorClaim)
@@ -259,6 +261,18 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
g.players.some((p) => p.id === v.target),
'Ask one other player.',
);
// A joint planner for Charity and other future-action questions is not yet
// available. Do not bind incompatible action obligations in the interim.
if (v.kind !== 'charity' && v.kind !== 'fact' && v.kind !== 'freeform' &&
g.charityPromises?.length && liveCharityPromises(g.charityPromises,v.target as string,g.turn).length)
throw new TruthError('Finish this player’s current Charity answer before binding another action question.');
if (v.kind === 'charity') {
check(Object.keys(v).sort().join(',') === 'kind,target,turn' && v.turn === g.turn &&
g.status === 'playing' && !g.advanced && g.phase === 2 && !g.charityOrder &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard,
'Choose the current quiet Basic Charity phase and its original turn.');
return { kind: 'charity', target: v.target as string, turn: g.turn };
}
if (v.kind === 'mixedRevival') {
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn &&
revivalExpressionPromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 4 &&
@@ -738,6 +752,7 @@ export function truthQuestionText(
playerName: (id: string) => string = id => id,
): string {
if (q.kind === 'freeform') return q.text;
if (q.kind === 'charity') return 'Will you claim Charity after this answer during this Charity phase?';
if (q.kind === 'revivalExpression') return `Will you ${revivalExpressionText(q.claim, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} from now until your Revival opportunity ends this turn?`;
if (q.kind === 'leaderRevival') return `Will you ${leaderRevivalClaimText(q, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))}?`;
if (q.kind === 'allianceExpression') return `Is it true that you ${allianceExpressionText(q.claim, playerName)}?`;
@@ -878,6 +893,8 @@ export function resolveTruthAction(
bindMixedBid?: (g: Game, p: Player, claim: MixedBidClaim, 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;
charityAnswers?: (g: Game, p: Player, turn: number) => TruthAnswer[];
bindCharity?: (g: Game, p: Player, turn: number, answer: boolean, asker: string) => void;
richeseBidAnswers?: (g: Game, p: Player, claim: RicheseBidClaim) => TruthAnswer[];
bindRicheseBid?: (g: Game, p: Player, claim: RicheseBidClaim, answer: boolean, asker: string) => void;
phaseBidAnswers?: (g: Game, p: Player, claim: PhaseBidClaim) => TruthAnswer[];
@@ -1066,6 +1083,9 @@ export function resolveTruthAction(
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 === 'charity')
check(effects.charityAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question.turn).length,
'Choose a Charity question compatible with the current native opportunity.');
if (w.question.kind === 'richeseBid')
check(effects.richeseBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a bidder with a remaining Richese auction opportunity.');
@@ -1202,6 +1222,11 @@ export function resolveTruthAction(
'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 === 'charity') {
check(effects.charityAnswers && effects.bindCharity && effects.charityAnswers(g, p, q.turn).includes(a.answer),
'Choose an answer consistent with this Charity opportunity and earlier answers.');
if (a.answer !== 'unknown') effects.bindCharity(g, p, q.turn, a.answer === 'yes', current.player);
}
if (q.kind === 'richeseBid') {
check(effects.richeseBidAnswers && effects.bindRicheseBid && effects.richeseBidAnswers(g, p, q).includes(a.answer),
'Choose an answer consistent with this Richese lot and your legal bid.');