Files
Dune/tests/moritani-placement.test.ts
T
2026-09-08 07:37:52 +10:00

311 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import { faction } from '../game/catalog';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
function setup() {
const g = createGame('MORITANI', newPlayer('m', 'Moritani', 'moritani'));
g.players.push(
newPlayer('a', 'Atreides', 'atreides'),
newPlayer('e', 'Emperor', 'emperor'),
);
g.status = 'setup';
g.order = ['m', 'a', 'e'];
g.moritaniTerror = createTerrorState(() => 0.4);
for (const p of g.players) {
p.spice = faction(p.faction).spice;
p.traitors = [p.leaders[0].id];
}
g.players[1].traitors = [];
g.players[1].traitorChoices = [g.players[1].leaders[0].id];
return g;
}
function lastSetup(g = setup()) {
return applyAction(g, 'a', {
type: 'traitor',
leader: g.players[1].traitorChoices[0],
});
}
function playing(choam = false) {
const g = setup();
g.status = 'playing';
g.phase = 7;
g.players[1].traitors = [g.players[1].leaders[0].id];
g.players[1].traitorChoices = [];
g.players[0].reserves = 14;
g.players[0].forces = { 'polar_sink:0': 6 };
if (choam) {
const p = newPlayer('c', 'CHOAM', 'choam');
g.players.push(p);
g.order.push(p.id);
}
return g;
}
function mentat(state = playing()) {
let g = state;
for (const p of state.players) g = applyAction(g, p.id, { type: 'ready' });
if (g.decision?.kind === 'choamMarket')
g = applyAction(g, 'c', { type: 'decision', done: true });
assert.equal(g.phase, 8);
assert.equal(g.decision?.kind, 'moritaniPlacement');
return g;
}
function allow(state: Game) {
let g = state;
for (let i = 0; g.response && i < 12; i++) {
const id = g.players.find((p) => !g.response!.passed.includes(p.id))!.id;
g = applyAction(g, id, { type: 'passResponse' });
}
assert.equal(g.response, null);
return g;
}
function reject(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
function declare(
g: Game,
token = g.moritaniTerror!.tokens[0].id,
territory = 'arrakeen',
) {
return applyAction(g, 'm', { type: 'decision', token, territory });
}
void test('Moritani waits for other setup choices and then places exactly six with fourteen reserves and twelve spice', () => {
const initial = setup();
reject(initial, 'm', { type: 'decision', territory: 'arrakeen', sector: 10 });
const ready = lastSetup(initial);
assert.deepEqual(ready.decision, { kind: 'moritaniSetup', player: 'm' });
assert.equal(ready.status, 'setup');
const g = applyAction(ready, 'm', {
type: 'decision',
territory: 'arrakeen',
sector: 10,
});
assert.equal(g.status, 'playing');
assert.equal(g.players[0].forces['arrakeen:10'], 6);
assert.equal(g.players[0].reserves, 14);
assert.equal(g.players[0].spice, 12);
assert.equal(g.players[0].tanks, 0);
assert.deepEqual(g.moritaniTerror, initial.moritaniTerror);
reject(g, 'm', { type: 'decision', territory: 'carthag', sector: 11 });
});
void test('BG starting choices delay Moritani and advisors make the entire territory occupied', () => {
const initial = setup();
const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit');
bg.traitors = [bg.leaders[0].id];
bg.prediction = { faction: 'moritani', turn: 2 };
initial.players.push(bg);
initial.order.push(bg.id);
initial.advanced = true;
let g = lastSetup(initial);
assert.equal(g.decision, null);
g = applyAction(g, 'b', {
type: 'advisorSetup',
territory: 'arrakeen',
sector: 10,
});
assert.equal(g.decision?.kind, 'moritaniSetup');
// Persist an advisor stance with an opponent in the territory, as an advanced table can contain.
g.players[1].forces = { 'arrakeen:10': 1 };
g.players[1].reserves = 19;
g.players[3].advisors = { arrakeen: {} };
reject(g, 'm', { type: 'decision', territory: 'arrakeen', sector: 10 });
// Advisors alone still count as occupancy, irrespective of automatic stance cleanup.
g.players[1].forces = {};
g.players[1].reserves = 20;
reject(g, 'm', { type: 'decision', territory: 'arrakeen', sector: 10 });
});
void test('setup validates printed sectors but does not impose shipment storm restrictions', () => {
const g = lastSetup();
for (const [territory, sector] of [
['arrakeen', 8],
['mobile_stronghold', 0],
['unknown', 1],
['arrakeen', '10'],
])
reject(g, 'm', { type: 'decision', territory, sector });
g.storm = 10;
const placed = applyAction(g, 'm', {
type: 'decision',
territory: 'arrakeen',
sector: 10,
});
assert.equal(placed.players[0].forces['arrakeen:10'], 6);
});
void test('Mentat placement precedes victory and finishing the opportunity performs the deferred check', () => {
const g = playing();
g.players[1].forces = {
'arrakeen:10': 1,
'carthag:11': 1,
'tueks_sietch:5': 1,
};
g.players[1].reserves = 17;
const opened = mentat(g);
assert.equal(opened.status, 'playing');
assert.deepEqual(opened.winner, []);
assert.equal(viewGame(opened, 'a').mentatVictoryPending, true);
const done = applyAction(opened, 'm', { type: 'decision', decline: true });
assert.equal(done.status, 'finished');
assert.deepEqual(done.winner, ['a']);
assert.equal(done.moritaniTerror!.placementTurn, done.turn);
});
void test('hidden declarations keep custody and public response identical for different secret tokens', () => {
const initial = mentat();
initial.players[1].hand = [baseDeck().find((c) => c.effect === 'karama')!];
const first = declare(initial, initial.moritaniTerror!.tokens[0].id);
const second = declare(initial, initial.moritaniTerror!.tokens[1].id);
assert.deepEqual(first.moritaniTerror, initial.moritaniTerror);
assert.deepEqual(first.response, {
kind: 'moritaniPlacement',
owner: 'm',
passed: [],
});
assert.deepEqual(viewGame(first, 'a'), viewGame(second, 'a'));
assert.equal(viewGame(first, 'm').moritaniTerror!.tokens.length, 6);
assert.deepEqual(viewGame(first, 'a').moritaniTerror!.tokens, []);
assert.equal(viewGame(first, 'a').moritaniPendingPlacement, null);
assert.deepEqual(
viewGame(JSON.parse(JSON.stringify(first)), 'm').moritaniPendingPlacement,
first.pendingMoritaniPlacement,
);
const g = allow(JSON.parse(JSON.stringify(first)));
assert.equal(g.moritaniTerror!.tokens[0].location, 'arrakeen');
assert.equal(g.moritaniTerror!.placementTurn, g.turn);
assert.equal(g.pendingMoritaniPlacement, null);
const publicToken = viewGame(g, 'a').moritaniTerror!.tokens[0];
assert.equal(publicToken.location, 'arrakeen');
assert.equal('kind' in publicToken, false);
assert.equal(
g.moritaniTerror!.tokens.filter((t) => t.status === 'available').length,
5,
);
reject(g, 'm', {
type: 'decision',
token: g.moritaniTerror!.tokens[1].id,
territory: 'carthag',
});
});
void test('Karama prevents relocation without moving the hidden token and consumes this turn’s opportunity', () => {
let g = playing();
g.turn = 2;
const id = g.moritaniTerror!.tokens[0].id;
g.moritaniTerror = placeTerror(g.moritaniTerror!, id, 'carthag', 1);
const karama = baseDeck().find((c) => c.effect === 'karama')!;
g.players[1].hand = [karama];
g = declare(mentat(g), id, 'arrakeen');
const originalTokens = structuredClone(g.moritaniTerror!.tokens);
g = applyAction(g, 'a', { type: 'card', mode: 'cancel', card: karama.id });
assert.deepEqual(g.moritaniTerror!.tokens, originalTokens);
assert.equal(g.moritaniTerror!.placementTurn, 2);
assert.equal(g.pendingMoritaniPlacement, null);
assert.equal(g.response, null);
assert.ok(g.discard.some((c) => c.id === karama.id));
reject(g, 'm', { type: 'decision', token: id, territory: 'arrakeen' });
});
void test('a later Mentat opportunity relocates the same token into storm without duplicating it', () => {
const initial = playing();
initial.turn = 2;
initial.storm = 10;
const id = initial.moritaniTerror!.tokens[0].id;
initial.moritaniTerror = placeTerror(
initial.moritaniTerror!,
id,
'carthag',
1,
);
const g = allow(declare(mentat(initial), id, 'arrakeen'));
assert.equal(g.moritaniTerror!.placementTurn, 2);
assert.equal(g.moritaniTerror!.tokens.length, 6);
assert.equal(
g.moritaniTerror!.tokens.filter((t) => t.status === 'placed').length,
1,
);
assert.deepEqual(
g.moritaniTerror!.tokens.find((t) => t.id === id),
{
...initial.moritaniTerror!.tokens[0],
location: 'arrakeen',
},
);
assert.equal(
g.moritaniTerror!.tokens.some((t) => t.location === 'carthag'),
false,
);
assert.deepEqual(g.players, initial.players);
});
void test('declining survives reload, invalid placements are atomic and stale pending settlement cannot commit', () => {
const opened = mentat();
for (const territory of ['polar_sink', 'mobile_stronghold', 'homeworld'])
reject(opened, 'm', {
type: 'decision',
token: opened.moritaniTerror!.tokens[0].id,
territory,
});
reject(opened, 'a', { type: 'decision', decline: true });
const declined = JSON.parse(
JSON.stringify(
applyAction(opened, 'm', { type: 'decision', decline: true }),
),
) as Game;
assert.equal(declined.moritaniTerror!.placementTurn, declined.turn);
assert.equal(declined.decision, null);
reject(declined, 'm', {
type: 'decision',
token: declined.moritaniTerror!.tokens[0].id,
territory: 'arrakeen',
});
opened.players[1].hand = [baseDeck().find((c) => c.effect === 'karama')!];
const stale = declare(opened);
stale.pendingMoritaniPlacement!.turn--;
stale.response!.passed = ['a', 'e'];
reject(stale, 'm', { type: 'passResponse' });
});
void test('CHOAM retains its closing market and Mentat opportunity after Moritani completes placement', () => {
let g = mentat(playing(true));
g = applyAction(g, 'm', { type: 'decision', decline: true });
assert.equal(g.phase, 8);
assert.equal(g.status, 'playing');
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'choamMarket');
g = applyAction(g, 'c', { type: 'decision', done: true });
assert.equal(g.decision?.kind, 'choamMentat');
assert.equal(g.phase, 8);
g = applyAction(g, 'c', { type: 'decision', done: true });
assert.equal(g.turn, 2);
assert.equal(g.phase, 0);
assert.equal(g.moritaniTerror!.placementTurn, 1);
});
void test('all four AI profiles provide legal Moritani setup and placement choices', () => {
for (const difficulty of DIFFICULTIES) {
for (const state of [lastSetup(), mentat()]) {
state.players[0].bot = difficulty;
const actions = botActions(viewGame(state, 'm'));
assert.ok(actions.length > 0, difficulty + ':' + state.decision?.kind);
for (const action of actions)
assert.doesNotThrow(() => applyAction(state, 'm', action));
}
}
});