Files
Dune/tests/homeworld-revival-benefits-engine.test.ts

405 lines
16 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 { DIFFICULTIES } from '../game/bot-profiles';
import type { FactionId } from '../game/catalog';
import { forceRevivalQuote, newRevivalRules } from '../game/revival';
const sum = (counts: Record<string, number>) =>
Object.values(counts).reduce((a, b) => a + b, 0);
const p = (g: Game, id: string) =>
g.players.find((player) => player.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function setup(roster: FactionId[] = ['fremen', 'emperor'], advanced = false) {
let g = createGame(
'HOMEREVIVALBENEFITS',
newPlayer(roster[0], roster[0], roster[0]),
advanced,
roster.some((id) => id === 'tleilaxu' || id === 'ixians') ? ['ix'] : [],
);
for (const id of roster.slice(1)) joinGame(g, newPlayer(id, id, id));
g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
for (const id of roster) g = applyAction(g, id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
for (let i = 0; g.status === 'setup' && i < 30; i++) {
let moved = false;
for (const player of g.players) {
const view = viewGame(g, player.id);
view.players.find((v) => v.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (!action) continue;
g = applyAction(g, player.id, action);
moved = true;
break;
}
assert.ok(moved);
}
assert.equal(g.status, 'playing');
// Precisely stage a later Charity phase. Physical setup cards and counters
// remain in their real zones; later positions below conserve every counter.
Object.assign(g, {
phase: 2,
turn: 2,
storm: 18,
active: null,
ready: [],
order: roster,
phaseOpening: null,
});
for (const player of g.players) {
g.deck.push(...player.hand);
player.hand = [];
}
return g;
}
function position(g: Game, id: string, reserves: number, tanks: number) {
const player = p(g, id);
const board = 20 - reserves - tanks;
assert.ok(board >= 0);
Object.assign(player, {
reserves,
tanks,
forces: board ? { 'polar_sink:0': board } : {},
});
if (player.elites) {
const total =
player.faction === 'emperor' ? 5 : player.faction === 'fremen' ? 3 : 7;
// These fixtures put only ordinary forces in Tanks. Remaining stars keep
// their physical identities in native reserves or on the neutral board.
const eliteReserves = Math.min(total, reserves);
const eliteBoard = total - eliteReserves;
assert.ok(eliteBoard <= board);
player.elites = {
reserves: eliteReserves,
tanks: 0,
forces: eliteBoard ? { 'polar_sink:0': eliteBoard } : {},
revived: 0,
};
}
}
function inventory(g: Game) {
for (const player of g.players) {
assert.equal(player.reserves + player.tanks + sum(player.forces), 20);
if (player.elites)
assert.equal(
player.elites.reserves +
player.elites.tanks +
sum(player.elites.forces),
player.faction === 'emperor' ? 5 : player.faction === 'fremen' ? 3 : 7,
);
assert.ok(player.spice >= 0);
}
const cards = [
...g.deck,
...g.discard,
...g.players.flatMap((player) => player.hand),
...(g.auction?.cards.slice(g.auction.index) ?? []),
];
assert.equal(new Set(cards.map((card) => card.id)).size, cards.length);
const before = reload(g);
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), before);
}
/** Genuine Charity last-ready, real unsold auction lots, and public Amal passes. */
function enterRevival(state: Game) {
let g = state;
for (const player of g.players)
g = applyAction(g, player.id, { type: 'ready' });
for (let i = 0; (g.phase === 3 || g.phaseOpening) && i < 100; i++) {
if (g.phaseOpening) {
const id = g.players.find(
(player) => !g.phaseOpening!.passed.includes(player.id),
)!.id;
g = applyAction(g, id, { type: 'ready' });
} else {
assert.equal(g.phase, 3);
assert.ok(g.auction);
g = applyAction(g, g.auction.active!, { type: 'passBid' });
}
}
assert.equal(g.phase, 4);
assert.equal(g.phaseOpening ?? null, null);
inventory(g);
return g;
}
function rejected(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
function lowFremen(withTleilaxu = false) {
const g = setup(withTleilaxu ? ['fremen', 'emperor', 'tleilaxu'] : undefined);
position(g, 'fremen', 2, 6);
if (withTleilaxu) position(g, 'tleilaxu', 12, 0);
return enterRevival(g);
}
function hold(g: Game, owner: string, effect: string) {
const index = g.deck.findIndex((card) => card.effect === effect);
assert.ok(
index >= 0,
`Unheld ${effect} must exist in the real shuffled deck.`,
);
const [card] = g.deck.splice(index, 1);
p(g, owner).hand.push(card);
return card.id;
}
void test('one low Fremen group returns four free without a Tleilaxu expansion-of-limit response', () => {
for (const tleilaxu of [false, true]) {
const g = lowFremen(tleilaxu);
const view = viewGame(g, 'fremen');
assert.equal(view.revival.freeRemaining, 4);
assert.equal(view.revival.forcesRemaining, 4);
assert.equal(view.revival.limit, 4);
const quote = forceRevivalQuote(g, p(g, 'fremen'), 4, 0);
assert.equal(quote.cost, 0);
assert.equal(quote.free, 4);
const beforeSpice = p(g, 'fremen').spice;
const done = applyAction(g, 'fremen', {
type: 'revive',
amount: 4,
elite: 0,
});
assert.equal(done.pendingRevival ?? null, null);
assert.equal(done.response, null);
assert.equal(p(done, 'fremen').reserves, 6);
assert.equal(p(done, 'fremen').tanks, 2);
assert.equal(p(done, 'fremen').spice, beforeSpice);
assert.equal(p(done, 'fremen').revived, 4);
assert.equal(p(done, 'fremen').freeForcesRevived, 4);
assert.equal(viewGame(done, 'fremen').revival.forcesRemaining, 0);
assert.equal(
done.log.some((entry) =>
/expanded.*revival|limit.*five/i.test(entry.text),
),
false,
);
inventory(done);
}
});
void test('later separate Fremen requests recompute a crossed native threshold instead of retaining a phase-wide fourth free return', () => {
let g = lowFremen();
g = applyAction(g, 'fremen', { type: 'revive', amount: 1, elite: 0 });
assert.equal(p(g, 'fremen').reserves, 3);
assert.equal(viewGame(g, 'fremen').revival.freeRemaining, 2);
assert.equal(viewGame(g, 'fremen').revival.limit, 3);
rejected(g, 'fremen', { type: 'revive', amount: 3, elite: 0 });
g = applyAction(reload(g), 'fremen', { type: 'revive', amount: 2, elite: 0 });
assert.equal(p(g, 'fremen').revived, 3);
assert.equal(p(g, 'fremen').tanks, 3);
rejected(g, 'fremen', { type: 'revive', amount: 1, elite: 0 });
inventory(g);
});
void test('Fremen alliance supplies three, with a fourth coming only from the recipient own low Homeworld', () => {
for (const recipientLow of [false, true])
for (const donorLow of [false, true]) {
let g = setup();
position(g, 'fremen', donorLow ? 2 : 10, 0);
position(g, 'emperor', recipientLow ? 4 : 10, 5);
p(g, 'fremen').ally = 'emperor';
p(g, 'emperor').ally = 'fremen';
p(g, 'fremen').allySinceTurn = 1;
p(g, 'emperor').allySinceTurn = 1;
g = enterRevival(g);
g = applyAction(g, 'fremen', { type: 'grantRevival' });
const amount = recipientLow ? 4 : 3;
assert.equal(viewGame(g, 'emperor').revival.freeRemaining, amount);
assert.equal(viewGame(g, 'emperor').revival.limit, amount);
const before = p(g, 'emperor').spice;
if (!recipientLow)
rejected(g, 'emperor', { type: 'revive', amount: 4, elite: 0 });
g = applyAction(g, 'emperor', { type: 'revive', amount, elite: 0 });
assert.equal(p(g, 'emperor').spice, before);
assert.equal(p(g, 'emperor').freeForcesRevived, amount);
inventory(g);
}
});
void test('La La La saved free-block rule suppresses the whole low rate, with no claim of a live unsupported CHOAM deck path', () => {
let g = setup();
position(g, 'fremen', 2, 5);
position(g, 'emperor', 4, 5);
g = enterRevival(g);
// Explicit existing rule-context fixture: CHOAM's complete deck/setup remains
// gated, so this does not fabricate a live Worthless declaration or receipt.
g.revivalRules = { ...newRevivalRules(), freeBlocked: ['fremen', 'emperor'] };
assert.equal(viewGame(g, 'fremen').revival.freeRemaining, 0);
assert.equal(viewGame(g, 'emperor').revival.freeRemaining, 0);
rejected(g, 'fremen', { type: 'revive', amount: 1, elite: 0 });
const before = p(g, 'emperor').spice;
const done = applyAction(g, 'emperor', {
type: 'revive',
amount: 2,
elite: 0,
});
assert.equal(p(done, 'emperor').spice, before - 4);
assert.equal(p(done, 'emperor').freeForcesRevived, 0);
inventory(done);
});
void test('all four AI policies propose legal current low revival quantities without reading another player private cards', () => {
const g = lowFremen();
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g, 'fremen');
view.players.find((player) => player.id === 'fremen')!.bot = difficulty;
assert.ok(
view.players
.filter((player) => player.id !== 'fremen')
.every((player) => player.hand === undefined),
);
const before = structuredClone(view);
const candidates = botActions(view);
assert.deepEqual(view, before);
const revivals = candidates.filter((action) => action.type === 'revive');
assert.ok(revivals.length, `${difficulty} must expose some revival policy`);
for (const action of revivals) {
assert.ok(Number(action.amount) <= 4);
const quote = forceRevivalQuote(
g,
p(g, 'fremen'),
Number(action.amount),
Number(action.elite ?? 0),
);
assert.equal(quote.cost, 0);
assert.doesNotThrow(() => applyAction(g, 'fremen', action));
}
}
});
void test('actual Bidding exit snapshots low Tleilax; later crossing high suppresses others free income but preserves own, paid and Ghola income', () => {
let g = setup(['tleilaxu', 'fremen', 'emperor']);
position(g, 'tleilaxu', 7, 3);
position(g, 'fremen', 2, 6);
position(g, 'emperor', 12, 5);
g = enterRevival(g);
assert.deepEqual(g.homeworldRevival, {
turn: 2,
tleilaxu: { player: 'tleilaxu', low: true },
});
const initial = p(g, 'tleilaxu').spice;
const before = structuredClone(g);
// The crossing return now qualifies under the provisional post-return
// population policy: it stages its own redeployment choice instead of
// blocking, while the source state stays untouched.
const crossing = applyAction(reload(g), 'tleilaxu', { type: 'revive', amount: 2, elite: 0 });
assert.equal(crossing.homeworldRevivalReturn!.stage, 'choice');
assert.equal(viewGame(crossing, 'tleilaxu').homeworldRevivalDeployment?.player, 'tleilaxu');
assert.deepEqual(g, before);
g = applyAction(g, 'tleilaxu', { type: 'revive', amount: 1, elite: 0 });
assert.equal(p(g, 'tleilaxu').reserves, 8);
assert.equal(
p(g, 'tleilaxu').spice,
initial + 1,
'The low card does not suppress its owner free-revival income.',
);
// A later physical return from the board changes the current native side.
// This narrow position seam preserves all twenty counters and the actual
// phase-opening income receipt without inventing a threshold-crossing revival.
position(g, 'tleilaxu', 9, p(g, 'tleilaxu').tanks);
assert.equal(p(g, 'tleilaxu').reserves, 9);
assert.equal(
viewGame(g, 'tleilaxu').homeworlds!.worlds!.find(
(world) => world.native === 'tleilaxu',
)!.side,
'high',
);
g = applyAction(reload(g), 'fremen', { type: 'revive', amount: 4, elite: 0 });
assert.equal(p(g, 'tleilaxu').spice, initial + 1);
assert.equal(g.revivalFreeIncome!.fremen, 2);
g = applyAction(g, 'emperor', { type: 'revive', amount: 3, elite: 0 });
assert.equal(
p(g, 'tleilaxu').spice,
initial + 5,
'Four paid spice still reach Tleilaxu; the free counter gives no bank bonus.',
);
const card = hold(g, 'emperor', 'ghola');
g = applyAction(g, 'emperor', { type: 'card', card, amount: 2, elite: 0 });
assert.equal(
p(g, 'tleilaxu').spice,
initial + 6,
'Ghola remains its separately named income event.',
);
assert.deepEqual(g.homeworldRevival, {
turn: 2,
tleilaxu: { player: 'tleilaxu', low: true },
});
inventory(g);
});
void test('actual high Tleilax phase opening permits one ordinary free-income payment per other faction and does not recreate a consumed receipt', () => {
let g = setup(['tleilaxu', 'emperor']);
position(g, 'tleilaxu', 9, 0);
position(g, 'emperor', 4, 5);
g = enterRevival(g);
assert.equal(g.homeworldRevival!.tleilaxu.low, false);
const before = p(g, 'tleilaxu').spice;
g = applyAction(g, 'emperor', { type: 'revive', amount: 1, elite: 0 });
assert.equal(p(g, 'tleilaxu').spice, before + 1);
// Emperor crosses to high after the first return; its second return is now
// paid. This tests both dynamic rate and the independent income receipt.
g = applyAction(reload(g), 'emperor', {
type: 'revive',
amount: 1,
elite: 0,
});
assert.equal(p(g, 'tleilaxu').spice, before + 3);
assert.equal(g.revivalFreeIncome!.emperor, 2);
inventory(g);
});
void test('controlled Low Homeworld keeps its rate while human and minimal policies can prevent the whole return', () => {
let g = setup(['fremen', 'tleilaxu', 'atreides'], true);
position(g, 'fremen', 2, 12);
position(g, 'tleilaxu', 12, 0);
// Controlled later army/card allocation; the separate native fixture earns
// its actual Low population and Tanks through setup/shipment/Storm losses.
g = enterRevival(g);
const card = hold(g, 'tleilaxu', 'karama');
g = applyAction(g, 'fremen', { type: 'revive', amount: 1, elite: 0 });
assert.equal(g.decision?.kind, 'revivalStop');
const before = reload(g);
const offer = viewGame(g, 'tleilaxu').tleilaxuSpecialKarama!;
assert.equal(offer.blocked, null);
assert.deepEqual(offer.targets, [{ player: 'fremen', blocked: null }]);
assert.equal(viewGame(g, 'tleilaxu').revival.specialKaramaBlock, null);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(reload(g), 'tleilaxu');
view.players.find((player) => player.id === 'tleilaxu')!.bot = difficulty;
const action = botActions(view)[0];
assert.deepEqual(action, { type: 'card', card, mode: 'special', target: 'fremen' });
const stopped = applyAction(reload(g), 'tleilaxu', action);
assert.equal(stopped.pendingRevival ?? null, null);
assert.equal(p(stopped, 'fremen').reserves, 2);
assert.equal(p(stopped, 'fremen').tanks, 12);
assert.equal(p(stopped, 'fremen').revived, 0);
assert.equal(p(stopped, 'fremen').freeForcesRevived, 0);
assert.equal(p(stopped, 'tleilaxu').specialKaramaUsed, true);
assert.equal(p(stopped, 'tleilaxu').spice, p(before, 'tleilaxu').spice);
assert.ok(!p(stopped, 'tleilaxu').hand.some(held => held.id === card));
assert.equal(stopped.discard.filter(held => held.id === card).length, 1);
const recipient = viewGame(stopped, 'fremen');
assert.equal(recipient.revival.prevented, true);
assert.equal(recipient.revival.freeRemaining, 4);
rejected(stopped, 'fremen', { type: 'revive', amount: 1, elite: 0 });
inventory(stopped);
}
const allowed = applyAction(reload(g), 'tleilaxu', { type: 'decision', decline: true });
assert.equal(p(allowed, 'fremen').reserves, 3);
assert.equal(p(allowed, 'fremen').tanks, 11);
assert.ok(p(allowed, 'tleilaxu').hand.some(held => held.id === card));
assert.deepEqual(g, before);
});