Files
Dune/tests/homeworld-payment-income-engine.test.ts
Quintine 178251ad35 Restore native Ecaz simulation continuation and current rule fixtures
Match original Ecaz sample snapshots to their native Occupy and Advanced assassination state. Preserve older absent-power samples and resume the original scenario instead of rejecting fresh saved output. Update stale fixture reactions and module expectations while retaining meaningful Banker guidance and positive legal commitments. Correct the unsupported rules-completion claim.

Validation: typecheck/lint, 173 affected tests across 16 files, bounded independent source review, and one direct Ecaz continuation completed through 178 accepted actions. Runtime fingerprint 95d009d3957650b08bb24826b70aa5f00a74cdd9992b6a6c222f0665d3e3759c; documentation-only proof update a91ab248109bee74a74373fb544027a2e7cc8f178e26d2c9dad475a9618eb64d. Full rules, comprehensive assurance and deployed acceptance remain open. No saved-room or provider changes.
2026-10-18 10:08:55 +11:00

577 lines
20 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeHomeworldGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { baseDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
const own = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function fixture(third: 'atreides' | 'richese' | 'ecaz' = 'atreides') {
let g = createGame(
'PAYMENTINCOME',
newPlayer('e', 'Emperor', 'emperor'),
true,
[],
);
joinGame(g, newPlayer('g', 'Guild', 'guild'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
// Final audit roster is fixed before any setup or occupation history exists.
if (third !== 'atreides') g.players[2] = newPlayer('a', third, third);
g = applyAction(g, 'e', { type: 'homeworlds', enabled: true });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
for (let n = 0; g.status === 'setup' && n < 40; n++) {
let next: Game | undefined;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
const command = botActions(view)[0];
if (command) {
next = applyAction(g, p.id, command);
break;
}
}
assert.ok(next);
g = next;
}
assert.equal(g.status, 'playing');
for (const p of g.players) {
g.deck.push(...p.hand.splice(0));
p.spice = 20;
}
Object.assign(g, {
phase: 2,
turn: 2,
phaseOpening: null,
response: null,
decision: null,
active: null,
order: ['a', 'g', 'e'],
ready: [],
storm: 18,
});
inventory(g);
return g;
}
function population(g: Game, id: string, count: number) {
const p = own(g, id);
// Every original special counter stays on Salusa; count is Kaitain's native pool.
p.reserves = count + (p.elites?.reserves ?? 0);
p.tanks = 0;
p.forces = { 'imperial_basin:10': 20 - p.reserves };
homeworldGameIntegrity(g);
}
function hold(g: Game, id: string, effect: string) {
const at = g.deck.findIndex((c) => c.effect === effect || c.kind === effect);
assert.ok(at >= 0);
const [card] = g.deck.splice(at, 1);
own(g, id).hand.push(card);
return card.id;
}
function inventory(g: Game) {
homeworldGameIntegrity(g);
for (const p of g.players) {
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
if (p.elites)
assert.equal(
p.elites.reserves +
p.elites.tanks +
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
5,
);
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.spice, undefined);
}
}
const cards = [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
// A committed normal lot remains in the auction until its income responses
// finish, but its physical card is already in the winner's hand.
...(g.auction?.cards.slice(
g.auction.index + (g.currentAuctionSale?.origin === 'normal' ? 1 : 0),
) ?? []),
...(g.richeseCache ?? []),
...(g.richeseRemoved ?? []),
];
const expected = [...baseDeck(), ...(g.richeseCache ? richeseCards() : [])]
.map((c) => c.id)
.sort();
assert.deepEqual(cards.map((c) => c.id).sort(), expected);
}
function auction(g: Game, buyer: string, amount: number) {
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.ok(g.auction);
// Atreides' real auction foresight precedes the first bid.
for (let n = 0; g.response && n < 10; n++) {
const responder = g.players.find((p) => {
const controls = viewGame(g, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length;
});
assert.ok(responder);
g = applyAction(g, responder.id, { type: 'passResponse' });
}
for (let n = 0; g.auction!.active !== buyer && n < 10; n++)
g = applyAction(g, g.auction!.active, { type: 'passBid' });
const card = g.auction!.cards[g.auction!.index].id;
g = applyAction(g, buyer, { type: 'bid', amount });
for (
let n = 0;
!g.response &&
!g.decision &&
!own(g, buyer).hand.some((c) => c.id === card) &&
n < 20;
n++
)
g = applyAction(g, g.auction!.active, { type: 'passBid' });
if (g.decision?.kind === 'auctionPayment')
g = applyAction(g, buyer, { type: 'decision', karama: false });
assert.ok(own(g, buyer).hand.some((c) => c.id === card));
return { g, card };
}
function movement(g: Game, actor: string) {
Object.assign(g, {
phase: 5,
active: actor,
movementRemaining: [
actor,
...g.players.filter((p) => p.id !== actor).map((p) => p.id),
],
});
for (const p of g.players) {
p.shipped = false;
p.moved = 0;
}
return g;
}
function allowShipment(g: Game) {
assert.equal(g.decision?.kind, 'guildShipment');
let done = applyAction(reload(g), 'g', { type: 'decision', allow: true });
// Native Guild-rate shipments first offer the discount to Karama; pass it.
while (done.response?.kind === 'guildRate') {
const passer = done.players.find(p => !done.response!.passed.includes(p.id) && p.id !== done.response!.owner)!;
done = applyAction(reload(done), passer.id, { type: 'passResponse' });
}
return done;
}
function settle(g: Game, responder: string, karama: string, cancel: boolean) {
const original = reload(g);
let done = applyAction(
reload(g),
responder,
cancel
? { type: 'card', mode: 'cancel', card: karama }
: { type: 'passResponse' },
);
// The auction income response now stamps optional Bureaucrat fields whose
// absent values are JSON-invisible; compare the serialized forms.
assert.deepEqual(reload(g), original);
inventory(done);
for (
let n = 0;
!cancel &&
done.response?.kind === original.response?.kind &&
n < done.players.length;
n++
) {
const remaining = done.players.find((player) => {
const controls = viewGame(done, player.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
assert.ok(
remaining,
'An unsettled income window must retain an eligible responder.',
);
done = applyAction(reload(done), remaining.id, { type: 'passResponse' });
inventory(done);
}
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
const before = reload(done);
if (!done.response)
assert.throws(() => applyAction(done, responder, { type: 'passResponse' }));
else assert.notEqual(done.response.kind, original.response?.kind);
assert.deepEqual(done, before);
return done;
}
function preview(
g: Game,
owner: string,
gross: number,
income: number,
low: boolean,
) {
assert.deepEqual(viewGame(reload(g), owner).paymentIncome, {
owner,
kind: owner === 'g' ? 'shipment' : 'treachery',
gross,
income,
bank: gross - income,
low,
});
}
void test('genuine auction pays full odd/even bid while current low Kaitain receives rounded-up half only after allowance', () => {
for (const native of [4, 5])
for (const bid of [3, 4]) {
let g = fixture();
population(g, 'e', native);
const karama = hold(g, 'g', 'karama');
// Put the other physical Karama first: its buyer becomes a second legal
// income responder after acquiring it, making the former random case fixed.
const next = g.deck.findIndex((card) => card.effect === 'karama');
assert.ok(next >= 0);
g.deck.unshift(g.deck.splice(next, 1)[0]);
const purchased = auction(g, 'a', bid);
g = purchased.g;
assert.equal(g.response?.kind, 'emperorIncome');
assert.equal(own(g, 'a').spice, 20 - bid);
assert.equal(own(g, 'e').spice, 20);
const income = native === 4 ? Math.ceil(bid / 2) : bid;
preview(g, 'e', bid, income, native === 4);
const waiting = applyAction(reload(g), 'g', { type: 'passResponse' });
assert.equal(waiting.response?.kind, 'emperorIncome');
assert.equal(
own(waiting, 'e').spice,
20,
'The newly acquired Karama still gives its buyer an income response.',
);
assert.ok(
viewGame(waiting, 'a').responseControls!.cancelCards.some(
(card) => card === purchased.card,
),
);
inventory(waiting);
const done = settle(g, 'g', karama, false);
assert.equal(own(done, 'e').spice, 20 + income);
assert.equal(own(done, 'a').spice, 20 - bid);
assert.ok(own(done, 'a').hand.some((c) => c.id === purchased.card));
}
});
void test('Karama cancels all low-Kaitain auction income while preserving the full price and purchased card', () => {
let g = fixture();
population(g, 'e', 4);
const karama = hold(g, 'g', 'karama');
const purchased = auction(g, 'a', 3);
g = purchased.g;
preview(g, 'e', 3, 2, true);
const done = settle(g, 'g', karama, true);
assert.equal(own(done, 'e').spice, 20);
assert.equal(own(done, 'a').spice, 17);
assert.equal(done.discard.filter((c) => c.id === karama).length, 1);
assert.ok(own(done, 'a').hand.some((c) => c.id === purchased.card));
});
void test('Emperor self-purchase goes entirely to the bank at both Kaitain populations', () => {
for (const native of [4, 5]) {
let g = fixture();
population(g, 'e', native);
const purchased = auction(g, 'e', 3);
g = purchased.g;
assert.notEqual(g.response?.kind, 'emperorIncome');
assert.equal(viewGame(g, 'e').paymentIncome, null);
assert.equal(own(g, 'e').spice, 17);
assert.ok(own(g, 'e').hand.some((c) => c.id === purchased.card));
inventory(g);
}
});
void test('ordinary shipments preserve odd/even tariffs and Karama cancellation at high and low Junction', () => {
for (const native of [4, 5])
for (const amount of [3, 4])
for (const cancel of [false, true]) {
let g = movement(fixture(), 'a');
population(g, 'g', native);
const karama = hold(g, 'e', 'karama');
const reserves = own(g, 'a').reserves;
g = applyAction(g, 'a', {
type: 'ship',
amount,
territory: 'carthag',
sector: 11,
});
g = allowShipment(g);
assert.equal(g.response?.kind, 'guildIncome');
assert.equal(own(g, 'a').spice, 20 - amount);
assert.equal(own(g, 'a').reserves, reserves - amount);
assert.equal(own(g, 'a').forces['carthag:11'], amount);
const income = native === 4 ? Math.ceil(amount / 2) : amount;
preview(g, 'g', amount, income, native === 4);
const done = settle(g, 'e', karama, cancel);
assert.equal(own(done, 'g').spice, 20 + (cancel ? 0 : income));
assert.equal(own(done, 'a').spice, 20 - amount);
assert.equal(own(done, 'a').shipped, true);
}
});
void test('low Junction routes even non-Guild contributions and excludes its own pledged contribution', () => {
for (const donor of ['e', 'g']) {
let g = movement(fixture(), 'a');
population(g, 'g', 4);
own(g, 'a').ally = donor;
own(g, donor).ally = 'a';
const karama = hold(g, donor === 'e' ? 'a' : 'e', 'karama');
const pledge = donor === 'g' ? 1 : 2;
g = applyAction(g, donor, { type: 'pledgeAid', amount: pledge });
// Non-Guild contributions are 2 + 2, agreeing under both unresolved rounding readings.
// Guild alliance halves five counters to three spice and its own pledged spice goes to bank.
const amount = donor === 'g' ? 5 : 4;
g = applyAction(g, 'a', {
type: 'ship',
amount,
territory: 'carthag',
sector: 11,
allyPayment: pledge,
});
g = allowShipment(g);
const gross = donor === 'g' ? 2 : 4;
preview(g, 'g', gross, Math.ceil(gross / 2), true);
assert.equal(own(g, 'a').spice, 18);
assert.equal(own(g, donor).spice, 20 - pledge);
assert.equal(g.aid[donor].amount, 0);
const done = settle(g, donor === 'e' ? 'a' : 'e', karama, false);
assert.equal(
own(done, 'g').spice,
(donor === 'g' ? 19 : 20) + Math.ceil(gross / 2),
);
assert.equal(own(done, 'a').spice, 18);
}
});
void test('native Guild shipment crosses from five to low before collecting its ally-funded payment', () => {
let g = movement(fixture(), 'g');
population(g, 'g', 5);
own(g, 'g').ally = 'e';
own(g, 'e').ally = 'g';
const karama = hold(g, 'a', 'karama');
g = applyAction(g, 'e', { type: 'pledgeAid', amount: 3 });
g = applyAction(g, 'g', {
type: 'ship',
amount: 5,
territory: 'carthag',
sector: 11,
allyPayment: 3,
});
g = allowShipment(g);
assert.equal(own(g, 'g').reserves, 0);
assert.equal(own(g, 'g').spice, 20);
assert.equal(own(g, 'e').spice, 17);
preview(g, 'g', 3, 2, true);
const done = settle(g, 'a', karama, false);
assert.equal(own(done, 'g').spice, 22);
assert.equal(own(done, 'e').spice, 17);
});
void test('Richese special cache purchase keeps its three-spice cost while low Kaitain income allows or cancels after reload', () => {
for (const cancel of [false, true]) {
// Genuine final Richese roster also initializes its physical cache.
let g = fixture('richese');
population(g, 'e', 4);
const payment = hold(g, 'a', 'karama');
const cancellation = hold(g, 'g', 'karama');
g = applyAction(g, 'a', {
type: 'card',
card: payment,
mode: 'special',
acquire: 'richese-ornithopter',
});
assert.equal(g.response?.kind, 'richesePurchaseIncome');
assert.equal(own(g, 'a').spice, 17);
preview(g, 'e', 3, 2, true);
const done = settle(g, 'g', cancellation, cancel);
assert.equal(own(done, 'e').spice, cancel ? 20 : 22);
assert.equal(own(done, 'a').spice, 17);
assert.equal(own(done, 'a').specialKaramaUsed, true);
assert.ok(own(done, 'a').hand.some((c) => c.id === 'richese-ornithopter'));
assert.equal(done.discard.filter((c) => c.id === payment).length, 1);
}
});
void test('real Richese Ambassador purchase applies low Kaitain income after reload and rejects corrupted owner or gross before mutation', () => {
for (const cancel of [false, true]) {
let g = movement(fixture('ecaz'), 'g');
population(g, 'e', 4);
// Genuine final Ecaz roster; only this known Ambassador layout is staged.
// Arrival, trigger, paid draw and income still use production actions.
const karama = hold(g, 'a', 'karama');
let ambassadors = createAmbassadors(() => 0.2);
const token = ambassadors.tokens.find((t) => t.effect === 'richese')!;
ambassadors.cohort = [
token.id,
...ambassadors.tokens
.filter((t) => t.effect !== 'ecaz' && t.id !== token.id)
.slice(0, 4)
.map((t) => t.id),
];
for (const t of ambassadors.tokens) {
t.zone =
t.effect === 'ecaz' || ambassadors.cohort.includes(t.id)
? 'supply'
: 'pool';
t.location = null;
}
ambassadors = placeAmbassador(ambassadors, token.id, {
turn: 1,
availableSpice: 20,
destination: {
id: 'arrakeen',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
g.ecazAmbassadors = ambassadors;
g = applyAction(g, 'g', {
type: 'ship',
amount: 1,
territory: 'arrakeen',
sector: 10,
});
g = allowShipment(g);
assert.ok(g.pendingAmbassador);
const card = g.deck[0].id;
g = applyAction(reload(g), 'a', {
type: 'decision',
event: g.pendingAmbassador.event,
trigger: true,
beneficiary: 'a',
});
assert.equal(g.response?.kind, 'emperorIncome');
assert.equal(g.response?.source, 'ambassador');
assert.equal(g.pendingAmbassador?.purchaseReceipt?.card, card);
assert.equal(own(g, 'a').spice, 17);
assert.equal(own(g, 'g').spice, 19);
preview(g, 'e', 3, 2, true);
for (const mutate of [
(state: Game) => {
state.response!.owner = 'g';
},
(state: Game) => {
state.response!.amount = 4;
},
(state: Game) => {
state.response!.amount = -1;
},
]) {
const corrupt = reload(g);
mutate(corrupt);
const before = reload(corrupt);
assert.throws(() => viewGame(corrupt, 'e'));
assert.throws(() =>
applyAction(corrupt, 'a', {
type: 'card',
mode: 'cancel',
card: karama,
}),
);
assert.deepEqual(corrupt, before);
}
const done = settle(g, 'a', karama, cancel);
assert.equal(own(done, 'e').spice, cancel ? 20 : 22);
assert.equal(own(done, 'a').spice, 17);
assert.ok(own(done, 'a').hand.some((c) => c.id === card));
assert.equal(done.pendingAmbassador, null);
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.id === token.id)!.zone,
'used',
);
assert.equal(own(done, 'g').spice, 19);
assert.equal(own(done, 'g').forces['arrakeen:10'], 1);
}
});
void test('a fully pledged low-Junction shipment retains the original two-spice debit and whole-invoice native income', () => {
let g = movement(fixture(), 'a');
population(g, 'g', 4);
own(g, 'a').ally = 'e';
own(g, 'e').ally = 'a';
const karama = hold(g, 'e', 'karama');
g = applyAction(g, 'e', { type: 'pledgeAid', amount: 2 });
g = applyAction(g, 'a', {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 2,
allyPayment: 2,
});
assert.equal(g.decision?.kind, 'guildShipment');
assert.equal(own(g, 'a').spice, 20);
assert.equal(g.aid.e.amount, 2);
g = allowShipment(g);
preview(g, 'g', 2, 1, true);
assert.deepEqual(g.response?.guildContributions, [2]);
assert.equal(own(g, 'a').spice, 20);
assert.equal(own(g, 'e').spice, 18);
assert.equal(g.aid.e.amount, 0);
const done = settle(g, 'e', karama, false);
assert.equal(own(done, 'g').spice, 21);
});
void test('Guild payment contributor receipts reject partial deletion or changed gross and remain absent from private projections', () => {
let g = movement(fixture(), 'a');
population(g, 'g', 4);
const karama = hold(g, 'e', 'karama');
g = applyAction(g, 'a', {
type: 'ship',
amount: 2,
territory: 'carthag',
sector: 11,
});
g = allowShipment(g);
assert.equal(g.response?.kind, 'guildIncome');
assert.deepEqual(g.response.guildContributions, [2]);
assert.equal(typeof g.response.guildPaymentProof, 'string');
for (const p of g.players) {
const view = viewGame(reload(g), p.id);
assert.equal(view.response?.guildContributions, undefined);
assert.equal(view.response?.guildPaymentProof, undefined);
assert.ok(!JSON.stringify(view.response).includes('guildContributions'));
assert.ok(!JSON.stringify(view.response).includes('guildPaymentProof'));
}
for (const mutate of [
(state: Game) => {
state.response!.guildContributions = null as unknown as number[];
},
(state: Game) => {
delete state.response!.guildContributions;
},
(state: Game) => {
delete state.response!.guildPaymentProof;
},
(state: Game) => {
state.response!.amount = 3;
},
]) {
const corrupt = reload(g);
mutate(corrupt);
const before = reload(corrupt);
assert.throws(() => viewGame(corrupt, 'g'));
assert.throws(() => normalizeAutomaticGame(corrupt));
assert.throws(() =>
applyAction(corrupt, 'e', { type: 'card', mode: 'cancel', card: karama }),
);
assert.deepEqual(corrupt, before);
}
const done = settle(g, 'e', karama, false);
assert.equal(own(done, 'g').spice, 21);
});