Files
Dune/tests/homeworld-charity-engine.test.ts
T
Quintine d63efccb55 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.
2026-10-24 18:03:08 +11:00

401 lines
14 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeHomeworldGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { richeseCards } from '../game/richese-cards';
const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function setup(advanced = true, ix = false) {
let g = createGame(
'HOMECHARITYENGINE',
newPlayer('a', 'Atreides', 'atreides'),
advanced,
ix ? ['ix', 'choam'] : ['choam'],
);
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
joinGame(g, newPlayer('c', 'CHOAM', 'choam'));
// Audit CHOAM's faction with only the implemented physical base/Ix deck.
// The lobby roster is final before any setup pieces or history are created.
g.expansions = ix ? ['ix'] : [];
g = applyAction(g, 'a', { type: 'homeworlds', enabled: true });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
for (let i = 0; g.status === 'setup' && i < 30; i++) {
let next: Game | undefined;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((owner) => owner.id === p.id)!.bot = 'Easy';
const [command] = botActions(view);
if (!command) continue;
next = applyAction(g, p.id, command);
break;
}
assert.ok(next, 'Genuine setup must have a legal next action.');
g = reload(next);
}
assert.equal(g.status, 'playing');
// Keep all physical cards, while isolating explicit cancellation holders.
for (const p of g.players) g.deck.push(...p.hand.splice(0));
g.phase = 2;
g.phaseOpening = null;
g.response = null;
g.decision = null;
g.ready = [];
low(g, 'a', 5);
low(g, 'b', 10);
return g;
}
function low(g: Game, id: string, reserves: number) {
const p = seat(g, id);
const departing = p.reserves - reserves;
assert.ok(departing >= 0);
p.reserves = reserves;
p.forces['imperial_basin:10'] =
(p.forces['imperial_basin:10'] ?? 0) + departing;
homeworldGameIntegrity(g);
}
function choam(g: Game) {
// Faction identity and both histories originate in genuine setup. Stage only
// the phase's existing payer balance/settlement, preserving physical custody.
const p = seat(g, 'c');
assert.equal(p.faction, 'choam');
p.spice = 12;
g.choamCharity = { turn: g.turn, canceled: false };
homeworldGameIntegrity(g);
return g;
}
function holdKarama(g: Game, id = 'a') {
const index = g.deck.findIndex((card) => card.effect === 'karama');
assert.ok(index >= 0);
const [card] = g.deck.splice(index, 1);
seat(g, id).hand.push(card);
return card;
}
function allow(state: Game) {
let g = state;
for (let i = 0; g.response && i < 10; i++) {
const p = g.players.find((p) => !g.response!.passed.includes(p.id))!;
assert.ok(p);
g = applyAction(g, p.id, { type: 'passResponse' });
}
assert.equal(g.response, null);
return g;
}
function rejects(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
function privacy(g: Game) {
for (const p of g.players) {
const view = viewGame(reload(g), p.id);
for (const other of view.players.filter((other) => other.id !== p.id)) {
assert.equal(other.hand, undefined);
assert.equal(other.traitors, undefined);
assert.equal(other.prediction, undefined);
}
}
}
void test('actual poor claims collect a bank low bonus while CHOAM pays only ordinary need, including supported Ix deck setup', () => {
for (const ix of [false, true])
for (const spice of [0, 1]) {
let g = choam(setup(false, ix));
seat(g, 'a').spice = spice;
const before = structuredClone(g);
const view = viewGame(g, 'a');
assert.equal(view.charity.amount, 3 - spice);
g = applyAction(g, 'a', { type: 'charity' });
assert.equal(seat(g, 'a').spice, 3);
assert.equal(seat(g, 'c').spice, 12 - (2 - spice));
assert.equal(seat(g, 'a').charityTurn, g.turn);
assert.deepEqual(
g.players.map((p) => p.forces),
before.players.map((p) => p.forces),
);
assert.deepEqual(g.homeworlds, before.homeworlds);
assert.deepEqual(g.deck, before.deck);
privacy(g);
const saved = reload(g);
assert.deepEqual(reload(normalizeAutomaticGame(saved)), saved);
rejects(saved, 'a', { type: 'charity' });
}
});
void test('wealthy Advanced BG waits for actual held Karama response and pays neither portion when the claim is canceled', () => {
for (const canceled of [false, true]) {
let g = choam(setup());
seat(g, 'b').spice = 12;
const card = holdKarama(g);
g = applyAction(g, 'b', { type: 'charity' });
assert.equal(g.response?.kind, 'bgCharity');
assert.equal(g.response?.amount, 3);
assert.equal(g.response?.charityHomeworld, 1);
assert.equal(seat(g, 'b').spice, 12);
assert.equal(seat(g, 'c').spice, 12);
privacy(g);
g = canceled
? applyAction(reload(g), 'a', {
type: 'card',
card: card.id,
mode: 'cancel',
})
: allow(reload(g));
assert.equal(seat(g, 'b').spice, canceled ? 12 : 15);
assert.equal(seat(g, 'c').spice, canceled ? 12 : 10);
assert.equal(
g.discard.filter((c) => c.id === card.id).length,
canceled ? 1 : 0,
);
assert.equal(seat(g, 'b').charityTurn, g.turn);
rejects(reload(g), 'b', { type: 'charity' });
const saved = reload(g);
assert.deepEqual(reload(normalizeAutomaticGame(saved)), saved);
}
});
void test('Inflation doubles the ordinary and bank portions after placement and canceled charity offers no payable claim', () => {
for (const bg of [false, true]) {
let g = choam(setup());
const id = bg ? 'b' : 'a';
seat(g, id).spice = bg ? 9 : 1;
g.turn = 2;
g.choamCharity!.turn = 2;
g.inflation = {
side: 'double',
placedTurn: 1,
updatedTurn: 1,
flipped: false,
};
holdKarama(g);
g = applyAction(g, id, { type: 'charity' });
if (bg) {
assert.equal(g.response?.charityHomeworld, 2);
assert.equal(g.response?.amount, 6);
g = allow(reload(g));
}
assert.equal(seat(g, id).spice, bg ? 15 : 5);
assert.equal(seat(g, 'c').spice, bg ? 8 : 10);
const cancel = choam(setup());
cancel.turn = 2;
cancel.choamCharity!.turn = 2;
cancel.inflation = { ...g.inflation!, side: 'cancel' };
seat(cancel, id).spice = bg ? 9 : 1;
assert.equal(viewGame(cancel, id).charity.amount, 0);
rejects(cancel, id, { type: 'charity' });
}
});
void test('actual cancellation of CHOAM phase-opening income makes both ordinary charity and the low bonus bank-paid', () => {
let g = choam(setup());
g.phase = 1;
g.nexus = true;
g.choamCharity = undefined;
seat(g, 'a').spice = 0;
const card = holdKarama(g);
const originalPayer = seat(g, 'c').spice;
for (const id of ['a', 'b', 'c']) g = applyAction(g, id, { type: 'ready' });
while (g.decision?.kind === 'choamMarket')
g = applyAction(g, 'c', { type: 'decision', done: true });
assert.equal(g.phase, 2);
assert.equal(g.response?.kind, 'choamCharity');
g = applyAction(reload(g), 'a', {
type: 'card',
card: card.id,
mode: 'cancel',
});
assert.deepEqual(g.choamCharity, { turn: g.turn, canceled: true });
assert.equal(seat(g, 'c').spice, originalPayer);
assert.equal(viewGame(g, 'a').charity.payer, null);
g = applyAction(g, 'a', { type: 'charity' });
assert.equal(seat(g, 'a').spice, 3);
assert.equal(seat(g, 'c').spice, originalPayer);
rejects(reload(g), 'a', { type: 'charity' });
});
void test('the bank bonus never creates ordinary eligibility or makes CHOAM fund more than its exact ordinary portion', () => {
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' });
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', () => {
let g = choam(setup());
seat(g, 'b').spice = 12;
const card = holdKarama(g);
g = applyAction(g, 'b', { type: 'charity' });
for (const edit of [
(state: Game) => {
state.response!.charityHomeworld = 99;
},
(state: Game) => {
state.response!.charityHomeworld = 0;
},
(state: Game) => {
state.response!.amount = 99;
},
]) {
const corrupt = reload(g);
edit(corrupt);
rejects(corrupt, 'a', { type: 'passResponse' });
rejects(corrupt, 'a', { type: 'card', card: card.id, mode: 'cancel' });
const before = structuredClone(corrupt);
assert.throws(() => normalizeAutomaticGame(corrupt));
assert.throws(() => viewGame(corrupt, 'b'));
assert.deepEqual(corrupt, before);
}
});
void test('a paid Nullentropy search preserves the actual BG charity split and rejects forged saved parents before selection', () => {
let g = choam(setup());
seat(g, 'a').spice = 10;
seat(g, 'b').spice = 12;
holdKarama(g);
// Canonical Richese component seam, matching the existing Box engine tests.
// This does not certify Richese setup: stage its complete separate cache,
// then relocate its unique Box and two genuinely dealt base cards.
g.richeseCache = richeseCards();
const boxIndex = g.richeseCache.findIndex(
(card) => card.effect === 'nullentropyBox',
);
assert.ok(boxIndex >= 0);
const [box] = g.richeseCache.splice(boxIndex, 1);
seat(g, 'a').hand.push(box);
for (const name of ['Shield', 'Maula Pistol']) {
const index = g.deck.findIndex((card) => card.name === name);
assert.ok(index >= 0);
g.discard.push(...g.deck.splice(index, 1));
}
const inventory = (state: Game) =>
[
...state.deck,
...state.discard,
...state.richeseCache!,
...state.players.flatMap((p) => p.hand),
].sort((a, b) => a.id.localeCompare(b.id));
const cardsBefore = structuredClone(inventory(g));
const forcesBefore = structuredClone(g.players.map((p) => p.forces));
g = applyAction(g, 'b', { type: 'charity' });
assert.equal(g.response?.kind, 'bgCharity');
assert.equal(g.response.amount, 3);
assert.equal(g.response.charityHomeworld, 1);
const parent = structuredClone(g.response);
g = applyAction(reload(g), 'a', { type: 'card', card: box.id });
assert.equal(g.decision?.kind, 'nullentropy');
assert.equal(g.response, null);
assert.deepEqual(g.pendingNullentropy!.resume.response, parent);
assert.equal(seat(g, 'a').spice, 8);
assert.equal(seat(g, 'b').spice, 12);
assert.equal(seat(g, 'c').spice, 12);
privacy(g);
const selected = g.discard[0].id;
const command: Action = {
type: 'decision',
event: g.pendingNullentropy!.event,
card: selected,
};
for (const patch of [
{ amount: 9 },
{ charityHomeworld: 7 },
{ amount: 9, charityHomeworld: 7 },
]) {
const corrupt = reload(g);
Object.assign(corrupt.pendingNullentropy!.resume.response!, patch);
const before = structuredClone(corrupt);
for (const p of corrupt.players)
assert.throws(() => viewGame(corrupt, p.id));
assert.throws(() => normalizeAutomaticGame(corrupt));
rejects(corrupt, 'a', command);
assert.deepEqual(corrupt, before);
}
const saved = reload(g);
assert.deepEqual(reload(normalizeAutomaticGame(saved)), saved);
g = applyAction(saved, 'a', command);
assert.equal(g.pendingNullentropy, null);
assert.deepEqual(g.response, parent);
assert.equal(seat(g, 'a').spice, 8);
assert.equal(seat(g, 'b').spice, 12);
assert.equal(seat(g, 'c').spice, 12);
assert.equal(g.discard.at(-1)?.id, box.id);
assert.equal(
seat(g, 'a').hand.filter((card) => card.id === selected).length,
1,
);
g = allow(reload(g));
assert.equal(seat(g, 'b').spice, 15);
assert.equal(seat(g, 'c').spice, 10);
assert.equal(seat(g, 'a').spice, 8);
assert.deepEqual(inventory(g), cardsBefore);
assert.deepEqual(
g.players.map((p) => p.forces),
forcesBefore,
);
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
rejects(reload(g), 'a', command);
rejects(reload(g), 'b', { type: 'charity' });
homeworldGameIntegrity(g);
});
void test('low-population Tupile adds its bank bonus to CHOAM opening income under the same Inflation multiplier', () => {
// User ruling 7 October 2026: CHOAM's opening income counts as a qualifying
// collection, so the low-population bank bonus applies (scaled by Inflation).
const run = (lowTupile: boolean, multiplier: number) => {
let g = choam(setup());
g.phase = 1;
g.nexus = true;
g.choamCharity = undefined;
if (lowTupile) low(g, 'c', 5); // Tupile below its 11-reserve high threshold
if (multiplier === 2) {
g.turn = 2;
g.inflation = {
side: 'double',
placedTurn: 1,
updatedTurn: 1,
flipped: false,
};
}
seat(g, 'a').spice = 0;
seat(g, 'b').spice = 0;
const before = seat(g, 'c').spice;
for (const id of ['a', 'b', 'c']) g = applyAction(g, id, { type: 'ready' });
while (g.decision?.kind === 'choamMarket')
g = applyAction(g, 'c', { type: 'decision', done: true });
assert.equal(g.phase, 2);
assert.equal(g.choamCharity?.canceled, false);
assert.equal(viewGame(g, 'a').charity.incomeHomeworld, lowTupile ? 1 : 0);
return seat(g, 'c').spice - before;
};
for (const multiplier of [1, 2]) {
const withLow = run(true, multiplier);
const withoutLow = run(false, multiplier);
assert.equal(withoutLow, 2 * 3 * multiplier, 'Ordinary opening income');
assert.equal(withLow - withoutLow, multiplier, 'Low-population bank bonus');
}
});