Support Truthtrance in ordered Charity
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 08:57:58 +11:00
1 parent 31c6e79c80
commit 68c7434a20
11 files changed
+425 -19

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { actAdvancedCharityOrder as act, advancedCharityOrderInventory as inventory,
advancedCharityOrderPlayer as player, allowAdvancedCharityOpening as allow,
createAdvancedCharityOrderFixture, passAdvancedCharityResponses as pass } from './fixture-advanced-charity-order';
let cached: ReturnType<typeof createAdvancedCharityOrderFixture> | undefined;
const source = () => cached ??= createAdvancedCharityOrderFixture();
/** Genuine Advanced CHOAM/Bene Gesserit setup, phase opening and original
* ordered Charity. Exchange one conserved physical Truthtrance into CHOAM's
* hand for an existing card; no wallet, order, response or promise is staged. */
function beforeAnswer() {
const f = source(); let game = allow(structuredClone(f.game));
const asker = player(game,'choam'), target = 'beneGesserit';
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
assert.ok(asker.hand.length > 0);
[asker.hand[0],game.deck[index]] = [game.deck[index],asker.hand[0]];
const truth = asker.hand[0].id;
assert.ok(player(game,target).spice >= 2);
assert.ok(game.charityOrder?.remaining.includes(target));
assert.deepEqual(inventory(game),f.original);
game = act(game,'choam',{type:'card',card:truth});
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
game = act(game,actor.id,{type:'truthPass'});
}
assert.equal(game.truthtrance?.stage,'ask');
game = act(game,'choam',{type:'truthAsk',question:{kind:'charity',target,turn:game.turn}});
assert.ok(viewGame(game,target).truthCharityAnswers?.includes('yes'));
game = act(game,target,{type:'truthAnswer',answer:'yes'});
const promise = viewGame(game,target).truthCharityPromises.find(row => row.answer); assert.ok(promise);
return {f,game,target,truth,event:promise.event};
}
function reachBeneGesserit(sourceGame:Game):Game {
let game = sourceGame;
for (let count = 0; game.charityOrder?.remaining[0] !== 'beneGesserit' && count < 4; count++) {
const actor = game.charityOrder?.remaining[0]; assert.ok(actor);
game = act(game,actor,{type:'ready'});
}
assert.equal(game.charityOrder?.remaining[0],'beneGesserit');
return game;
}
for (const cancel of [false,true]) void test(
`Advanced wealthy Bene Gesserit promised Charity retains its native ${cancel ? 'cancellation' : 'payment'} response`, () => {
const f = beforeAnswer(); let game = reachBeneGesserit(f.game);
const bgSpice = player(game,f.target).spice, choamSpice = player(game,'choam').spice;
const order = structuredClone(game.charityOrder), guide = viewGame(game,f.target).truthCharityGuide;
assert.ok(guide); assert.equal(guide.action.type,'charity');
game = act(game,f.target,guide.action);
assert.equal(game.response?.kind,'bgCharity');
assert.equal(game.response?.owner,f.target);
assert.deepEqual(game.charityOrder,order);
assert.equal(player(game,f.target).charityTurn,game.turn);
assert.equal(viewGame(game,f.target).truthCharityPromises.find(row => row.event === f.event)?.fulfilled,true);
assert.equal(player(game,f.target).spice,bgSpice);
if (cancel) {
assert.ok(player(game,'atreides').hand.some(card => card.id === f.f.karama));
game = act(game,'atreides',{type:'card',card:f.f.karama,mode:'cancel'});
} else game = pass(game);
assert.equal(game.response ?? null,null);
assert.deepEqual(game.charityOrder,order);
assert.equal(player(game,f.target).spice,bgSpice + (cancel ? 0 : 2));
assert.equal(player(game,'choam').spice,choamSpice - (cancel ? 0 : 2));
assert.equal(viewGame(game,f.target).truthCharityPromises.find(row => row.event === f.event)?.fulfilled,true);
assert.equal(player(game,f.target).charityTurn,game.turn);
game = act(game,f.target,{type:'ready'});
assert.ok(game.phase !== 2 || !game.charityOrder?.remaining.includes(f.target));
assert.deepEqual(inventory(game),f.f.original);
},
);
@@ -0,0 +1,99 @@
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 { actTruthCharityPayments as act, createTruthCharityPaymentsFixture,
declareTruthCharityPayments, truthCharityPaymentsPlayer as player } 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 {TruthCharityPromise} = await import('../components/truth-charity-promise');
const {CharityClaim} = await import('../components/charity-claim');
aliases.deregister();
let cached: ReturnType<typeof createTruthCharityPaymentsFixture> | undefined;
const source = () => cached ??= createTruthCharityPaymentsFixture({source:'phase2',advanced:true});
/** Genuine Advanced setup, Amal, an actual eligibility bribe and Truthtrance
* declaration/answer. The original Charity order is advanced only by real
* Ready actions; no promise, phase, order or wallet is staged. */
function projected(waiting:boolean, answer = true): GameView {
const f = source(); let game = structuredClone(f.game);
const queue = game.charityOrder; assert.ok(queue && queue.remaining.length >= 3);
const target = queue.remaining.find(id => id !== f.asker && id !== queue.remaining[0]); assert.ok(target);
const amount = player(game,target).spice - 1;
if (amount > 0) game = act(game,target,{type:'bribe',target:f.asker,amount});
game = declareTruthCharityPayments(f,game).game;
game = act(game,f.asker,{type:'truthAsk',question:{kind:'charity',target,turn:game.turn}});
game = act(game,target,{type:'truthAnswer',answer:answer ? 'yes' : 'no'});
if (!waiting) for (let count = 0; game.charityOrder?.remaining[0] !== target && count < 6; count++) {
const actor = game.charityOrder?.remaining[0]; assert.ok(actor);
game = act(game,actor,{type:'ready'});
}
const view = viewGame(game,target);
assert.ok(view.charityOrder && view.phase === 2 && view.advanced);
return view;
}
type ButtonProps = {'data-testid'?:string;disabled?:boolean;onClick?:()=>void};
function guide(view:GameView,busy = false) {
const sent:Action[] = [];
const element = TruthCharityPromise({game:view,busy,send(action){sent.push(action);}});
assert.ok(element);
const button = Children.toArray(element.props.children).find(node =>
isValidElement<ButtonProps>(node) && node.props['data-testid'] === 'charity-promise-guide');
return {element,button:isValidElement<ButtonProps>(button) ? button : null,sent};
}
const markup = (view:GameView) => renderToStaticMarkup(createElement(TruthCharityPromise,
{game:view,busy:false,send() { assert.fail('Rendering must not send.'); }}));
void test('ordered Charity shows a waiting promise without an active guided claim', () => {
const view = projected(true), {button} = guide(view);
assert.equal(view.truthCharityGuide,null); assert.equal(button,null);
assert.match(markup(view),/[Ww]ait[^<]*[Cc]harity/);
assert.doesNotMatch(markup(view),/data-testid="charity-promise-guide"/);
const claim = renderToStaticMarkup(createElement(CharityClaim,{game:view,busy:false,act() {}}));
assert.match(claim,/Your turn comes later in the public order/);
assert.match(claim,/<button(?=[^>]*data-testid="charity-claim-submit")(?=[^>]*disabled="")[^>]*>/);
});
void test('the current ordered seat receives the exact native guided claim and a busy state cannot send', () => {
const view = projected(false), {button,sent} = guide(view);
const claim = renderToStaticMarkup(createElement(CharityClaim,{game:view,busy:false,act() {}}));
assert.match(claim,/<button(?=[^>]*data-testid="charity-turn-finish")(?=[^>]*disabled="")[^>]*>/);
assert.match(claim,/Complete your Truthtrance Charity Yes before finishing/);
assert.ok(button); assert.equal(button.props.disabled,false);
assert.ok(button.props.onClick); button.props.onClick();
assert.deepEqual(sent,[view.truthCharityGuide!.action]);
const busy = guide(view,true); assert.ok(busy.button); assert.equal(busy.button.props.disabled,true);
busy.button.props.onClick?.(); assert.deepEqual(busy.sent,[]);
});
void test('a saved guide callback cannot claim after the ordered turn changes', () => {
const view = projected(false), {button,sent} = guide(view);
assert.ok(button?.props.onClick); assert.equal(button.props.disabled,false);
const order = view.charityOrder!;
const prior = order.order.find(id => id !== view.me); assert.ok(prior);
order.remaining = [prior,view.me];
button.props.onClick();
assert.deepEqual(sent,[]);
});
void test('an ordered No retains its restriction without presenting a guided action', () => {
const view = projected(true,false), {button} = guide(view);
assert.equal(button,null); assert.match(markup(view),/You answered No/);
assert.match(markup(view),/No forbids claiming/);
});
+178
View File
@@ -0,0 +1,178 @@
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 { actTruthCharityPayments as act, createTruthCharityPaymentsFixture,
declareTruthCharityPayments, truthCharityPaymentsInventory as inventory,
truthCharityPaymentsPlayer as player } from './fixture-truth-charity-payments';
let original: ReturnType<typeof createTruthCharityPaymentsFixture> | undefined;
const source = () => original ??= createTruthCharityPaymentsFixture({source:'phase2',advanced:true});
/** Public Advanced selected-Ix setup, original starting cards and actual Amal
* chronology. A real bribe before asking makes the chosen future actor eligible
* for the native claim; no wallet, phase, order, readiness or promise is staged. */
function fixture(options: {poor?:boolean} = {}) {
const f = source(); let game = structuredClone(f.game);
assert.equal(game.advanced,true); assert.equal(game.phase,2);
const queue = game.charityOrder; assert.ok(queue && queue.remaining.length >= 3);
const target = queue.remaining.find(id => id !== f.asker && id !== queue.remaining[0]); assert.ok(target);
const amount = player(game,target).spice - 1;
if (options.poor !== false && amount > 0) game = act(game,target,{type:'bribe',target:f.asker,amount});
if (options.poor !== false) assert.ok(player(game,target).spice < 2);
assert.deepEqual(game.charityOrder,queue);
assert.equal(player(game,target).charityTurn,undefined);
return {f,game,target,asker:f.asker,queue:structuredClone(queue)};
}
function reject(game:Game, actor:string, action:Action) {
const before = structuredClone(game);
assert.throws(() => act(game,actor,action));
assert.deepEqual(game,before);
}
function ask(f:ReturnType<typeof fixture>, source:Game = f.game) {
const declared = declareTruthCharityPayments(f.f,source).game;
return act(declared,f.asker,{type:'truthAsk',question:{kind:'charity',target:f.target,turn:source.turn}});
}
function bind(f:ReturnType<typeof fixture>, answer:'yes'|'no', source:Game = 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})};
}
function toTarget(game:Game, target:string):Game {
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;
}
void test('Advanced Charity Yes preserves the order and guides only the actual current turn', () => {
const f = fixture(); const {game: bound} = bind(f,'yes');
assert.deepEqual(bound.charityOrder,f.queue);
assert.equal(viewGame(bound,f.target).truthCharityGuide,null);
assert.ok(viewGame(bound,f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released));
reject(bound,f.target,{type:'charity'}); reject(bound,f.target,{type:'ready'});
let game = toTarget(bound,f.target);
reject(game,f.target,{type:'ready'});
const guide = viewGame(game,f.target).truthCharityGuide; assert.ok(guide);
assert.equal(guide.action.type,'charity');
const queue = structuredClone(game.charityOrder);
game = act(game,f.target,guide.action);
assert.deepEqual(game.charityOrder,queue);
assert.equal(player(game,f.target).charityTurn,game.turn);
assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => row.answer && row.fulfilled));
game = act(game,f.target,{type:'ready'});
assert.ok(game.phase !== 2 || !game.charityOrder?.remaining.includes(f.target));
assert.deepEqual(inventory(game),f.f.originalCards);
});
void test('Advanced Charity No remains binding through the owned turn then completes', () => {
const f = fixture(); const {game: bound} = bind(f,'no');
assert.deepEqual(bound.charityOrder,f.queue);
let game = toTarget(bound,f.target);
reject(game,f.target,{type:'charity'});
assert.equal(viewGame(game,f.target).truthCharityGuide,null);
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.equal(player(game,f.target).charityTurn,undefined);
assert.deepEqual(inventory(game),f.f.originalCards);
});
void test('Advanced Charity accepts a question during the respondent’s own unfinished turn', () => {
const f = fixture(), current = toTarget(f.game,f.target), queue = structuredClone(current.charityOrder);
const {game} = bind(f,'yes',current);
assert.deepEqual(game.charityOrder,queue);
assert.equal(game.charityOrder?.remaining[0],f.target);
reject(game,f.target,{type:'ready'});
const guide = viewGame(game,f.target).truthCharityGuide; assert.ok(guide);
const completed = act(game,f.target,guide.action);
assert.equal(player(completed,f.target).charityTurn,completed.turn);
});
void test('Advanced Charity Yes guides a real bribe only once the future target owns its turn', () => {
const f = fixture({poor:false}); assert.ok(player(f.game,f.target).spice >= 2);
let {game} = bind(f,'yes');
assert.equal(viewGame(game,f.target).truthCharityGuide,null);
assert.deepEqual(game.charityOrder,f.queue);
game = toTarget(game,f.target);
reject(game,f.target,{type:'ready'});
const bribe = viewGame(game,f.target).truthCharityGuide; assert.ok(bribe);
assert.equal(bribe.action.type,'bribe');
const before = player(game,f.target).spice;
game = act(game,f.target,bribe.action);
assert.ok(player(game,f.target).spice < 2);
assert.equal(player(game,f.target).spice,before - Number(bribe.action.amount));
const claim = viewGame(game,f.target).truthCharityGuide; assert.ok(claim);
assert.equal(claim.action.type,'charity');
game = act(game,f.target,claim.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.f.originalCards);
});
void test('a No by the final Advanced Charity actor completes on the actual phase transition', () => {
const base = source(); let game = structuredClone(base.game);
const queue = game.charityOrder; assert.ok(queue && queue.remaining.length >= 3);
const target = queue.remaining.at(-1)!;
let asker = base.asker;
// If the final actor holds Truthtrance, exchange it with another real hand
// card so a different player can ask; this conserves the physical inventory.
if (target === asker) {
const other = game.players.find(seat => seat.id !== target && seat.hand.length); assert.ok(other);
const holder = player(game,asker), index = holder.hand.findIndex(card => card.id === base.truths[0]); assert.ok(index >= 0);
[holder.hand[index],other.hand[0]] = [other.hand[0],holder.hand[index]];
asker = other.id;
}
const amount = player(game,target).spice - 1;
if (amount > 0) game = act(game,target,{type:'bribe',target:asker,amount});
const alternate = {...base,asker};
const declared = declareTruthCharityPayments(alternate,game).game;
game = act(declared,asker,{type:'truthAsk',question:{kind:'charity',target,turn:game.turn}});
assert.ok(viewGame(game,target).truthCharityAnswers?.includes('no'));
game = act(game,target,{type:'truthAnswer',answer:'no'});
game = toTarget(game,target);
assert.deepEqual(game.charityOrder?.remaining,[target]);
reject(game,target,{type:'charity'});
game = act(game,target,{type:'ready'});
assert.notEqual(game.phase,2);
assert.ok(viewGame(game,target).truthCharityPromises.some(row => !row.answer && row.fulfilled));
assert.deepEqual(inventory(game),base.originalCards);
});
void test('Advanced Charity cannot reopen an already finished ordered turn', () => {
const f = fixture(); let game = toTarget(f.game,f.target);
game = act(game,f.target,{type:'ready'});
if (game.phase !== 2) {
assert.equal(game.charityOrder ?? null,null);
return;
}
assert.ok(!game.charityOrder?.remaining.includes(f.target));
const declared = declareTruthCharityPayments(f.f,game).game;
reject(declared,f.asker,{type:'truthAsk',question:{kind:'charity',target:f.target,turn:game.turn}});
});
void test('all four minimal bots use the native Charity guide on their actual ordered turn', () => {
const f = fixture(), asked = ask(f);
for (const difficulty of DIFFICULTIES) {
const answerView = viewGame(asked,f.target);
answerView.players.find(row => row.id === f.target)!.bot = difficulty;
const answer = botActions(answerView)[0]; assert.ok(answer);
assert.equal(answer.type,'truthAnswer');
assert.ok(answerView.truthCharityAnswers?.includes(answer.answer as 'yes'|'no'));
// A separate Yes branch tests the promised legal path for every profile,
// regardless of its provisional answering strategy.
let game = act(structuredClone(asked),f.target,{type:'truthAnswer',answer:'yes'});
const waiting = viewGame(game,f.target); waiting.players.find(row => row.id === f.target)!.bot = difficulty;
assert.equal(waiting.truthCharityGuide,null);
assert.ok(!botActions(waiting).some(action => action.type === 'charity' || action.type === 'ready'));
game = toTarget(game,f.target);
const view = viewGame(game,f.target); view.players.find(row => row.id === f.target)!.bot = difficulty;
const guide = view.truthCharityGuide; assert.ok(guide);
const action = botActions(view).find(candidate => candidate.type === 'charity');
assert.deepEqual(action,guide.action);
const completed = act(game,f.target,action!);
assert.equal(player(completed,f.target).charityTurn,completed.turn);
}
});