Support early Charity Truthtrance commitments

This commit is contained in:
Quintine committed 2026-10-27 19:13:48 +11:00
1 parent 72ff9db5e8
commit 2cd2a60a36
12 files changed
+407 -32

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
import {
actTruthCharityPayments as act,
advanceTruthCharityPayments,
createTruthCharityPaymentsFixture,
reachTruthCharityPaymentsClaim,
truthCharityPaymentsClean as clean,
truthCharityPaymentsInventory as inventory,
truthCharityPaymentsPlayer as player,
} from './fixture-truth-charity-payments';
import { createTruthTurnAmalFixture } from './fixture-truth-turn-amal';
export { act, clean, inventory, player };
/** Two original selected-Ix classic Basic starts: phase 0 from the real setup,
* or phase 1 after actually resolving Amal. Only original unopened deal/Spice
* positions and native scalar lotteries are selected, as in parent fixtures.
* No played wallet, hand, pass, phase, promise or income is installed. */
export function createTruthCharityEarlyFixture(phase: 0 | 1 = 1) {
const base = phase === 0
? createTruthTurnAmalFixture({ phase: 0, advanced: false })
: createTruthCharityPaymentsFixture({ source: 'afterAmal', advanced: false });
const game = phase === 0
? advanceTruthCharityPayments(structuredClone(base.game), g => g.phase === 0 && clean(g), [], true)
: structuredClone(base.game);
assert.equal(game.phase, phase);
assert.ok(clean(game));
assert.equal(game.advanced, false);
assert.equal(player(game, base.target).charityTurn, undefined);
assert.deepEqual(inventory(game), base.originalCards);
const charityQuestion = { kind:'charity',target:base.target,turn:game.turn } satisfies TruthQuestion;
return { ...base, game, charityQuestion, initialPhase:phase };
}
export type TruthCharityEarlyFixture = ReturnType<typeof createTruthCharityEarlyFixture>;
export function declareTruthCharityEarly(f: TruthCharityEarlyFixture, source = f.game, copy: 0 | 1 = 0) {
let game = act(source,f.asker,{type:'card',card:f.truths[copy]});
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor);
game = act(game,actor.id,{type:'truthPass'});
}
assert.equal(game.truthtrance?.stage,'ask');
return game;
}
export function askTruthCharityEarly(f: TruthCharityEarlyFixture,source = f.game,copy:0|1 = 0): Game {
const declared = declareTruthCharityEarly(f,source,copy);
return act(declared,f.asker,{type:'truthAsk',question:f.charityQuestion});
}
export function bindTruthCharityEarly(f: TruthCharityEarlyFixture,answer:'yes'|'no',source=f.game,copy:0|1=0) {
const asked = askTruthCharityEarly(f,source,copy);
assert.ok(viewGame(asked,f.target).truthCharityAnswers?.includes(answer));
return {asked,game:act(asked,f.target,{type:'truthAnswer',answer})};
}
export function reachEarlyCharity(game: Game): Game {
let current = game;
if (current.phase === 0) {
current = advanceTruthCharityPayments(current,beforeIxSpiceDraw,[],true);
// Select only a safe unopened physical Spice prefix, matching the parent
// native source; this does not alter any previously played card or money.
orderIxSpiceCards(current,[{territory:'hagga_basin'}]);
}
return reachTruthCharityPaymentsClaim(current);
}
export function rejectTruthCharityEarly(game: Game,actor: string,action: Action): void {
const before = structuredClone(game);
assert.throws(() => act(game,actor,action));
assert.deepEqual(game,before,'Rejected action must not mutate its input');
}
@@ -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 { Children, createElement, isValidElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type Action, type GameView } from '../game/engine';
import { act, askTruthCharityEarly as ask, createTruthCharityEarlyFixture as fixture,
declareTruthCharityEarly as declare, inventory, player, reachEarlyCharity as reach,
rejectTruthCharityEarly as reject } from './fixture-truth-charity-early';
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(){}}));
for (const phase of [0,1] as const) {
void test(`Basic phase ${phase} Truthtrance presents Charity question and private answer controls`, () => {
const f=fixture(phase), declared=declare(f), asker=viewGame(declared,f.asker);
assert.ok(asker.truthCharityTargets.includes(f.target));
assert.match(truth(asker),/<option value="charity">[^<]*[Cc]harity/);
const asked=ask(f), target=viewGame(asked,f.target);
assert.equal(target.truthtrance?.question?.kind,'charity');
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);
assert.match(truth(target),/Answer Yes/);
assert.match(truth(target),/Answer No/);
assert.match(table(target),/Answer Yes/);
const bound=act(asked,f.target,{type:'truthAnswer',answer:'yes'}), early=viewGame(bound,f.target);
assert.equal(early.truthCharityGuide,null);
assert.match(table(early),/[Cc]harity/);
assert.doesNotMatch(table(early),/data-testid="charity-promise-guide"/);
assert.deepEqual(inventory(bound),f.originalCards);
});
}
void test('An early Yes presents the original native guide only when Charity actually opens', () => {
const f=fixture(1), asked=ask(f);
const early=act(asked,f.target,{type:'truthAnswer',answer:'yes'});
assert.equal(viewGame(early,f.target).truthCharityGuide,null);
const game=reach(early), own=viewGame(game,f.target), sent:Action[]=[];
assert.ok(own.truthCharityGuide);
const panel=TruthCharityPromise({game:own,busy:false,send(action){sent.push(action);}});
assert.ok(panel);
type GuideProps={'data-testid'?:string;disabled?:boolean;onClick?:()=>void};
const button=Children.toArray(panel.props.children).find(node =>
isValidElement<GuideProps>(node) && node.props['data-testid'] === 'charity-promise-guide');
assert.ok(isValidElement<GuideProps>(button));
assert.equal(button.props.disabled,false);
assert.ok(button.props.onClick); button.props.onClick();
assert.deepEqual(sent,[own.truthCharityGuide.action]);
let progressed=act(game,f.target,sent[0]);
if (sent[0].type === 'bribe') {
const claim=viewGame(progressed,f.target).truthCharityGuide; assert.ok(claim);
assert.equal(claim.action.type,'charity'); progressed=act(progressed,f.target,claim.action);
}
assert.equal(player(progressed,f.target).charityTurn,progressed.turn);
const no=reach(act(asked,f.target,{type:'truthAnswer',answer:'no'}));
reject(no,f.target,sent[0]);
assert.deepEqual(inventory(progressed),f.originalCards);
});
+143
View File
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { advanceTruthCharityPayments } from './fixture-truth-charity-payments';
import { actTruthtranceChoamPromises as actChoam, advanceTruthtranceChoamPromises as advanceChoam,
createTruthtranceChoamPromisesFixture as choamFixture, truthtranceChoamPromisesInventory as choamInventory,
truthtranceChoamPromisesPlayer as choamPlayer } from './fixture-truthtrance-choam-promises';
import { act, askTruthCharityEarly as ask, bindTruthCharityEarly as bind, clean,
createTruthCharityEarlyFixture as fixture, declareTruthCharityEarly as declare,
inventory, player, reachEarlyCharity as reach, rejectTruthCharityEarly as reject } from './fixture-truth-charity-early';
for (const phase of [0,1] as const) {
void test(`Basic phase ${phase} Charity Yes survives native openings and uses its real later claim`, () => {
const f = fixture(phase), {asked,game:bound} = bind(f,'yes');
assert.equal(viewGame(asked,f.asker).truthCharityAnswers,null);
assert.deepEqual(bound.ready,asked.ready,'Answering does not reopen readiness in an earlier phase');
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.truth).length,1);
const charity = reach(bound);
assert.equal(charity.phase,2);
assert.ok(viewGame(charity,f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released));
reject(charity,f.target,{type:'ready'});
const guide = viewGame(charity,f.target).truthCharityGuide; assert.ok(guide);
let game = act(charity,f.target,guide.action);
if (guide.action.type === 'bribe') {
assert.ok(player(game,f.target).spice < 2);
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.originalCards);
});
void test(`Basic phase ${phase} Charity No remains binding until the actual later phase 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);
assert.ok(viewGame(bound,f.target).truthCharityPromises.some(row => !row.answer && !row.fulfilled && !row.released));
let game = reach(bound);
assert.equal(game.phase,2);
assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => !row.answer && !row.fulfilled));
// Earn eligibility by the real ordinary payment, so rejecting a physical
// claim proves the answered No rather than the ordinary wealth threshold.
const amount = player(game,f.target).spice - 1;
assert.ok(amount > 0);
game = act(game,f.target,{type:'bribe',target:f.asker,amount});
assert.equal(player(game,f.target).spice,1);
reject(game,f.target,{type:'charity'});
game = act(game,f.target,{type:'ready'});
for (const seat of game.players) if (game.phase === 2 && !game.ready.includes(seat.id))
game = act(game,seat.id,{type:'ready'});
assert.notEqual(game.phase,2);
assert.equal(player(game,f.target).charityTurn,undefined);
assert.deepEqual(inventory(game),f.originalCards);
});
}
void test('An earlier-phase Charity answer preserves an actual prior Spice Blow Ready', () => {
const f = fixture(1);
const blow = advanceTruthCharityPayments(f.game,g => !!g.spiceWindow && clean(g),[],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,'Only the future Charity Ready may be reopened');
const charity = reach(bound);
assert.equal(charity.phase,2);
assert.ok(viewGame(charity,f.target).truthCharityGuide);
});
void test('Earlier-phase stale turns and foreign answers reject without changes', () => {
const f = fixture(1), declared = declare(f);
reject(declared,f.asker,{type:'truthAsk',question:{...f.charityQuestion,turn:f.game.turn+1}});
const asked = ask(f);
reject(asked,f.asker,{type:'truthAnswer',answer:'yes'});
assert.deepEqual(inventory(asked),f.originalCards);
});
void test('All four bots answer early and follow the later native Charity guide', () => {
const f = fixture(1), asked = ask(f);
for (const difficulty of DIFFICULTIES) {
const projected = viewGame(asked,f.target);
projected.players.find(row => row.id === f.target)!.bot = difficulty;
const answer = botActions(projected)[0]; assert.ok(answer,difficulty);
assert.equal(answer.type,'truthAnswer');
assert.ok(projected.truthCharityAnswers?.includes(answer.answer as 'yes'|'no'));
let game = act(structuredClone(asked),f.target,answer);
assert.equal(viewGame(game,f.target).truthCharityGuide,null);
game = reach(game);
const own = viewGame(game,f.target); own.players.find(row => row.id === f.target)!.bot = difficulty;
if (answer.answer === 'yes') {
assert.ok(own.truthCharityGuide);
assert.deepEqual(botActions(own)[0],own.truthCharityGuide.action);
game = act(game,f.target,own.truthCharityGuide.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);
} else {
const action = botActions(own)[0]; assert.ok(action,difficulty);
game = act(game,f.target,action);
}
assert.deepEqual(inventory(game),f.originalCards);
}
});
void test('An early CHOAM Charity question does not invent an unanswered opening income', () => {
// Original native Basic four-seat CHOAM setup and unopened physical deals;
// CHOAM's earned opening is deliberately not allowed, canceled or credited.
const f=choamFixture(), 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'});
}
assert.equal(game.truthtrance?.stage,'ask');
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('yes'),'A not-yet-resolved CHOAM opening is not guaranteed income');
assert.equal(viewGame(game,f.asker).truthCharityAnswers,null);
assert.equal(choamPlayer(game,f.target).spice,f.rawCash);
assert.equal(game.choamCharity,undefined);
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));
const opening=advanceChoam(game,state => state.response?.kind === 'choamCharity');
assert.equal(opening.response?.kind,'choamCharity');
assert.equal(opening.response.owner,f.target);
assert.equal(opening.choamCharity,undefined);
assert.equal(choamPlayer(opening,f.target).spice,f.rawCash);
assert.deepEqual(choamInventory(opening),f.originalInventory);
});