Support early Advanced Charity commitments
Verify and publish checkpoint container / container (push) Successful in 11m25s

This commit is contained in:
Quintine committed 2026-10-29 09:00:38 +11:00
1 parent 68c7434a20
commit ad0abfef98
8 files changed
+294 -14

No files matched your search

+14 -2
View File
@@ -17,6 +17,18 @@ open mode gates.
## Current checkpoint and work
**Upcoming Advanced Charity Truthtrance — 29 October. Prototyped / Partial.**
In quiet Advanced Storm and Spice Blow, a Charity answer can bind the player's
upcoming ordered turn. Existing early readiness and the Charity queue remain;
the private action guide appears only on the recipient's own turn. Genuine
Advanced setups exercise Yes/No, a prior Spice Ready, actual claims and bribes,
unresolved CHOAM income, controls and four legal bot profiles. The Storm case
selects unopened Spice cards. Fifteen related files pass 81 cases, including
nine new cases; type checking, lint and production build pass. The forecast
and timing are provisional; other preparations, combinations and deployed
acceptance remain open.
[Scope](TRUTHTRANCE_CHARITY.md#upcoming-advanced-charity--29-october-2026).
**Advanced ordered Truthtrance Charity — 29 October. Prototyped / Partial.**
During live Advanced Charity, an unclaimed player whose turn remains can
answer a claim question without changing the original queue. An attainable Yes
@@ -27,8 +39,8 @@ bribes, current/future and final turns, four legal bot profiles, and Bene
Gesserit's normal and Karama-canceled claim with a conserved Truth-card swap.
Thirteen related files pass 72 cases, including 13 new cases; type checking,
lint and production build pass. The forecast and timing are provisional;
upcoming Advanced windows, broader combinations and deployed acceptance remain
open. [Scope](TRUTHTRANCE_CHARITY.md#live-advanced-ordered-charity--29-october-2026).
the follow-up above extends quiet upcoming windows; broader combinations and
deployed acceptance remain open. [Scope](TRUTHTRANCE_CHARITY.md#live-advanced-ordered-charity--29-october-2026).
**Basic leaderless preparation with Harkonnen — 29 October. Prototyped /
Partial.** Fresh classic Basic games with Harkonnen now use public native
+6 -1
View File
@@ -1010,11 +1010,16 @@ phase; do not treat an intervening native response as lost eligibility. Use the
known own purse and one legal ordinary payment as a bounded future witness,
without inventing future spice, CHOAM outcomes or foreign actions. The FAQ
supports current-turn action commitments but does not prescribe this forecast.
Other preparation paths, combined promises and upcoming Advanced timing remain separate.
Other preparation paths and combined promises remain separate; the later
Advanced forecast is recorded below.
[Source and boundaries](TRUTHTRANCE_CHARITY.md#upcoming-basic-charity--27-october-2026).
## Truthtrance Charity commitment — 27 October 2026
The provisional upcoming Charity forecast also covers quiet Advanced Storm
and Spice Blow, retaining the original ordered turn and its native continuation.
[Scope](TRUTHTRANCE_CHARITY.md#upcoming-advanced-charity--29-october-2026).
The same provisional claim commitment now includes the live Advanced ordered
Charity phase for players with an unclaimed turn remaining. The current queue
is preserved, and a private action guide appears only on the respondent's turn.
+20 -3
View File
@@ -1,5 +1,21 @@
# Truthtrance: Charity claim commitment
## Upcoming Advanced Charity — 29 October 2026
**Prototyped / Partial.** In a quiet Advanced Storm or Spice Blow window, the
existing Charity question can bind a player's upcoming ordered Charity turn.
The same detached claim or one-payment forecast uses current known resources
without drawing cards, inventing another player's actions or crediting a
pending CHOAM opening. Storm and Spice readiness remain intact; no future
claim or payment is offered as a live action. The answer carries into the
native Charity queue, where the recipient's own turn supplies the existing
claim guide and finish restrictions. A pending interruption does not itself
release an answer.
The FAQ supports current-turn action commitments; the forecast and digital
ordering are provisional. Upcoming income, additional payment routes, broader
combinations and deployed acceptance remain open.
## Live Advanced ordered Charity — 29 October 2026
**Prototyped / Partial.** In a quiet live Advanced Charity phase, a player may
@@ -12,9 +28,10 @@ the claim until that turn closes. A completed turn cannot be reopened. The
existing native claim, payment and cancellation procedures remain authoritative.
This is a provisional digital interpretation of the FAQ's current-turn
commitment, not a Charity-specific publisher ruling. Upcoming Advanced phases,
broader payment forecasts, pending responses and other combinations remain
open. Native ordered Charity and the original Basic paths remain unchanged.
commitment, not a Charity-specific publisher ruling. The follow-up above adds
quiet upcoming Advanced windows; broader payment forecasts, pending responses
and other combinations remain open. Native ordered Charity and the original
Basic paths remain unchanged.
## Upcoming Basic Charity — 27 October 2026
+3 -3
View File
@@ -17735,8 +17735,8 @@ function charityPromiseRule<T>(run: () => T): T {
}
}
function charityQuestionWindow(g: Game): boolean {
const timing = !g.advanced && g.phase >= 0 && g.phase <= 2 && !g.charityOrder ||
g.advanced && g.phase === 2 && !!g.charityOrder;
const timing = g.phase >= 0 && g.phase < 2 && !g.charityOrder ||
g.phase === 2 && (g.advanced ? !!g.charityOrder : !g.charityOrder);
return g.status === 'playing' && timing &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
(g.phase < 2 || !byFaction(g, 'choam') || g.choamCharity?.turn === g.turn);
@@ -17812,7 +17812,7 @@ function reconcileCharityTruth(g: Game,voluntary?: {actor:string;action:Action})
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.turn === turn &&
(!g.advanced && g.phase >= 0 && g.phase <= 2 ||
(g.phase >= 0 && g.phase < 2 || !g.advanced && g.phase === 2 ||
g.advanced && g.phase === 2 && !!g.charityOrder?.remaining.includes(player));
const before: CharityPromise[] = promises;
promises = charityPromiseRule(() => reconcileCharityPromises(before,player,turn,
+4 -4
View File
@@ -3974,20 +3974,20 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Ask whether another player will claim Charity during a supported Basic or live Advanced Charity opportunity.',
summary: 'Ask whether another player will claim Charity during a supported Basic or Advanced Charity opportunity.',
steps: [
'Yes requires an actual native Charity claim while possible; No forbids it through the original Basic Charity phase or the player’s Advanced ordered turn. An answer in concurrent Basic Charity reopens that player’s Ready; earlier Storm or Spice readiness and the Advanced order stay intact, and an earlier claim does not count.',
'During Charity a private guide can suggest the exact claim or an ordinary bribe that makes the claim eligible. Before Charity, the forecast offers no executable future action. 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.',
'During live Advanced Charity, an unclaimed player whose turn remains can answer without changing the order. Their private guide appears only on their own turn; No ends when that turn closes. Quiet timing and the forecast remain provisional. Upcoming Advanced Charity, other preparations, pending windows and broader combinations remain unfinished.',
'In quiet Advanced Storm or Spice Blow, an answer can bind the upcoming Charity turn without changing the current readiness. During live Advanced Charity, an unclaimed player whose turn remains can answer without changing the order. Their private guide appears only on their own turn; No ends when that turn closes. Timing and the forecast remain provisional; other preparations, pending windows and broader combinations remain unfinished.',
],
related: ['card-truthtrance', 'charity', 'truthtrance-bribe'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Native Basic upcoming/current and live Advanced ordered claims, one-payment route, turn-bound 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: 'Implementation', status: 'Partial', detail: 'Native Basic and Advanced upcoming/current claims, one-payment route, turn-bound 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 and ordered Advanced prototypes; 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', 'tests/truth-charity-early-runtime.test.ts', 'tests/truth-charity-early-controls.test.tsx', 'tests/truth-charity-ordered-runtime.test.ts', 'tests/truth-charity-ordered-controls.test.tsx', 'tests/truth-charity-ordered-bg.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused native and rendered controls cover Basic and Advanced upcoming and ordered prototypes; 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', 'tests/truth-charity-early-runtime.test.ts', 'tests/truth-charity-early-controls.test.tsx', 'tests/truth-charity-ordered-runtime.test.ts', 'tests/truth-charity-ordered-controls.test.tsx', 'tests/truth-charity-ordered-bg.test.ts', 'tests/truth-charity-advanced-early.test.ts', 'tests/truth-charity-advanced-early-controls.test.tsx'] },
],
},
{
+2 -1
View File
@@ -294,7 +294,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
throw new TruthError('Finish this player’s conditional battle 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 >= 0 && g.phase <= 2 && !g.charityOrder ||
g.status === 'playing' && (g.phase >= 0 && g.phase < 2 && !g.charityOrder ||
!g.advanced && g.phase === 2 && !g.charityOrder ||
g.advanced && g.phase === 2 && !!g.charityOrder?.remaining.includes(v.target as string)) &&
g.players.find(player => player.id === v.target)?.charityTurn !== g.turn &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard,
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type Game } from '../game/engine';
import { createTruthTurnAmalFixture } from './fixture-truth-turn-amal';
import { actTruthCharityPayments as act, advanceTruthCharityPayments as advance,
createTruthCharityPaymentsFixture, truthCharityPaymentsClean as clean,
truthCharityPaymentsInventory as inventory } 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 {Truthtrance} = await import('../components/truthtrance');
const {TruthCharityPromise} = await import('../components/truth-charity-promise');
aliases.deregister();
/** Actual Advanced setup and original Truth cards; phase 1 also plays Amal.
* The native fixture selects only unopened starting cards and Spice order. */
function fixture(phase:0|1) {
const base = phase === 0 ? createTruthTurnAmalFixture({phase:0,advanced:true})
: createTruthCharityPaymentsFixture({source:'afterAmal',advanced:true});
const game = phase === 0
? advance(structuredClone(base.game), state => state.phase === 0 && clean(state), [], true)
: structuredClone(base.game);
assert.equal(game.phase,phase); assert.equal(game.advanced,true); assert.ok(clean(game));
assert.deepEqual(inventory(game),base.originalCards);
return {...base,game};
}
function declare(f:ReturnType<typeof fixture>):Game {
let game = act(f.game,f.asker,{type:'card',card:f.truths[0]});
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = act(game,actor.id,{type:'truthPass'});
}
assert.equal(game.truthtrance?.stage,'ask'); return game;
}
for (const phase of [0,1] as const) void test(
`Advanced phase ${phase} presents the upcoming Charity question and private guidance`, () => {
const f = fixture(phase), declared = declare(f), asker = viewGame(declared,f.asker);
const questionMarkup = renderToStaticMarkup(createElement(Truthtrance,{game:asker,busy:false,act() {}}));
assert.ok(asker.truthCharityTargets.includes(f.target));
assert.match(questionMarkup,/<option value="charity">[^<]*[Cc]harity/);
const asked = act(declared,f.asker,{type:'truthAsk',question:{kind:'charity',target:f.target,turn:declared.turn}});
const target = viewGame(asked,f.target);
assert.ok(target.truthCharityAnswers?.includes('yes'));
assert.ok(target.truthCharityAnswers?.includes('no'));
assert.equal(viewGame(asked,f.asker).truthCharityAnswers,null);
assert.equal(viewGame(asked,f.observer).truthCharityAnswers,null);
const bound = act(asked,f.target,{type:'truthAnswer',answer:'yes'});
const own = viewGame(bound,f.target), observer = viewGame(bound,f.observer);
assert.deepEqual(bound.ready,asked.ready);
assert.equal(own.truthCharityGuide,null);
assert.ok(own.truthCharityPromises.some(promise => promise.answer && !promise.fulfilled && !promise.released));
const markup = renderToStaticMarkup(createElement(TruthCharityPromise,
{game:own,busy:false,send() { assert.fail('Rendering must not send.'); }}));
assert.match(markup,/upcoming Charity phase/);
assert.match(markup,/Charity has not opened yet/);
assert.doesNotMatch(markup,/data-testid="charity-promise-guide"/);
assert.equal(observer.truthCharityPromises.length,0);
assert.equal(observer.truthCharityGuide,null);
assert.deepEqual(inventory(bound),f.originalCards);
},
);
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
import { createTruthTurnAmalFixture } from './fixture-truth-turn-amal';
import { actTruthCharityPayments as act, advanceTruthCharityPayments as advance,
createTruthCharityPaymentsFixture, truthCharityPaymentsClean as clean,
truthCharityPaymentsInventory as inventory, truthCharityPaymentsPlayer as player } from './fixture-truth-charity-payments';
import { actTruthtranceChoamPromises as actChoam,
createTruthtranceChoamPromisesFixture as choamFixture,
truthtranceChoamPromisesInventory as choamInventory,
truthtranceChoamPromisesPlayer as choamPlayer } from './fixture-truthtrance-choam-promises';
/** Genuine Advanced selected-Ix public start and original starting cards. Storm
* uses a quiet opening after real confirmations; Spice uses the actual Amal
* and unopened native Spice ordering. The Storm continuation selects a safe
* unopened Spice prefix. No wallet, readiness, promise or phase is installed. */
function fixture(phase:0|1) {
const base = phase === 0 ? createTruthTurnAmalFixture({phase:0,advanced:true})
: createTruthCharityPaymentsFixture({source:'afterAmal',advanced:true});
const game = phase === 0
? advance(structuredClone(base.game), state => state.phase === 0 && clean(state), [], true)
: structuredClone(base.game);
assert.equal(game.phase,phase); assert.equal(game.advanced,true); assert.ok(clean(game));
assert.equal(player(game,base.target).charityTurn,undefined);
assert.deepEqual(inventory(game),base.originalCards);
return {...base,game,initialPhase:phase,
charityQuestion:{kind:'charity' as const,target:base.target,turn:game.turn}};
}
function ask(f:ReturnType<typeof fixture>, source = f.game):Game {
let game = act(source,f.asker,{type:'card',card:f.truths[0]});
while (game.truthtrance?.stage === 'priority') {
const next = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(next);
game = act(game,next.id,{type:'truthPass'});
}
assert.equal(game.truthtrance?.stage,'ask');
return act(game,f.asker,{type:'truthAsk',question:f.charityQuestion});
}
function bind(f:ReturnType<typeof fixture>, answer:'yes'|'no', source = f.game) {
const asked = ask(f,source);
assert.ok(viewGame(asked,f.target).truthCharityAnswers?.includes(answer));
return {asked, game:act(asked,f.target,{type:'truthAnswer',answer})};
}
void test('an early Advanced Charity answer preserves a real prior Spice Blow Ready', () => {
const f=fixture(1);
const blow=advance(f.game,state => !!state.spiceWindow && clean(state),[],true);
const ready=act(blow,f.target,{type:'ready'});
assert.ok(ready.ready.includes(f.target));
const {asked,game:bound}=bind(f,'yes',ready);
assert.ok(asked.ready.includes(f.target));
assert.deepEqual(bound.ready,asked.ready);
let game=reachCharity(bound);
assert.ok(game.charityOrder);
if (game.charityOrder.remaining[0]!==f.target) assert.equal(viewGame(game,f.target).truthCharityGuide,null);
game=ownTurn(game,f.target);
assert.ok(viewGame(game,f.target).truthCharityGuide);
assert.deepEqual(inventory(game),f.originalCards);
});
function reachCharity(source:Game):Game {
let game = source;
if (game.phase === 0) {
game = advance(game,beforeIxSpiceDraw,[],true);
orderIxSpiceCards(game,[{territory:'hagga_basin'},{territory:'broken_land'}]);
}
return advance(game,state => state.phase === 2 && !!state.charityOrder && clean(state),[],true);
}
function ownTurn(source:Game,target:string):Game {
let game=source;
for (let count=0; game.phase===2 && game.charityOrder?.remaining[0]!==target && count<6; count++) {
const actor=game.charityOrder?.remaining[0]; assert.ok(actor);
game=act(game,actor,{type:'ready'});
}
assert.equal(game.phase,2); assert.equal(game.charityOrder?.remaining[0],target);
return game;
}
function reject(game:Game,actor:string,action:Action) {
const before=structuredClone(game);
assert.throws(() => act(game,actor,action)); assert.deepEqual(game,before);
}
for (const phase of [0,1] as const) {
void test(`Advanced phase ${phase} Charity Yes survives openings and the ordered queue`, () => {
const f=fixture(phase), {asked,game:bound}=bind(f,'yes');
assert.deepEqual(bound.ready,asked.ready);
assert.equal(viewGame(bound,f.target).truthCharityGuide,null);
assert.ok(viewGame(bound,f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released));
assert.equal(bound.discard.filter(card => card.id===f.truths[0]).length,1);
let game=reachCharity(bound);
assert.equal(game.phase,2); assert.ok(game.charityOrder);
assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released));
if (game.charityOrder.remaining[0]!==f.target) assert.equal(viewGame(game,f.target).truthCharityGuide,null);
game=ownTurn(game,f.target);
reject(game,f.target,{type:'ready'});
let guide=viewGame(game,f.target).truthCharityGuide; assert.ok(guide);
game=act(game,f.target,guide.action);
if (guide.action.type==='bribe') {
guide=viewGame(game,f.target).truthCharityGuide; assert.ok(guide);
assert.equal(guide.action.type,'charity'); game=act(game,f.target,guide.action);
}
assert.equal(player(game,f.target).charityTurn,game.turn);
assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => row.answer && row.fulfilled));
assert.deepEqual(inventory(game),f.originalCards);
});
void test(`Advanced phase ${phase} Charity No remains until the respondent's ordered turn closes`, () => {
const f=fixture(phase), {asked,game:bound}=bind(f,'no');
assert.deepEqual(bound.ready,asked.ready);
assert.equal(viewGame(bound,f.target).truthCharityGuide,null);
let game=ownTurn(reachCharity(bound),f.target);
assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => !row.answer && !row.fulfilled && !row.released));
const amount=player(game,f.target).spice-1;
if (amount>0) game=act(game,f.target,{type:'bribe',target:f.asker,amount});
assert.ok(player(game,f.target).spice<2);
reject(game,f.target,{type:'charity'});
game=act(game,f.target,{type:'ready'});
assert.ok(game.phase!==2 || !game.charityOrder?.remaining.includes(f.target));
assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => !row.answer && row.fulfilled));
assert.deepEqual(inventory(game),f.originalCards);
});
}
void test('all four bots can answer before Advanced Charity and use the later legal guide', () => {
const f=fixture(1), asked=ask(f);
for (const difficulty of DIFFICULTIES) {
const response=viewGame(asked,f.target);
response.players.find(row => row.id===f.target)!.bot=difficulty;
const answer=botActions(response)[0]; assert.ok(answer);
assert.equal(answer.type,'truthAnswer');
assert.ok(response.truthCharityAnswers?.includes(answer.answer as 'yes'|'no'));
let game=act(structuredClone(asked),f.target,{type:'truthAnswer',answer:'yes'});
assert.equal(viewGame(game,f.target).truthCharityGuide,null);
game=ownTurn(reachCharity(game),f.target);
const own=viewGame(game,f.target); own.players.find(row => row.id===f.target)!.bot=difficulty;
const guide=own.truthCharityGuide; assert.ok(guide);
assert.deepEqual(botActions(own)[0],guide.action);
game=act(game,f.target,guide.action);
const claim=viewGame(game,f.target).truthCharityGuide;
if (claim) game=act(game,f.target,claim.action);
assert.equal(player(game,f.target).charityTurn,game.turn);
assert.deepEqual(inventory(game),f.originalCards);
}
});
void test('an early Advanced CHOAM question does not invent unresolved opening income', () => {
const f=choamFixture({advanced:true}), source=structuredClone(f.game);
assert.equal(source.phase,0); assert.equal(source.choamCharity,undefined);
assert.equal(choamPlayer(source,f.target).spice,f.rawCash);
let game=actChoam(source,f.asker,{type:'card',card:f.truth});
while (game.truthtrance?.stage==='priority') {
const actor=game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor);
game=actChoam(game,actor.id,{type:'truthPass'});
}
game=actChoam(game,f.asker,{type:'truthAsk',question:{kind:'charity',target:f.target,turn:game.turn}});
const own=viewGame(game,f.target);
assert.ok(own.truthCharityAnswers?.includes('no'));
assert.ok(own.truthCharityAnswers?.includes('unknown'));
assert.ok(!own.truthCharityAnswers?.includes('yes'));
const before=structuredClone(game);
assert.throws(() => actChoam(game,f.target,{type:'truthAnswer',answer:'yes'}));
assert.deepEqual(game,before);
game=actChoam(game,f.target,{type:'truthAnswer',answer:'no'});
assert.equal(choamPlayer(game,f.target).spice,f.rawCash);
assert.equal(game.choamCharity,undefined);
assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => !row.answer && !row.fulfilled && !row.released));
assert.deepEqual(choamInventory(game),f.originalInventory);
});