Files
Dune/tests/homeworld-basic-counters.test.ts
T

326 lines
11 KiB
TypeScript

import { observeHomeworldOccupation } from '../game/homeworld-occupation-history';
import { homeworldContext } from '../game/homeworld-game';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeBaseGameForAudit,
initializeHomeworldGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { Casualties } from '../game/combat';
const sum = (counts: Record<string, number>) =>
Object.values(counts).reduce((a, b) => a + b, 0);
const player = (g: Game, id = 'p') => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
/** Real Basic setup first; only the later phase, board and Tanks position is staged. */
function setup(faction: 'emperor' | 'fremen', homeworlds = true) {
let g = createGame(
'BASICHOMECOUNTERS',
newPlayer('p', faction, faction),
false,
);
joinGame(g, newPlayer('q', 'Guild opponent', 'guild'));
if (homeworlds)
g = applyAction(g, 'p', { type: 'homeworlds', enabled: true });
for (const id of ['p', 'q']) g = applyAction(g, id, { type: 'ready' });
g = homeworlds
? initializeHomeworldGameForAudit(g)
: initializeBaseGameForAudit(g);
for (let step = 0; g.status === 'setup' && step < 20; step++) {
let progressed = false;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((v) => v.id === p.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (!action) continue;
g = applyAction(g, p.id, action);
progressed = true;
break;
}
assert.ok(progressed);
}
assert.equal(g.status, 'playing');
// Return initial Arrakis counters and cards to their actual source piles.
for (const p of g.players) {
p.reserves += sum(p.forces);
p.forces = {};
if (p.elites) {
p.elites.reserves += sum(p.elites.forces);
p.elites.forces = {};
}
g.deck.push(...p.hand);
p.hand = [];
}
Object.assign(g, {
turn: 2,
phase: 4,
storm: 18,
active: null,
order: ['p', 'q'],
ready: [],
});
return g;
}
/** Record the explicit conserved fixture position before checking read/reload purity. */
function recordPosition(g: Game) {
if (g.homeworldOccupationHistory)
g.homeworldOccupationHistory = observeHomeworldOccupation(g.homeworldOccupationHistory,
homeworldContext(g), g.homeworlds!.custody!, g.turn, 'change',
`fixture-position-${g.homeworldOccupationHistory.sources.length}`);
}
function inventory(g: Game) {
for (const p of g.players) {
assert.equal(p.reserves + p.tanks + sum(p.forces), 20);
if (p.elites) {
assert.equal(
p.elites.reserves + p.elites.tanks + sum(p.elites.forces),
p.faction === 'emperor' ? 5 : 3,
);
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
for (const [key, n] of Object.entries(p.elites.forces))
assert.ok(n <= p.forces[key]);
}
}
const physical = [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
];
assert.equal(new Set(physical.map((c) => c.id)).size, physical.length);
assert.doesNotThrow(() => viewGame(g, 'p'));
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
}
function reject(g: Game, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'p', action));
assert.deepEqual(g, before);
}
function battle(faction: 'emperor' | 'fremen', homeworlds = true) {
let g = setup(faction, homeworlds);
Object.assign(g, { phase: 6, active: 'p' });
Object.assign(player(g), { reserves: 17, forces: { 'arrakeen:10': 3 } });
if (player(g).elites) {
player(g).elites!.reserves--;
player(g).elites!.forces['arrakeen:10'] = 1;
}
Object.assign(player(g, 'q'), { reserves: 19, forces: { 'arrakeen:10': 1 } });
recordPosition(g);
inventory(g);
g = applyAction(g, 'p', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'q',
});
assert.equal(
g.response,
null,
'Basic physical tracking must not create Advanced power responses.',
);
assert.equal(g.battle!.preparation ?? null, null);
return g;
}
function resolve(g: Game, ownLeader?: string, rivalLeader?: string) {
const own =
ownLeader ??
player(g)
.leaders.slice()
.sort((a, b) => b.strength - a.strength)[0].id;
const rival =
rivalLeader ??
player(g, 'q')
.leaders.slice()
.sort((a, b) => a.strength - b.strength)[0].id;
g = applyAction(g, 'p', {
type: 'battlePlan',
leader: own,
dial: 1,
support: 0,
});
g = applyAction(g, 'q', {
type: 'battlePlan',
leader: rival,
dial: 0,
support: 0,
});
g = applyAction(g, 'p', { type: 'traitorCall', call: false });
return applyAction(g, 'q', { type: 'traitorCall', call: false });
}
function tanks(faction: 'emperor' | 'fremen', homeworlds = true) {
const g = setup(faction, homeworlds);
const p = player(g);
p.reserves -= 3;
p.tanks = 3;
if (p.elites) {
p.elites.reserves -= 3;
p.elites.tanks = 3;
}
recordPosition(g);
inventory(g);
return g;
}
function holdGhola(g: Game) {
const index = g.deck.findIndex((card) => card.effect === 'ghola');
assert.ok(index >= 0);
const [card] = g.deck.splice(index, 1);
player(g).hand.push(card);
return card.id;
}
void test('Basic Homeworld Fedaykin and Sardaukar expose strength one and offer either physical counter as one winning casualty', () => {
for (const faction of ['emperor', 'fremen'] as const) {
const before = battle(faction);
const forces = viewGame(before, 'p').battle!.ownForces!;
assert.equal(forces.normal, 2);
assert.equal(forces.elite, 1);
assert.equal(forces.eliteStrength, 1);
assert.equal(forces.freeSupport, true);
reject(before, {
type: 'battlePlan',
leader: player(before).leaders[0].id,
dial: 4,
support: 0,
});
const g = resolve(before);
assert.equal(g.decision?.kind, 'battleLosses');
if (g.decision?.kind !== 'battleLosses')
throw Error('Missing typed casualty decision.');
assert.deepEqual(
g.decision.options
.map(({ normal, elite }) => [normal, elite])
.sort((a, b) => a[0] - b[0]),
[
[0, 1],
[1, 0],
],
);
assert.equal(player(g).tanks, 0);
for (let choice = 0; choice < g.decision.options.length; choice++) {
const losses: Casualties = g.decision.options[choice];
const done = applyAction(reload(g), 'p', { type: 'decision', choice });
assert.equal(player(done).tanks, 1);
assert.equal(player(done).elites!.tanks, losses.elite);
assert.equal(sum(player(done).forces), 2);
assert.equal(
player(done).spice,
player(before).spice,
'Basic forces consume no spice support.',
);
inventory(done);
}
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g, 'p');
view.players.find((p) => p.id === 'p')!.bot = difficulty;
const candidates = botActions(view);
assert.ok(candidates.some((a) => a.type === 'decision'));
for (const action of candidates)
assert.doesNotThrow(() => applyAction(g, 'p', action));
}
}
});
void test('Basic Fremen cannot turn a one-strength dial into a tied win through physical star metadata', () => {
const g = battle('fremen');
const own = player(g).leaders.find((l) => l.strength === 3)!;
const rival = player(g, 'q').leaders.find((l) => l.strength === 5)!;
const done = resolve(g, own.id, rival.id);
assert.equal(done.lastBattleContext!.winner, 'q');
assert.equal(player(done).tanks, 3);
assert.equal(player(done).elites!.tanks, 1);
inventory(done);
});
void test('Basic Homeworld normal revival permits a second and third special counter after a genuine first revival', () => {
for (const faction of ['emperor', 'fremen'] as const) {
let g = tanks(faction);
const initialSpice = player(g).spice;
g = applyAction(g, 'p', { type: 'revive', amount: 1, elite: 1 });
assert.equal(player(g).elites!.revived, 1);
assert.equal(viewGame(g, 'p').revival.eliteRemaining, 2);
assert.equal(viewGame(g, 'p').revival.forcesRemaining, 2);
g = applyAction(reload(g), 'p', { type: 'revive', amount: 2, elite: 2 });
assert.equal(player(g).tanks, 0);
assert.equal(player(g).elites!.tanks, 0);
assert.equal(player(g).elites!.revived, 3);
assert.equal(player(g).revived, 3);
assert.equal(
player(g).spice,
initialSpice - (faction === 'emperor' ? 4 : 0),
);
assert.equal(viewGame(g, 'p').revival.eliteRemaining, 0);
inventory(g);
}
});
void test('Basic Homeworld Ghola preserves two further star identities after normal revival without spending another normal allowance', () => {
for (const faction of ['emperor', 'fremen'] as const) {
let g = tanks(faction);
const card = holdGhola(g);
g = applyAction(g, 'p', { type: 'revive', amount: 1, elite: 1 });
const view = viewGame(g, 'p');
assert.equal(view.ghola.available, true);
assert.equal(view.ghola.maxForces, 2);
assert.equal(view.ghola.eliteRemaining, 2);
const before = structuredClone(player(g));
g = applyAction(reload(g), 'p', {
type: 'card',
card,
amount: 2,
elite: 2,
});
assert.equal(player(g).tanks, 0);
assert.equal(player(g).elites!.tanks, 0);
assert.equal(player(g).elites!.revived, 3);
assert.equal(player(g).revived, before.revived);
assert.equal(player(g).freeForcesRevived, before.freeForcesRevived);
assert.equal(player(g).spice, before.spice);
assert.equal(g.discard.filter((c) => c.id === card).length, 1);
assert.equal(
player(g).hand.some((c) => c.id === card),
false,
);
inventory(g);
}
});
void test('Basic Homeworld default elite allocation retains a wholly special Tanks group and rejects excess typed quantities atomically', () => {
let g = tanks('fremen');
reject(g, { type: 'revive', amount: 2, elite: 0 });
reject(g, { type: 'revive', amount: 2, elite: 3 });
g = applyAction(g, 'p', { type: 'revive', amount: 2 });
assert.equal(player(g).elites!.tanks, 1);
assert.equal(player(g).elites!.reserves, 2);
inventory(g);
});
void test('ordinary Basic without Homeworlds keeps automatic untyped winner losses and ordinary revival/Ghola accounting', () => {
for (const faction of ['emperor', 'fremen'] as const) {
const done = resolve(battle(faction, false));
assert.notEqual(done.decision?.kind, 'battleLosses');
assert.equal(player(done).tanks, 1);
assert.equal(player(done).elites, undefined);
assert.equal(viewGame(done, 'p').homeworlds, null);
inventory(done);
let g = tanks(faction, false);
const card = holdGhola(g);
g = applyAction(g, 'p', { type: 'revive', amount: 1 });
assert.equal(viewGame(g, 'p').revival.eliteRemaining, 0);
g = applyAction(g, 'p', { type: 'card', card, amount: 2 });
assert.equal(player(g).reserves, 20);
assert.equal(player(g).tanks, 0);
assert.equal(player(g).revived, 1);
assert.equal(player(g).elites, undefined);
inventory(g);
}
});