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

455 lines
16 KiB
TypeScript

import { placeFixtureHand } from './fixture-hand';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { TECH_TOKENS, createTechTokens, ownedTech } from '../game/tech-tokens';
import { baseDeck } from '../game/cards';
import { type FactionId } from '../game/catalog';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
/** Transfer fixture cards from the actual deck; never duplicate another seat's hand. */
function setBattleHand(g: Game, id: string, ids: string[]) {
const owner = g.players.find((p) => p.id === id)!;
g.deck.push(...owner.hand);
owner.hand = [];
owner.hand = ids.map((cardId) => {
assert.ok(
!g.players.some((p) => p.hand.some((card) => card.id === cardId)),
`Fixture card ${cardId} is already held by another seat`,
);
const index = g.deck.findIndex((card) => card.id === cardId);
assert.ok(
index >= 0,
`Physical fixture card ${cardId} must be in the deck`,
);
return g.deck.splice(index, 1)[0];
});
}
function table(factions: FactionId[] = ['emperor', 'harkonnen', 'guild']) {
const ids = ['e', 'h', 'g', 'x'];
let g = createGame('TECHTOK2', newPlayer('e', 'Host', factions[0]));
factions.slice(1).forEach((f, i) => joinGame(g, newPlayer(ids[i + 1], f, f)));
g = applyAction(g, 'e', { type: 'techTokens', enabled: true });
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'e', { type: 'start' });
const bg = g.players.find((p) => p.faction === 'beneGesserit');
if (bg)
g = applyAction(g, bg.id, {
type: 'predict',
faction: 'emperor',
turn: 3,
});
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
const fremen = g.players.find((p) => p.faction === 'fremen');
if (fremen)
g = applyAction(g, fremen.id, {
type: 'fremenSetup',
placements: { sietch_tabr: 10 },
});
assert.equal(g.status, 'playing');
return g;
}
function fixture(factions?: FactionId[]) {
const g = table(factions);
g.phase = 2;
g.order = g.players.map((p) => p.id);
g.storm = 18;
g.players.forEach((p) => {
g.deck.push(...p.hand);
p.hand = [];
p.forces = {};
p.reserves = 20;
p.spice = 10;
p.traitors = [];
});
g.techTokens = createTechTokens();
g.techTokens.axlotl.owner = 'e';
g.techTokens.heighliners.owner = 'h';
g.techTokens.production.owner = 'g';
return g;
}
function ready(state: Game) {
let g = state;
for (const p of state.players)
if (!g.ready.includes(p.id)) g = applyAction(g, p.id, { type: 'ready' });
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 duel(
state: Game,
attacker = 'e',
defender = 'h',
dial = 0,
scenario?: { explosion?: boolean; calls?: [boolean, boolean] },
) {
let g = structuredClone(state);
g.phase = 6;
g.active = attacker;
g.players.forEach((p) => {
p.forces = [attacker, defender].includes(p.id) ? { 'arrakeen:10': 3 } : {};
p.reserves = [attacker, defender].includes(p.id) ? 17 : 20;
});
g.order = [
attacker,
defender,
...g.players
.map((p) => p.id)
.filter((id) => ![attacker, defender].includes(id)),
];
const lasgun = baseDeck().find((c) => c.kind === 'lasgun')!;
const shield = baseDeck().find((c) => c.kind === 'shield')!;
for (const [i, id] of [attacker, defender].entries()) {
const p = g.players.find((p) => p.id === id)!;
const enemy = g.players.find((p) => p.id === (i ? attacker : defender))!;
if (scenario?.calls?.[i]) p.traitors = [`${enemy.faction}-0`];
if (scenario?.explosion) setBattleHand(g, id, [(i ? shield : lasgun).id]);
}
g = allow(
applyAction(g, attacker, {
type: 'chooseBattle',
territory: 'arrakeen',
target: defender,
}),
);
for (const id of [attacker, defender])
g = applyAction(g, id, {
type: 'battlePlan',
dial: id === attacker ? dial : 0,
support: dial,
leader: `${g.players.find((p) => p.id === id)!.faction}-0`,
...(scenario?.explosion
? id === attacker
? { weapon: lasgun.id }
: { defense: shield.id }
: {}),
});
for (const [i, id] of [attacker, defender].entries())
g = applyAction(g, id, {
type: 'traitorCall',
call: scenario?.calls?.[i] ?? false,
});
return g;
}
void test('host controls the optional variant, changing it clears readiness, and two-player tech setup is explicitly rejected', () => {
let g = createGame('TECHOPT2', newPlayer('e', 'Host', 'emperor'));
joinGame(g, newPlayer('h', 'Guest', 'harkonnen'));
g.players.forEach((p) => (p.ready = true));
assert.throws(
() => applyAction(g, 'h', { type: 'techTokens', enabled: true }),
/host/,
);
assert.throws(
() => applyAction(g, 'e', { type: 'techTokens', enabled: 'true' }),
/whether/,
);
g = applyAction(g, 'e', { type: 'techTokens', enabled: true });
assert.ok(g.techTokens);
assert.ok(g.players.every((p) => !p.ready));
g.players.forEach((p) => (p.ready = true));
assert.throws(() => applyAction(g, 'e', { type: 'start' }), /three players/);
g = applyAction(g, 'e', { type: 'techTokens', enabled: false });
g.players.forEach((p) => (p.ready = true));
assert.equal(applyAction(g, 'e', { type: 'start' }).status, 'setup');
});
void test('default faction assignments precede the first storm; missing tokens go randomly to unassigned factions in storm order', () => {
const defaults = createTechTokens([
{ id: 't', faction: 'tleilaxu' },
{ id: 'i', faction: 'ixians' },
{ id: 'f', faction: 'fremen' },
]);
assert.deepEqual(
TECH_TOKENS.map((t) => defaults[t.id].owner),
['t', 'i', 'f'],
);
let g = table(['emperor', 'fremen', 'guild', 'harkonnen']);
assert.equal(g.techTokens?.production.owner, 'h');
assert.equal(g.techTokens?.axlotl.owner, null);
for (const id of g.stormDialers)
g = applyAction(g, id, { type: 'stormDial', amount: 0 });
g = ready(g);
const recipients = g.order.filter((id) => id !== 'h').slice(0, 2);
assert.deepEqual(
new Set([g.techTokens!.axlotl.owner, g.techTokens!.heighliners.owner]),
new Set(recipients),
);
assert.equal(g.techTokens?.production.owner, 'h');
assert.equal(
new Set(TECH_TOKENS.map((t) => g.techTokens![t.id].owner)).size,
3,
);
});
void test('charity income accrues once by total token ownership and is unspendable until phase end', () => {
const before = fixture();
before.techTokens!.axlotl.owner = 'g';
before.players[0].spice = 0;
before.players[1].spice = 1;
let g = applyAction(before, 'e', { type: 'charity' });
assert.equal(g.techTokens?.production.spice, 2);
assert.equal(g.players[2].spice, 10);
g = applyAction(g, 'h', { type: 'charity' });
assert.equal(g.techTokens?.production.spice, 2);
g = ready(g);
assert.equal(g.players[2].spice, 12);
assert.equal(g.techTokens?.production.spice, 0);
assert.equal(g.phase, 3);
});
void test('Bene Gesserit charity alone does not trigger production, but another faction later does', () => {
let g = fixture(['emperor', 'beneGesserit', 'guild']);
g.players[1].spice = 0;
g = applyAction(g, 'h', { type: 'charity' });
assert.equal(g.techTokens?.production.spice, 0);
g.players[0].spice = 0;
g = applyAction(g, 'e', { type: 'charity' });
assert.equal(g.techTokens?.production.spice, 1);
});
void test('free revival triggers Axlotl once, paid-only revival does not, and payment waits for phase end', () => {
let g = fixture();
g.phase = 4;
g.players[0].tanks = 3;
g.players[0].reserves = 17;
g.techTokens!.heighliners.owner = 'e';
g = applyAction(g, 'e', { type: 'revive', amount: 1 });
assert.equal(g.techTokens?.axlotl.spice, 2);
assert.equal(g.players[0].spice, 10);
g = applyAction(g, 'e', { type: 'revive', amount: 1 });
assert.equal(g.techTokens?.axlotl.spice, 2);
assert.equal(g.players[0].spice, 8);
g = allow(ready(g));
assert.equal(g.players[0].spice, 10);
assert.equal(g.techTokens?.axlotl.spice, 0);
const paid = fixture();
paid.phase = 4;
paid.players[0].tanks = 1;
paid.players[0].reserves = 19;
paid.players[0].revived = 1;
assert.equal(
applyAction(paid, 'e', { type: 'revive', amount: 1 }).techTokens?.axlotl
.spice,
0,
);
});
void test('Tleilaxu-only free revival is excluded and free Ghola revival accrues only in the Revival phase', () => {
const before = fixture();
before.phase = 4;
before.players[0].faction = 'tleilaxu';
before.players[0].tanks = 1;
before.players[0].reserves = 19;
assert.equal(
applyAction(before, 'e', { type: 'revive', amount: 1 }).techTokens?.axlotl
.spice,
0,
);
const ghola = baseDeck().find((c) => c.effect === 'ghola')!;
before.players[0].faction = 'emperor';
placeFixtureHand(before, 0, [ghola]);
const action = { type: 'card', card: ghola.id, amount: 1 };
assert.equal(applyAction(before, 'e', action).techTokens?.axlotl.spice, 1);
before.phase = 6;
assert.equal(applyAction(before, 'e', action).techTokens?.axlotl.spice, 0);
});
void test('off-planet shipments accrue Heighliner income once, excluding Guild-only and cross-planet transport', () => {
let g = fixture();
g.phase = 5;
g.active = 'g';
g.order = ['g', 'e', 'h'];
g.movementRemaining = [...g.order];
g = applyAction(g, 'g', {
type: 'ship',
territory: 'polar_sink',
sector: 0,
amount: 1,
});
assert.equal(g.techTokens?.heighliners.spice, 0);
g = applyAction(g, 'g', { type: 'endMovement' });
g = allow(
applyAction(g, 'e', {
type: 'ship',
territory: 'polar_sink',
sector: 0,
amount: 1,
}),
);
assert.equal(g.techTokens?.heighliners.spice, 1);
const held = g.players[1].spice;
g = applyAction(g, 'e', { type: 'endMovement' });
g = applyAction(g, 'h', { type: 'endMovement' });
assert.equal(g.players[1].spice, held + 1);
assert.equal(g.techTokens?.heighliners.spice, 0);
const cross = fixture();
cross.phase = 5;
cross.active = 'g';
cross.players[2].forces = { 'arrakeen:10': 2 };
cross.players[2].reserves = 18;
assert.equal(
applyAction(cross, 'g', {
type: 'guildShip',
from: 'arrakeen:10',
territory: 'polar_sink',
sector: 0,
amount: 1,
}).techTokens?.heighliners.spice,
0,
);
});
void test('a canceled shipment produces no tech income, while an allowed spiritual advisor can trigger it after a Guild shipment', () => {
const before = fixture(['emperor', 'beneGesserit', 'guild']);
before.phase = 5;
before.active = 'e';
before.advanced = true;
const karama = baseDeck().find((c) => c.effect === 'karama')!;
before.players[2].hand = [karama];
let g = applyAction(before, 'e', {
type: 'ship',
territory: 'polar_sink',
sector: 0,
amount: 1,
});
g = applyAction(g, 'g', { type: 'card', mode: 'special', card: karama.id });
assert.equal(g.techTokens?.heighliners.spice, 0);
before.advanced = false;
before.active = 'g';
g = applyAction(before, 'g', {
type: 'ship',
territory: 'polar_sink',
sector: 0,
amount: 1,
});
assert.equal(g.techTokens?.heighliners.spice, 0);
g = applyAction(g, 'h', { type: 'decision', accept: true });
g = allow(g);
assert.equal(g.techTokens?.heighliners.spice, 1);
});
void test('battle winner takes one chosen token, with ownership and decision authority preserved through JSON', () => {
const before = fixture();
before.techTokens!.axlotl.owner = 'h';
let g = duel(before);
assert.equal(g.decision?.kind, 'techToken');
assert.equal(g.techTokens?.axlotl.owner, 'h');
assert.throws(
() => applyAction(g, 'g', { type: 'decision', token: 'axlotl' }),
/pending decision/,
);
assert.throws(
() => applyAction(g, 'e', { type: 'decision', token: 'production' }),
/defeated/,
);
g = applyAction(JSON.parse(JSON.stringify(g)), 'e', {
type: 'decision',
token: 'axlotl',
});
assert.equal(g.techTokens?.axlotl.owner, 'e');
assert.equal(g.techTokens?.heighliners.owner, 'h');
assert.equal(g.pendingTech, null);
assert.equal(g.phase, 7);
});
void test('a winner that loses its last forces still takes a single token automatically', () => {
const g = duel(fixture(), 'e', 'h', 3);
assert.equal(g.players[0].forces['arrakeen:10'], undefined);
assert.equal(g.techTokens?.heighliners.owner, 'e');
assert.equal(g.decision, null);
});
void test('mutual traitors and explosions retain ownership; a single traitor win overrides an explosion and transfers after card decisions', () => {
const before = fixture();
for (const scenario of [
{ explosion: true },
{ calls: [true, true] as [boolean, boolean] },
]) {
const g = duel(before, 'e', 'h', 0, scenario);
assert.deepEqual(g.techTokens, before.techTokens);
assert.equal(g.pendingTech, undefined);
assert.equal(g.decision, null);
assert.equal(g.phase, 7);
}
let g = duel(before, 'e', 'h', 0, { explosion: true, calls: [true, false] });
assert.equal(g.decision?.kind, 'battleCards');
assert.equal(g.techTokens?.heighliners.owner, 'h');
g = applyAction(g, 'e', { type: 'decision', discard: [] });
assert.equal(g.techTokens?.heighliners.owner, 'e');
assert.equal(g.players[0].forces['arrakeen:10'], 3);
assert.equal(g.pendingTech, null);
});
void test('tech choice completes before a pending Harkonnen capture without losing that continuation', () => {
const before = fixture();
before.advanced = true;
before.techTokens!.production.owner = 'e';
let g = duel(before, 'h', 'e');
assert.equal(g.decision?.kind, 'techToken');
assert.equal(g.players.find((p) => p.id === 'h')!.tanks, 0);
assert.equal(g.players.find((p) => p.id === 'e')!.tanks, 3);
assert.equal(g.pendingCapture?.player, 'h');
g = applyAction(g, 'h', { type: 'decision', token: 'production' });
assert.equal(g.decision?.kind, 'captureOffer');
assert.equal(g.techTokens?.production.owner, 'h');
});
void test('all three tokens add one stronghold to one owner, allow simultaneous wins, and cannot be assembled across allies', () => {
const before = fixture();
before.phase = 7;
TECH_TOKENS.forEach((t) => (before.techTokens![t.id].owner = 'e'));
before.players[0].forces = { 'arrakeen:10': 1, 'carthag:11': 1 };
before.players[1].forces = {
'sietch_tabr:14': 1,
'habbanya_ridge_sietch:17': 1,
'tueks_sietch:5': 1,
};
const g = ready(before);
assert.deepEqual(g.winner, ['e', 'h']);
const allied = structuredClone(before);
allied.players[0].ally = 'g';
allied.players[2].ally = 'e';
allied.players[1].forces = {};
allied.players[2].forces = { 'tueks_sietch:5': 1 };
allied.techTokens!.production.owner = 'g';
assert.deepEqual(ready(allied).winner, []);
allied.techTokens!.production.owner = 'e';
assert.deepEqual(ready(allied).winner, ['e', 'g']);
});
void test('token income can trigger again next turn and public token data excludes no player', () => {
const before = fixture();
before.players[0].spice = 0;
let g = applyAction(before, 'e', { type: 'charity' });
g = ready(g);
g.turn++;
g.phase = 2;
g.ready = [];
g.players[0].spice = 0;
g = applyAction(g, 'e', { type: 'charity' });
assert.equal(g.techTokens?.production.spice, 1);
assert.equal(g.techTokens?.production.triggeredTurn, 2);
for (const p of g.players)
assert.deepEqual(viewGame(g, p.id).techTokens, g.techTokens);
});
void test('all AI levels choose a legal captured token from public holdings', () => {
const before = fixture();
before.techTokens!.axlotl.owner = 'h';
for (const difficulty of DIFFICULTIES) {
const g = duel(before);
g.players[0].bot = difficulty;
const action = botActions(viewGame(g, 'e'))[0];
assert.equal(action.type, 'decision');
const result = applyAction(g, 'e', action);
assert.equal(ownedTech(result.techTokens, 'e').length, 1);
assert.equal(ownedTech(result.techTokens, 'h').length, 1);
}
});