Initial commit

This commit is contained in:
Quintine committed 2026-09-08 07:37:52 +10:00
commit 8c709fb52f
771 files changed
+222983

No files matched your search

+310
View File
@@ -0,0 +1,310 @@
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));
}
}
});