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

267 lines
9.6 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';
import { forceRevivalRemaining, newRevivalRules } from '../game/revival';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
function fixture(tleilaxu = true) {
const g = createGame('FREEREV', newPlayer('f', 'Fremen', 'fremen'));
g.players.push(
newPlayer(
't',
tleilaxu ? 'Tleilaxu' : 'Atreides',
tleilaxu ? 'tleilaxu' : 'atreides',
),
newPlayer('e', 'Emperor', 'emperor'),
);
g.status = 'playing';
g.phase = 4;
g.order = ['f', 't', 'e'];
g.revivalRules = newRevivalRules();
for (const p of g.players) {
p.reserves = 10;
p.tanks = 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;
}
function unchangedRejection(g: Game, amount: number) {
const before = structuredClone(g);
assert.throws(
() => applyAction(g, 'f', { type: 'revive', amount }),
/Forces/,
);
assert.deepEqual(g, before);
}
void test('official FAQ permits Fremen to purchase additional revivals under the Tleilaxu five-force allowance', () => {
let g = applyAction(fixture(), 't', {
type: 'tleilaxuRevivalLimit',
target: 'f',
});
assert.equal(viewGame(g, 'f').revival.limit, 5);
assert.equal(viewGame(g, 'f').revival.forcesRemaining, 5);
g = allow(applyAction(g, 'f', { type: 'revive', amount: 2 }));
assert.equal(forceRevivalRemaining(g, g.players[0]), 3);
g = allow(applyAction(g, 'f', { type: 'revive', amount: 1 }));
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[0].freeForcesRevived, 3);
assert.equal(g.players[0].revived, 3);
assert.equal(g.players[0].tanks, 7);
g = allow(applyAction(g, 'f', { type: 'revive', amount: 2 }));
assert.equal(g.players[0].spice, 16);
assert.equal(g.players[0].freeForcesRevived, 3);
assert.equal(g.players[0].revived, 5);
assert.equal(g.players[0].tanks, 5);
assert.equal(forceRevivalRemaining(g, g.players[0]), 0);
unchangedRejection(g, 1);
});
void test('without Tleilaxu, free-revival prevention still leaves Fremen no ordinary paid revival method', () => {
const g = fixture(false);
g.expansions = ['ix'];
g.revivalRules!.freeBlocked = ['f'];
assert.equal(viewGame(g, 'f').revival.forcesRemaining, 0);
unchangedRejection(g, 1);
assert.equal(forceRevivalRemaining(g, g.players[2]), 3);
const paid = allow(applyAction(g, 'e', { type: 'revive', amount: 3 }));
assert.equal(paid.players[2].revived, 3);
});
void test('Emperor-paid extra returns and Ghola remain separate from Fremen normal revival', () => {
let g = fixture();
g.players[0].ally = 'e';
g.players[2].ally = 'f';
g = allow(applyAction(g, 'f', { type: 'revive', amount: 3 }));
g = allow(applyAction(g, 'e', { type: 'emperorRevival', amount: 3 }));
assert.equal(g.players[0].revived, 3);
assert.equal(g.players[0].freeForcesRevived, 3);
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[2].spice, 14);
assert.equal(g.emperorExtra.f, 3);
const ghola = baseDeck().find((c) => c.effect === 'ghola')!;
g.players[0].hand = [ghola];
g = allow(applyAction(g, 'f', { type: 'card', card: ghola.id, amount: 4 }));
assert.equal(g.players[0].tanks, 0);
assert.equal(g.players[0].reserves, 20);
assert.equal(g.players[0].revived, 3);
assert.equal(g.players[0].freeForcesRevived, 3);
});
void test('a stale pending paid Fremen request without Tleilaxu is rejected before any custody changes', () => {
let g = fixture(false);
g.revivalRules!.expanded = ['f'];
g.pendingRevival = {
player: 'f',
kind: 'forces',
amount: 5,
elite: 0,
free: 3,
cost: 4,
normalCost: 4,
checks: [],
};
g.response = { kind: 'revivalLimit', owner: 't', recipient: 'f', passed: [] };
g = allow(JSON.parse(JSON.stringify(g)));
assert.equal(g.pendingRevival, null);
assert.equal(g.players[0].reserves, 10);
assert.equal(g.players[0].tanks, 10);
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[0].revived, 0);
assert.equal(g.players[0].freeForcesRevived, 0);
assert.match(
g.log.at(-1)!.text,
/exceeds their current force-revival allowance/,
);
g = allow(applyAction(g, 'f', { type: 'revive', amount: 3 }));
assert.equal(g.players[0].revived, 3);
});
void test('all four AI policies use the correct Fremen paid permission and private allowance', () => {
for (const difficulty of DIFFICULTIES) {
const g = fixture();
g.players[0].bot = difficulty;
g.revivalRules!.expanded = ['f'];
const proposals = botActions(viewGame(g, 'f')).filter(
(a) => a.type === 'revive',
);
assert.ok(proposals.length > 0, difficulty);
assert.ok(
proposals.some((a) => Number(a.amount) === 5),
difficulty,
);
for (const action of proposals)
assert.doesNotThrow(() => applyAction(g, 'f', action));
g.revivalRules!.freeBlocked = ['f'];
const paid = botActions(viewGame(g, 'f')).filter(
(a) => a.type === 'revive',
);
assert.ok(
paid.some((a) => Number(a.amount) === 5),
difficulty,
);
for (const action of paid)
assert.doesNotThrow(() => applyAction(g, 'f', action));
const baseOnly = fixture(false);
baseOnly.players[0].bot = difficulty;
baseOnly.expansions = ['ix'];
baseOnly.revivalRules!.freeBlocked = ['f'];
assert.ok(
botActions(viewGame(baseOnly, 'f')).every((a) => a.type !== 'revive'),
difficulty,
);
}
});
void test('Tleilaxu presence does not itself raise the normal limit, while blocked free revival may be purchased', () => {
let g = fixture();
g.revivalRules!.freeBlocked = ['f'];
assert.equal(viewGame(g, 'f').revival.freeRemaining, 0);
assert.equal(viewGame(g, 'f').revival.forcesRemaining, 3);
unchangedRejection(g, 4);
g = allow(applyAction(g, 'f', { type: 'revive', amount: 3 }));
assert.equal(g.players[0].spice, 14);
assert.equal(g.players[0].freeForcesRevived, 0);
assert.equal(g.players[0].revived, 3);
assert.equal(g.players[1].spice, 26);
assert.equal(g.players[0].reserves + g.players[0].tanks, 20);
});
void test('canceling Tleilaxu expanded revival returns no forces and charges no revival fee', () => {
let g = applyAction(fixture(), 't', {
type: 'tleilaxuRevivalLimit',
target: 'f',
});
const karama = baseDeck().find((c) => c.effect === 'karama')!;
g.players[2].hand = [karama];
g = applyAction(g, 'f', { type: 'revive', amount: 5 });
assert.equal(g.response?.kind, 'revivalLimit');
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[0].tanks, 10);
g = applyAction(g, 'e', { type: 'card', mode: 'cancel', card: karama.id });
assert.equal(g.pendingRevival, null);
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[0].tanks, 10);
assert.equal(g.players[0].revived, 0);
assert.equal(viewGame(g, 'f').revival.forcesRemaining, 3);
unchangedRejection(g, 5);
g.revivalRules!.freeBlocked = ['f'];
g = allow(applyAction(g, 'f', { type: 'revive', amount: 3 }));
assert.equal(g.players[0].spice, 14);
assert.equal(g.players[0].revived, 3);
});
void test('a serialized five-force pending revival settles its free and paid portions exactly once', () => {
let g = applyAction(fixture(), 't', {
type: 'tleilaxuRevivalLimit',
target: 'f',
});
g.players.find((p) => p.id === 'e')!.hand = [
baseDeck().find((c) => c.effect === 'karama')!,
];
g = applyAction(g, 'f', { type: 'revive', amount: 5 });
assert.equal(g.pendingRevival?.free, 3);
assert.equal(g.pendingRevival?.cost, 4);
assert.equal(g.players[0].spice, 20);
g = allow(JSON.parse(JSON.stringify(g)));
assert.equal(g.pendingRevival, null);
assert.equal(g.players[0].spice, 16);
assert.equal(g.players[1].spice, 25); // Four paid spice plus once-per-turn free-revival income.
assert.equal(g.players[0].tanks, 5);
assert.equal(g.players[0].reserves, 15);
assert.equal(g.players[0].revived, 5);
assert.equal(g.players[0].freeForcesRevived, 3);
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'e', { type: 'passResponse' }));
assert.deepEqual(g, before);
});
void test('actual reactive La La La preserves the Tleilaxu paid exception but grants none without Tleilaxu', () => {
for (const tleilaxu of [true, false]) {
let g = fixture(tleilaxu);
const choam = newPlayer('c', 'CHOAM', 'choam');
const card = baseDeck().find((c) => c.name === 'La La La')!;
choam.hand = [card];
g.players.push(choam);
g.order.push('c');
if (tleilaxu)
g = applyAction(g, 't', { type: 'tleilaxuRevivalLimit', target: 'f' });
const amount = tleilaxu ? 5 : 3;
g = applyAction(g, 'f', { type: 'revive', amount });
assert.equal(g.decision?.kind, 'choamFreeRevival');
g = applyAction(g, 'c', { type: 'card', mode: 'choam', card: card.id });
g = allow(JSON.parse(JSON.stringify(g)));
assert.equal(g.pendingRevival, null);
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[0].tanks, 10);
assert.equal(g.players[0].revived, 0);
assert.equal(g.players[0].freeForcesRevived, 0);
assert.equal(viewGame(g, 'f').revival.freeRemaining, 0);
if (tleilaxu) {
g = allow(applyAction(g, 'f', { type: 'revive', amount: 5 }));
assert.equal(g.players[0].spice, 10);
assert.equal(g.players[1].spice, 30);
assert.equal(g.players[0].reserves, 15);
assert.equal(g.players[0].freeForcesRevived, 0);
} else unchangedRejection(g, 3);
}
});