198 lines
6.7 KiB
TypeScript
198 lines
6.7 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeNexusGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { drawNexusCard } from '../game/nexus-cards';
|
|
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
|
import { maxCombatDial, maxCombatSupport } from '../game/combat';
|
|
import {
|
|
enterNexusSpice,
|
|
orderNexusSpice,
|
|
finishNexusSpice,
|
|
nexusAllow,
|
|
nexusReload,
|
|
} from './fixture-nexus-cards';
|
|
import {
|
|
beginNexusSardaukar,
|
|
prepareSardaukarBattle,
|
|
nexusSardaukarInventory,
|
|
} from './fixture-nexus-sardaukar';
|
|
|
|
/** Both modules and the final roster precede the genuine setup initializer.
|
|
* Later board positions conserve all counters; Salusa is an explicit subset
|
|
* of existing reserves, including the low case's four stars at Kaitain. */
|
|
function fixture(salusa: 1 | 2 | 5 = 5) {
|
|
let g = createGame('SARDHOME', newPlayer('p', 'Emperor', 'emperor'), true);
|
|
joinGame(g, newPlayer('q', 'Guild', 'guild'));
|
|
joinGame(g, newPlayer('r', 'Fremen', 'fremen'));
|
|
g = applyAction(g, 'p', { type: 'homeworlds', enabled: true });
|
|
g.nexusCards = { cards: null, phase: null };
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
g = initializeNexusGameForAudit(g);
|
|
for (let step = 0; g.status === 'setup' && step < 80; step++) {
|
|
let next: Game | undefined;
|
|
for (const p of g.players) {
|
|
const view = viewGame(g, p.id);
|
|
view.players.find((player) => player.id === p.id)!.bot = 'Easy';
|
|
const action = botActions(view)[0];
|
|
if (action) {
|
|
next = applyAction(g, p.id, action);
|
|
break;
|
|
}
|
|
}
|
|
assert.ok(next, 'Combined setup must progress through real choices.');
|
|
g = next;
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
for (const p of g.players) g.deck.push(...p.hand.splice(0));
|
|
g = enterNexusSpice(g);
|
|
orderNexusSpice(g, ['land', 'land']);
|
|
g = finishNexusSpice(g);
|
|
for (const p of g.players) {
|
|
p.forces = {};
|
|
p.reserves = 20;
|
|
p.tanks = 0;
|
|
p.spice = 20;
|
|
if (p.elites) {
|
|
p.elites.forces = {};
|
|
p.elites.reserves = p.faction === 'emperor' ? 5 : 3;
|
|
p.elites.tanks = 0;
|
|
}
|
|
}
|
|
g.players[0].forces = { 'pasty_mesa:5': 7 };
|
|
g.players[0].reserves = 13;
|
|
g.players[1].forces = { 'pasty_mesa:5': 2 };
|
|
g.players[1].reserves = 18;
|
|
g.homeworlds!.custody!.salusa = { normal: 0, elite: salusa };
|
|
const cards = g.nexusCards!.cards!;
|
|
cards.deck = ['emperor', ...cards.deck.filter((card) => card !== 'emperor')];
|
|
g.nexusCards!.cards = drawNexusCard(cards, 'p', g.players, () => 0);
|
|
Object.assign(g, {
|
|
phase: 6,
|
|
phaseOpening: null,
|
|
response: null,
|
|
decision: null,
|
|
active: 'p',
|
|
order: ['p', 'q', 'r'],
|
|
ready: [],
|
|
storm: 18,
|
|
});
|
|
// This conserved controlled Battle setup skips the actual Charity turns;
|
|
// their ordered frame does not belong to the newly staged phase.
|
|
delete g.charityOrder;
|
|
g = applyAction(g, 'p', {
|
|
type: 'chooseBattle',
|
|
territory: 'pasty_mesa',
|
|
target: 'q',
|
|
});
|
|
for (const id of ['p', 'q'])
|
|
if (g.battle!.preLeader && !g.battle!.preLeader.closed)
|
|
g = applyAction(g, id, {
|
|
type: 'battlePreparationReady',
|
|
event: g.battle!.event,
|
|
});
|
|
g = prepareSardaukarBattle(g);
|
|
inventory(g);
|
|
return g;
|
|
}
|
|
function inventory(g: Game) {
|
|
homeworldGameIntegrity(g);
|
|
nexusSardaukarInventory(g);
|
|
}
|
|
function activate(g: Game) {
|
|
return nexusAllow(beginNexusSardaukar(g));
|
|
}
|
|
function plan(g: Game, support: number) {
|
|
const leader = [...g.players[0].leaders].sort(
|
|
(a, b) => b.strength - a.strength,
|
|
)[0];
|
|
return { type: 'battlePlan', dial: 12, support, leader: leader.id };
|
|
}
|
|
function resolve(g: Game, support: number) {
|
|
g = applyAction(g, 'p', plan(g, support));
|
|
g = applyAction(g, 'q', {
|
|
type: 'battlePlan',
|
|
dial: 0,
|
|
support: 0,
|
|
leader: g.players[1].leaders[0].id,
|
|
});
|
|
for (const id of ['p', 'q'])
|
|
g = applyAction(g, id, { type: 'traitorCall', call: false });
|
|
if (g.decision?.kind === 'battleLosses')
|
|
g = applyAction(g, 'p', { type: 'decision', choice: 0 });
|
|
return g;
|
|
}
|
|
|
|
void test('real high-Salusa Cunning pays only two normal support and all seven losses remain ordinary counters', () => {
|
|
const original = fixture();
|
|
let g = activate(nexusReload(original));
|
|
const forces = viewGame(g, 'p').battle!.ownForces!;
|
|
assert.deepEqual(
|
|
[forces.normal, forces.elite, forces.temporaryElite],
|
|
[7, 0, 5],
|
|
);
|
|
assert.equal(forces.eliteFreeSupport, true);
|
|
assert.equal(maxCombatDial(forces), 12);
|
|
assert.equal(maxCombatSupport(forces), 2);
|
|
assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 0, elite: 5 });
|
|
const before = nexusReload(g);
|
|
assert.throws(() => applyAction(g, 'p', plan(g, 3)));
|
|
assert.deepEqual(g, before);
|
|
for (const p of g.players)
|
|
assert.deepEqual(viewGame(nexusReload(g), p.id), viewGame(g, p.id));
|
|
g = resolve(g, 2);
|
|
assert.equal(g.players[0].tanks, 7);
|
|
assert.equal(g.players[0].spice, original.players[0].spice - 2);
|
|
assert.equal(g.players[0].elites!.tanks, 0);
|
|
assert.equal(g.players[0].elites!.reserves, 5);
|
|
assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 0, elite: 5 });
|
|
assert.equal(g.players[0].reserves, original.players[0].reserves);
|
|
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
|
|
inventory(g);
|
|
});
|
|
|
|
void test('one Salusa star stays low despite five temporary Sardaukar and requires all seven support payments', () => {
|
|
const original = fixture(1);
|
|
let g = activate(nexusReload(original));
|
|
const forces = viewGame(g, 'p').battle!.ownForces!;
|
|
assert.equal(forces.temporaryElite, 5);
|
|
assert.equal(!!forces.eliteFreeSupport, false);
|
|
assert.equal(maxCombatDial(forces), 12);
|
|
assert.equal(maxCombatSupport(forces), 7);
|
|
const before = nexusReload(g);
|
|
assert.throws(() => applyAction(g, 'p', plan(g, 2)));
|
|
assert.deepEqual(g, before);
|
|
g = resolve(g, 7);
|
|
assert.equal(g.players[0].tanks, 7);
|
|
assert.equal(g.players[0].spice, original.players[0].spice - 7);
|
|
assert.equal(g.players[0].elites!.tanks, 0);
|
|
assert.equal(g.players[0].elites!.reserves, 5);
|
|
assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 0, elite: 1 });
|
|
inventory(g);
|
|
});
|
|
|
|
void test('the actual Salusa one-to-two threshold controls the shared profile without counting stars at Kaitain', () => {
|
|
for (const [salusa, support] of [
|
|
[1, 7],
|
|
[2, 2],
|
|
] as const) {
|
|
const g = activate(fixture(salusa));
|
|
assert.equal(
|
|
maxCombatSupport(viewGame(g, 'p').battle!.ownForces!),
|
|
support,
|
|
);
|
|
assert.equal(g.players[0].elites!.reserves, 5);
|
|
assert.equal(g.homeworlds!.custody!.salusa!.elite, salusa);
|
|
inventory(g);
|
|
}
|
|
});
|