Fund ordinary promises from native CHOAM opening income

This commit is contained in:
Quintine committed 2026-10-27 06:42:56 +11:00
1 parent 38738eabac
commit 3abde581db
13 files changed
+663 -24

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { choamOpeningIncome } from '../game/charity';
import { treacheryDeck } from '../game/cards';
import { traitorDeck } from '../game/traitors';
import type { TruthQuestion } from '../game/truthtrance';
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
import { actChoamCharityFunding, nextChoamCharityFundingStep } from './fixture-choam-charity-funding';
import { truthCharityPaymentsFact, truthCharityPaymentsGroup, truthCharityPaymentsPayment,
truthCharityPaymentsShip, truthCharityPaymentsPromise, type TruthCharityPaymentsKind } from './fixture-truth-charity-payments';
import { truthTurnThumperInventory, truthTurnThumperPlayer, truthTurnThumperSpice,
truthTurnThumperTraitors, type TruthTurnThumperStep } from './fixture-truth-turn-thumper';
export type TruthChoamIncomeStep = TruthTurnThumperStep;
export type TruthChoamIncomeKind = TruthCharityPaymentsKind;
export const actTruthChoamIncome = actChoamCharityFunding;
export const truthChoamIncomePlayer = truthTurnThumperPlayer;
export const truthChoamIncomeInventory = truthTurnThumperInventory;
export const truthChoamIncomeSpice = truthTurnThumperSpice;
export const truthChoamIncomeTraitors = truthTurnThumperTraitors;
export const truthChoamIncomePromise = truthCharityPaymentsPromise;
export const truthChoamIncomeFact = truthCharityPaymentsFact;
export const truthChoamIncomeGroup = truthCharityPaymentsGroup;
export const truthChoamIncomePayment = truthCharityPaymentsPayment;
export const truthChoamIncomeShip = truthCharityPaymentsShip;
export const truthChoamIncomeClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival && !game.pendingShipment;
/** Native setup and original quiet controls. Market opportunities are passed;
* no sale, ordinary claim or counter decision is manufactured by this driver. */
export function nextTruthChoamIncomeNative(game: Game): TruthChoamIncomeStep | null {
if (game.truthtrance) return null;
const bg = game.players.find(player => player.faction === 'beneGesserit');
if (game.status === 'setup' && game.setupStage === 'prediction' && bg)
return { actor: bg.id, action: { type: 'predict', faction: 'harkonnen', turn: 10 } };
if (game.status === 'setup' && game.setupStage === 'forces' && bg && !bg.advisorSetup)
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'polar_sink', sector: 0 } };
if (!game.phaseOpening && !game.response && game.decision?.kind === 'advisorBattle')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
return nextChoamCharityFundingStep(game);
}
export function nextTruthChoamIncome(game: Game): TruthChoamIncomeStep | 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 nextTruthChoamIncomeNative(game);
}
export function advanceTruthChoamIncome(game: Game, until: (state: Game) => boolean,
actions: TruthChoamIncomeStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original CHOAM income source ended before its native boundary');
const step = nativeOnly ? nextTruthChoamIncomeNative(game) : nextTruthChoamIncome(game);
assert.ok(step, 'Supply the actual Truthtrance question or original private Battle Plan');
game = actTruthChoamIncome(game, step.actor, step.action, actions);
}
throw Error('The genuine CHOAM income chronology did not reach its finite boundary');
}
export function settleTruthChoamIncome(game: Game, actions: TruthChoamIncomeStep[] = []): Game {
return advanceTruthChoamIncome(game, truthChoamIncomeClean, actions, true);
}
export function reachTruthChoamIncomeResponse(game: Game, actions: TruthChoamIncomeStep[] = []): Game {
return advanceTruthChoamIncome(game, state => state.phase === 2 && !state.phaseOpening && !state.truthtrance &&
state.response?.kind === 'choamCharity', actions, true);
}
export function allowTruthChoamIncome(game: Game, actions: TruthChoamIncomeStep[] = []): Game {
assert.equal(game.response?.kind, 'choamCharity'); const turn = game.turn;
return advanceTruthChoamIncome(game, state => state.choamCharity?.turn === turn && truthChoamIncomeClean(state), actions, true);
}
/** Public original H/A/CHOAM selected-Ix game. Select H's two unopened Truths
* and A's printed Karama. The optional BG counter source also selects the
* second original Karama in CHOAM's native one-card initial deal slot. */
export function createTruthChoamIncomeFixture(options: {
advanced?: boolean; bg?: boolean; providerKarama?: boolean; stage?: 'beforeIncome' | 'afterIncome';
} = {}) {
const advanced = options.advanced ?? false, withBG = options.bg ?? false, stage = options.stage ?? 'beforeIncome';
const providerKarama = options.providerKarama ?? withBG;
assert.ok(!withBG || advanced, 'The bounded wealthy BG claim source uses its native Advanced rule');
const asker = 'harkonnen', observer = 'atreides', target = 'choam', recipient = asker, bg = withBG ? 'beneGesserit' : null;
let game = createGame('TRUTHCHOAMINCOME', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced, ['ix', 'choam']);
joinGame(game, newPlayer(observer, 'Atreides', 'atreides')); joinGame(game, newPlayer(target, 'CHOAM', 'choam'));
if (bg) joinGame(game, newPlayer(bg, 'Bene Gesserit', 'beneGesserit'));
const initial = structuredClone(game), actions: TruthChoamIncomeStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthChoamIncome(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthChoamIncome(game, until, actions, true); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length));
if (bg) act(bg, { type: 'predict', faction: 'harkonnen', turn: 10 });
const originalCards = truthChoamIncomeInventory(game), originalTraitors = truthChoamIncomeTraitors(game), originalSpice = truthChoamIncomeSpice(game);
assert.deepEqual(originalCards, treacheryDeck(['ix', 'choam']).map(card => card.id).sort());
assert.deepEqual(originalTraitors, traitorDeck(game.players, true).sort());
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
const providerCard = providerKarama ? game.deck.find(card => card.effect === 'karama' && card.id !== karama.id) : null;
assert.ok(!providerKarama || providerCard, 'Select only the second physical printed Karama already in the unopened original deck');
// Native dealStartingTreachery deals H two, A one, then CHOAM one: index3.
const dealOrder = [...truths.map(card => card.id), karama.id, ...(providerCard ? [providerCard.id] : [])].map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), startCash = truthChoamIncomePlayer(game, target).spice;
const startingChoamCards = truthChoamIncomePlayer(game, target).hand.map(card => card.id);
assert.equal(startCash, 2); assert.deepEqual(truthChoamIncomePlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
assert.ok(truthChoamIncomePlayer(game, observer).hand.some(card => card.id === karama.id));
if (providerCard) assert.deepEqual(startingChoamCards, [providerCard.id]);
reach(beforeIxSpiceDraw);
const beforeIncome = structuredClone(game), beforeIncomeActions = structuredClone(actions);
const spiceOrder = orderIxSpiceCards(game, [{ territory: 'hagga_basin' }, ...(advanced ? [{ territory: 'broken_land' }] : [])]);
const incomeAmount = choamOpeningIncome(game, truthChoamIncomePlayer(game, target));
assert.equal(game.choamCharity?.turn === game.turn, false); assert.notEqual(truthChoamIncomePlayer(game, target).charityTurn, game.turn);
let income: { before: Game; steps: TruthChoamIncomeStep[]; after: Game } | null = null;
if (stage === 'afterIncome') {
game = reachTruthChoamIncomeResponse(game, actions); const before = structuredClone(game), from = actions.length;
game = allowTruthChoamIncome(game, actions); income = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
assert.deepEqual(game.choamCharity, { turn: game.turn, canceled: false });
assert.equal(truthChoamIncomePlayer(game, target).spice, startCash + incomeAmount);
}
assert.equal(truthChoamIncomePlayer(game, target).spice, startCash + (stage === 'afterIncome' ? incomeAmount : 0));
assert.notEqual(truthChoamIncomePlayer(game, target).charityTurn, game.turn);
assert.deepEqual(truthChoamIncomePlayer(game, target).hand.map(card => card.id), startingChoamCards);
assert.deepEqual(truthChoamIncomeInventory(game), originalCards); assert.deepEqual(truthChoamIncomeTraitors(game), originalTraitors);
assert.deepEqual(truthChoamIncomeSpice(game), originalSpice);
const minimum = startCash + incomeAmount, maximum = minimum + 2;
const spiceFact = { kind: 'spice', compare: 'eq', value: truthChoamIncomePlayer(game, target).spice } as const;
const question: TruthQuestion = { kind: 'turnActions', target, turn: game.turn,
mixed: truthChoamIncomeGroup('and', truthChoamIncomeFact(spiceFact), truthChoamIncomePayment(recipient, minimum)) };
return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, beforeIncome, beforeIncomeActions,
asker, target, recipient, observer, bg, advanced, stage, providerKarama, providerCard: providerCard?.id ?? null,
startCash, incomeAmount, minimum, maximum,
startingChoamCards, truths: truths.map(card => card.id), truth: truths[0].id, karama: karama.id, question, spiceFact,
payment: { type: 'bribe', target: recipient, amount: minimum } satisfies Action,
charity: { type: 'charity' } satisfies Action,
cancel: { type: 'card', card: karama.id, mode: 'cancel' } satisfies Action,
dealOrder, spiceOrder, income, originalCards, originalTraitors, originalSpice,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { selected: actions.length, beforeIncome: beforeIncomeActions.length, actualIncomeResponse: income?.steps.length ?? 0 },
sourceDisclosure: 'Genuine public Harkonnen/Atreides/CHOAM selected-Ix/CHOAM setup, with optional original Advanced BG prediction and advisor setup. ' +
'Only original H Truths, A printed Karama and optionally the second original printed Karama in CHOAM native deal slot3, safe unopened Spice positions and scalar lotteries are selected; BG retains its normal native card. ' +
'Native first Storm, closing market passes and Amal-opening passes reach original phase1 before the first real reveal. Any allowed opening receipt follows the actual phase2 counter-response passes. ' +
'Forecast assertions are bounded to current cash, the one original automatic CHOAM opening receipt and one unused ordinary Charity claim. Later held-card market sales, collection, gifts and foreign choices are not forecast or claimed globally impossible. No hand, purse, army, phase, pass list, receipt or claim marker is installed. Counts derive from actual action arrays.',
};
}
export type TruthChoamIncomeFixture = ReturnType<typeof createTruthChoamIncomeFixture>;
export function truthChoamIncomeQuestion(f: TruthChoamIncomeFixture, kind: TruthChoamIncomeKind,
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: truthChoamIncomeGroup('and', truthChoamIncomeFact(f.spiceFact), truthChoamIncomePayment(recipient, minimum)) };
}
export function declareTruthChoamIncome(f: TruthChoamIncomeFixture, source = f.game, copy: 0 | 1 = 0, actions: TruthChoamIncomeStep[] = []) {
let game = actTruthChoamIncome(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 = actTruthChoamIncome(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions };
}
export function askTruthChoamIncome(game: Game, asker: string, question: TruthQuestion, actions: TruthChoamIncomeStep[] = []): Game {
return actTruthChoamIncome(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions);
}
export function answerTruthChoamIncome(game: Game, target: string, answer: 'yes' | 'no' | 'unknown', actions: TruthChoamIncomeStep[] = []): Game {
return actTruthChoamIncome(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthChoamIncome(f: TruthChoamIncomeFixture, answer: 'yes' | 'no' = 'yes', question: TruthQuestion = f.question,
source = f.game, copy: 0 | 1 = 0, actions: TruthChoamIncomeStep[] = []) {
const declared = declareTruthChoamIncome(f, source, copy, actions), asked = askTruthChoamIncome(declared.game, f.asker, question, actions);
return { game: answerTruthChoamIncome(asked, f.target, answer, actions), declared, asked, actions };
}
+34
View File
@@ -122,6 +122,40 @@ void test('own bribe promises persist with named cumulative progress, delayed sp
assert.equal(BribePromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('own pending automatic CHOAM income guidance preserves legal current prepayment callbacks', () => {
for (const phase of [0, 2]) {
const game = projected(phase); game.players.find(player => player.id === 'p')!.faction = 'choam'; game.expansions = ['choam'];
game.charity.incomePending = true; game.charity.incomeCanceled = false;
const html = markup(game); assert.match(html, /data-testid="choam-pending-payment-income"/);
assert.match(html, /Automatic CHOAM income resolves at the Charity opening/); assert.match(html, /not current spendable cash/);
assert.match(html, /ordinary Charity claim is separate/); assert.match(html, /Karama or opposing claims can change the funding/);
assert.match(html, /may still offer legal prepayments/);
if (phase === 0) {
const sent: Action[] = [], 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);
game.bribeOptions.available = 0; press(button); assert.equal(sent.length, 1, 'Pending automatic income does not raise the current payment quote.');
}
}
});
void test('automatic CHOAM payment note is owner-only, live-only and ends when income resolves or is canceled', () => {
for (const change of [
(game: GameView) => { game.players.find(player => player.id === 'p')!.faction = 'emperor'; game.players.find(player => player.id === 'a')!.faction = 'choam'; },
(game: GameView) => { game.me = 'a'; },
(game: GameView) => { game.charity.incomePending = false; },
(game: GameView) => { game.charity.multiplier = 0; },
(game: GameView) => { game.charity.incomePending = false; game.charity.incomeCanceled = true; },
(game: GameView) => { game.phase = 3; },
(game: GameView) => { game.bribePromises[0].fulfilled = true; },
(game: GameView) => { game.bribePromises[0].released = true; },
]) {
const game = projected(1); game.players.find(player => player.id === 'p')!.faction = 'choam'; game.expansions = ['choam'];
game.charity.incomePending = true; game.charity.incomeCanceled = false; game.bribePromiseGuide = null;
assert.match(markup(game), /data-testid="choam-pending-payment-income"/); change(game);
assert.doesNotMatch(markup(game), /data-testid="choam-pending-payment-income"/);
}
});
void test('actual guide callbacks forward exact ordinary bribes out of turn throughout their supported phases', () => {
for (const phase of [0, 3, 4, 7]) {
const game = projected(phase), sent: Action[] = [];
+246
View File
@@ -0,0 +1,246 @@
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 {
actTruthChoamIncome as act, advanceTruthChoamIncome as advance, allowTruthChoamIncome as allow,
answerTruthChoamIncome as answer, askTruthChoamIncome as ask, bindTruthChoamIncome as bind,
createTruthChoamIncomeFixture as fixture, declareTruthChoamIncome as declare,
nextTruthChoamIncomeNative as nativeStep, reachTruthChoamIncomeResponse as reachIncome,
settleTruthChoamIncome as settle, truthChoamIncomeClean as clean,
truthChoamIncomeGroup as group, truthChoamIncomeInventory as inventory,
truthChoamIncomePayment as payment, truthChoamIncomePlayer as player,
truthChoamIncomePromise as promise, truthChoamIncomeQuestion as question,
truthChoamIncomeShip as ship, truthChoamIncomeSpice as spice, truthChoamIncomeTraitors as traitors,
type TruthChoamIncomeFixture, type TruthChoamIncomeStep,
} from './fixture-truth-choam-income';
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: TruthChoamIncomeFixture, copies: 0 | 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: TruthChoamIncomeFixture, amount: number, recipient = f.recipient,
actions: TruthChoamIncomeStep[] = []) => act(game, f.target, { type: 'bribe', target: recipient, amount }, actions);
void test('native CHOAM income sources retain the original Ix deal, automatic receipt and distinct unused ordinary claim', () => {
for (const options of [{}, { stage: 'afterIncome' as const }, { advanced: true }, { advanced: true, bg: true }] ) {
const f = fixture(options); 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', 'choam']).map(card => card.id).sort()); assert.equal(f.originalCards.length, 47);
assert.deepEqual(f.originalTraitors, traitorDeck(f.afterSetup.players, true).sort()); assert.ok(f.originalTraitors.includes(CHEAP_HERO_TRAITOR));
assert.equal(f.originalSpice.length, spiceDeck(true).length); assert.equal(f.originalSpice.length, 22);
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
assert.ok(player(f.game, f.observer).hand.some(card => card.id === f.karama));
assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), f.startingChoamCards);
assert.equal(f.startCash, 2); assert.equal(f.incomeAmount, 2 * f.game.players.length);
assert.equal(player(f.game, f.target).spice, f.startCash + (f.income ? f.incomeAmount : 0));
assert.notEqual(player(f.game, f.target).charityTurn, f.game.turn);
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.sourceCounts.selected, f.actions.length);
assert.equal(f.sourceCounts.beforeIncome, f.beforeIncomeActions.length); assert.equal(f.sourceCounts.actualIncomeResponse, f.income?.steps.length ?? 0);
if (f.income) {
assert.equal(f.income.before.response?.kind, 'choamCharity'); assert.ok(f.income.steps.every(step => step.action.type === 'passResponse'));
assert.equal(player(f.income.before, f.target).spice, 2); assert.equal(player(f.income.after, f.target).spice, 2 + f.incomeAmount);
assert.deepEqual(f.game.choamCharity, { turn: 1, canceled: false });
} else assert.notEqual(f.game.choamCharity?.turn, f.game.turn);
const asked = ask(declare(f).game, f.asker, question(f, 'bribe', f.maximum)), before = structuredClone(asked);
assert.deepEqual(viewGame(asked, f.target).truthBribeAnswers, ['yes', 'no']);
assert.deepEqual(asked, before, 'A quote never credits automatic income or stamps an ordinary claim'); physical(asked, f, 0);
}
});
void test('allowed automatic CHOAM opening income funds whole, bare, grouped and mixed installments with frozen facts', () => {
for (const kind of ['turnActions', 'bribe', 'bribeExpression', 'mixedBribe'] as const) {
const f = fixture(), raw = question(f, kind); const actions: TruthChoamIncomeStep[] = [];
let game = bind(f, 'yes', raw, f.game, 0, actions).game;
const recipientCash = player(game, f.recipient).spice;
game = pay(game, f, f.startCash, f.recipient, actions); assert.equal(player(game, f.target).spice, 0);
assert.equal(player(game, f.recipient).spice, recipientCash); assert.equal(player(game, f.recipient).bribes, f.startCash);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.ok(!promise(game, kind).fulfilled);
assert.notEqual(game.choamCharity?.turn, game.turn); assert.notEqual(player(game, f.target).charityTurn, game.turn);
game = reachIncome(game, actions); assert.equal(game.response?.kind, 'choamCharity');
rejected(game, f.target, f.charity); assert.equal(player(game, f.target).spice, 0);
game = allow(game, actions); assert.equal(player(game, f.target).spice, f.incomeAmount);
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false }); assert.notEqual(player(game, f.target).charityTurn, game.turn);
game = pay(game, f, f.incomeAmount, f.recipient, actions);
assert.equal(promise(game, kind).fulfilled, true); assert.equal(player(game, f.target).spice, 0);
assert.equal(player(game, f.recipient).spice, recipientCash); assert.equal(player(game, f.recipient).bribes, f.minimum);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'bribe').length, 2);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'charity').length, 0); physical(game, f);
}
});
void test('the fixed CHOAM receipt and one ordinary Charity fund the maximum without sharing either marker twice', () => {
const f = fixture(); const actions: TruthChoamIncomeStep[] = [];
let game = bind(f, 'yes', question(f, 'turnActions', f.maximum), f.game, 0, actions).game;
game = pay(game, f, f.startCash, f.recipient, actions); game = allow(reachIncome(game, actions), actions);
game = pay(game, f, f.incomeAmount, f.recipient, actions); assert.equal(player(game, f.target).spice, 0);
assert.equal(charityQuote(game, player(game, f.target)).total, 2); assert.notEqual(player(game, f.target).charityTurn, game.turn);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'charity');
assert.equal(guide.action.turnActionPromiseEvent, promise(game).event); assert.equal(guide.action.turnActionPromisePhase, 2);
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, guide.action, actions);
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).charityTurn, game.turn);
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
assert.equal(game.turnActionPromises![0].payments[0].amount, f.minimum, 'Neither automatic nor claimed income is itself a payment');
game = pay(game, f, 2, f.recipient, actions); assert.equal(player(game, f.target).spice, 0);
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.recipient).bribes, f.maximum);
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('two original Truths cannot reserve the same automatic CHOAM income and ordinary claim for different recipients', () => {
const f = fixture(); let game = bind(f, 'yes', question(f, 'bribe', f.maximum)).game;
const earlier = structuredClone(game.bribePromises![0]), history = structuredClone(game.truthHistory![0]);
const asked = ask(declare(f, game, 1).game, f.asker, question(f, 'bribe', 1, f.observer));
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, f.startCash); assert.notEqual(game.choamCharity?.turn, game.turn);
assert.notEqual(player(game, f.target).charityTurn, game.turn); physical(game, f, 2);
});
void test('real Karama cancellation releases a large fixed-income commitment while a current-cash plus ordinary-claim alternative survives', () => {
for (const minimum of [10, 4]) {
const f = fixture(); let game = bind(f, 'yes', question(f, 'turnActions', minimum)).game;
game = pay(game, f, f.startCash); game = reachIncome(game);
game = settle(act(game, f.observer, f.cancel));
assert.deepEqual(game.choamCharity, { turn: 1, canceled: true }); assert.equal(player(game, f.target).spice, 0);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(game.turnActionPromises![0].payments[0].amount, f.startCash);
if (minimum === 10) { assert.equal(promise(game).released, true); assert.ok(!promise(game).fulfilled); }
else {
assert.ok(!promise(game).released); const claim = viewGame(game, f.target).turnActionGuide; assert.ok(claim);
assert.equal(claim.action.type, 'charity'); game = act(game, f.target, claim.action); game = pay(game, f, 2);
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.recipient).bribes, 4);
}
physical(game, f);
}
});
void test('already collected automatic income and claimed or expired ordinary Charity cannot be quoted a second time', () => {
const f = fixture({ stage: 'afterIncome' });
let asked = ask(declare(f).game, f.asker, question(f, 'bribe', f.maximum + 1));
assert.deepEqual(viewGame(asked, f.target).truthBribeAnswers, ['no']);
let game = pay(f.game, f, f.minimum); game = act(game, f.target, f.charity); game = pay(game, f, 2);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).charityTurn, game.turn);
rejected(game, f.target, f.charity); asked = ask(declare(f, game).game, f.asker, question(f, 'bribe', 1));
assert.deepEqual(viewGame(asked, f.target).truthBribeAnswers, ['no']); game = answer(asked, f.target, 'no'); physical(game, f);
const expired = fixture({ stage: 'afterIncome' }); game = pay(expired.game, expired, expired.minimum);
game = advance(game, state => state.phase === 3 && clean(state), [], true);
assert.equal(player(game, expired.target).spice, 0); assert.notEqual(player(game, expired.target).charityTurn, game.turn);
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false }); 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('payments and a later original shipment share the same single CHOAM receipt and ordinary claim', () => {
const f = fixture();
const unfunded: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
mixed: group('and', payment(f.recipient, f.maximum - 1), ship()) };
const asked = ask(declare(f).game, f.asker, unfunded);
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, f.minimum), ship()) };
let game = bind(f, 'yes', funded).game; game = pay(game, f, f.startCash); game = allow(reachIncome(game));
game = pay(game, f, f.incomeAmount); game = act(game, f.target, f.charity);
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.recipient).bribes, f.minimum);
assert.equal(player(game, f.target).charityTurn, 1); assert.deepEqual(game.choamCharity, { turn: 1, canceled: false }); physical(game, f);
});
void test('an actual opposing ordinary claim debits CHOAM and releases a commitment beyond the remaining fixed income without veto', () => {
const f = fixture({ stage: 'afterIncome' }); let game = bind(f, 'yes', question(f, 'bribe', f.maximum)).game;
const opponentCash = player(game, f.observer).spice;
game = act(game, f.observer, { type: 'bribe', target: f.recipient, amount: opponentCash });
assert.equal(player(game, f.observer).spice, 0); assert.equal(player(game, f.target).spice, f.minimum);
game = act(game, f.observer, f.charity);
assert.equal(player(game, f.observer).spice, 2); assert.equal(player(game, f.target).spice, f.minimum - 2);
assert.equal(player(game, f.observer).charityTurn, game.turn); assert.notEqual(player(game, f.target).charityTurn, game.turn);
assert.equal(promise(game, 'bribe').released, true); assert.equal(game.bribePromises![0].progress, 0); physical(game, f);
});
void test('Advanced BG forced settlement after CHOAM passes last releases the fixed-income promise without attributing a voluntary debit', () => {
const f = fixture({ advanced: true, bg: true }); assert.ok(f.bg);
assert.ok(f.providerCard); assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.providerCard]);
for (const kind of ['turnActions', 'bribe'] as const) {
let game = bind(f, 'yes', question(f, kind, f.maximum), structuredClone(f.game)).game;
game = pay(game, f, f.startCash); game = allow(reachIncome(game));
assert.equal(player(game, f.target).spice, f.incomeAmount); assert.equal(player(game, f.bg).spice, 5);
game = act(game, f.bg, f.charity); assert.equal(game.response?.kind, 'bgCharity');
assert.equal(player(game, f.target).spice, f.incomeAmount); assert.ok(!promise(game, kind).released);
while (game.response?.kind === 'bgCharity') {
const response = game.response;
const actor = game.players.find(candidate => candidate.id !== f.target && candidate.id !== response.owner &&
!response.passed.includes(candidate.id) && !!viewGame(game, candidate.id).responseControls?.cancelCards.length);
if (!actor) break;
game = act(game, actor.id, { type: 'passResponse' });
}
assert.equal(game.response?.kind, 'bgCharity'); assert.ok(!game.response.passed.includes(f.target));
assert.ok(viewGame(game, f.target).responseControls?.cancelCards.includes(f.providerCard));
game = act(game, f.target, { type: 'passResponse' });
assert.equal(game.response, null); assert.equal(player(game, f.bg).spice, 7);
assert.equal(player(game, f.target).spice, f.incomeAmount - 2); assert.equal(player(game, f.bg).charityTurn, game.turn);
assert.notEqual(player(game, f.target).charityTurn, game.turn); assert.equal(promise(game, kind).released, true);
assert.equal(kind === 'turnActions' ? game.turnActionPromises![0].payments[0].amount : game.bribePromises![0].progress, f.startCash);
assert.ok(player(game, f.target).hand.some(card => card.id === f.providerCard)); physical(game, f);
}
});
void test('actual Charity guides retain the original CHOAM promise event and owner through JSON without crediting income twice', () => {
for (const kind of ['turnActions', 'bribe'] as const) {
const f = fixture({ stage: 'afterIncome' }); let game = bind(f, 'yes', question(f, kind, f.maximum)).game;
game = pay(game, f, f.minimum);
const view = viewGame(game, f.target), guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide;
assert.ok(guide); assert.equal(guide.action.type, 'charity');
assert.equal(kind === 'turnActions' ? guide.action.turnActionPromiseEvent : guide.action.bribePromiseEvent, promise(game, kind).event);
rejected(game, f.asker, guide.action);
rejected(game, f.target, kind === 'turnActions' ? { ...guide.action, turnActionPromiseEvent: 'stale-choam-income-event' }
: { ...guide.action, bribePromiseEvent: 'stale-choam-income-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).spice, 2); assert.equal(player(game, f.target).charityTurn, game.turn);
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
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, 2);
game = act(game, f.target, final.action); assert.equal(promise(game, kind).fulfilled, true); physical(game, f);
}
});
void test('all four minimal profiles await the real CHOAM response then use legal installments and one ordinary Charity', () => {
for (const kind of ['turnActions', 'bribe'] as const) {
const f = fixture(), asked = ask(declare(f).game, f.asker, question(f, kind, f.maximum));
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthChoamIncomeStep[] = [];
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 < 160 && !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 step = nativeStep(game); assert.ok(step, `${kind}/${difficulty}`); game = act(game, step.actor, step.action, actions);
}
}
assert.equal(promise(game, kind).fulfilled, true, `${kind}/${difficulty}`);
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false }); assert.equal(player(game, f.target).charityTurn, game.turn);
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 >= 3);
assert.equal(player(game, f.recipient).bribes, f.maximum); assert.equal(player(game, f.target).spice, 0); physical(game, f);
}
}
});
+32
View File
@@ -1470,6 +1470,38 @@ void test('whole payment timelines forward an exact positive unused Charity quot
assert.match(markup(game), /future claim is not current spendable spice/);
});
void test('whole CHOAM commitments explain pending automatic income while ordinary Charity still waits for its native quote', () => {
const game = projected(2, { type: 'charity' }); game.players.find(player => player.id === 'p')!.faction = 'choam'; game.expansions = ['choam'];
game.charity.incomePending = true; game.charity.incomeCanceled = false;
const html = markup(game); assert.match(html, /data-testid="choam-pending-payment-income"/);
assert.match(html, /not current spendable cash/); assert.match(html, /ordinary Charity claim is separate/);
assert.match(html, /Karama or opposing claims can change the funding/); assert.match(html, /may still offer legal prepayments/);
const sent: Action[] = [], pending = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(pending.props.disabled, true); press(pending); assert.equal(sent.length, 0);
game.charity.incomePending = false; game.charity.incomeCanceled = true;
press(pending); assert.equal(sent.length, 0, 'The old source quote remains stale after automatic income is canceled.');
assert.doesNotMatch(markup(game), /data-testid="choam-pending-payment-income"/);
const current = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(current.props.disabled, false); press(current); assert.equal(sent[0], game.turnActionGuide!.action);
});
void test('whole automatic-income guidance stays private to live CHOAM owners before the Charity opening resolves', () => {
for (const change of [
(game: GameView) => { game.players.find(player => player.id === 'p')!.faction = 'emperor'; game.players.find(player => player.id === 'a')!.faction = 'choam'; },
(game: GameView) => { game.me = 'a'; },
(game: GameView) => { game.charity.incomePending = false; game.charity.incomeCanceled = true; },
(game: GameView) => { game.charity.multiplier = 0; },
(game: GameView) => { game.phase = 3; },
(game: GameView) => { game.turnActionPromises[0].fulfilled = true; },
(game: GameView) => { game.turnActionPromises[0].released = true; },
]) {
const game = projected(1); game.players.find(player => player.id === 'p')!.faction = 'choam'; game.expansions = ['choam'];
game.charity.incomePending = true; game.charity.incomeCanceled = false; game.turnActionGuide = null;
assert.match(markup(game), /data-testid="choam-pending-payment-income"/); change(game);
assert.doesNotMatch(markup(game), /data-testid="choam-pending-payment-income"/);
}
});
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; },