Support Truthtrance charity commitments in Basic play

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

No files matched your search

+87
View File
@@ -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');
});
+1
View File
@@ -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);
+45
View File
@@ -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<typeof createTruthCharityCommitmentFixture>;
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');
}
@@ -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), /<option value="charity">[^<]*[Cc]harity/);
assert.match(table(asker), /[Cc]harity/);
// SSR confirms the selection is present; engine actions supply the question.
const asked = ask(f), answerView = viewGame(asked, f.target);
assert.equal(answerView.truthtrance?.question?.kind, 'charity');
assert.ok(answerView.truthCharityAnswers?.includes('yes'));
assert.ok(answerView.truthCharityAnswers?.includes('no'));
assert.equal(viewGame(asked, f.asker).truthCharityAnswers, null);
assert.match(truth(answerView), /Answer Yes/);
assert.match(truth(answerView), /Answer No/);
assert.match(table(answerView), /Answer Yes/);
const bound = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const own = viewGame(bound, f.target), guide = own.truthCharityGuide;
assert.ok(guide); assert.equal(guide.action.type, 'charity');
assert.match(table(own), /[Cc]harity/);
const claimed = act(bound, f.target, guide.action);
assert.equal(player(claimed, f.target).charityTurn, claimed.turn);
assert.deepEqual(inventory(claimed), f.originalCards);
});
void test('Basic No answer keeps its own readable Charity restriction and blocks the physical claim', () => {
const f = fixture(), bound = bind(f, 'no').game;
const own = viewGame(bound, f.target);
assert.match(table(own), /[Cc]harity/);
assert.throws(() => act(bound, f.target, { type: 'charity' }));
assert.deepEqual(inventory(bound), f.originalCards);
});
void test('A live Charity bribe guide sends the native packet and cannot replay in a No continuation', () => {
const f = fixture(), asked = ask(f, f.unprepared);
const yes = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const own = viewGame(yes, f.target), sent: Action[] = [];
assert.equal(own.truthCharityGuide?.action.type, 'bribe');
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]);
const paid = act(yes, f.target, sent[0]);
assert.ok(player(paid, f.target).spice < 2);
const no = act(asked, f.target, { type: 'truthAnswer', answer: 'no' });
reject(no, f.target, sent[0]);
});
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { charityQuote } from '../game/charity';
import { declareTruthCharityPayments } from './fixture-truth-charity-payments';
import { viewGame } from '../game/engine';
import { act, askTruthCharityCommitment as ask, bindTruthCharityCommitment as bind,
createTruthCharityCommitmentFixture as fixture, inventory, player, rejectTruthCharityCommitment as reject } from './fixture-truth-charity-commitment';
void test('Basic eligible Charity Yes reopens ready and requires one actual native claim', () => {
const f = fixture();
const source = act(f.game, f.target, { type: 'ready' });
assert.ok(source.ready.includes(f.target));
const { asked, game: bound } = bind(f, 'yes', source);
assert.equal(viewGame(asked, f.asker).truthCharityAnswers, null);
assert.ok(!bound.ready.includes(f.target));
assert.equal(bound.discard.filter(card => card.id === f.truth).length, 1);
assert.equal(player(bound, f.target).charityTurn, undefined);
reject(bound, f.target, { type: 'ready' });
const guide = viewGame(bound, f.target).truthCharityGuide; assert.ok(guide);
assert.equal(guide.action.type, 'charity');
const claimed = act(bound, f.target, guide.action);
assert.equal(player(claimed, f.target).charityTurn, claimed.turn);
assert.equal(player(claimed, f.target).spice, 2);
assert.equal(charityQuote(claimed, player(claimed, f.target)).total, 0);
reject(claimed, f.target, { type: 'charity' });
const ready = act(claimed, f.target, { type: 'ready' });
assert.ok(ready.ready.includes(f.target));
assert.deepEqual(inventory(ready), f.originalCards);
});
void test('Basic Charity No prohibits the claim through ready and normal phase close', () => {
const f = fixture(); const source = act(f.game, f.target, { type: 'ready' });
const { game: bound } = bind(f, 'no', source);
assert.ok(!bound.ready.includes(f.target));
reject(bound, f.target, { type: 'charity' });
let game = act(bound, f.target, { type: 'ready' });
assert.ok(game.ready.includes(f.target));
reject(game, f.target, { type: 'charity' });
// A second actual Truthtrance answer reopens concurrent Basic readiness.
// The first No must still forbid claiming through the same Charity phase.
game = bind(f, 'no', game, 1).game;
assert.ok(!game.ready.includes(f.target));
reject(game, f.target, { type: 'charity' });
game = act(game, f.target, { type: 'ready' });
for (const p of game.players) if (!game.ready.includes(p.id) && game.phase === 2)
game = act(game, p.id, { type: 'ready' });
assert.notEqual(game.phase, 2);
assert.equal(player(game, f.target).charityTurn, undefined);
assert.equal(player(game, f.target).spice, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('Basic stale Charity questions and wrong answerers cannot change the native phase', () => {
const f = fixture();
const declared = declareTruthCharityPayments(f).game;
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('Basic all four minimal bot profiles answer and use an allowed Charity continuation', () => {
const f = fixture(); const 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, difficulty);
assert.equal(answer.type, 'truthAnswer');
assert.ok(answerView.truthCharityAnswers?.includes(answer.answer as 'yes' | 'no'));
const bound = act(structuredClone(asked), f.target, answer);
if (answer.answer === 'yes') {
const own = viewGame(bound, f.target); own.players.find(row => row.id === f.target)!.bot = difficulty;
assert.ok(own.truthCharityGuide);
const choice = botActions(own)[0]; assert.deepEqual(choice, own.truthCharityGuide.action);
const after = act(bound, f.target, choice);
assert.equal(player(after, f.target).charityTurn, after.turn);
assert.deepEqual(inventory(after), f.originalCards);
} else {
reject(bound, f.target, { type: 'charity' });
const own = viewGame(bound, f.target); own.players.find(row => row.id === f.target)!.bot = difficulty;
const choice = botActions(own)[0]; assert.ok(choice, difficulty);
const after = act(bound, f.target, choice);
assert.deepEqual(inventory(after), f.originalCards);
}
}
});
void test('Basic Charity Yes at five spice guides an actual bribe and claim; its guide cannot replay against No', () => {
const f = fixture();
// This is the actual pre-bribe native source, with five spendable spice.
assert.equal(player(f.unprepared, f.target).spice, 5);
const asked = ask(f, f.unprepared);
assert.ok(viewGame(asked, f.target).truthCharityAnswers?.includes('yes'));
const yes = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
reject(yes, f.target, { type: 'ready' });
const bribe = viewGame(yes, f.target).truthCharityGuide; assert.ok(bribe);
assert.equal(bribe.action.type, 'bribe');
assert.ok(typeof bribe.action.amount === 'number' && bribe.action.amount >= 4);
const paid = act(yes, f.target, bribe.action);
assert.ok(player(paid, f.target).spice < 2);
assert.ok(viewGame(paid, f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released));
reject(paid, f.target, bribe.action);
const claim = viewGame(paid, f.target).truthCharityGuide; assert.ok(claim);
assert.equal(claim.action.type, 'charity');
const claimed = act(paid, f.target, claim.action);
assert.equal(player(claimed, f.target).charityTurn, claimed.turn);
assert.ok(viewGame(claimed, f.target).truthCharityPromises.some(row => row.answer && row.fulfilled));
assert.deepEqual(inventory(claimed), f.originalCards);
// The same actual question has a separate valid No continuation; a normal
// bribe remains legal here, so rejecting the old Yes packet requires its
// original guide identity, not simply a no-bribes rule.
const no = act(asked, f.target, { type: 'truthAnswer', answer: 'no' });
reject(no, f.target, bribe.action);
const ordinaryBribe = act(no, f.target, { type: 'bribe', target: f.asker, amount: 1 });
assert.equal(player(ordinaryBribe, f.target).spice, 4);
assert.deepEqual(inventory(ordinaryBribe), f.originalCards);
});
void test('A controlled external spice gain retains Charity Yes while an actual bribe route remains', () => {
const f = fixture(); const bound = bind(f, 'yes').game;
assert.equal(player(bound, f.target).spice, 1);
// Controlled supplement: transfer three actual spice between two seated
// wallets, representing an external gain. The native fixture makes Emperor
// the respondent, so it cannot receive its own allied Emperor gift. No card,
// phase, readiness or promise is manufactured; the sum stays unchanged.
const changed = structuredClone(bound);
const donor = changed.players.find(p => p.id !== f.target && p.spice >= 3); assert.ok(donor);
const total = changed.players.reduce((sum, p) => sum + p.spice, 0);
donor.spice -= 3; player(changed, f.target).spice += 3;
assert.equal(changed.players.reduce((sum, p) => sum + p.spice, 0), total);
const observer = changed.players.find(p => p.id !== f.target && !changed.ready.includes(p.id)); assert.ok(observer);
const reconciled = act(changed, observer.id, { type: 'ready' });
assert.equal(player(reconciled, f.target).spice, 4);
assert.ok(viewGame(reconciled, f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released));
reject(reconciled, f.target, { type: 'ready' });
const bribe = viewGame(reconciled, f.target).truthCharityGuide; assert.ok(bribe);
assert.equal(bribe.action.type, 'bribe');
const paid = act(reconciled, f.target, bribe.action);
assert.ok(player(paid, f.target).spice < 2);
const claim = viewGame(paid, f.target).truthCharityGuide; assert.ok(claim);
assert.equal(claim.action.type, 'charity');
const completed = act(paid, f.target, claim.action);
assert.equal(player(completed, f.target).charityTurn, completed.turn);
assert.deepEqual(inventory(completed), f.originalCards);
});