Files
Dune/tests/homeworld-payment-income-engine.test.ts
T

618 lines
21 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');
return applyAction(reload(g), 'g', { type: 'decision', allow: true });
}
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' },
);
assert.deepEqual(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('low Junction rejects ambiguous one-plus-one funding before interception and accepts a single two-spice contribution', () => {
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 });
const before = reload(g);
assert.throws(
() =>
applyAction(g, 'a', {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 2,
allyPayment: 1,
}),
/rounding.*ruling/,
);
assert.deepEqual(g, before);
assert.equal(g.pendingShipment ?? null, null);
assert.equal(g.decision, null);
assert.equal(g.aid.e.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('all AI profiles omit shipment candidates with unresolved low-Junction allied rounding', () => {
let g = movement(fixture(), 'a');
population(g, 'g', 4);
own(g, 'a').ally = 'e';
own(g, 'e').ally = 'a';
own(g, 'a').spice = 1;
g = applyAction(g, 'e', { type: 'pledgeAid', amount: 1 });
const blocked: { level: string; action: unknown }[] = [];
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(reload(g), 'a');
view.players.find((p) => p.id === 'a')!.bot = level;
const actions = botActions(view);
assert.ok(actions.length > 0, `${level} must retain a legal fallback.`);
for (const action of actions.filter((a) => a.type === 'ship')) {
try {
applyAction(reload(g), 'a', action);
} catch (error) {
if (/rounding.*ruling/.test(String(error)))
blocked.push({ level, action });
}
}
}
assert.deepEqual(
Object.fromEntries(
[...new Set(blocked.map((entry) => entry.level))].map((level) => [
level,
blocked.filter((entry) => entry.level === level).length,
]),
),
{},
'Every profile must omit the unresolved funding split.',
);
});
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);
});