455 lines
16 KiB
TypeScript
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);
|
|
}
|
|
});
|