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

473 lines
16 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 { placeFixtureHand } from './fixture-hand';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
joinGame,
newPlayer,
viewGame,
type Game,
type Player,
} from '../game/engine';
import { baseDeck, leaders } from '../game/cards';
import { forceRevivalLimit, forceRevivalQuote } from '../game/revival';
import { botActions, runBots } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createTechTokens } from '../game/tech-tokens';
function fixture() {
let g = createGame('TREVIVAL2', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
joinGame(g, newPlayer('t', 'Tleilaxu', 'guild'));
joinGame(g, newPlayer('h', 'Observer', 'harkonnen'));
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'a', { type: 'start' });
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
g.players[2].faction = 'tleilaxu';
g.players[2].leaders = leaders('tleilaxu');
g.players[2].traitors = [];
g.phase = 4;
g.order = ['a', 'e', 't', 'h'];
g.active = null;
for (const p of g.players) {
p.hand = [];
p.forces = {};
p.reserves = 20;
p.tanks = 0;
p.spice = 20;
p.revived = 0;
}
return g;
}
function tanks(p: Player, n: number) {
p.tanks = n;
p.reserves = 20 - n;
}
function dead(p: Player, indices = [0]) {
for (const i of indices) {
p.leaders[i].dead = true;
p.leaders[i].deaths = 1;
}
}
function allow(state: Game) {
let g = state;
while (g.response || g.decision?.kind === 'revivalStop')
g =
g.decision?.kind === 'revivalStop'
? applyAction(g, g.decision.player, { type: 'decision', decline: true })
: applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
return g;
}
function passWindow(state: Game) {
let g = state;
const kind = g.response!.kind;
while (g.response?.kind === kind)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
return g;
}
const revive = (g: Game, id: string, amount: number, elite = 0) =>
applyAction(g, id, { type: 'revive', amount, elite });
const karama = baseDeck().find((c) => c.effect === 'karama')!;
function cancel(g: Game, id = 'h') {
return applyAction(g, id, { type: 'card', card: karama.id, mode: 'cancel' });
}
function request(g: Game, id = 'a', leader = 'atreides-0') {
return applyAction(g, id, { type: 'requestLeaderRevival', leader });
}
function quote(g: Game, amount: number, id = 'a') {
return applyAction(g, 't', {
type: 'quoteLeaderRevival',
target: id,
amount,
});
}
function conserved(g: Game) {
for (const p of g.players) {
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
assert.ok(p.spice >= 0);
}
}
void test('paid and free force revival income goes to Tleilaxu after a separate response', () => {
const before = fixture();
before.players[3].hand = [karama];
tanks(before.players[0], 3);
let g = revive(before, 'a', 3);
assert.equal(g.players[0].spice, 18);
assert.equal(g.players[0].reserves, 20);
assert.equal(g.players[2].spice, 20);
assert.equal(g.response?.kind, 'revivalIncome');
g = allow(JSON.parse(JSON.stringify(g)));
assert.equal(g.players[2].spice, 23);
conserved(g);
});
void test('splitting free revival into individual forces never duplicates its income', () => {
const before = fixture();
tanks(before.players[0], 3);
let g = allow(revive(before, 'a', 1));
assert.equal(g.players[2].spice, 21);
g = allow(revive(g, 'a', 1));
assert.equal(g.players[2].spice, 21);
g = allow(revive(g, 'a', 1));
assert.equal(g.players[2].spice, 23);
});
void test('Karama redirects one revival payment without undoing force return or granting a second free-income claim', () => {
const before = fixture();
tanks(before.players[0], 3);
before.players[3].hand = [karama];
let g = cancel(revive(before, 'a', 1));
assert.equal(g.players[2].spice, 20);
assert.equal(g.players[0].tanks, 2);
g = allow(revive(g, 'a', 1));
assert.equal(g.players[2].spice, 20);
g = allow(revive(g, 'a', 1));
assert.equal(g.players[2].spice, 22);
});
void test('Tleilaxu can revive all twenty forces at half price and gain their own free-revival income', () => {
const before = fixture();
before.players[3].hand = [karama];
tanks(before.players[2], 20);
let g = revive(before, 't', 20);
assert.equal(g.response?.kind, 'revivalLimit');
assert.equal(g.players[2].tanks, 20);
assert.equal(g.players[2].spice, 20);
g = passWindow(g);
assert.equal(g.response?.kind, 'revivalDiscount');
g = allow(g);
assert.equal(g.players[2].tanks, 0);
assert.equal(g.players[2].revived, 20);
assert.equal(g.players[2].spice, 3);
conserved(g);
});
void test('canceling the expanded limit aborts the pending revival and restores three-force limits for everyone', () => {
const before = fixture();
tanks(before.players[0], 5);
tanks(before.players[2], 6);
before.players[3].hand = [karama];
let g = applyAction(before, 't', {
type: 'tleilaxuRevivalLimit',
target: 'a',
});
g = cancel(revive(g, 't', 6));
assert.equal(g.pendingRevival, null);
assert.equal(g.players[2].revived, 0);
assert.equal(g.players[2].spice, 20);
assert.equal(forceRevivalLimit(g, g.players[0]), 3);
assert.throws(() => revive(g, 'a', 5), /integer/);
assert.throws(
() => applyAction(g, 't', { type: 'tleilaxuRevivalLimit', target: 'a' }),
/unavailable/,
);
g = allow(revive(g, 't', 3));
assert.equal(g.players[2].revived, 3);
conserved(g);
});
void test('a canceled discount recomputes the price before any payment or force mutation', () => {
const before = fixture();
tanks(before.players[2], 3);
before.players[3].hand = [karama];
let g = revive(before, 't', 3);
assert.equal(g.response?.kind, 'revivalDiscount');
g = allow(cancel(g));
assert.equal(g.players[2].spice, 19);
assert.equal(g.revivalRules?.fullPrice, true);
// Two spice paid at full rate and one free-revival income returned.
const poor = fixture();
tanks(poor.players[2], 3);
poor.players[2].spice = 1;
poor.players[3].hand = [karama];
const stopped = cancel(revive(poor, 't', 3));
assert.equal(stopped.pendingRevival, null);
assert.equal(stopped.players[2].tanks, 3);
assert.equal(stopped.players[2].spice, 1);
});
void test('Tleilaxu grant five force revivals and optional half price to an ally who pays the cost', () => {
const before = fixture();
before.players[0].ally = 't';
before.players[2].ally = 'a';
tanks(before.players[0], 5);
let g = applyAction(before, 't', {
type: 'tleilaxuRevivalLimit',
target: 'a',
});
g = applyAction(g, 't', { type: 'tleilaxuAllyDiscount' });
assert.deepEqual(forceRevivalQuote(g, g.players[0], 5), {
free: 2,
normalCost: 6,
cost: 3,
});
g = allow(revive(g, 'a', 5));
assert.equal(g.players[0].spice, 17);
assert.equal(g.players[2].spice, 24);
conserved(g);
});
void test('an ally discount can be canceled without changing another faction’s standard free allowance', () => {
const before = fixture();
before.players[0].ally = 't';
before.players[2].ally = 'a';
tanks(before.players[0], 3);
before.players[3].hand = [karama];
let g = applyAction(before, 't', { type: 'tleilaxuAllyDiscount' });
g = allow(cancel(revive(g, 'a', 3)));
assert.equal(g.players[0].spice, 18);
assert.equal(g.players[2].spice, 23);
assert.equal(g.revivalRules?.discountBlocked, true);
});
void test('ordinary leader revivals pay Tleilaxu and preserve native revival-cycle eligibility', () => {
const before = fixture();
dead(before.players[0], [0, 1, 2, 3, 4]);
let g = applyAction(before, 'a', {
type: 'reviveLeader',
leader: 'atreides-0',
});
assert.equal(g.response, null);
g = allow(g);
assert.equal(g.players[0].spice, 15);
assert.equal(g.players[2].spice, 25);
assert.equal(g.players[0].revivalCycle, 1);
assert.throws(
() => applyAction(g, 'a', { type: 'reviveLeader', leader: 'atreides-1' }),
/one leader/,
);
});
void test('Tleilaxu revive multiple own leaders early at rounded half price including Zoal', () => {
const before = fixture();
dead(before.players[2], [0, 1, 2]);
let g = applyAction(before, 't', {
type: 'reviveLeader',
leader: 'tleilaxu-0',
});
assert.equal(g.response, null);
assert.equal(g.players[2].leaders[0].dead, false);
g = allow(g);
assert.equal(g.players[2].spice, 18);
g = allow(
applyAction(g, 't', { type: 'reviveLeader', leader: 'tleilaxu-1' }),
);
g = allow(
applyAction(g, 't', { type: 'reviveLeader', leader: 'tleilaxu-2' }),
);
assert.equal(g.players[2].spice, 14);
assert.ok(g.players[2].leaders.every((l) => !l.dead));
});
void test('negotiated early revival is requested, privately quoted, accepted and settled once', () => {
const before = fixture();
before.players[3].hand = [karama];
dead(before.players[0]);
let g = request(before);
assert.deepEqual(viewGame(g, 'h').revivalRequests, {});
assert.equal(viewGame(g, 't').revivalRequests.a.leader, 'atreides-0');
g = quote(g, 7);
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[0].leaders[0].dead, true);
g = applyAction(g, 'a', { type: 'acceptLeaderRevival' });
assert.equal(g.response?.kind, 'earlyRevival');
assert.equal('pendingRevival' in viewGame(g, 'h'), false);
g = passWindow(JSON.parse(JSON.stringify(g)));
assert.equal(g.response?.kind, 'revivalIncome');
assert.equal(viewGame(g, 'h').response?.amount, undefined);
g = allow(g);
assert.equal(g.players[0].spice, 13);
assert.equal(g.players[2].spice, 27);
assert.equal(g.players[0].leaders[0].dead, false);
assert.deepEqual(g.revivalRequests, {});
assert.throws(() => applyAction(g, 'a', { type: 'acceptLeaderRevival' }));
});
void test('a negotiated zero-spice return is allowed but all-unavailable leaders use ordinary paid revival', () => {
const before = fixture();
dead(before.players[0]);
const g = allow(
applyAction(quote(request(before), 0), 'a', {
type: 'acceptLeaderRevival',
}),
);
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[2].spice, 20);
const all = fixture();
dead(all.players[0], [0, 1, 2, 3, 4]);
assert.throws(() => request(all), /ordinary revival/);
all.players[0].leaders[4].dead = false;
all.players[0].leaders[4].capturedBy = 'h';
assert.throws(() => request(all), /ordinary revival/);
});
void test('an early-return cancellation preserves a concealed leader and keeps its identity private', () => {
const before = fixture();
dead(before.players[0]);
before.players[0].leaders[0].deaths = 2;
before.players[0].leaders[0].concealed = {
captor: 'h',
dead: false,
deaths: 0,
};
before.players[1].hand = [karama];
let g = applyAction(quote(request(before), 3), 'a', {
type: 'acceptLeaderRevival',
});
g = cancel(g, 'e');
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[2].spice, 20);
assert.deepEqual(viewGame(g, 'e').revivalRules.earlyBlocked, []);
assert.deepEqual(viewGame(g, 'a').revivalRules.earlyBlocked, [
'a:atreides-0',
]);
assert.throws(
() => applyAction(g, 'a', { type: 'acceptLeaderRevival' }),
/canceled/,
);
});
void test('quote ownership, negative prices, missing requests, refusal and withdrawal are enforced', () => {
const before = fixture();
dead(before.players[0]);
let g = request(before);
const snapshot = structuredClone(g);
assert.throws(
() =>
applyAction(g, 'e', {
type: 'quoteLeaderRevival',
target: 'a',
amount: 0,
}),
/Only Tleilaxu/,
);
assert.throws(() => quote(g, -1), /integer/);
assert.throws(() =>
applyAction(g, 't', {
type: 'quoteLeaderRevival',
target: '__proto__',
amount: 1,
}),
);
assert.equal(
Object.prototype.hasOwnProperty.call(Object.prototype, 'price'),
false,
);
assert.deepEqual(g, snapshot);
assert.throws(
() => applyAction(g, 'a', { type: 'acceptLeaderRevival' }),
/quote/,
);
g = applyAction(g, 't', { type: 'declineLeaderRevival', target: 'a' });
assert.throws(
() => applyAction(g, 'a', { type: 'acceptLeaderRevival' }),
/declined/,
);
g = quote(g, 2);
g = applyAction(g, 'a', { type: 'cancelLeaderRequest' });
assert.deepEqual(g.revivalRequests, {});
});
void test('KH can be revived through a negotiated early return', () => {
const before = fixture();
before.advanced = true;
before.players[0].kwisatz = { dead: true };
let g = request(before, 'a', 'kwisatz');
g = allow(applyAction(quote(g, 4), 'a', { type: 'acceptLeaderRevival' }));
assert.equal(g.players[0].kwisatz?.dead, false);
assert.equal(g.players[0].kwisatz?.revivalCycle, 2);
assert.equal(g.players[2].spice, 24);
});
void test('Ghola treachery grants one income separately from normal free revival, including outside Revival', () => {
const before = fixture();
tanks(before.players[0], 6);
const ghola = baseDeck().find((c) => c.effect === 'ghola')!;
placeFixtureHand(before, 0, [ghola]);
let g = allow(revive(before, 'a', 1));
g.phase = 8;
g = applyAction(g, 'a', { type: 'card', card: ghola.id, amount: 5 });
assert.equal(g.response, null);
g = allow(g);
assert.equal(g.players[2].spice, 22);
assert.equal(g.players[0].revived, 1);
conserved(g);
});
void test('Emperor extra revival payments go to Tleilaxu after the Emperor response', () => {
const before = fixture();
before.players[3].hand = [karama];
before.players[0].ally = 'e';
before.players[1].ally = 'a';
tanks(before.players[0], 8);
let g = applyAction(before, 't', {
type: 'tleilaxuRevivalLimit',
target: 'a',
});
g = allow(revive(g, 'a', 5));
g = applyAction(g, 'e', { type: 'emperorRevival', amount: 3 });
assert.equal(g.response?.kind, 'emperorRevival');
g = passWindow(g);
assert.equal(g.response?.kind, 'revivalIncome');
g = allow(g);
assert.equal(g.players[0].tanks, 0);
assert.equal(g.players[0].revived, 5);
assert.equal(g.emperorExtra.a, 3);
assert.equal(g.players[1].spice, 14);
assert.equal(g.players[2].spice, 33);
conserved(g);
});
void test('elite revival cap is preserved under the larger limit and pending-discount serialization', () => {
const before = fixture();
before.advanced = true;
tanks(before.players[1], 5);
before.players[1].elites = { reserves: 3, tanks: 2, forces: {}, revived: 0 };
let g = applyAction(before, 't', {
type: 'tleilaxuRevivalLimit',
target: 'e',
});
assert.throws(() => revive(g, 'e', 5, 2), /one elite/);
g = allow(revive(g, 'e', 4, 1));
assert.equal(g.players[1].elites?.revived, 1);
assert.equal(g.players[1].elites?.reserves, 4);
assert.throws(() => revive(g, 'e', 1, 1), /one elite/);
conserved(g);
});
void test('tech-token income stays separate from Tleilaxu revival income', () => {
const before = fixture();
tanks(before.players[0], 2);
before.techTokens = createTechTokens();
before.techTokens.axlotl.owner = 't';
const g = allow(revive(before, 'a', 2));
assert.equal(g.players[2].spice, 21);
assert.equal(g.techTokens?.axlotl.spice, 1);
});
void test('all AI levels handle Tleilaxu permissions, prices and private revival requests', () => {
for (const difficulty of DIFFICULTIES) {
const g = fixture();
g.players[2].bot = difficulty;
tanks(g.players[0], 5);
g.revivalRequests = { a: { leader: 'atreides-0', price: null } };
const choices = botActions(viewGame(g, 't'));
assert.ok(choices.some((a) => a.type === 'quoteLeaderRevival'));
assert.ok(choices.some((a) => a.type === 'tleilaxuRevivalLimit'));
assert.doesNotThrow(() => applyAction(g, 't', choices[0]));
const changed = structuredClone(g);
changed.players[0].spice = 999;
assert.deepEqual(botActions(viewGame(changed, 't')), choices);
}
});
void test('AI-only revival phase completes without a pending transaction or negative resources', () => {
let g = fixture();
for (const p of g.players) {
p.bot = 'Hard';
tanks(p, 8);
dead(p, [0, 1, 2]);
}
for (let i = 0; i < 20 && g.phase === 4; i++) g = runBots(g, 20);
assert.notEqual(g.phase, 4);
assert.equal(g.pendingRevival ?? null, null);
conserved(g);
});