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

324 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import {
forceRevivalQuote,
forceRevivalLimit,
newRevivalRules,
} from '../game/revival';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
function fixture(tleilaxu = false) {
const g = createGame('CHOAMREV', newPlayer('c', 'CHOAM', 'choam'), false, [
'choam',
'ix',
]);
g.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('b', 'Bene Gesserit', 'beneGesserit'),
);
if (tleilaxu) g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
g.status = 'playing';
g.phase = 4;
g.order = g.players.map((p) => p.id);
g.deck = baseDeck();
g.revivalRules = newRevivalRules();
g.players.forEach((p) => {
p.spice = 40;
p.tanks = 10;
p.reserves = 10;
p.traitorChoices = [];
});
return g;
}
function allow(state: Game, one = false) {
let g = state;
const kind = g.response?.kind;
while (g.response && (!one || g.response.kind === kind)) {
const p = g.players.find(
(p) =>
!viewGame(g, p.id).responseControls?.hasPassed &&
!!viewGame(g, p.id).responseControls?.cancelCards.length,
)!;
g = applyAction(g, p.id, { type: 'passResponse' });
}
return g;
}
function contest(g: Game, count = 1, id = 'e') {
for (let n = 0; n < count; n++) {
const index = g.deck.findIndex((card) => card.effect === 'karama');
assert.ok(index >= 0);
g.players.find((p) => p.id === id)!.hand.push(g.deck.splice(index, 1)[0]);
}
return g;
}
function cancel(g: Game) {
const card = g.players
.find((p) => p.id === 'e')!
.hand.find((card) => card.effect === 'karama');
assert.ok(card, 'canceller must hold Karama before the declaration');
return applyAction(g, 'e', { type: 'card', card: card.id, mode: 'cancel' });
}
const revive = (g: Game, amount: number) =>
applyAction(g, 'c', { type: 'revive', amount });
function grant(state: Game, discount = false) {
let g = state;
if (discount) {
g.players[0].ally = 't';
g.players[3].ally = 'c';
g = applyAction(g, 't', { type: 'tleilaxuAllyDiscount' });
} else g = applyAction(g, 't', { type: 'tleilaxuRevivalLimit', target: 'c' });
return g;
}
function conserved(g: Game) {
for (const p of g.players) {
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((n, x) => n + x, 0),
20,
);
assert.ok(p.spice >= 0);
}
}
void test('CHOAM has zero free revival and can revive any number of available forces at one spice each', () => {
for (const n of [1, 3, 4, 10, 20]) {
let g = fixture();
g.players[0].tanks = 20;
g.players[0].reserves = 0;
assert.equal(forceRevivalLimit(g, g.players[0]), 20);
assert.deepEqual(forceRevivalQuote(g, g.players[0], n), {
free: 0,
normalCost: 2 * n,
cost: n,
});
g = revive(g, n);
assert.equal(g.response, null); // No opponent can cancel; payment is immediate.
g = allow(g);
assert.equal(g.players[0].spice, 40 - n);
assert.equal(g.players[0].reserves, n);
assert.equal(g.players[0].revived, n);
conserved(g);
}
});
void test('canceling a request within the standard allowance reprices before moving forces', () => {
const g = cancel(revive(contest(fixture()), 3));
assert.equal(g.revivalRules?.choamBlocked, true);
assert.equal(g.players[0].spice, 34);
assert.equal(g.players[0].reserves, 13);
assert.equal(g.players[0].revived, 3);
assert.equal(g.pendingRevival, null);
assert.throws(() => revive(g, 1), /Forces/);
conserved(g);
});
void test('canceling an oversized request makes no partial revival or payment and allows a smaller retry', () => {
let g = cancel(revive(contest(fixture()), 8));
assert.equal(g.players[0].spice, 40);
assert.equal(g.players[0].tanks, 10);
assert.equal(g.players[0].revived, 0);
assert.equal(g.pendingRevival, null);
g = revive(g, 2);
assert.equal(g.response, null);
assert.equal(g.players[0].spice, 36);
g = revive(g, 1);
assert.equal(g.players[0].spice, 34);
conserved(g);
});
void test('an unaffordable cancellation consumes no forces, spice or allowance', () => {
let g = fixture();
g.players[0].spice = 3;
g = cancel(revive(contest(g), 3));
assert.equal(g.players[0].spice, 3);
assert.equal(g.players[0].tanks, 10);
assert.equal(g.players[0].revived, 0);
g = revive(g, 1);
assert.equal(g.players[0].spice, 1);
conserved(g);
});
void test('CHOAM cancellation preserves Tleilaxu unlimited revival and independent five-force permission', () => {
let g = cancel(revive(contest(grant(fixture(true)), 2), 5));
assert.equal(g.response?.kind, 'revivalLimit');
assert.equal(g.response?.owner, 't');
assert.equal(forceRevivalLimit(g, g.players[3]), 20);
assert.equal(forceRevivalLimit(g, g.players[0]), 5);
g = allow(g);
assert.equal(g.players[0].spice, 30);
assert.equal(g.players[3].spice, 50);
assert.equal(g.players[0].revived, 5);
conserved(g);
});
void test('Tleilaxu five-force permission can itself be canceled after CHOAM cancellation', () => {
let g = cancel(revive(contest(grant(fixture(true)), 2), 5));
g = cancel(g);
assert.equal(g.revivalRules?.choamBlocked, true);
assert.equal(g.revivalRules?.limitBlocked, true);
assert.equal(forceRevivalLimit(g, g.players[0]), 3);
assert.equal(g.players[0].revived, 0);
assert.equal(g.players[0].spice, 40);
conserved(g);
});
void test('canceling Tleilaxu expanded revival does not cancel CHOAM native unlimited revival', () => {
let g = fixture(true);
g = applyAction(g, 't', { type: 'tleilaxuRevivalLimit', target: 'b' });
contest(g);
g = applyAction(g, 'b', { type: 'revive', amount: 4 });
g = applyAction(g, 'c', { type: 'decision', decline: true });
g = cancel(g);
assert.equal(forceRevivalLimit(g, g.players[0]), 20);
g = allow(revive(g, 8));
assert.equal(g.players[0].spice, 32);
assert.equal(g.players[0].revived, 8);
conserved(g);
});
void test('Tleilaxu allied discount is separate from CHOAM price and transfers only actual payment', () => {
let g = contest(grant(fixture(true), true));
assert.equal(forceRevivalQuote(g, g.players[0], 5).cost, 3);
g = allow(revive(g, 5), true);
assert.equal(g.response?.kind, 'revivalDiscount');
assert.equal(g.response?.owner, 't');
g = allow(g);
assert.equal(g.players[0].spice, 37);
assert.equal(g.players[3].spice, 43);
conserved(g);
});
void test('canceling the allied discount retains CHOAM one-spice price and unlimited allowance', () => {
let g = allow(revive(contest(grant(fixture(true), true)), 5), true);
g = allow(cancel(g));
assert.equal(g.players[0].spice, 35);
assert.equal(g.players[3].spice, 45);
assert.equal(forceRevivalLimit(g, g.players[0]), 20);
conserved(g);
});
void test('repricing CHOAM can introduce a meaningful Tleilaxu discount response for a one-force request', () => {
let g = cancel(revive(contest(grant(fixture(true), true), 2), 1));
assert.equal(g.response?.kind, 'revivalDiscount');
assert.equal(g.players[0].spice, 40);
g = allow(g);
assert.equal(g.players[0].spice, 39);
assert.equal(g.players[3].spice, 41);
conserved(g);
});
void test('Fremen free allowance is retained and a completely free standard request needs no CHOAM response', () => {
let g = fixture();
g.players.push(newPlayer('f', 'Fremen', 'fremen'));
g.players[0].ally = 'f';
g.players[3].ally = 'c';
g = applyAction(g, 'f', { type: 'grantRevival' });
g = revive(g, 3);
assert.equal(g.response, null);
assert.equal(g.players[0].spice, 40);
g = allow(revive(g, 4));
assert.equal(g.players[0].spice, 36);
assert.equal(g.players[0].revived, 7);
conserved(g);
});
void test('Emperor extra revival still uses the Emperor price and separate allowance', () => {
let g = fixture();
g.players[0].ally = 'e';
g.players[1].ally = 'c';
g = applyAction(g, 'e', { type: 'emperorRevival', amount: 3 });
assert.equal(g.response, null);
g = allow(g);
assert.equal(g.players[0].spice, 40);
assert.equal(g.players[1].spice, 34);
assert.equal(g.players[0].revived, 0);
assert.equal(g.emperorExtra.c, 3);
conserved(g);
});
void test('CHOAM price cancellation resets when the next Revival phase opens', () => {
let g = cancel(revive(contest(fixture()), 1));
g.turn++;
g.phase = 3;
g.auction = null;
g.expansions = [];
// Enter Revival through the real empty-auction phase transition.
g.phase = 2;
for (const p of g.players) {
p.hand = [];
p.spice = 0;
}
g.deck = [];
g.discard = [];
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
while (g.decision?.kind === 'choamMarket')
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
assert.equal(g.phase, 4);
assert.equal(g.revivalRules?.choamBlocked, undefined);
assert.equal(forceRevivalLimit(g, g.players[0]), 20);
assert.equal(g.players[0].revived, 0);
});
void test('all AI levels revive within their budget with native and canceled CHOAM prices', () => {
for (const difficulty of DIFFICULTIES)
for (const blocked of [false, true]) {
let g = fixture();
g.players[0].bot = difficulty;
g.players[0].spice = 12;
g.revivalRules!.choamBlocked = blocked;
const v = viewGame(g, 'c');
assert.equal(v.revival.choamBlocked, blocked);
const action = botActions(v).find((a: Action) => a.type === 'revive');
assert.ok(action, difficulty);
g = allow(applyAction(g, 'c', action));
assert.equal(
g.players[0].spice,
12 - Number(action.amount) * (blocked ? 2 : 1),
);
assert.ok(g.players[0].revived <= (blocked ? 3 : 20));
conserved(g);
}
});
void test('cancellation after earlier revival preserves completed forces but prevents exceeding the new total allowance', () => {
let g = allow(revive(fixture(), 6));
g = cancel(revive(contest(g), 2));
assert.equal(g.players[0].reserves, 16);
assert.equal(g.players[0].revived, 6);
assert.equal(g.players[0].spice, 34);
assert.throws(() => revive(g, 1), /Forces/);
conserved(g);
});
void test('advanced Tleilaxu prevention precedes the CHOAM price response and can cancel the entire normal revival', () => {
for (const prevent of [false, true]) {
let g = fixture(true);
g.advanced = true;
const card = g.deck.find((c) => c.effect === 'karama')!;
g.deck = g.deck.filter((c) => c.id !== card.id);
g.players[3].hand.push(card);
g = revive(g, 5);
assert.equal(g.decision?.kind, 'revivalStop');
assert.equal(g.response, null);
if (prevent) {
g = applyAction(g, 't', { type: 'card', mode: 'special', card: card.id });
assert.equal(g.pendingRevival, null);
assert.equal(g.players[0].revived, 0);
assert.equal(g.players[0].spice, 40);
assert.throws(() => revive(g, 1), /prevented/);
} else {
g = applyAction(g, 't', { type: 'decision', decline: true });
assert.equal(g.response?.kind, 'choamRevival');
g = allow(g);
assert.equal(g.players[0].spice, 35);
assert.equal(g.players[3].spice, 45);
}
conserved(g);
}
});
void test('a pending CHOAM revival survives reconnect without exposing another player’s spice', () => {
let g = revive(contest(fixture()), 7);
g = JSON.parse(JSON.stringify(g)) as Game;
const v = viewGame(g, 'e');
assert.equal(v.response?.owner, 'c');
assert.equal(v.players[0].spice, undefined);
assert.equal(v.players[0].hand, undefined);
g = allow(g);
assert.equal(g.players[0].revived, 7);
assert.equal(g.players[0].spice, 33);
conserved(g);
});