Files
Dune/tests/nexus-sardaukar-engine.test.ts
T

416 lines
14 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
viewGame,
normalizeAutomaticGame,
type Game,
} from '../game/engine';
import { casualtyOptions, maxCombatDial } from '../game/combat';
import {
createNexusSardaukar,
validateNexusSardaukar,
} from '../game/nexus-sardaukar';
import {
nexusSardaukarFixture,
nexusSardaukarPlayer,
nexusSardaukarInventory,
prepareSardaukarBattle,
beginNexusSardaukar,
allowNexusSardaukar,
} from './fixture-nexus-sardaukar';
import { nexusReload, nexusReject, nexusAllow } from './fixture-nexus-cards';
import { drawNexusCard } from '../game/nexus-cards';
function hold(g: Game, id: string, effect: string): string {
const index = g.deck.findIndex(
(card) => card.effect === effect || card.kind === effect,
);
assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0];
g.players.find((p) => p.id === id)!.hand.push(card);
return card.id;
}
function activate(g: Game): Game {
return allowNexusSardaukar(beginNexusSardaukar(g));
}
function request(g: Game) {
const p = nexusSardaukarPlayer(g);
return {
type: 'nexusSardaukar',
event: viewGame(g, p.id).nexusSardaukar!.offer!.event,
};
}
function resolveOrdinary(state: Game, dial = 6, support = 3): Game {
let g = prepareSardaukarBattle(state);
const owner = nexusSardaukarPlayer(g),
target = g.players.find((p) => p.id === g.battle!.defender)!;
const leader = [...owner.leaders].sort((a, b) => b.strength - a.strength)[0];
const opposing = [...target.leaders].sort(
(a, b) => a.strength - b.strength,
)[0];
g = applyAction(g, owner.id, {
type: 'battlePlan',
dial,
support,
leader: leader.id,
});
g = applyAction(g, target.id, {
type: 'battlePlan',
dial: 0,
leader: opposing.id,
});
for (const id of [owner.id, target.id])
g = applyAction(g, id, { type: 'traitorCall', call: false });
return g;
}
void test('Sardaukar receipt binds the original five-counter battle role without depending on later force custody', () => {
const g = nexusSardaukarFixture();
const receipt = createNexusSardaukar(
g,
'p',
g.battle!.event!,
'pasty_mesa',
7,
);
assert.equal(receipt.elite, 0);
assert.equal(receipt.count, 5);
validateNexusSardaukar({ turn: g.turn + 1, players: g.players }, receipt);
for (const patch of [
{ normal: 4 },
{ normal: 8 },
{ count: 4 },
{ elite: 1 },
{ owner: 'q' },
{ territory: 'hagga_basin' },
{ battle: 'other' },
{ version: 0 },
]) {
const bad = { ...receipt, ...patch };
assert.throws(() => validateNexusSardaukar(g, bad as typeof receipt));
}
assert.throws(() => createNexusSardaukar(g, 'q', 'battle', 'pasty_mesa', 7));
assert.throws(() => createNexusSardaukar(g, 'p', 'battle', 'pasty_mesa', 4));
const changed = g.players.map((p) => ({ id: p.id, faction: p.faction }));
changed[1].id = 'another-seat';
assert.throws(() =>
validateNexusSardaukar({ turn: g.turn, players: changed }, receipt),
);
});
void test('five temporary Sardaukar enhance an owned battle while every actual force and starred counter stays in place', () => {
let g = nexusSardaukarFixture();
hold(g, 'q', 'karama');
const before = structuredClone(g.players);
g = beginNexusSardaukar(g);
assert.equal(g.response?.kind, 'nexusSardaukar');
g = allowNexusSardaukar(g);
assert.deepEqual(g.players, before);
const forces = viewGame(g, 'p').battle!.ownForces!;
assert.equal(forces.normal, 7);
assert.equal(forces.elite, 0);
assert.equal(forces.temporaryElite, 5);
assert.equal(maxCombatDial(forces), 12);
assert.deepEqual(
casualtyOptions(forces, 6, 3).map((x) => [x.normal, x.elite]),
[
[3, 0],
[4, 0],
[5, 0],
],
);
for (const p of g.players)
assert.equal(viewGame(g, p.id).nexusSardaukar!.active, true);
nexusSardaukarInventory(g);
});
void test('temporary Sardaukar retain the ordinary Fremen exception and need support for full strength', () => {
const g = activate(nexusSardaukarFixture({ opponentFaction: 'fremen' }));
const forces = viewGame(g, 'p').battle!.ownForces!;
assert.equal(forces.temporaryElite, 5);
assert.equal(forces.eliteStrength, 1);
assert.equal(forces.freeSupport, false);
assert.equal(maxCombatDial(forces), 7);
assert.deepEqual(
casualtyOptions(forces, 5, 5).map((x) => [x.normal, x.elite]),
[[5, 0]],
);
assert.deepEqual(casualtyOptions(forces, 5, 0), []);
});
void test('Karama stops the new role grant entirely and leaves all physical forces normal', () => {
let g = nexusSardaukarFixture();
const karama = hold(g, 'q', 'karama');
const original = structuredClone(nexusSardaukarPlayer(g).elites);
g = beginNexusSardaukar(g);
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
g = allowNexusSardaukar(g);
const forces = viewGame(g, 'p').battle!.ownForces!;
assert.equal(forces.temporaryElite ?? 0, 0);
assert.equal(maxCombatDial(forces), 7);
assert.deepEqual(nexusSardaukarPlayer(g).elites, original);
assert.equal(g.discard.filter((c) => c.id === karama).length, 1);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'emperor').length,
1,
);
nexusSardaukarInventory(g);
});
void test('every winning casualty choice removes ordinary counters and never invents Sardaukar in the Tanks', () => {
const initial = activate(nexusSardaukarFixture());
for (const [index, losses] of [3, 4, 5].entries()) {
let g = resolveOrdinary(nexusReload(initial));
assert.equal(g.decision?.kind, 'battleLosses');
g = applyAction(g, 'p', { type: 'decision', choice: index });
const p = nexusSardaukarPlayer(g);
assert.equal(p.tanks, losses);
assert.equal(p.forces['pasty_mesa:5'], 7 - losses);
assert.equal(p.elites!.tanks, 0);
assert.equal(p.elites!.reserves, 5);
assert.deepEqual(p.elites!.forces, {});
nexusSardaukarInventory(g);
}
});
void test('the completed grant expires before a second battle in the same turn', () => {
let g = resolveOrdinary(activate(nexusSardaukarFixture()), 0, 0);
assert.equal(g.battle, null);
g = applyAction(g, 'p', {
type: 'chooseBattle',
territory: 'hagga_basin',
target: 'q',
});
assert.equal(viewGame(g, 'p').battle!.ownForces!.temporaryElite ?? 0, 0);
assert.equal(viewGame(g, 'p').nexusSardaukar!.active, false);
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
});
void test('Basic, short groups, actual Sardaukar, foreign actors and stale or repeated declarations reject unchanged', () => {
for (const options of [{ advanced: false }, { normal: 4 }, { starred: 1 }]) {
const g = nexusSardaukarFixture(options);
assert.ok(viewGame(g, 'p').nexusSardaukar!.offer!.blocked);
nexusReject(g, 'p', request(g), /Basic|Advanced|five|5|Sardaukar|ruling/i);
}
const g = nexusSardaukarFixture(),
action = request(g);
nexusReject(g, 'q', action, /Sardaukar|Cunning/);
nexusReject(g, 'p', { ...action, event: 'old' }, /Sardaukar|Cunning/);
nexusReject(g, 'p', { ...action, count: 4 }, /Sardaukar|Cunning/);
nexusReject(activate(g), 'p', action, /Sardaukar|Cunning/);
const ready = prepareSardaukarBattle(g);
const sealed = applyAction(ready, 'p', {
type: 'battlePlan',
dial: 0,
leader: ready.players[0].leaders[0].id,
});
nexusReject(sealed, 'p', action, /submitted|Sardaukar|Cunning/);
});
void test('saved declaration restores its public pending effect and only settles once', () => {
let g = nexusSardaukarFixture({
seatIds: ['emperor-seat', 'guild-seat', 'fremen-seat'],
});
hold(g, 'guild-seat', 'karama');
const action = request(g);
g = beginNexusSardaukar(g);
const saved = nexusReload(g);
for (const p of saved.players) {
const view = viewGame(saved, p.id);
assert.equal(view.nexusSardaukar!.pending!.owner, 'emperor-seat');
for (const other of view.players)
if (other.id !== p.id) assert.equal(other.hand, undefined);
}
assert.deepEqual(normalizeAutomaticGame(saved), saved);
g = allowNexusSardaukar(nexusReload(saved));
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
nexusReject(g, 'emperor-seat', action, /Sardaukar|Cunning/);
nexusSardaukarInventory(g);
});
void test('native Prescience accepts a future Cunning dial and keeps its answer binding after activation', () => {
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
g = nexusAllow(g);
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 10 });
assert.equal(g.nexusCards!.cards!.hands.p, 'emperor');
g = activate(prepareSardaukarBattle(g));
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 10,
support: 5,
leader: g.players[0].leaders[0].id,
});
assert.equal(g.battle!.plans.p.dial, 10);
assert.equal(g.battle!.prescience!.value, 10);
});
void test('canceling Cunning reopens an impossible native dial answer without granting a new inspection', () => {
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
const karama = hold(g, 'q', 'karama');
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
g = nexusAllow(g);
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 10 });
const battle = g.battle!.event;
g = beginNexusSardaukar(prepareSardaukarBattle(g));
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
assert.equal(g.battle!.event, battle);
assert.equal(g.battle!.prescience!.player, 'q');
assert.equal(g.battle!.prescience!.field, 'dial');
assert.equal(g.battle!.preparation?.kind, 'prescienceAnswer');
g = applyAction(nexusReload(g), 'p', { type: 'prescienceAnswer', value: 5 });
assert.equal(g.battle!.prescience!.value, 5);
g = prepareSardaukarBattle(g);
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 5,
support: 5,
leader: g.players[0].leaders[0].id,
});
assert.equal(g.battle!.plans.p.dial, 5);
assert.equal(
g.nexusCards!.cards!.discard.filter((card) => card === 'emperor').length,
1,
);
assert.equal(g.discard.filter((card) => card.id === karama).length, 1);
nexusSardaukarInventory(g);
});
void test('canceled Sardaukar reopen the affected Cunning inspection while preserving the other answered element', () => {
for (const [nativeDial, extraLeader] of [
[true, false],
[false, false],
[true, true],
]) {
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
const leader = g.players[0].leaders[0].id;
const unchangedField = extraLeader
? 'leader'
: nativeDial
? 'defense'
: 'weapon';
const unchangedValue = extraLeader ? leader : null;
const karama = hold(g, 'q', 'karama');
const cards = g.nexusCards!.cards!;
cards.deck = [
'atreides',
...cards.deck.filter((card) => card !== 'atreides'),
];
g.nexusCards!.cards = drawNexusCard(cards, 'q', g.players, () => 0);
g = applyAction(g, 'q', {
type: 'prescience',
field: nativeDial ? 'dial' : 'weapon',
});
g = nexusAllow(g);
g = applyAction(g, 'p', {
type: 'prescienceAnswer',
value: nativeDial ? 10 : null,
});
g = prepareSardaukarBattle(g);
g = applyAction(g, 'q', {
type: 'nexusAtreides',
event: g.battle!.event,
mode: 'cunning',
field: extraLeader ? 'leader' : nativeDial ? 'defense' : 'dial',
});
g = nexusAllow(g);
g = applyAction(g, 'p', {
type: 'nexusPrescienceAnswer',
event: g.battle!.event,
value: nativeDial ? unchangedValue : 10,
});
g = beginNexusSardaukar(prepareSardaukarBattle(g));
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
assert.equal(
g.battle!.preparation?.kind,
nativeDial ? 'prescienceAnswer' : 'nexusPrescienceAnswer',
);
assert.deepEqual(viewGame(g, 'r').battle!.nexusInsights, []);
g = applyAction(
nexusReload(g),
'p',
nativeDial
? { type: 'prescienceAnswer', value: 5 }
: { type: 'nexusPrescienceAnswer', event: g.battle!.event, value: 5 },
);
const commitments = viewGame(g, 'p').battle!.ownCommitments;
assert.ok(commitments.some((c) => c.field === 'dial' && c.value === 5));
assert.ok(
commitments.some(
(c) => c.field === unchangedField && c.value === unchangedValue,
),
);
g = prepareSardaukarBattle(g);
if (extraLeader)
nexusReject(
g,
'p',
{
type: 'battlePlan',
dial: 5,
support: 5,
leader: g.players[0].leaders.find(
(candidate) => candidate.id !== leader,
)!.id,
},
/Every element revealed by a battle inspection must remain unchanged/,
);
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 5,
support: 5,
leader,
});
assert.equal(g.battle!.plans.p.dial, 5);
nexusSardaukarInventory(g);
}
});
void test('Secret Ally inspection retains private history when canceled Sardaukar require a revised dial', () => {
let g = nexusSardaukarFixture();
const karama = hold(g, 'q', 'karama');
const cards = g.nexusCards!.cards!;
cards.deck = [
'atreides',
...cards.deck.filter((card) => card !== 'atreides'),
];
g.nexusCards!.cards = drawNexusCard(cards, 'q', g.players, () => 0);
g = applyAction(g, 'q', {
type: 'nexusAtreides',
event: g.battle!.event,
mode: 'secretAlly',
field: 'dial',
});
g = applyAction(g, 'p', {
type: 'nexusPrescienceAnswer',
event: g.battle!.event,
value: 10,
});
g = beginNexusSardaukar(prepareSardaukarBattle(g));
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
assert.equal(g.battle!.preparation?.kind, 'nexusPrescienceAnswer');
assert.deepEqual(viewGame(g, 'r').battle!.nexusInsights, []);
assert.ok(
viewGame(g, 'q').battle!.nexusInsights.some(
(insight) => insight.value === 10 && !insight.active,
),
);
g = applyAction(nexusReload(g), 'p', {
type: 'nexusPrescienceAnswer',
event: g.battle!.event,
value: 5,
});
const answers = viewGame(g, 'q').battle!.nexusInsights;
assert.ok(answers.some((insight) => insight.value === 10 && !insight.active));
assert.ok(answers.some((insight) => insight.value === 5 && insight.active));
g = prepareSardaukarBattle(g);
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 5,
support: 5,
leader: g.players[0].leaders[0].id,
});
nexusSardaukarInventory(g);
});