Plan ordinary promises across one native Charity claim

This commit is contained in:
Quintine committed 2026-10-27 05:52:48 +11:00
1 parent b81823bbec
commit 38738eabac
15 files changed
+831 -96

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
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 { createTruthTurnAmalFixture, closeTruthTurnAmal, settleTruthTurnAmal,
truthTurnAmalFact, truthTurnAmalGroup, truthTurnAmalPayment,
type TruthTurnAmalExpression } from './fixture-truth-turn-amal';
import { actTruthTurnThumper, createTruthTurnThumperFixture, nextTruthTurnThumperNative,
truthTurnThumperClean, truthTurnThumperInventory, truthTurnThumperPlayer, truthTurnThumperSpice,
truthTurnThumperTraitors, type TruthTurnThumperStep } from './fixture-truth-turn-thumper';
export type TruthCharityPaymentsStep = TruthTurnThumperStep;
export type TruthCharityPaymentsKind = 'turnActions' | 'bribe' | 'bribeExpression' | 'mixedBribe';
export const actTruthCharityPayments = actTruthTurnThumper;
export const truthCharityPaymentsClean = truthTurnThumperClean;
export const truthCharityPaymentsPlayer = truthTurnThumperPlayer;
export const truthCharityPaymentsInventory = truthTurnThumperInventory;
export const truthCharityPaymentsTraitors = truthTurnThumperTraitors;
export const truthCharityPaymentsSpice = truthTurnThumperSpice;
export const truthCharityPaymentsFact = truthTurnAmalFact;
export const truthCharityPaymentsGroup = truthTurnAmalGroup;
export const truthCharityPaymentsPayment = truthTurnAmalPayment;
export const truthCharityPaymentsShip = (territory = 'polar_sink', minimum = 1): TruthTurnAmalExpression =>
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
export const nextTruthCharityPaymentsNative = nextTruthTurnThumperNative;
export function nextTruthCharityPayments(game: Game): TruthCharityPaymentsStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const view = viewGame(game, player.id), guide = view.turnActionGuide ?? view.bribePromiseGuide;
if (guide) return { actor: player.id, action: guide.action };
}
return nextTruthCharityPaymentsNative(game);
}
export function advanceTruthCharityPayments(game: Game, until: (state: Game) => boolean,
actions: TruthCharityPaymentsStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1900; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Charity/payment source ended before its native boundary');
const step = nativeOnly ? nextTruthCharityPaymentsNative(game) : nextTruthCharityPayments(game);
assert.ok(step, 'Supply the actual Truthtrance question or original private plan');
game = actTruthCharityPayments(game, step.actor, step.action, actions);
}
throw Error('The native Charity/payment chronology did not reach its finite boundary');
}
export function settleTruthCharityPayments(game: Game, actions: TruthCharityPaymentsStep[] = []): Game {
return advanceTruthCharityPayments(game, truthCharityPaymentsClean, actions, true);
}
export function reachTruthCharityPaymentsClaim(game: Game, actions: TruthCharityPaymentsStep[] = []): Game {
return advanceTruthCharityPayments(game, state => state.phase === 2 && truthCharityPaymentsClean(state), actions, true);
}
/** Genuine selected-Ix original deals only. Every smaller purse is earned by
* actual Amal; no poverty, receipt, claim marker or pass list is assigned. */
export function createTruthCharityPaymentsFixture(options: {
source?: 'amalOpening' | 'afterAmal' | 'thumper' | 'phase2'; advanced?: boolean;
} = {}) {
const sourceKind = options.source ?? 'amalOpening';
const amal = sourceKind === 'thumper' ? null : createTruthTurnAmalFixture({ phase: 1, advanced: options.advanced });
const thumper = sourceKind === 'thumper' ? createTruthTurnThumperFixture({ advanced: options.advanced }) : null;
const base = amal ?? thumper!;
let game = structuredClone(base.game); const actions = structuredClone(base.sourceActions), inherited = actions.length;
let reduction: { before: Game; steps: TruthCharityPaymentsStep[]; after: Game } | null = null;
let spiceOrder: ReturnType<typeof orderIxSpiceCards> = [];
if (sourceKind === 'afterAmal' || sourceKind === 'phase2') {
assert.ok(amal); const before = structuredClone(game), from = actions.length;
game = actTruthCharityPayments(game, base.target, amal.nativeAction, actions);
game = settleTruthTurnAmal(game, actions); game = closeTruthTurnAmal(game, amal, actions);
reduction = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
assert.equal(truthCharityPaymentsPlayer(game, base.target).spice, 5); assert.equal(beforeIxSpiceDraw(game), true);
spiceOrder = orderIxSpiceCards(game, [{ territory: 'hagga_basin' }, ...(base.advanced ? [{ territory: 'broken_land' }] : [])]);
if (sourceKind === 'phase2') game = reachTruthCharityPaymentsClaim(game, actions);
}
const asker = base.asker, target = base.target, recipient = asker, observer = base.observer;
const minimum = sourceKind === 'thumper' ? 11 : 6;
const printed = sourceKind === 'amalOpening' || sourceKind === 'thumper'
? { kind: 'action', action: { kind: 'windowCardPlay', effect: base.effect, source: base.source } } as const : null;
const mixed = printed ? truthCharityPaymentsGroup('and', printed, truthCharityPaymentsPayment(recipient, minimum))
: truthCharityPaymentsPayment(recipient, minimum);
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
const spiceFact = { kind: 'spice', compare: 'eq', value: truthCharityPaymentsPlayer(game, target).spice } as const;
assert.deepEqual(truthCharityPaymentsPlayer(game, asker).hand.map(card => card.id), base.truths);
assert.notEqual(truthCharityPaymentsPlayer(game, target).charityTurn, game.turn);
assert.deepEqual(truthCharityPaymentsInventory(game), base.originalCards);
assert.deepEqual(truthCharityPaymentsTraitors(game), base.originalTraitors);
assert.deepEqual(truthCharityPaymentsSpice(game), base.originalSpice);
return { game, base, amal, thumper, sourceKind, initial: base.initial, setup: base.setup, afterSetup: base.afterSetup,
beforeStartingDeal: base.beforeStartingDeal, asker, target, recipient, observer, advanced: base.advanced,
truths: base.truths, truth: base.truth, card: base.card, effect: base.effect, cardSource: base.source,
nativeAction: base.nativeAction, printed, minimum, mixed, question, spiceFact, reduction, spiceOrder,
payment: { type: 'bribe', target: recipient, amount: minimum } satisfies Action,
charity: { type: 'charity' } satisfies Action,
originalCards: base.originalCards, originalTraitors: base.originalTraitors, originalSpice: base.originalSpice,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length,
actualAmalReduction: reduction?.steps.length ?? 0 },
sourceDisclosure: `${base.sourceDisclosure} This adapter follows only actual printed Amal, global opening passes and original native phase controls when requested. ` +
'The ordinary phase2 Charity remains unclaimed. Smaller purses come from the real all-seat halving, not assigned wealth. Later prepayments, one actual claim, later installments and ordinary shipment remain caller controls. All future recipients share the same physical purse and one current-turn native claim.',
};
}
export type TruthCharityPaymentsFixture = ReturnType<typeof createTruthCharityPaymentsFixture>;
export function truthCharityPaymentsQuestion(f: TruthCharityPaymentsFixture, kind: TruthCharityPaymentsKind,
minimum = f.minimum, recipient = f.recipient): TruthQuestion {
if (kind === 'bribe') return { kind, target: f.target, turn: f.game.turn, recipient, minimum };
if (kind === 'bribeExpression') return { kind, target: f.target, turn: f.game.turn, claim: { kind: 'payment', recipient, minimum } };
if (kind === 'mixedBribe') return { kind, target: f.target, turn: f.game.turn, mixed: { kind: 'and', terms: [
{ kind: 'fact', fact: f.spiceFact }, { kind: 'action', action: { recipient, minimum } },
] } };
return { kind, target: f.target, turn: f.game.turn, mixed: truthCharityPaymentsPayment(recipient, minimum) };
}
export function declareTruthCharityPayments(f: TruthCharityPaymentsFixture, source = f.game, copy: 0 | 1 = 0,
actions: TruthCharityPaymentsStep[] = []) {
let game = actTruthCharityPayments(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthCharityPayments(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions };
}
export function askTruthCharityPayments(game: Game, asker: string, question: TruthQuestion, actions: TruthCharityPaymentsStep[] = []): Game {
return actTruthCharityPayments(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions);
}
export function answerTruthCharityPayments(game: Game, target: string, answer: 'yes' | 'no' | 'unknown', actions: TruthCharityPaymentsStep[] = []): Game {
return actTruthCharityPayments(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthCharityPayments(f: TruthCharityPaymentsFixture, answer: 'yes' | 'no' = 'yes', question: TruthQuestion = f.question,
source = f.game, copy: 0 | 1 = 0, actions: TruthCharityPaymentsStep[] = []) {
const declared = declareTruthCharityPayments(f, source, copy, actions), asked = askTruthCharityPayments(declared.game, f.asker, question, actions);
return { game: answerTruthCharityPayments(asked, f.target, answer, actions), declared, asked, actions };
}
export function truthCharityPaymentsPromise(game: Game, kind: TruthCharityPaymentsKind = 'turnActions', target = game.truthHistory!.at(-1)!.question.target) {
const row = kind === 'bribe' ? game.bribePromises?.find(candidate => candidate.player === target)
: kind === 'bribeExpression' ? game.bribeExpressionPromises?.find(candidate => candidate.player === target)
: kind === 'mixedBribe' ? viewGame(game, target).mixedBribePromises.find(candidate => candidate.player === target)
: viewGame(game, target).turnActionPromises.find(candidate => candidate.player === target);
assert.ok(row); return row;
}
export function finishTruthCharityPayments(game: Game, actions: TruthCharityPaymentsStep[] = []): Game {
const turn = game.turn;
return advanceTruthCharityPayments(game, state => state.turn === turn + 1 && state.phase === 0 && !!state.phaseOpening &&
!state.response && !state.decision && !state.truthtrance && !state.pendingTreacheryDiscard, actions);
}
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { splitOrdinaryPayments } from '../game/ordinary-payment-plan';
void test('payment installments conserve the exact original deficits across recipients', () => {
const payments = [{ recipient: 'harkonnen', amount: 3 }, { recipient: 'atreides', amount: 3 }];
const original = structuredClone(payments), result = splitOrdinaryPayments(payments, 5);
assert.deepEqual(result, { before: [payments[0], { recipient: 'atreides', amount: 2 }],
after: [{ recipient: 'atreides', amount: 1 }], total: 6 });
for (const payment of payments) assert.equal([...result.before, ...result.after]
.filter(part => part.recipient === payment.recipient).reduce((sum, part) => sum + part.amount, 0), payment.amount);
assert.deepEqual(payments, original);
});
void test('no cash defers the whole payment and enough cash needs no later installment', () => {
const payments = [{ recipient: 'emperor', amount: 6 }];
assert.deepEqual(splitOrdinaryPayments(payments, 0), { before: [], after: payments, total: 6 });
assert.deepEqual(splitOrdinaryPayments(payments, 6), { before: payments, after: [], total: 6 });
assert.deepEqual(splitOrdinaryPayments([], 2), { before: [], after: [], total: 0 });
});
void test('invalid or overflowing payment partitions cannot become a funding witness', () => {
for (const available of [-1, 1.5, Infinity]) assert.throws(() => splitOrdinaryPayments([], available));
for (const payments of [[{ recipient: ' e', amount: 1 }], [{ recipient: 'e', amount: 0 }],
[{ recipient: 'e', amount: Number.MAX_SAFE_INTEGER }, { recipient: 'a', amount: 1 }]])
assert.throws(() => splitOrdinaryPayments(payments, 0));
});
+55
View File
@@ -66,6 +66,14 @@ function projected(phase = 3): GameView {
game.bribePromiseGuide = { action: { type: 'bribe', target: 'r', amount: 2, bribePromiseEvent: 'controlled-bribe-event' } };
return game;
}
function charityGame(amount = 2): GameView {
const game = projected(2), me = game.players.find(player => player.id === 'p')!;
me.spice = 2 - amount; me.charityClaimed = false; game.ready = [];
game.charity = { ...game.charity, ordinary: amount, homeworld: 0, total: amount, amount, incomePending: false };
game.bribeOptions = { ...game.bribeOptions, available: me.spice, targets: [] };
game.bribePromiseGuide = { action: { type: 'charity', bribePromiseEvent: 'controlled-bribe-event' } };
return game;
}
void test('bribe claim fields keep the turn fixed and accept only current recipients and positive safe whole minima', () => {
const value: BribeClaim = { turn: 2, recipient: 'r', minimum: 3 }, changes: BribeClaim[] = [];
@@ -130,6 +138,53 @@ void test('actual guide callbacks forward exact ordinary bribes out of turn thro
}
});
void test('ordinary payment guides forward exact currently quoted Charity claims and explain later installments', () => {
for (const amount of [1, 2]) {
const game = charityGame(amount), sent: Action[] = [], ownCash = game.players.find(player => player.id === 'p')!.spice;
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.bribePromiseGuide!.action);
assert.deepEqual(sent[0], { type: 'charity', bribePromiseEvent: 'controlled-bribe-event' });
assert.equal(game.players.find(player => player.id === 'p')!.spice, ownCash, 'The UI does not install future Charity as cash.');
const html = markup(game); assert.match(html, new RegExp(`Claim ${amount} Charity spice to continue your payments`));
assert.match(html, /payments now, one remaining Charity claim and later installments/);
assert.match(html, /future claim is not current spendable spice/);
}
});
void test('Charity payment callbacks reject changed native claims, quotes, cash, Ready, phase and callback ownership', () => {
for (const change of [
(game: GameView) => { game.players.find(player => player.id === 'p')!.charityClaimed = true; },
(game: GameView) => { game.charity.amount = 0; },
(game: GameView) => { game.charity = { ...game.charity }; },
(game: GameView) => { game.charity.incomePending = true; },
(game: GameView) => { game.players.find(player => player.id === 'p')!.spice = 1; },
(game: GameView) => { game.ready.push('p'); },
(game: GameView) => { game.phase = 3; },
(game: GameView) => { game.bribePromises[0].fulfilled = true; },
(game: GameView) => { game.bribePromiseGuide!.action.bribePromiseEvent = 'different-event'; },
]) {
const game = charityGame(), saved = guide({ game, busy: false, send() { assert.fail('A stale Charity continuation cannot send'); } });
assert.equal(saved.props.disabled, false); change(game); press(saved);
}
const pending = charityGame(); pending.charity.incomePending = true;
assert.equal(guide({ game: pending, busy: false, send() { assert.fail('CHOAM income must finish before an ordinary claim'); } }).props.disabled, true);
for (const game of [charityGame(), projected()]) {
const props = { game, busy: false, send() { assert.fail('A saved payment callback cannot use a replaced sender'); } };
const saved = guide(props); props.send = () => assert.fail('A replacement sender cannot receive an old quoted action'); press(saved);
}
});
void test('Charity payment guides reject extra native parameters and unrelated nonpayment fallbacks', () => {
for (const extra of [{ target: 'r' }, { amount: 1 }, { card: 'private-card' }, { mode: 'shipment' }]) {
const game = charityGame(); Object.assign(game.bribePromiseGuide!.action, extra);
const button = guide({ game, busy: false, send() { assert.fail('Charity accepts only its exact native action and event'); } });
assert.equal(button.props.disabled, true); press(button);
}
const unrelated = projected(2); unrelated.bribePromiseGuide!.action.type = 'bid';
const button = guide({ game: unrelated, busy: false, send() { assert.fail('An unrelated action cannot pass the ordinary bribe path'); } });
assert.equal(button.props.disabled, true); press(button);
});
void test('controlled quiet Mentat guide forwards the exact payment for an already-ready payer and explains next-turn collection', () => {
const game = projected(8), sent: Action[] = [];
game.auction = null; game.active = null; game.ready = ['p'];
@@ -0,0 +1,255 @@
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 { spiceDeck, treacheryDeck } from '../game/cards';
import { viewGame, type Action, type Game } from '../game/engine';
import { CHEAP_HERO_TRAITOR, traitorDeck } from '../game/traitors';
import { truthFactAnswer, type TruthQuestion } from '../game/truthtrance';
import {
actTruthCharityPayments as act, advanceTruthCharityPayments as advance, answerTruthCharityPayments as answer,
askTruthCharityPayments as ask, bindTruthCharityPayments as bind, createTruthCharityPaymentsFixture as fixture,
declareTruthCharityPayments as declare, finishTruthCharityPayments as finish,
nextTruthCharityPaymentsNative as nativeStep, reachTruthCharityPaymentsClaim as reachClaim,
settleTruthCharityPayments as settle, truthCharityPaymentsClean as clean, truthCharityPaymentsFact as fact,
truthCharityPaymentsGroup as group, truthCharityPaymentsInventory as inventory,
truthCharityPaymentsPayment as payment, truthCharityPaymentsPlayer as player,
truthCharityPaymentsPromise as promise, truthCharityPaymentsQuestion as question,
truthCharityPaymentsShip as ship, truthCharityPaymentsSpice as spice, truthCharityPaymentsTraitors as traitors,
type TruthCharityPaymentsFixture, type TruthCharityPaymentsKind, type TruthCharityPaymentsStep,
} from './fixture-truth-charity-payments';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function physical(game: Game, f: TruthCharityPaymentsFixture, copies: 1 | 2 = 1) {
for (const truth of f.truths.slice(0, copies)) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
assert.deepEqual(spice(game), f.originalSpice);
}
const pay = (game: Game, f: TruthCharityPaymentsFixture, amount: number, recipient = f.recipient,
actions: TruthCharityPaymentsStep[] = []) => act(game, f.target, { type: 'bribe', target: recipient, amount }, actions);
void test('Charity installment sources preserve native Amal reductions, openings, original cards and an unused actual claim', () => {
for (const source of ['amalOpening', 'afterAmal', 'thumper', 'phase2'] as const) {
const f = fixture({ source }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.deepEqual(f.originalCards, treacheryDeck(['ix']).map(card => card.id).sort());
assert.deepEqual(f.originalTraitors, traitorDeck(f.setup.players, true).sort()); assert.ok(f.originalTraitors.includes(CHEAP_HERO_TRAITOR));
assert.equal(f.originalCards.length, 47); assert.equal(f.originalSpice.length, spiceDeck(true).length);
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
assert.equal(f.game.phase, source === 'phase2' ? 2 : 1);
assert.equal(!!f.game.phaseOpening, source === 'amalOpening');
assert.equal(player(f.game, f.target).spice, source === 'amalOpening' || source === 'thumper' ? 10 : 5);
assert.notEqual(player(f.game, f.target).charityTurn, f.game.turn);
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.sourceActions.length);
assert.equal(f.sourceCounts.inherited, f.base.sourceActionCount);
assert.equal(f.sourceCounts.nativeContinuation, f.sourceActions.length - f.base.sourceActionCount);
assert.equal(f.sourceCounts.actualAmalReduction, f.reduction?.steps.length ?? 0);
if (f.reduction) {
assert.equal(player(f.reduction.before, f.target).spice, 10); assert.equal(player(f.reduction.after, f.target).spice, 5);
assert.ok(f.reduction.steps.some(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.card));
}
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
assert.deepEqual(spice(f.game), f.originalSpice);
}
});
void test('actual Amal then five paid, one native Charity claim and one later installment fulfills six with frozen facts', () => {
const f = fixture(), raw: TruthQuestion = { ...f.question, mixed: group('and', fact(f.spiceFact), f.mixed) };
const actions: TruthCharityPaymentsStep[] = []; let game = bind(f, 'yes', raw, f.game, 0, actions).game;
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card);
game = act(game, f.target, guide.action, actions); assert.equal(player(game, f.target).spice, 5);
assert.ok(game.phaseOpening); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 5 }); game = settle(game, actions);
game = pay(game, f, 5, f.recipient, actions); assert.equal(player(game, f.target).spice, 0);
assert.notEqual(viewGame(game, f.target).turnActionGuide?.action.type, 'charity', 'A future-phase quote is not an executable current action');
game = reachClaim(game, actions); assert.notEqual(player(game, f.target).charityTurn, game.turn);
assert.equal(charityQuote(game, player(game, f.target)).total, 2);
const claim = viewGame(game, f.target).turnActionGuide; assert.ok(claim); assert.equal(claim.action.type, 'charity');
assert.equal(claim.action.turnActionPromiseEvent, promise(game).event); assert.equal(claim.action.turnActionPromisePhase, 2);
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, claim.action, actions);
assert.equal(player(game, f.target).charityTurn, game.turn); assert.equal(player(game, f.target).spice, 2);
assert.equal(game.turnActionPromises![0].payments.find(row => row.recipient === f.recipient)?.amount, 5);
const final = viewGame(game, f.target).turnActionGuide; assert.ok(final); assert.equal(final.action.type, 'bribe'); assert.equal(final.action.amount, 1);
game = act(game, f.target, final.action, actions);
assert.equal(player(game, f.target).spice, 1); assert.equal(player(game, f.recipient).bribes, 6); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(game.turnActionPromises![0].mixed, raw.kind === 'turnActions' ? raw.mixed : null);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'charity').length, 1);
rejected(game, f.target, f.charity); physical(game, f);
});
void test('bare, grouped and mixed payment questions use the same real Charity installment path after actual Amal', () => {
for (const kind of ['bribe', 'bribeExpression', 'mixedBribe'] as const) {
const f = fixture({ source: 'afterAmal' }), raw = question(f, kind);
let game = bind(f, 'yes', raw).game;
const first = viewGame(game, f.target).bribePromiseGuide; assert.ok(first, kind);
assert.equal(first.action.bribePromiseEvent, promise(game, kind).event); assert.notEqual(first.action.type, 'charity');
game = pay(game, f, 5); game = reachClaim(game);
const claim = viewGame(game, f.target).bribePromiseGuide; assert.ok(claim, kind); assert.equal(claim.action.type, 'charity');
assert.equal(claim.action.bribePromiseEvent, promise(game, kind).event);
game = act(game, f.target, claim.action); assert.equal(player(game, f.target).spice, 2);
const final = viewGame(game, f.target).bribePromiseGuide; assert.ok(final); assert.equal(final.action.type, 'bribe'); assert.equal(final.action.amount, 1);
game = act(game, f.target, final.action);
assert.equal(promise(game, kind).fulfilled, true); assert.equal(player(game, f.target).spice, 1);
assert.equal(player(game, f.recipient).bribes, 6); assert.equal(player(game, f.target).charityTurn, game.turn); physical(game, f);
}
});
void test('required printed Thumper occurs before future Charity and cumulative eleven is funded by ten plus one actual claim', () => {
const f = fixture({ source: 'thumper' }); let game = bind(f).game;
game = settle(act(game, f.target, f.nativeAction));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.equal(game.turnActionPromises![0].windowCards?.thumper?.played, true);
assert.equal(player(game, f.target).spice, 10); assert.notEqual(player(game, f.target).charityTurn, game.turn);
game = pay(game, f, 10); game = reachClaim(game);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'charity');
game = act(game, f.target, guide.action); game = pay(game, f, 1);
assert.equal(player(game, f.target).spice, 1); assert.equal(player(game, f.recipient).bribes, 11);
assert.equal(player(game, f.target).charityTurn, game.turn); assert.equal(promise(game).fulfilled, true); physical(game, f);
});
void test('different recipients share one purse and one actual Charity while their installments accumulate separately', () => {
const f = fixture({ source: 'afterAmal' });
const raw: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
mixed: group('and', payment(f.recipient, 3), payment(f.observer, 3)) };
let game = bind(f, 'yes', raw).game; game = pay(game, f, 3); game = pay(game, f, 2, f.observer);
assert.equal(player(game, f.target).spice, 0); game = reachClaim(game); game = act(game, f.target, f.charity);
game = pay(game, f, 1, f.observer);
assert.equal(player(game, f.target).spice, 1); assert.equal(player(game, f.recipient).bribes, 3); assert.equal(player(game, f.observer).bribes, 3);
assert.deepEqual(game.turnActionPromises![0].payments.slice().sort((a, b) => a.recipient.localeCompare(b.recipient)),
[{ recipient: f.observer, amount: 3 }, { recipient: f.recipient, amount: 3 }].sort((a, b) => a.recipient.localeCompare(b.recipient)));
assert.equal(promise(game).fulfilled, true); rejected(game, f.target, f.charity); physical(game, f);
});
void test('a cumulative bare No cap survives the genuine Charity income and never treats that claim as another payment', () => {
const f = fixture({ source: 'afterAmal' }), raw = question(f, 'bribe'); let game = bind(f, 'no', raw).game;
game = pay(game, f, 5); game = reachClaim(game); game = act(game, f.target, f.charity);
assert.equal(player(game, f.target).spice, 2); assert.equal(game.bribePromises![0].progress, 5);
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.ok(!game.bribePromises![0].released); game = finish(game);
assert.equal(game.bribePromises![0].progress, 5); assert.equal(player(game, f.target).charityTurn, 1); physical(game, f);
});
void test('already claimed or genuinely expired Charity cannot be reserved again for a new positive payment', () => {
const claimed = fixture({ source: 'phase2' }); let game = pay(claimed.game, claimed, 5);
game = act(game, claimed.target, claimed.charity); game = pay(game, claimed, 2);
assert.equal(player(game, claimed.target).spice, 0); assert.equal(player(game, claimed.target).charityTurn, game.turn);
rejected(game, claimed.target, claimed.charity);
let asked = ask(declare(claimed, game).game, claimed.asker, question(claimed, 'bribe', 1));
assert.deepEqual(viewGame(asked, claimed.target).truthBribeAnswers, ['no']); rejected(asked, claimed.target, { type: 'truthAnswer', answer: 'yes' });
game = answer(asked, claimed.target, 'no'); assert.equal(game.bribePromises![0].progress, 0); physical(game, claimed);
const expired = fixture({ source: 'afterAmal' }); game = pay(expired.game, expired, 5);
game = advance(game, state => state.phase === 3 && clean(state), [], true);
assert.notEqual(player(game, expired.target).charityTurn, game.turn); assert.equal(player(game, expired.target).spice, 0);
rejected(game, expired.target, expired.charity);
asked = ask(declare(expired, game).game, expired.asker, question(expired, 'bribe', 1));
assert.deepEqual(viewGame(asked, expired.target).truthBribeAnswers, ['no']);
game = answer(asked, expired.target, 'no'); physical(game, expired);
});
void test('a phase2 positive answer reopens real own Ready and cannot forfeit its necessary Charity claim', () => {
for (const kind of ['turnActions', 'bribe'] as const) {
const f = fixture({ source: 'phase2' }); let source = pay(f.game, f, 5);
source = act(source, f.target, { type: 'ready' }); assert.ok(source.ready.includes(f.target));
let game = bind(f, 'yes', question(f, kind, 1), source).game;
assert.equal(game.ready.includes(f.target), false); rejected(game, f.target, { type: 'ready' });
const view = viewGame(game, f.target), guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide;
assert.ok(guide); assert.equal(guide.action.type, 'charity'); game = act(game, f.target, guide.action);
game = pay(game, f, 1); assert.equal(promise(game, kind).fulfilled, true); assert.equal(player(game, f.target).spice, 1);
assert.equal(player(game, f.recipient).bribes, 6); assert.equal(player(game, f.target).charityTurn, game.turn); physical(game, f);
}
});
void test('one native Charity is shared with shipment and cannot be claimed early to strand remaining payments', () => {
const f = fixture({ source: 'afterAmal' });
const impossible: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
mixed: group('and', payment(f.recipient, 6), ship()) };
const asked = ask(declare(f).game, f.asker, impossible);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const funded: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
mixed: group('and', payment(f.recipient, 5), ship()) };
let game = bind(f, 'yes', funded).game; game = pay(game, f, 5); game = reachClaim(game); game = act(game, f.target, f.charity);
assert.equal(player(game, f.target).spice, 2);
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
game = settle(act(game, f.target, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 }));
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).charityTurn, game.turn); physical(game, f);
const opening = fixture(); assert.ok(opening.printed);
const seven: TruthQuestion = { kind: 'turnActions', target: opening.target, turn: opening.game.turn,
mixed: group('and', opening.printed, payment(opening.recipient, 7)) };
let manual = bind(opening, 'yes', seven).game;
manual = settle(act(manual, opening.target, opening.nativeAction)); manual = pay(manual, opening, 4);
manual = reachClaim(manual); assert.equal(player(manual, opening.target).spice, 1);
assert.equal(charityQuote(manual, player(manual, opening.target)).total, 1);
rejected(manual, opening.target, opening.charity);
assert.notEqual(player(manual, opening.target).charityTurn, manual.turn); assert.ok(!promise(manual).released);
const prepay = viewGame(manual, opening.target).turnActionGuide; assert.ok(prepay);
assert.equal(prepay.action.type, 'bribe'); assert.equal(prepay.action.amount, 1);
manual = act(manual, opening.target, prepay.action); assert.equal(player(manual, opening.target).spice, 0);
const claim = viewGame(manual, opening.target).turnActionGuide; assert.ok(claim); assert.equal(claim.action.type, 'charity');
manual = act(manual, opening.target, claim.action); assert.equal(player(manual, opening.target).spice, 2);
const remaining = viewGame(manual, opening.target).turnActionGuide; assert.ok(remaining);
assert.equal(remaining.action.type, 'bribe'); assert.equal(remaining.action.amount, 2);
manual = act(manual, opening.target, remaining.action);
assert.equal(player(manual, opening.target).spice, 0); assert.equal(player(manual, opening.recipient).bribes, 7);
assert.equal(promise(manual).fulfilled, true); assert.ok(!promise(manual).released); physical(manual, opening);
});
void test('two genuine Truths cannot reserve the same Charity twice across different recipient promises', () => {
const f = fixture({ source: 'afterAmal' }); let game = bind(f, 'yes', question(f, 'bribe', 6)).game;
const earlier = structuredClone(game.bribePromises![0]), history = structuredClone(game.truthHistory![0]);
const second = question(f, 'bribe', 2, f.observer), asked = ask(declare(f, game, 1).game, f.asker, second);
assert.deepEqual(viewGame(asked, f.target).truthBribeAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
game = answer(asked, f.target, 'no'); assert.deepEqual(game.bribePromises![0], earlier); assert.deepEqual(game.truthHistory![0], history);
assert.equal(player(game, f.target).spice, 5); assert.notEqual(player(game, f.target).charityTurn, game.turn); physical(game, f, 2);
});
void test('current Charity guide packets retain exact owner and original promise event through JSON and reject stale claims', () => {
for (const kind of ['turnActions', 'bribe'] as const) {
const f = fixture({ source: 'phase2' }); let game = bind(f, 'yes', question(f, kind)).game; game = pay(game, f, 5);
const view = viewGame(game, f.target), guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide;
assert.ok(guide); assert.equal(guide.action.type, 'charity');
const event = kind === 'turnActions' ? guide.action.turnActionPromiseEvent : guide.action.bribePromiseEvent;
assert.equal(event, promise(game, kind).event); rejected(game, f.asker, guide.action);
rejected(game, f.target, kind === 'turnActions' ? { ...guide.action, turnActionPromiseEvent: 'stale-charity-event' }
: { ...guide.action, bribePromiseEvent: 'stale-charity-event' });
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, packet);
rejected(game, f.target, packet); assert.equal(player(game, f.target).charityTurn, game.turn);
const next = viewGame(game, f.target), final = kind === 'turnActions' ? next.turnActionGuide : next.bribePromiseGuide;
assert.ok(final); assert.equal(final.action.type, 'bribe'); assert.equal(final.action.amount, 1); game = act(game, f.target, final.action);
assert.equal(promise(game, kind).fulfilled, true); physical(game, f);
}
});
void test('all four minimal profiles execute actual prepayments, one Charity and later installments using current-phase guides', () => {
for (const kind of ['turnActions', 'bribe'] as TruthCharityPaymentsKind[]) {
const f = fixture({ source: kind === 'turnActions' ? 'amalOpening' : 'afterAmal' });
const raw = kind === 'turnActions' ? f.question : question(f, kind), asked = ask(declare(f).game, f.asker, raw);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthCharityPaymentsStep[] = [];
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, `${kind}/${difficulty}`);
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
for (let count = 0; count < 100 && !promise(game, kind).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide;
if (guide) {
const action = botActions(view).find(candidate => kind === 'turnActions'
? candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent
: candidate.bribePromiseEvent === guide.action.bribePromiseEvent);
assert.ok(action, `${kind}/${difficulty}`); assert.deepEqual(action, guide.action);
if (action.type === 'charity') assert.equal(game.phase, 2);
game = act(game, f.target, action, actions);
} else {
const next = nativeStep(game); assert.ok(next, `${kind}/${difficulty}`); game = act(game, next.actor, next.action, actions);
}
}
assert.equal(promise(game, kind).fulfilled, true, `${kind}/${difficulty}`);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'charity').length, 1);
assert.ok(actions.filter(step => step.actor === f.target && step.action.type === 'bribe').length >= 2);
assert.equal(player(game, f.recipient).bribes, 6); assert.equal(player(game, f.target).charityTurn, game.turn); physical(game, f);
}
}
});
+39
View File
@@ -85,6 +85,10 @@ function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen
game.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 2, mixed: claim(), answer: true, fulfilled: false, released: false }];
game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } };
game.truthTurnActionAnswers = null;
if (next.type === 'charity') {
const me = game.players.find(player => player.id === 'p')!; me.spice = 0; me.charityClaimed = false;
game.charity = { ...game.charity, ordinary: 2, homeworld: 0, total: 2, amount: 2, incomePending: false };
}
if (next.type === 'card' && typeof next.card === 'string') {
const cardId = next.card, effect = (['hajr', 'ghola', 'weather', 'atomics', 'harvester', 'thumper', 'amal'] as const)
.find(effect => cardId.includes(effect)) ?? 'karama';
@@ -1455,6 +1459,41 @@ void test('turn-action current early native opportunities retain exact preparati
assert.match(markup(auction({ type: 'decision', karama: true, card: 'controlled-owned-karama' })), /Use the quoted Karama for the current auction payment/);
});
void test('whole payment timelines forward an exact positive unused Charity quote without treating it as current cash', () => {
const game = projected(2, { type: 'charity' }), sent: Action[] = [];
game.turnActionPromises[0].mixed = payment(3); game.bribeOptions.available = 0; game.bribeOptions.targets = [];
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.deepEqual(sent[0], { type: 'charity', turnActionPromiseEvent: event, turnActionPromisePhase: 2 });
assert.equal(game.players.find(player => player.id === 'p')!.spice, 0);
assert.match(markup(game), /Claim 2 Charity spice while preserving your whole answer/);
assert.match(markup(game), /future claim is not current spendable spice/);
});
void test('whole Charity guides reject claimed, stale, unready native quotes and extra fields without generic fallback', () => {
for (const change of [
(game: GameView) => { game.players.find(player => player.id === 'p')!.charityClaimed = true; },
(game: GameView) => { game.charity.amount = 0; },
(game: GameView) => { game.charity.incomePending = true; },
(game: GameView) => { game.charity = { ...game.charity }; },
(game: GameView) => { game.players.find(player => player.id === 'p')!.spice = 1; },
(game: GameView) => { game.ready.push('p'); },
(game: GameView) => { game.phase = 3; },
]) {
const game = projected(2, { type: 'charity' }), saved = guide({ game, busy: false, act() { assert.fail('A stale whole-answer Charity claim cannot send'); } });
assert.equal(saved.props.disabled, false); change(game); press(saved);
}
for (const extra of [{ target: 'r' }, { amount: 2 }, { card: 'private-card' }, { mode: 'shipment' }]) {
const game = projected(2, { type: 'charity' }); Object.assign(game.turnActionGuide!.action, extra);
const button = guide({ game, busy: false, act() { assert.fail('Charity accepts no extra native parameters'); } });
assert.equal(button.props.disabled, true); press(button);
}
const pending = projected(2, { type: 'charity' }); pending.charity.incomePending = true;
assert.equal(guide({ game: pending, busy: false, act() { assert.fail('Pending CHOAM income cannot be counted as an ordinary claim'); } }).props.disabled, true);
const ready = projected(2, { type: 'charity' }); ready.ready.push('p');
assert.equal(guide({ game: ready, busy: false, act() { assert.fail('A readied owner cannot open this preparation guide'); } }).props.disabled, true);
});
void test('turn-action current paid Box choices are exact through phases zero to eight without private custody reads', () => {
for (let phase = 0; phase <= 8; phase++) {
const game = projected(phase, { type: 'decision', event: 'controlled-paid-search', card: 'controlled-recovered-hajr' });