+ You answered {promise.answer ? 'Yes' : 'No'} to claiming Charity in this phase.
+ {' '}{promise.released ? 'Released: this commitment is no longer possible.'
+ : promise.fulfilled ? 'Fulfilled: this commitment is complete.' : 'This answer remains binding while possible.'}
+
)}
+
Yes requires an actual eligible claim after the answer; No forbids claiming during the rest of this Charity phase. Earlier claims do not count.
+ {guide && action?.type === 'bribe' &&
The quoted payment can make you eligible for Charity. You pay immediately; the other player receives the bribe at the start of Mentat Pause.
This asks whether you will claim Charity after your answer in this Charity phase. Yes requires a legal claim while it remains possible; No means you cannot claim during this phase. Native eligibility and payment rules still apply.
Yes requires a qualifying bid in your one remaining opportunity while possible. No forbids a qualifying bid. Silent amounts remain sealed until all submit; winning still requires payment.
@@ -600,6 +608,7 @@ export function Truthtrance({
+
@@ -745,6 +754,12 @@ export function Truthtrance({
{!g.truthRevivalTargets.includes(target) &&
Choose a respondent whose native Revival opportunity remains this turn and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported first.
Ask whether the selected player will claim Charity after answering during this phase. Earlier claims do not count; native eligibility and payment still apply.
+ {!g.truthCharityTargets.includes(target) &&
Choose a player with a supported remaining Charity opportunity.
- Supported battle-plan, Nexus-alliance, bid, force/leader and grouped revival, bribe, movement and reserve-shipment
+ Supported Charity, battle-plan, Nexus-alliance, bid, force/leader and grouped revival, bribe, movement and reserve-shipment
answers are enforced. Current-turn freeform promises remain binding when possible,
but their automatic enforcement is unfinished.
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index be959288..aa2da0f4 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -20,6 +20,20 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
+**Truthtrance Charity commitment — 27 October. Prototyped / Partial.**
+A current concurrent Basic Charity question can bind whether the target claims
+after answering. A Yes requires an attainable native claim; No prevents a claim
+until the phase closes. Private legal choices and guidance, the ordinary claim
+control and minimal legal AI use the same native entitlement. An event-bound
+guide can quote a real ordinary payment to reach eligibility before claiming,
+including after another player changes the target's funds. Voluntary and
+external loss of opportunity differ. Other future routes and unknown answers
+requiring joint planning remain partial; earlier-phase questions, Advanced,
+wider combinations and deployed acceptance remain open. The general
+FAQ supports current-turn questions; this particular binding procedure remains
+provisional. Fourteen related files pass 187/187; type/lint and build pass.
+[Scope and source](TRUTHTRANCE_CHARITY.md#live-basic-charity--27-october-2026).
+
**Sapho Truthtrance order in Richese cache auctions — 27 October. Prototyped / Partial.**
The public question-order opportunity also opens during a clean Once Around or
Silent cache lot. The same lot, bidder order, funding and aid continue after the
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 95dc8afc..e0439bdf 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,25 @@
# Dune implementation status
+## 27 October 2026 — Truthtrance Charity commitment
+
+Connect an explicit current Basic Charity claim question to target-only answer
+quotes, a saved binding, real native claim/Ready enforcement, player guidance
+and minimal legal AI. Yes uses the actual claim; No persists until phase close.
+Voluntary loss of an attainable Yes rejects and external impossibility releases
+it. A detached quote can use a native ordinary payment to reach eligibility;
+the event- and turn-bound guide sends the exact bribe or claim without paying
+on the quote. An external funds change retains Yes when this route remains.
+Other preparation, unknown answers and independent action combinations needing
+a joint planner remain partial.
+The source fixture uses original cards, genuine Amal and an actual four-spice
+bribe, with no reassigned wallets. This is provisional, limited to the current
+concurrent Basic phase; Advanced, earlier-phase and wider combinations plus
+browser/deployed acceptance remain open. Fourteen related files pass 187/187;
+type/lint and build pass. The external-income supplement uses a labeled,
+conserved controlled transfer. Private proof is
+`20261027-truth-charity-claim-promise-proof-v2/`.
+[Contract](TRUTHTRANCE_CHARITY.md#live-basic-charity--27-october-2026).
+
## 27 October 2026 — Sapho Truthtrance order in Richese cache auctions
Extend the public simultaneous-question ordering opportunity to clean live
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index d817c842..aae93480 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,19 @@
# Rule decision index
+## Truthtrance Charity commitment — 27 October 2026
+
+Provisionally bind a question about claiming after the answer in the current
+concurrent Basic Charity phase. Yes may use a quoted native ordinary payment
+to reach the actual claim entitlement; No prohibits claiming until phase close.
+The event- and turn-bound guide retains the exact bribe or claim. Preserve a
+feasible Yes against voluntary abandonment, including after an external funds
+change if the payment route survives, and release it when an external change
+makes every supported route impossible. The general FAQ describes action
+commitments but does not specify this Charity implementation. Other eligibility
+routes, unknown answers needing joint planning, earlier-phase questions and
+Advanced ordering remain separate.
+[Source and boundaries](TRUTHTRANCE_CHARITY.md#live-basic-charity--27-october-2026).
+
## Sapho Truthtrance order during Richese cache auctions — 27 October 2026
Provisionally apply the simultaneous-question ordering response to a clean
diff --git a/docs/TRUTHTRANCE_CHARITY.md b/docs/TRUTHTRANCE_CHARITY.md
new file mode 100644
index 00000000..7eb31dcb
--- /dev/null
+++ b/docs/TRUTHTRANCE_CHARITY.md
@@ -0,0 +1,56 @@
+# Truthtrance: Charity claim commitment
+
+## Live Basic Charity — 27 October 2026
+
+**Prototyped / Partial.** A Truthtrance holder may ask another player whether
+that player will claim Charity **after the answer** in the currently open,
+concurrent Basic Charity phase. The [GF9 November 2020 FAQ, page 8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8)
+supports current-turn questions about a player's actions while they remain
+possible; it does not provide a Charity-specific worked example. This precise
+online binding and release procedure is a provisional interpretation.
+
+The target's answer quote uses the native current claim entitlement and payer,
+including the original faction, turn, claim status, funds and any resolved CHOAM
+opening. Yes is offered when the native claim is possible now or the player
+can make a quoted native ordinary payment to reach eligibility, provided no
+earlier live No conflicts. No is available when no earlier live Yes conflicts.
+The quote does not invent future funding or unverified preparations beyond the
+supported payment route. A player cannot be asked again about a claim already
+completed that turn. Unknown answers and conflicting independent action
+families requiring a joint future-action planner remain partial.
+
+A definite answer reopens that player's concurrent Basic Ready, if already
+marked, without reopening a completed phase. The actual native Charity action
+fulfills Yes and pays through the existing rules; Ready cannot voluntarily close
+an attainable Yes. No forbids the native claim for the rest of the original
+Charity phase, including if a later response reopens Ready. A voluntary action
+cannot abandon an attainable Yes; after another player changes the target's
+funds, an existing Yes remains binding if the supported ordinary-payment route
+still makes the claim possible. A genuinely external change that removes every
+supported opportunity releases it, while a No completes at phase close. Pending
+responses are not treated as proof of impossibility. Ordinary eligibility,
+inflation, payment and the original once-per-turn claim remain authoritative.
+
+The respondent sees their available answers and persistent guidance; a live No
+explains why the ordinary claim control is disabled, while an attainable Yes
+provides an event- and turn-bound guide to the exact native claim or ordinary
+bribe. The payer spends the bribe immediately and the recipient receives it at
+Mentat Pause. AI uses the same target-only quote and legal action. Questions and
+answers are public as in the existing
+Truthtrance history; private receipts and target-only guide are not projected
+to other seats. This is a usable first version, not full enforcement of all
+Charity questions or a complete implementation of Truthtrance.
+
+The native fixture `tests/fixture-truth-charity-commitment.ts` starts original
+Basic classic factions with the selected Ix deck and original Truth cards,
+uses genuine Amal and Charity chronology, and makes a real four-spice bribe to
+leave the target at one spice. It does not install new wallets, cards, phase or
+readiness. A separate external-income test uses a labeled, conserved controlled
+transfer before real Ready, bribe and claim actions. Fourteen related files pass
+187/187; type/lint and build pass. Server-rendered controls are exercised, but
+that is not browser or deployed acceptance.
+
+Questions earlier than the live phase, other preparation or later eligibility
+forecasts, Advanced ordered Charity, broader overlapping promises,
+optional-module and pending-response combinations, full games, browser and
+deployed acceptance remain open. No independent joint planner is introduced.
diff --git a/game/bots.ts b/game/bots.ts
index 462f1310..16f51c45 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -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' }];
diff --git a/game/charity-promises.ts b/game/charity-promises.ts
new file mode 100644
index 00000000..f91dee47
--- /dev/null
+++ b/game/charity-promises.ts
@@ -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();
+ 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(
+ 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(
+ 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(
+ 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;
+ });
+}
diff --git a/game/engine.ts b/game/engine.ts
index 9f1b6bce..47a2a321 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -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(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(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),
diff --git a/game/reference.ts b/game/reference.ts
index 7f83c9bf..d6ffbd53 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -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',
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index fbb2c4eb..9a8aeb9b 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -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 };
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.');
diff --git a/tests/charity-promises.test.ts b/tests/charity-promises.test.ts
new file mode 100644
index 00000000..a56b2fbf
--- /dev/null
+++ b/tests/charity-promises.test.ts
@@ -0,0 +1,87 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import {
+ bindCharityPromise, charityPromiseAnswers, CharityPromiseError,
+ guardCharityClaim, guardCharityReady, liveCharityPromises, reconcileCharityPromises, validateCharityPromises,
+ type CharityPromise,
+} from '../game/charity-promises';
+
+const ctx = { player: 'emperor', asker: 'atreides', turn: 3 };
+const empty: CharityPromise[] = [];
+const bind = (rows: readonly CharityPromise[], answer: boolean, canClaim = true) =>
+ bindCharityPromise(rows, ctx.player, ctx.asker, ctx.turn, answer, canClaim);
+
+void test('Charity answer availability follows actual eligibility and incompatible earlier answers', () => {
+ assert.deepEqual(charityPromiseAnswers(empty, ctx.player, ctx.turn, true), ['yes', 'no']);
+ assert.deepEqual(charityPromiseAnswers(empty, ctx.player, ctx.turn, false), ['no']);
+ assert.deepEqual(charityPromiseAnswers(bind(empty, true), ctx.player, ctx.turn, true), ['yes']);
+ assert.deepEqual(charityPromiseAnswers(bind(empty, false), ctx.player, ctx.turn, true), ['no']);
+ assert.throws(() => bind(bind(empty, true), false), CharityPromiseError);
+ assert.throws(() => bind(bind(empty, false), true), CharityPromiseError);
+ assert.throws(() => bind(empty, true, false), CharityPromiseError);
+ assert.throws(() => bindCharityPromise(empty, ctx.player, ctx.player, ctx.turn, false, true), CharityPromiseError);
+ assert.deepEqual(empty, []);
+});
+
+void test('a Yes blocks ready until the actual claim and a No blocks the actual claim', () => {
+ const yes = bind(bind(empty, true), true);
+ assert.throws(() => guardCharityReady(yes, ctx.player, ctx.turn, true), CharityPromiseError);
+ assert.doesNotThrow(() => guardCharityReady(yes, ctx.player, ctx.turn, false));
+ const claimed = guardCharityClaim(yes, ctx.player, ctx.turn);
+ assert.equal(claimed.every(row => row.fulfilled && !row.released), true);
+ assert.equal(yes.every(row => !row.fulfilled), true, 'the original input is unchanged');
+ assert.doesNotThrow(() => guardCharityReady(claimed, ctx.player, ctx.turn, true));
+ assert.throws(() => guardCharityClaim(bind(empty, false), ctx.player, ctx.turn), CharityPromiseError);
+});
+
+void test('an external loss releases Yes, while a voluntary loss or phase exit rejects unchanged', () => {
+ const yes = bind(empty, true);
+ for (const phaseOpen of [true, false]) {
+ assert.throws(() => reconcileCharityPromises(yes, ctx.player, ctx.turn,
+ { phaseOpen, canClaim: false, change: 'voluntary' }), CharityPromiseError);
+ assert.equal(yes[0].released, false);
+ const released = reconcileCharityPromises(yes, ctx.player, ctx.turn,
+ { phaseOpen, canClaim: false, change: 'external' });
+ assert.equal(released[0].released, true);
+ assert.equal(released[0].fulfilled, false);
+ }
+});
+
+void test('No survives Basic ready while the phase is open and finishes at phase close', () => {
+ const no = bind(empty, false);
+ const ready = reconcileCharityPromises(no, ctx.player, ctx.turn,
+ { phaseOpen: true, canClaim: true, change: 'voluntary' });
+ assert.deepEqual(liveCharityPromises(ready, ctx.player, ctx.turn), no);
+ assert.throws(() => guardCharityClaim(ready, ctx.player, ctx.turn), CharityPromiseError);
+ const closed = reconcileCharityPromises(ready, ctx.player, ctx.turn,
+ { phaseOpen: false, canClaim: false, change: 'voluntary' });
+ assert.equal(closed[0].fulfilled, true);
+ assert.equal(closed[0].released, false);
+ assert.deepEqual(liveCharityPromises(closed, ctx.player, ctx.turn), []);
+});
+
+void test('promises for other players, turns and completed answers are unaffected', () => {
+ const yes = bind(empty, true);
+ const other = bindCharityPromise(yes, 'guild', 'atreides', 3, true, true);
+ const later = bindCharityPromise(other, 'emperor', 'atreides', 4, false, true);
+ const settled = guardCharityClaim(later, ctx.player, ctx.turn);
+ assert.equal(settled[0].fulfilled, true);
+ assert.deepEqual(settled.slice(1), later.slice(1));
+ assert.deepEqual(charityPromiseAnswers(settled, ctx.player, ctx.turn, true), ['yes', 'no']);
+ assert.throws(() => liveCharityPromises(settled, ctx.player, 0), CharityPromiseError);
+});
+
+void test('every answer has a stable unique receipt event through claim, release and closure', () => {
+ const yes = bindCharityPromise(empty, ctx.player, ctx.asker, ctx.turn, true, true, 'event-yes');
+ assert.equal(yes[0].event, 'event-yes');
+ assert.equal(guardCharityClaim(yes, ctx.player, ctx.turn)[0].event, 'event-yes');
+ assert.equal(reconcileCharityPromises(yes, ctx.player, ctx.turn,
+ { phaseOpen: true, canClaim: false, change: 'external' })[0].event, 'event-yes');
+ const no = bindCharityPromise(yes, 'guild', ctx.asker, ctx.turn, false, true, 'event-no');
+ assert.equal(reconcileCharityPromises(no, 'guild', ctx.turn,
+ { phaseOpen: false, canClaim: false, change: 'voluntary' })[1].event, 'event-no');
+ assert.throws(() => bindCharityPromise(yes, 'guild', ctx.asker, ctx.turn, true, true, 'event-yes'), CharityPromiseError);
+ assert.throws(() => validateCharityPromises([{ ...yes[0], event: '' }]), CharityPromiseError);
+ assert.throws(() => validateCharityPromises([{ ...yes[0], fulfilled: true, released: true }]), CharityPromiseError);
+ assert.ok(bind(empty, false)[0].event, 'normal binding assigns an event');
+});
diff --git a/tests/choam-charity.test.ts b/tests/choam-charity.test.ts
index 8cc3ce2f..6b387311 100644
--- a/tests/choam-charity.test.ts
+++ b/tests/choam-charity.test.ts
@@ -355,6 +355,7 @@ void test('charity projections expose the payer and own entitlement without leak
incomeCanceled: false,
incomeHomeworld: 0,
orderBlocked: null,
+ truthBlocked: null,
});
assert.equal(v.players[0].spice, undefined);
assert.equal(v.players[0].handLimit, 5);
diff --git a/tests/fixture-truth-charity-commitment.ts b/tests/fixture-truth-charity-commitment.ts
new file mode 100644
index 00000000..3d0be2ef
--- /dev/null
+++ b/tests/fixture-truth-charity-commitment.ts
@@ -0,0 +1,45 @@
+import assert from 'node:assert/strict';
+import { charityQuote } from '../game/charity';
+import { type Action, type Game, viewGame } from '../game/engine';
+import { type TruthQuestion } from '../game/truthtrance';
+import {
+ actTruthCharityPayments as act,
+ createTruthCharityPaymentsFixture,
+ declareTruthCharityPayments,
+ truthCharityPaymentsInventory as inventory,
+ truthCharityPaymentsPlayer as player,
+} from './fixture-truth-charity-payments';
+
+export { act, inventory, player };
+
+/** Original Basic classic factions with the selected Ix deck and original
+ * Truth cards, genuine Amal and Charity chronology. The only additional
+ * preparation is a real four-spice bribe, leaving the target at one spice;
+ * no wallet, cards, phase or readiness are installed by the fixture. */
+export function createTruthCharityCommitmentFixture() {
+ const base = createTruthCharityPaymentsFixture({ source: 'phase2', advanced: false });
+ assert.equal(base.game.phase, 2);
+ assert.equal(player(base.game, base.target).spice, 5);
+ const game = act(base.game, base.target, { type: 'bribe', target: base.asker, amount: 4 });
+ assert.equal(player(game, base.target).spice, 1);
+ assert.equal(charityQuote(game, player(game, base.target)).total, 1);
+ assert.deepEqual(inventory(game), base.originalCards);
+ return { ...base, game, unprepared: structuredClone(base.game), source: structuredClone(game),
+ charityQuestion: { kind: 'charity', target: base.target, turn: game.turn } satisfies TruthQuestion };
+}
+export type TruthCharityCommitmentFixture = ReturnType;
+
+export function askTruthCharityCommitment(f: TruthCharityCommitmentFixture, source = f.game, copy: 0 | 1 = 0) {
+ const declared = declareTruthCharityPayments(f, source, copy).game;
+ return act(declared, f.asker, { type: 'truthAsk', question: f.charityQuestion });
+}
+export function bindTruthCharityCommitment(f: TruthCharityCommitmentFixture, answer: 'yes' | 'no', source = f.game, copy: 0 | 1 = 0) {
+ const asked = askTruthCharityCommitment(f, source, copy);
+ assert.ok(viewGame(asked, f.target).truthCharityAnswers?.includes(answer));
+ return { asked, game: act(asked, f.target, { type: 'truthAnswer', answer }) };
+}
+export function rejectTruthCharityCommitment(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => act(game, actor, action));
+ assert.deepEqual(game, before, 'Rejected actions preserve the input');
+}
diff --git a/tests/truth-charity-commitment-controls.test.tsx b/tests/truth-charity-commitment-controls.test.tsx
new file mode 100644
index 00000000..e8d44585
--- /dev/null
+++ b/tests/truth-charity-commitment-controls.test.tsx
@@ -0,0 +1,80 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { registerHooks } from 'node:module';
+import { readFileSync } from 'node:fs';
+import { Children, createElement, isValidElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { viewGame, type Action, type GameView } from '../game/engine';
+import { act, askTruthCharityCommitment as ask, bindTruthCharityCommitment as bind,
+ createTruthCharityCommitmentFixture as fixture, inventory, player, rejectTruthCharityCommitment as reject } from './fixture-truth-charity-commitment';
+import { declareTruthCharityPayments } from './fixture-truth-charity-payments';
+
+const aliases = registerHooks({
+ resolve(specifier, context, next) {
+ return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
+ },
+ load(url, context, next) {
+ if (!url.endsWith('.module.css')) return next(url, context);
+ const css = readFileSync(new URL(url), 'utf8');
+ const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
+ return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
+ },
+});
+const { GameTable } = await import('../components/game-table');
+const { Truthtrance } = await import('../components/truthtrance');
+const { TruthCharityPromise } = await import('../components/truth-charity-promise');
+aliases.deregister();
+const table = (game: GameView) => renderToStaticMarkup(createElement(GameTable, { game, busy: false, send: async () => {}, onExit() {} }));
+const truth = (game: GameView) => renderToStaticMarkup(createElement(Truthtrance, { game, busy: false, act() {} }));
+
+void test('Basic native Charity question, private answer controls and guided claim appear in the real table', () => {
+ const f = fixture(); const declared = declareTruthCharityPayments(f).game;
+ const asker = viewGame(declared, f.asker);
+ assert.ok(asker.truthCharityTargets.includes(f.target));
+ assert.match(truth(asker), /