Files
Dune/tests/nexus-richese-engine.test.ts

476 lines
15 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { botActions } from '../game/bots';
import { orderNexusSpice } from './fixture-nexus-cards';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import {
nexusRicheseFixture,
nexusRicheseRequest,
nexusRicheseAllow,
nexusRicheseReload,
nexusRicheseInventory,
holdNexusRicheseCard,
} from './fixture-nexus-richese';
function reject(g: Game, id: string, action: Action) {
const before = nexusRicheseReload(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
function stable(g: Game) {
nexusRicheseInventory(g);
for (const p of g.players)
assert.deepEqual(viewGame(nexusRicheseReload(g), p.id), viewGame(g, p.id));
assert.deepEqual(
normalizeAutomaticGame(nexusRicheseReload(g)),
nexusRicheseReload(g),
);
}
void test('Richese Secret Ally ships five actual counters at one-counter cost for ordinary, Guild and Fremen factions', () => {
for (const ownerFaction of ['atreides', 'guild', 'fremen'] as const)
for (const amount of [1, 5]) {
const f = nexusRicheseFixture({ ownerFaction, spice: 2 });
const territory =
ownerFaction === 'fremen' ? 'the_great_flat' : 'red_chasm';
const sector = ownerFaction === 'fremen' ? 15 : 7;
const action = nexusRicheseRequest(f, amount, territory, sector);
const g = nexusRicheseAllow(applyAction(f.g, f.owner, action));
const p = g.players[0];
assert.equal(
p.spice,
ownerFaction === 'fremen' ? 2 : ownerFaction === 'guild' ? 1 : 0,
);
assert.equal(p.reserves, 20 - amount);
assert.equal(p.forces[`${territory}:${sector}`], amount);
assert.equal(p.shipped, true);
assert.equal(p.moved, 0);
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'richese').length,
1,
);
reject(g, f.owner, action);
stable(g);
}
});
void test('Richese declaration rejects excess forces, stale events, foreign seats, wrong ranges and insufficient payment immutably', () => {
const f = nexusRicheseFixture({ spice: 1 });
const action = nexusRicheseRequest(f);
for (const bad of [
{ ...action, amount: 0 },
{ ...action, amount: 6 },
{ ...action, nexus: 'old' },
{ ...action, territory: 'red_chasm', sector: 7 },
{ ...action, territory: 'arrakeen', sector: 9 },
{ ...action, homeworldSources: { unknown: { normal: 5, elite: 0 } } },
])
reject(f.g, f.owner, bad);
reject(f.g, f.target, action);
for (const type of ['guildShip', 'homeworldShip', 'guildHomeworldShip'])
reject(f.g, f.owner, { ...action, type });
const fremen = nexusRicheseFixture({ ownerFaction: 'fremen' });
reject(
fremen.g,
fremen.owner,
nexusRicheseRequest(fremen, 5, 'arrakeen', 10),
);
const storm = nexusRicheseReload(f.g);
storm.storm = 10;
reject(storm, f.owner, action);
});
void test('five typed Emperor forces withdraw from Kaitain and Salusa once while their single-force price stays one', () => {
const f = nexusRicheseFixture({
ownerFaction: 'emperor',
advanced: true,
homeworlds: true,
spice: 1,
});
const action = {
...nexusRicheseRequest(f),
elite: 2,
homeworldSources: {
'homeworld:emperor': { normal: 3, elite: 0 },
'homeworld:emperor:salusa': { normal: 0, elite: 2 },
},
};
const g = applyAction(f.g, f.owner, action);
assert.equal(g.players[0].spice, 0);
assert.equal(g.players[0].forces['arrakeen:10'], 5);
assert.equal(g.players[0].elites!.forces['arrakeen:10'], 2);
assert.equal(g.players[0].reserves, 15);
assert.equal(g.homeworlds!.custody!.salusa!.elite, 3);
stable(g);
});
void test('Guild interception preserves the complete five-force declaration and spends Nexus on either allowance or stop', () => {
for (const stop of [false, true]) {
const f = nexusRicheseFixture({
advanced: true,
guild: true,
karama: true,
spice: 1,
});
const action = nexusRicheseRequest(f);
let g = applyAction(f.g, f.owner, action);
assert.equal(g.decision?.kind, 'guildShipment');
assert.equal(g.pendingShipment!.amount, 5);
assert.equal(g.pendingShipment!.cost, 1);
assert.equal(g.players[0].reserves, 20);
assert.equal(g.players[0].spice, 1);
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
stable(g);
g = applyAction(
nexusRicheseReload(g),
f.target,
stop
? { type: 'card', mode: 'special', card: f.karama }
: { type: 'decision', allow: true },
);
g = nexusRicheseAllow(g);
assert.equal(g.players[0].reserves, stop ? 20 : 15);
assert.equal(g.players[0].spice, stop ? 1 : 0);
assert.equal(g.players[1].spice, stop ? 20 : 21);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'richese').length,
1,
);
reject(g, f.owner, action);
stable(g);
}
});
void test('independent Karama discounts the one-force price, routes payment to bank and preserves actual force count', () => {
const f = nexusRicheseFixture({ guild: true, spice: 1 });
const card = holdNexusRicheseCard(f.g, f.owner, 'karama');
let g = applyAction(f.g, f.owner, {
type: 'card',
card: card.id,
mode: 'shipment',
target: f.owner,
});
g = applyAction(g, f.owner, nexusRicheseRequest(f, 5, 'red_chasm', 7, g));
assert.equal(g.players[0].spice, 0);
assert.equal(g.players[0].forces['red_chasm:7'], 5);
assert.equal(g.players[1].spice, 20);
assert.equal(g.response, null);
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
stable(g);
});
void test('the real shipment opens BG accompaniment with all five arrivals and leaves normal movement available afterward', () => {
const f = nexusRicheseFixture({
advanced: true,
opponentFaction: 'beneGesserit',
});
let g = applyAction(f.g, f.owner, nexusRicheseRequest(f));
assert.equal(g.decision?.kind, 'advisor');
assert.equal(g.players[0].forces['arrakeen:10'], 5);
g = applyAction(nexusRicheseReload(g), f.target, {
type: 'decision',
accept: true,
accompany: true,
});
g = nexusRicheseAllow(g);
assert.equal(g.players[1].forces['arrakeen:10'], 1);
assert.equal(g.players[1].reserves, 19);
assert.equal(g.players[0].moved, 0);
assert.equal(g.active, f.owner);
stable(g);
});
void test('Richese held-card availability and original shipment evidence remain private across other seats and reload', () => {
const f = nexusRicheseFixture({ advanced: true, guild: true });
for (const id of [f.target, f.observer])
assert.equal(viewGame(f.g, id).nexusRichese, null);
let g = applyAction(f.g, f.owner, nexusRicheseRequest(f));
for (const id of [f.target, f.observer]) {
const view = viewGame(g, id);
assert.equal(Object.hasOwn(view, 'nexusRicheseHistory'), false);
assert.equal(view.nexusRichese, null);
assert.equal(view.decision?.kind, 'guildShipment');
if (view.decision?.kind === 'guildShipment')
assert.equal(view.decision.amount, 5);
}
g = applyAction(nexusRicheseReload(g), f.target, {
type: 'decision',
allow: true,
});
g = nexusRicheseAllow(g);
stable(g);
});
void test('Richese pending source rejects event/history deletion, original decision edits and valid-looking typed reallocations', () => {
const f = nexusRicheseFixture({
ownerFaction: 'emperor',
advanced: true,
homeworlds: true,
guild: true,
});
const pending = applyAction(f.g, f.owner, {
...nexusRicheseRequest(f),
elite: 2,
homeworldSources: {
'homeworld:emperor': { normal: 3, elite: 0 },
'homeworld:emperor:salusa': { normal: 0, elite: 2 },
},
});
for (const edit of [
(g: Game) => {
delete g.pendingShipment!.nexusEvent;
},
(g: Game) => {
delete g.nexusRicheseHistory;
},
(g: Game) => {
delete g.nexusRicheseLast;
},
(g: Game) => {
g.nexusRicheseHistory![0].signature = 'changed';
},
(g: Game) => {
g.nexusRicheseHistory![0].frame = '{}';
},
(g: Game) => {
g.pendingShipment!.elite = 1;
g.pendingShipment!.homeworldSources!['homeworld:emperor'].normal = 4;
g.pendingShipment!.homeworldSources!['homeworld:emperor:salusa'].elite =
1;
},
(g: Game) => {
if (g.decision?.kind === 'guildShipment') g.decision.amount = 4;
},
(g: Game) => {
if (g.decision?.kind === 'guildShipment') g.decision.shipper = f.observer;
},
(g: Game) => {
g.nexusRicheseLast!.stage = 'shipped';
},
]) {
const bad = nexusRicheseReload(pending);
edit(bad);
const before = nexusRicheseReload(bad);
for (const p of bad.players) assert.throws(() => viewGame(bad, p.id));
assert.throws(() => normalizeAutomaticGame(bad));
assert.throws(() =>
applyAction(bad, f.target, { type: 'decision', allow: true }),
);
assert.deepEqual(bad, before);
}
});
void test('a genuine Truthtrance interruption preserves Richese pending shipment, paid quote and Guild interception', () => {
const f = nexusRicheseFixture({ advanced: true, guild: true, spice: 1 });
const truth = holdNexusRicheseCard(f.g, f.observer, 'truthtrance');
let g = applyAction(f.g, f.owner, nexusRicheseRequest(f));
const shipment = structuredClone(g.pendingShipment),
decision = structuredClone(g.decision),
history = structuredClone(g.nexusRicheseHistory);
g = applyAction(g, f.observer, { type: 'card', card: truth.id });
while (g.truthtrance?.stage === 'priority')
g = applyAction(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
g = applyAction(g, f.observer, {
type: 'truthAsk',
question: {
kind: 'fact',
target: f.owner,
fact: { kind: 'hand', name: 'Shield' },
},
});
g = applyAction(nexusRicheseReload(g), f.owner, {
type: 'truthAnswer',
answer: 'no',
});
assert.deepEqual(g.pendingShipment, shipment);
assert.deepEqual(g.decision, decision);
assert.deepEqual(g.nexusRicheseHistory, history);
g = applyAction(g, f.target, { type: 'decision', allow: true });
g = nexusRicheseAllow(g);
assert.equal(g.players[0].reserves, 15);
assert.equal(g.players[0].spice, 0);
assert.equal(g.discard.filter((c) => c.id === truth.id).length, 1);
stable(g);
});
void test('completed Richese history survives ordinary movement, genuine next-turn phases and a later full-price shipment', () => {
const f = nexusRicheseFixture();
let g = applyAction(f.g, f.owner, nexusRicheseRequest(f));
const history = structuredClone(g.nexusRicheseHistory);
g = applyAction(g, f.owner, {
type: 'move',
from: 'arrakeen:10',
territory: 'hagga_basin',
sector: 12,
amount: 5,
});
assert.equal(g.players[0].forces['hagga_basin:12'], 5);
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
let ordered = false;
for (
let step = 0;
!(
g.turn === 2 &&
g.phase === 5 &&
!g.response &&
!g.decision &&
!g.phaseOpening
) && step < 180;
step++
) {
if (g.turn === 2 && g.phase === 1 && !ordered) {
orderNexusSpice(g, ['land', 'land']);
ordered = true;
}
let next: Game | undefined;
for (const p of g.players) {
const v = viewGame(g, p.id);
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
const action = botActions(v)[0];
if (action) {
next = applyAction(g, p.id, action);
break;
}
}
assert.ok(
next,
`Next turn stalled at ${g.turn}/${g.phase}/${g.decision?.kind}`,
);
g = next;
}
assert.equal(g.turn, 2);
assert.equal(g.phase, 5);
while (g.active !== f.owner)
g = applyAction(g, g.active!, { type: 'endMovement' });
assert.deepEqual(g.nexusRicheseHistory, history);
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
const before = g.players[0].spice,
reserves = g.players[0].reserves;
g = applyAction(g, f.owner, {
type: 'ship',
territory: 'red_chasm',
sector: 7,
amount: 1,
});
assert.equal(g.players[0].spice, before - 2);
assert.equal(g.players[0].reserves, reserves - 1);
assert.deepEqual(g.nexusRicheseHistory, history);
stable(g);
});
void test('native Ixians may ship five typed counters into their real mobile stronghold but another faction cannot', () => {
for (const advanced of [false, true]) {
const f = nexusRicheseFixture({
ownerFaction: 'ixians',
advanced,
spice: 1,
});
assert.ok(f.g.mobileStronghold?.location);
const g = applyAction(f.g, f.owner, {
...nexusRicheseRequest(f, 5, 'hidden_mobile_stronghold', 0),
elite: 2,
});
assert.equal(g.players[0].forces['hidden_mobile_stronghold:0'], 5);
assert.equal(g.players[0].elites!.forces['hidden_mobile_stronghold:0'], 2);
assert.equal(g.players[0].spice, 0);
stable(g);
}
const other = nexusRicheseFixture({ opponentFaction: 'ixians' });
assert.ok(other.g.mobileStronghold?.location);
reject(
other.g,
other.owner,
nexusRicheseRequest(other, 5, 'hidden_mobile_stronghold', 0),
);
});
void test('a genuinely placed Terror token sees five arriving Richese-discounted counters under low Grumman', () => {
const f = nexusRicheseFixture({
opponentFaction: 'moritani',
homeworlds: true,
});
let g = f.g;
g.players[1].reserves = 7;
g.players[1].forces = { 'polar_sink:0': 13 };
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
assert.equal(g.phase, 7);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'moritaniPlacement');
const token = g.moritaniTerror!.tokens.find((t) => t.kind === 'robbery')!;
g = nexusRicheseAllow(
applyAction(g, f.target, {
type: 'decision',
token: token.id,
territory: 'arrakeen',
}),
);
let ordered = false;
for (
let step = 0;
!(
g.turn === 2 &&
g.phase === 5 &&
!g.response &&
!g.decision &&
!g.phaseOpening
) && step < 180;
step++
) {
if (g.turn === 2 && g.phase === 1 && !ordered) {
orderNexusSpice(g, ['land', 'land']);
ordered = true;
}
let next: Game | undefined;
for (const p of g.players) {
const v = viewGame(g, p.id);
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
const action = botActions(v)[0];
if (action) {
next = applyAction(g, p.id, action);
break;
}
}
assert.ok(
next,
`Terror progression stalled at ${g.turn}/${g.phase}/${g.decision?.kind}`,
);
g = next;
}
assert.equal(g.turn, 2);
assert.equal(g.phase, 5);
while (g.active !== f.owner)
g = applyAction(g, g.active!, { type: 'endMovement' });
assert.equal(g.players[1].reserves, 7);
const spice = g.players[0].spice;
g = applyAction(g, f.owner, nexusRicheseRequest(f, 5, 'arrakeen', 10, g));
assert.equal(g.players[0].spice, spice - 1);
assert.equal(g.pendingTerrorEntry?.amount, 5);
assert.equal(g.players[0].forces['arrakeen:10'], 5);
const beforeRobbery = g.players[0].spice;
g = applyAction(nexusRicheseReload(g), f.target, {
type: 'decision',
reveal: true,
});
g = applyAction(g, f.target, { type: 'decision', choice: 'spice' });
assert.equal(
g.players[0].spice,
beforeRobbery - Math.ceil(beforeRobbery / 2),
);
assert.equal(
g.moritaniTerror!.tokens.find((t) => t.id === token.id)!.status,
'removed',
);
stable(g);
});