Files
Dune/tests/revival-usage.test.ts
T
2026-09-08 07:37:52 +10:00

125 lines
4.4 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
function fixture(tleilaxu = false) {
const g = createGame('FREEUSED', newPlayer('a', 'Atreides', 'atreides'));
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'));
if (tleilaxu) g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
g.status = 'playing';
g.phase = 4;
g.order = g.players.map((p) => p.id);
for (const p of g.players) {
p.tanks = 10;
p.reserves = 10;
p.spice = 20;
}
return g;
}
function allow(state: Game) {
let g = state;
while (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
return g;
}
void test('normal revival records actual free returns separately from paid units without changing prices', () => {
let g = fixture();
g = applyAction(g, 'a', { type: 'revive', amount: 2 });
assert.equal(g.players[0].freeForcesRevived, 2);
assert.equal(g.players[0].revived, 2);
assert.equal(g.players[0].spice, 20);
g = applyAction(JSON.parse(JSON.stringify(g)), 'a', {
type: 'revive',
amount: 1,
});
assert.equal(g.players[0].freeForcesRevived, 2);
assert.equal(g.players[0].revived, 3);
assert.equal(g.players[0].spice, 18);
assert.equal(g.players[0].reserves, 13);
assert.equal(g.players[0].tanks, 7);
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'a', { type: 'revive', amount: 1 }));
assert.deepEqual(g, before);
});
void test('pending revival records usage only on settlement, and canceled oversized requests consume none', () => {
let g = fixture(true);
const karama = baseDeck().find((c) => c.effect === 'karama')!;
g.players[1].hand = [karama];
g = applyAction(g, 't', { type: 'tleilaxuRevivalLimit', target: 'a' });
g = applyAction(g, 'a', { type: 'revive', amount: 5 });
assert.equal(g.pendingRevival?.free, 2);
assert.equal(g.players[0].freeForcesRevived, 0);
const accepted = allow(JSON.parse(JSON.stringify(g)));
assert.equal(accepted.players[0].freeForcesRevived, 2);
assert.equal(accepted.players[0].revived, 5);
g = applyAction(g, 'h', { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(g.pendingRevival, null);
assert.equal(g.players[0].freeForcesRevived, 0);
assert.equal(g.players[0].revived, 0);
assert.equal(g.players[0].spice, 20);
});
void test('Ghola and leader revival do not consume the normal free-force usage ledger', () => {
let g = fixture();
const ghola = baseDeck().find((c) => c.effect === 'ghola')!;
g.players[0].hand = [ghola];
g = applyAction(g, 'a', { type: 'card', card: ghola.id, amount: 5 });
assert.equal(g.players[0].freeForcesRevived, 0);
assert.equal(g.players[0].revived, 0);
g.players[0].leaders[0].dead = true;
g.players[0].leaders[0].deaths = 1;
g.players[0].revivalCycle = 1;
g = applyAction(g, 'a', {
type: 'reviveLeader',
leader: g.players[0].leaders[0].id,
});
assert.equal(g.players[0].freeForcesRevived, 0);
assert.equal(g.players[0].revived, 0);
});
void test('legacy usage stays unknown after prior returns but starts exactly when no normal revival occurred', () => {
let old = fixture();
delete old.players[0].freeForcesRevived;
old.players[0].revived = 2;
old = applyAction(old, 'a', { type: 'revive', amount: 1 });
assert.equal(old.players[0].freeForcesRevived, undefined);
let fresh = fixture();
delete fresh.players[0].freeForcesRevived;
fresh = applyAction(fresh, 'a', { type: 'revive', amount: 2 });
assert.equal(fresh.players[0].freeForcesRevived, 2);
for (const id of fresh.players.map((p) => p.id))
assert.ok(
viewGame(fresh, id).players.every((p) => !('freeForcesRevived' in p)),
);
});
void test('a new Revival phase resets the ledger, including previously unknown legacy usage', () => {
let g = fixture();
g.phase = 2;
g.players[0].revived = 3;
g.players[0].freeForcesRevived = 2;
delete g.players[1].freeForcesRevived;
// Complete Charity; the empty auction advances through the ordinary lifecycle.
for (const id of g.players.map((p) => p.id))
g = applyAction(g, id, { type: 'ready' });
// Empty hands are eligible, but an empty draw/discard deck skips bidding.
assert.equal(g.phase, 4);
assert.ok(
g.players.every((p) => p.revived === 0 && p.freeForcesRevived === 0),
);
});