Settle CHOAM charity claims after depleted funding

Keep CHOAM's own poverty claim bank-funded. Prototype full other-faction
claims using available CHOAM cash and an explicitly provisional bank residual;
retain original eligibility, cancellation, Homeworld portions and claim limits.
Connect persistent controls and genuine public setup/bribe evidence. Complete
rules and deployed acceptance remain open.
This commit is contained in:
Quintine committed 2026-10-24 18:03:08 +11:00
1 parent 52832dc0fd
commit d63efccb55
15 files changed
+798 -58

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Children, createElement, isValidElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { CharityClaim } from '../components/charity-claim';
import {
createGame,
newPlayer,
viewGame,
type Action,
type GameView,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
// The client consumes entitlement and funding projections. Source tests cover
// the actual CHOAM debit and provisional bank shortfall without private shares.
function projected({
advanced = false,
faction = 'emperor',
spice = 0,
}: {
advanced?: boolean;
faction?: FactionId;
spice?: number;
} = {}): GameView {
const game = createGame('CHARITYCONTROLS', newPlayer('p', 'Claimant', faction), advanced, ['choam']);
game.players.push(newPlayer('other', 'Other', faction === 'choam' ? 'emperor' : 'choam'));
Object.assign(game, {
status: 'playing', phase: 2, turn: 3, order: ['p', 'other'],
choamCharity: { turn: 3, canceled: false },
});
game.players[0].spice = spice;
return viewGame(game, 'p');
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(CharityClaim, { game, busy, act() {} }));
}
function click(node: ReactNode): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{
children?: ReactNode;
onClick?: () => void;
'data-testid'?: string;
}>(child)) continue;
if (child.props['data-testid'] === 'charity-claim-submit' && child.props.onClick) {
child.props.onClick();
return true;
}
if (click(child.props.children)) return true;
}
return false;
}
function claim(game: GameView, busy = false): Action[] {
const actions: Action[] = [];
click(CharityClaim({ game, busy, act(action) { actions.push(action); } }));
return actions;
}
void test('the actual charity control submits one ordinary claim action from its current entitlement', () => {
const game = projected({ spice: 1 });
assert.match(markup(game), /If you have fewer than two spice, claim charity once to reach two/);
assert.match(markup(game), /Claim 1 charity spice/);
assert.deepEqual(claim(game), [{ type: 'charity' }]);
});
void test('busy, pending income, a completed claim and zero entitlement block the actual callback', () => {
const game = projected();
assert.deepEqual(claim(game, true), []);
assert.match(markup(game, true), /<button\b[^>]*\sdisabled=""/);
for (const field of ['incomePending', 'charityClaimed', 'amount'] as const) {
const changed = structuredClone(game);
if (field === 'incomePending') changed.charity.incomePending = true;
if (field === 'charityClaimed') changed.players[0].charityClaimed = true;
if (field === 'amount') changed.charity.amount = 0;
assert.deepEqual(claim(changed), [], field);
assert.match(markup(changed), /<button\b[^>]*\sdisabled=""/, field);
}
const noClaim = projected({ spice: 2 });
assert.equal(noClaim.charity.amount, 0);
assert.deepEqual(claim(noClaim), []);
assert.match(markup(noClaim), />Claim charity<svg/);
});
void test('charity controls preserve the existing Truthtrance and Nexus pending blocks and phase boundary', () => {
const game = projected();
const truthtrance = structuredClone(game);
truthtrance.truthtrance = {} as NonNullable<GameView['truthtrance']>;
assert.deepEqual(claim(truthtrance), []);
const nexus = structuredClone(game);
nexus.nexusTraitors = {
offer: null,
pending: {} as NonNullable<NonNullable<GameView['nexusTraitors']>['pending']>,
};
assert.deepEqual(claim(nexus), []);
for (const field of ['phase', 'status', 'viewer'] as const) {
const changed = structuredClone(game);
if (field === 'phase') changed.phase = 3;
if (field === 'status') changed.status = 'lobby';
if (field === 'viewer') changed.me = 'missing';
assert.equal(markup(changed), '', field);
assert.deepEqual(claim(changed), [], field);
}
});
void test('Basic and Advanced Bene Gesserit retain their separate eligibility and Karama guidance', () => {
const basicPoor = projected({ faction: 'beneGesserit', spice: 1 });
assert.equal(basicPoor.charity.amount, 1);
assert.match(markup(basicPoor), /fewer than two spice/);
assert.doesNotMatch(markup(basicPoor), /cancel the faction benefit with Karama/);
assert.deepEqual(claim(basicPoor), [{ type: 'charity' }]);
const basicRich = projected({ faction: 'beneGesserit', spice: 7 });
assert.equal(basicRich.charity.amount, 0);
assert.deepEqual(claim(basicRich), []);
const advanced = projected({ advanced: true, faction: 'beneGesserit', spice: 7 });
assert.equal(advanced.charity.amount, 2);
assert.match(markup(advanced), /Receive two spice once per turn/);
assert.match(markup(advanced), /opponents may cancel the faction benefit with Karama/);
assert.match(markup(advanced), /Claim 2 charity spice/);
assert.deepEqual(claim(advanced), [{ type: 'charity' }]);
});
void test('CHOAM poverty itself projects a normal bank claim without a self-funded payment explanation', () => {
const game = projected({ faction: 'choam', spice: 0 });
assert.equal(game.charity.payer, null);
assert.equal(game.charity.amount, 2);
const html = markup(game);
assert.match(html, /Charity is paid by the Spice Bank/);
assert.doesNotMatch(html, /CHOAM pays as much|This funding rule is provisional/);
assert.deepEqual(claim(game), [{ type: 'charity' }]);
});
void test('Homeworld bank bonuses and Inflation guidance persist with their projected doubled amounts', () => {
const game = projected();
game.charity.homeworld = 1;
game.charity.amount = 3;
assert.match(markup(game), /Low Homeworld population adds 1 spice directly from the bank/);
assert.match(markup(game), /Claim 3 charity spice/);
game.charity.multiplier = 2;
game.charity.ordinary = 4;
game.charity.homeworld = 2;
game.charity.amount = 6;
const doubled = markup(game);
assert.match(doubled, /Inflation doubles the amount of charity you receive/);
assert.match(doubled, /Ordinary eligibility still requires fewer than two spice/);
assert.match(doubled, /advanced Bene Gesserit receives four regardless of wealth/);
assert.match(doubled, /Low Homeworld population adds 2 spice directly from the bank after Inflation doubles the bonus/);
assert.match(doubled, /Claim 6 charity spice/);
game.charity.multiplier = 0;
game.charity.ordinary = 0;
game.charity.homeworld = 0;
game.charity.amount = 0;
assert.match(markup(game), /Inflation cancels charity for every faction this turn/);
assert.deepEqual(claim(game), []);
});
void test('active CHOAM payer guidance always states the provisional bank fallback without reading rival wallets', () => {
const game = projected();
const rival = game.players.find((player) => player.id === game.charity.payer)!;
Object.defineProperty(rival, 'spice', {
get() { throw new Error('Charity guidance read rival spice'); },
});
const guidance = 'CHOAM pays as much of the ordinary claim as it can; the Spice Bank supplies any shortfall. This funding rule is provisional.';
for (let render = 0; render < 3; render++) {
const html = markup(game);
assert.ok(html.includes(guidance));
assert.match(html, /The ordinary claim is 2 spice/);
assert.doesNotMatch(html, /remaining spice|current balance|bank supplies [0-9]/i);
}
assert.deepEqual(claim(game), [{ type: 'charity' }]);
game.charity.payer = null;
assert.match(markup(game), /Charity is paid by the Spice Bank/);
assert.ok(!markup(game).includes(guidance));
});
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CharityFundingError, quoteCharityFunding, charityPayer } from '../game/charity';
import { createGame, newPlayer } from '../game/engine';
void test('the full ordinary claim uses available CHOAM first, with a provisional bank remainder', () => {
for (const [cash, choam, bank] of [[0, 0, 2], [1, 1, 1], [2, 2, 0], [8, 2, 0]]) {
const amounts = { ordinary: 2, homeworld: 0 }, before = structuredClone(amounts);
assert.deepEqual(quoteCharityFunding(amounts, cash), { choam, bank });
assert.deepEqual(amounts, before);
}
});
void test('Homeworld additions stay bank-funded with doubled, partial and absent CHOAM funding', () => {
assert.deepEqual(quoteCharityFunding({ ordinary: 4, homeworld: 2 }, 3), { choam: 3, bank: 3 });
assert.deepEqual(quoteCharityFunding({ ordinary: 2, homeworld: 1 }, 8), { choam: 2, bank: 1 });
assert.deepEqual(quoteCharityFunding({ ordinary: 1, homeworld: 1 }, null), { choam: 0, bank: 2 });
assert.deepEqual(quoteCharityFunding({ ordinary: 0, homeworld: 0 }, 0), { choam: 0, bank: 0 });
});
void test('invalid balances or claim amounts reject before any private funding calculation', () => {
for (const invalid of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
assert.throws(() => quoteCharityFunding({ ordinary: invalid, homeworld: 0 }, 2), CharityFundingError);
assert.throws(() => quoteCharityFunding({ ordinary: 2, homeworld: invalid }, 2), CharityFundingError);
assert.throws(() => quoteCharityFunding({ ordinary: 2, homeworld: 0 }, invalid), CharityFundingError);
}
assert.throws(() => quoteCharityFunding({ ordinary: Number.MAX_SAFE_INTEGER, homeworld: 1 }, null), CharityFundingError);
});
void test('CHOAM pays only other factions after current allowed opening; its own poverty remains bank-funded', () => {
const g = createGame('CHARITYFUND', newPlayer('c', 'CHOAM', 'choam'), false, ['choam']);
g.players.push(newPlayer('a', 'Atreides', 'atreides'));
assert.equal(charityPayer(g, 'a'), undefined);
g.choamCharity = { turn: g.turn, canceled: false };
const before = structuredClone(g);
assert.equal(charityPayer(g, 'a')?.id, 'c');
assert.equal(charityPayer(g, 'c'), undefined);
assert.deepEqual(g, before);
g.choamCharity.canceled = true;
assert.equal(charityPayer(g, 'a'), undefined);
g.choamCharity = { turn: g.turn - 1, canceled: false };
assert.equal(charityPayer(g, 'a'), undefined);
});
@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { viewGame } from '../game/engine';
import {
createChoamCharityFundingFixture as fixture,
actChoamCharityFunding as act,
choamCharityFundingPlayer as player,
choamCharityFundingInventory as inventory,
} from './fixture-choam-charity-funding';
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} native CHOAM poverty claim draws from Bank after actual earned-income bribes`, () => {
for (const choamSpice of [0, 1] as const) {
const f = fixture({ advanced, choamSpice }), before = structuredClone(f.game);
const g = act(f.game, f.choam, f.selfClaim);
assert.equal(player(g, f.choam).spice, 2);
assert.equal(player(g, f.guild).spice, 0);
assert.equal(player(g, f.recipient).bribes, 13 - choamSpice);
assert.equal(viewGame(g, f.choam).charity.payer, null);
assert.equal(viewGame(g, f.choam).players.find(p => p.id === f.choam)!.charityClaimed, true);
assert.deepEqual(inventory(g), f.originalInventory);
assert.deepEqual(f.game, before);
assert.throws(() => act(g, f.choam, f.selfClaim), /once per turn|fewer than/);
}
});
void test(`${advanced ? 'Advanced' : 'Basic'} native other-faction full claim uses provisional shortfall funding`, () => {
for (const choamSpice of [0, 1] as const) {
const f = fixture({ advanced, choamSpice }), before = structuredClone(f.game);
const g = act(f.game, f.guild, f.otherClaim);
assert.equal(player(g, f.choam).spice, 0);
assert.equal(player(g, f.guild).spice, 2);
assert.equal(player(g, f.recipient).bribes, 13 - choamSpice);
assert.deepEqual(g.choamCharity, f.game.choamCharity);
assert.deepEqual(g.players.map(p => p.forces), f.game.players.map(p => p.forces));
assert.deepEqual(inventory(g), f.originalInventory);
assert.deepEqual(f.game, before);
assert.throws(() => act(g, f.guild, f.otherClaim), /once per turn|fewer than/);
}
});
void test(`${advanced ? 'Advanced' : 'Basic'} each low faction keeps exactly one independent claim in either order`, () => {
const f = fixture({ advanced, choamSpice: 1, guildSpice: 1 });
for (const order of [[f.choam, f.guild], [f.guild, f.choam]]) {
let g = structuredClone(f.game);
for (const actor of order) g = act(g, actor, { type: 'charity' });
assert.equal(player(g, f.guild).spice, 2);
assert.equal(player(g, f.choam).spice, order[0] === f.choam ? 1 : 2);
assert.equal(player(g, f.recipient).bribes, 11);
for (const actor of order) {
assert.equal(viewGame(g, actor).players.find(p => p.id === actor)!.charityClaimed, true);
assert.throws(() => act(g, actor, { type: 'charity' }), /once per turn|fewer than/);
}
assert.deepEqual(inventory(g), f.originalInventory);
}
});
}
+42 -6
View File
@@ -239,6 +239,23 @@ void test('wealthy Bene Gesserit payment waits for its response; cancellation ne
assert.equal(viewGame(g, 'b').players[2].charityClaimed, true);
}
});
void test('wealthy BG response preserves cancellation before provisional shortfall funding', () => {
for (const canceled of [false, true]) {
let g = allow(ready(fixture(true)));
contest(g);
g = applyAction(g, 'c', { type: 'bribe', target: 'e', amount: 6 });
g.players[2].spice = 12;
g = applyAction(g, 'b', { type: 'charity' });
assert.equal(g.response?.kind, 'bgCharity');
assert.equal(g.players[0].spice, 0);
assert.equal(g.players[2].spice, 12);
g = canceled ? cancel(g) : allow(g);
assert.equal(g.players[0].spice, 0);
assert.equal(g.players[2].spice, canceled ? 12 : 14);
assert.equal(viewGame(g, 'b').players[2].charityClaimed, true);
assert.equal(g.players[1].bribes, 6);
}
});
void test('Karama cancels CHOAM income, restores bank payments and permits CHOAM ordinary charity', () => {
let g = cancel(ready(contest(fixture(true))));
assert.equal(g.players[0].spice, 0);
@@ -337,12 +354,31 @@ void test('ordinary funded claims still accrue and settle Spice Production once'
assert.equal(g.players[1].spice, 3);
assert.equal(g.techTokens!.production.spice, 0);
});
void test('unresolved insolvency fails atomically without minted spice or negative balances', () => {
let g = allow(ready(fixture()));
g = applyAction(g, 'c', { type: 'bribe', target: 'e', amount: 6 });
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'b', { type: 'charity' }), /cannot fund/);
assert.deepEqual(g, before);
void test('provisional shortfall funding completes the full claim after actual CHOAM bribes', () => {
for (const remaining of [0, 1, 2]) {
let g = allow(ready(fixture()));
g = applyAction(g, 'c', { type: 'bribe', target: 'e', amount: 6 - remaining });
const before = structuredClone(g);
const claimed = applyAction(g, 'b', { type: 'charity' });
assert.equal(claimed.players[0].spice, 0);
assert.equal(claimed.players[2].spice, 2);
assert.equal(claimed.players[1].bribes, 6 - remaining);
assert.equal(viewGame(claimed, 'b').players[2].charityClaimed, true);
assert.throws(() => applyAction(claimed, 'b', { type: 'charity' }), /once per turn|fewer than/);
assert.deepEqual(g, before);
}
});
void test('CHOAM self-claim uses the bank after its original income is legally given away', () => {
for (const remaining of [0, 1]) {
let g = allow(ready(fixture()));
g = applyAction(g, 'c', { type: 'bribe', target: 'e', amount: 6 - remaining });
assert.equal(viewGame(g, 'c').charity.payer, null);
const claimed = applyAction(g, 'c', { type: 'charity' });
assert.equal(claimed.players[0].spice, 2);
assert.equal(claimed.players[1].bribes, 6 - remaining);
assert.equal(viewGame(claimed, 'c').players[0].charityClaimed, true);
assert.equal(viewGame(claimed, 'b').charity.payer, 'c');
}
});
void test('CHOAM can buy its fifth card, but a full five-card hand cannot take an auction with Karama', () => {
let g = allow(ready(fixture()));
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type ChoamCharityFundingStep = { actor: string; action: Action };
export type ChoamCharityFundingTransition = {
before: Game; steps: ChoamCharityFundingStep[]; after: Game;
};
export const choamCharityFundingPlayer = (game: Game, id: string) => {
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
};
export const choamCharityFundingClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export function cloneChoamCharityFunding(game: Game): Game {
assert.ok(choamCharityFundingClean(game), 'Clone an actual clean Charity choice boundary');
return structuredClone(game);
}
export const choamCharityFundingInventory = (game: Game) => {
const cards = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(game.auction.index) ?? []),
...game.players.flatMap(player => player.hand)];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Each original physical Treachery Card has one current slot');
return ids;
};
/** Choose only original scalar shuffle order. UUIDs and all other native byte
* entropy remain untouched; no played card or resource is replaced. */
function originalScalarOrder<T>(run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actChoamCharityFunding(game: Game, actor: string, action: Action,
actions: ChoamCharityFundingStep[] = []): Game {
const next = originalScalarOrder(() => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
/** Native setup, market, response and phase controls only. This helper never
* claims Charity, initiates a bribe or makes a ruling about unpaid obligations. */
export function nextChoamCharityFundingStep(game: Game): ChoamCharityFundingStep | null {
if (!game.phaseOpening && !game.response && game.decision?.kind === 'choamMarket')
return { actor: game.decision.player, action: { type: 'decision', done: true } };
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceChoamCharityFunding(game: Game, until: (state: Game) => boolean,
actions: ChoamCharityFundingStep[] = []): Game {
for (let count = 0; count < 1000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine CHOAM chronology ended before its requested boundary');
const next = nextChoamCharityFundingStep(game);
assert.ok(next, `A caller must choose the original control at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actChoamCharityFunding(game, next.actor, next.action, actions);
}
throw Error('The original CHOAM Charity chronology did not reach its finite boundary');
}
export function passChoamCharityFundingResponses(game: Game,
actions: ChoamCharityFundingStep[] = []): Game {
for (let count = 0; game.response && count < 60; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actChoamCharityFunding(game, actor.id, { type: 'passResponse' }, actions);
}
assert.ok(!game.response); return game;
}
/** Fresh public three-seat E2 source through its FIRST allowed Charity income.
* Ordinary bribes then remove spendable spice from CHOAM and Guild. Incoming
* bribes remain native deferred receipts, so they do not fund the pending claim.
* Stop BEFORE either CHOAM or Guild claims. This reproduces the legal funding boundary without
* choosing a remedy or asserting how the claim ought to be paid. */
export function createChoamCharityFundingFixture(options: {
advanced?: boolean; choamSpice?: 0 | 1; guildSpice?: 0 | 1;
} = {}) {
const advanced = options.advanced ?? false;
const choamSpice = options.choamSpice ?? 0, claimantSpice = options.guildSpice ?? 0;
const choam = 'choam', claimant = 'guild', recipient = 'atreides';
let game = createGame('CHOAMCHARITYFUNDING', newPlayer(choam, 'CHOAM', 'choam'), advanced, ['choam']);
joinGame(game, newPlayer(recipient, 'Atreides', 'atreides'));
joinGame(game, newPlayer(claimant, 'Guild', 'guild'));
const initial = structuredClone(game), actions: ChoamCharityFundingStep[] = [];
const act = (actor: string, action: Action) => { game = actChoamCharityFunding(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceChoamCharityFunding(game, until, actions); };
const transition = (run: () => void): ChoamCharityFundingTransition => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
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.equal(game.advanced, advanced);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalInventory = choamCharityFundingInventory(game);
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
const karama = game.deck.find(card => card.effect === 'karama');
assert.equal(ordinary.length, 2); assert.ok(karama);
const startingCards = [ordinary[0], karama, ordinary[1]];
const dealOrder = startingCards.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), startingCards.map(card => card.id));
assert.equal(choamCharityFundingPlayer(game, choam).spice, 2);
assert.equal(choamCharityFundingPlayer(game, claimant).spice, 5);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => state.phase === 2 && state.response?.kind === 'choamCharity');
const originalIncomeResponse = structuredClone(game);
const income = transition(() => {
game = passChoamCharityFundingResponses(game, actions);
reach(state => state.phase === 2 && choamCharityFundingClean(state));
});
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
assert.equal(choamCharityFundingPlayer(game, choam).spice, 8);
const beforeBribes = structuredClone(game);
const claimantBribe = transition(() => act(claimant, {
type: 'bribe', target: recipient, amount: choamCharityFundingPlayer(game, claimant).spice - claimantSpice,
}));
const beforeFundingBribe = structuredClone(game);
const fundingBribe = transition(() => act(choam, {
type: 'bribe', target: recipient, amount: choamCharityFundingPlayer(game, choam).spice - choamSpice,
}));
assert.equal(game.turn, 1); assert.equal(game.phase, 2); assert.ok(choamCharityFundingClean(game));
assert.equal(choamCharityFundingPlayer(game, choam).spice, choamSpice);
assert.equal(choamCharityFundingPlayer(game, claimant).spice, claimantSpice);
assert.equal(choamCharityFundingPlayer(game, recipient).spice, choamCharityFundingPlayer(beforeBribes, recipient).spice);
assert.equal(choamCharityFundingPlayer(game, recipient).bribes, 13 - claimantSpice - choamSpice);
assert.notEqual(choamCharityFundingPlayer(game, choam).charityTurn, game.turn);
assert.notEqual(choamCharityFundingPlayer(game, claimant).charityTurn, game.turn);
assert.deepEqual(choamCharityFundingInventory(game), originalInventory);
const claim: Action = { type: 'charity' };
const claimStep: ChoamCharityFundingStep = { actor: choam, action: claim };
const otherClaimStep: ChoamCharityFundingStep = { actor: claimant, action: claim };
return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, originalIncomeResponse,
beforeBribes, beforeFundingBribe, income, claimantBribe, fundingBribe,
choam, owner: choam, payer: choam, claimant: choam, guild: claimant, otherClaimant: claimant,
recipient, holder: recipient, karama: karama.id,
advanced, choamSpice, guildSpice: claimantSpice, amount: 2 - choamSpice, otherAmount: 2 - claimantSpice,
claim, claimStep, selfClaim: claim, selfClaimStep: claimStep, otherClaim: claim, otherClaimStep,
actions, sourceActionCount: actions.length, originalInventory, originalSpice, dealOrder, spiceOrder,
staging: [
'Actual public three-seat CHOAM/Atreides/Guild ready/start, native private Traitor choices, initial armies and starting wallets. Advanced uses the original explicit preview start; no played Basic game is converted.',
'Select only original scalar shuffle order, three original unopened Treachery positions and two original unopened ordinary Spice positions. Atreides receives its original starting Karama, keeping the first CHOAM income response genuinely open.',
'Actually complete first Storm, native closing CHOAM market and Spice controls, then pass the original first Charity income response. CHOAM earns six bank spice on top of its original two.',
`During clean first Charity, Guild actually pays a ${5 - claimantSpice}-spice bribe and CHOAM actually pays an ${8 - choamSpice}-spice bribe to unallied Atreides. The original ${13 - claimantSpice - choamSpice} deferred incoming spice stays unspendable until Mentat. No phase, wallet, force, alliance, claim stamp or payment receipt is assigned.`,
'Stop before either original Charity claim. The primary claimStep belongs to CHOAM itself; otherClaimStep belongs to Guild. Expected funding/remedies are deliberately not asserted by this source constructor.',
],
};
}
export type ChoamCharityFundingFixture = ReturnType<typeof createChoamCharityFundingFixture>;
+13 -8
View File
@@ -226,18 +226,23 @@ void test('actual cancellation of CHOAM phase-opening income makes both ordinary
});
void test('the bank bonus never creates ordinary eligibility or makes CHOAM fund more than its exact ordinary portion', () => {
let g = choam(setup(false));
const g = choam(setup(false));
seat(g, 'a').spice = 2;
seat(g, 'b').spice = 9;
rejects(g, 'a', { type: 'charity' });
rejects(g, 'b', { type: 'charity' });
seat(g, 'a').spice = 1;
seat(g, 'c').spice = 0;
rejects(g, 'a', { type: 'charity' });
seat(g, 'c').spice = 1;
g = applyAction(g, 'a', { type: 'charity' });
assert.equal(seat(g, 'a').spice, 3);
assert.equal(seat(g, 'c').spice, 0);
for (const cash of [0, 1, 4]) {
const source = structuredClone(g);
seat(source, 'a').spice = 1;
seat(source, 'c').spice = cash;
const before = structuredClone(source);
const claimed = applyAction(source, 'a', { type: 'charity' });
assert.equal(seat(claimed, 'a').spice, 3);
assert.equal(seat(claimed, 'c').spice, Math.max(0, cash - 1));
assert.equal(viewGame(claimed, 'a').players.find(p => p.id === 'a')!.charityClaimed, true);
assert.deepEqual(source, before);
rejects(claimed, 'a', { type: 'charity' });
}
});
void test('a forged saved BG bank split or total is rejected unchanged before allowance and Karama card spend', () => {