311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
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));
|
||
}
|
||
}
|
||
});
|