Connect bounded Moritani Atomics Aftermath

This commit is contained in:
Quintine committed 2026-09-29 19:13:16 +10:00
1 parent b2603f6715
commit 66a427ab55
17 files changed
+1036 -68

No files matched your search

+3 -4
View File
@@ -126,10 +126,9 @@ void test('all four bot profiles can legally perform Ecaz setup', () => {
let g = readyTable(['beneGesserit', 'ecaz', 'atreides', 'harkonnen']);
g.players.forEach((p) => (p.bot = difficulty));
g = initializeFactionExpansionsGameForAudit(g);
for (let i = 0; i < 20 && g.status === 'setup'; i++) {
g = runBots(g);
if (g.setupStage === undefined) break;
}
// Stop at the setup boundary; a bulk bot run may already finish a short game.
for (let i = 0; i < 200 && g.status === 'setup'; i++)
g = runBots(g, 1);
assert.equal(g.status, 'playing', `${difficulty} did not complete setup`);
assert.ok(g.ecazLoyalty?.card);
}
+45
View File
@@ -0,0 +1,45 @@
import type { Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
/** A clean classic Moritani arrival; the room variant supplies authenticated IDs. */
export function atomicsFixtureGame(
game: Game,
ids: { moritani: string; entrant: string; ally: string },
options: { advanced?: boolean; allied?: boolean; target?: string } = {},
) {
const { moritani, entrant, ally } = ids;
const owner = game.players.find(p => p.id === moritani)!;
const incoming = game.players.find(p => p.id === entrant)!;
const partner = game.players.find(p => p.id === ally)!;
const target = options.target ?? 'arrakeen';
Object.assign(game, {
status: 'playing', phase: 5, turn: 2, storm: 18, active: entrant,
order: [entrant, moritani, ally], deck: baseDeck(),
movementRemaining: [entrant, moritani, ally],
});
for (const p of game.players)
Object.assign(p, { hand: [], spice: 20, forces: {}, reserves: 20, tanks: 0,
shipped: false, moved: 0 });
if (options.allied !== false) {
owner.ally = ally;
partner.ally = moritani;
}
owner.forces = { 'carthag:11': 1 };
owner.reserves = 19;
partner.forces = { 'arrakeen:10': 2 };
partner.reserves = 18;
if (options.advanced) {
incoming.forces = { 'arrakeen:10': 1 };
incoming.reserves = 19;
incoming.elites = {
reserves: 2, tanks: 0, forces: { 'arrakeen:10': 1 }, revived: 0,
};
}
owner.hand = game.deck.splice(0, 4);
if (options.allied !== false) partner.hand = game.deck.splice(0, 4);
game.moritaniTerror = createTerrorState(() => 0.2);
const token = game.moritaniTerror.tokens.find(row => row.kind === 'atomics')!;
game.moritaniTerror = placeTerror(game.moritaniTerror, token.id, target, 1);
return { game, token: token.id };
}
+71
View File
@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MoritaniEntry, MoritaniTerrorSupply } from '../components/moritani-terror';
import { TerrorBoardMarkers } from '../components/terror-board-markers';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import { createTerrorState, placeTerror, revealTerror } from '../game/moritani-terror';
function fixture() {
const game = createGame('ATOMICS-CONTROLS', newPlayer('m', 'Moritani', 'moritani'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('f', 'Fremen', 'fremen'));
game.players.find((player) => player.id === 'm')!.ally = 'a';
game.players.find((player) => player.id === 'a')!.ally = 'm';
game.moritaniTerror = createTerrorState(() => 0);
const token = game.moritaniTerror.tokens.find((item) => item.kind === 'atomics')!;
game.moritaniTerror = placeTerror(game.moritaniTerror, token.id, 'arrakeen', 1);
return { game, token };
}
const supply = (view: GameView) =>
renderToStaticMarkup(createElement(MoritaniTerrorSupply, { game: view }));
const markers = (view: GameView) =>
renderToStaticMarkup(createElement('svg', null, createElement(TerrorBoardMarkers, {
tokens: view.moritaniTerror?.tokens ?? [], atomics: view.moritaniAtomics,
})));
void test('a placed hidden face stays private; only owner sees the Atomics reveal consequences', () => {
const { game } = fixture();
const owner = viewGame(game, 'm');
owner.decision = { kind: 'moritaniTerror', player: 'm', entrant: 'f', territory: 'arrakeen' };
owner.terrorEntry = {
entrant: 'f', territory: 'arrakeen', sector: 10, cause: 'shipment', stage: 'offer',
kind: 'atomics', canReveal: true, canOfferAlliance: false,
};
const offer = renderToStaticMarkup(createElement(MoritaniEntry, { game: owner, busy: false, act() {} }));
assert.match(offer, /Reveal Atomics/);
assert.match(offer, /every faction’s forces/);
assert.match(offer, /including Fremen reinforcements/);
assert.match(offer, /discards one random card per excess/);
const rival = viewGame(game, 'f');
assert.match(supply(rival), /Inspect Hidden Terror/);
assert.doesNotMatch(supply(rival), /Inspect Atomics|Atomics Aftermath/);
assert.match(markers(rival), /hidden Terror token in Arrakeen/);
assert.doesNotMatch(markers(rival), /Atomics Aftermath/);
assert.equal(renderToStaticMarkup(createElement(MoritaniEntry, { game: rival, busy: false, act() {} })), '');
});
void test('after revelation all seats see persistent Aftermath and its exact shipment and hand-limit consequences', () => {
const { game, token } = fixture();
game.moritaniTerror = revealTerror(game.moritaniTerror!, token.id);
game.moritaniAtomics = { territory: 'arrakeen', turn: 2, moritaniId: 'm',
allyAtActivation: 'a', alliancePolicy: 'unresolved' };
game.turn = 2;
game.players.find((player) => player.id === 'm')!.atomicsHandLimitPenalty = true;
game.players.find((player) => player.id === 'a')!.atomicsHandLimitPenalty = true;
for (const id of ['m', 'a', 'f']) {
const view = viewGame(game, id);
const notice = supply(view);
assert.match(notice, /Atomics Aftermath · Arrakeen/);
assert.match(notice, /From turn 2, no faction may ship forces/);
assert.match(notice, /including Fremen reinforcements/);
assert.match(notice, /ordinary movement and Sneak Attack remain possible/);
assert.match(notice, /Moritani and Atreides/);
assert.match(notice, /lasting hand-limit reduction of one/);
const board = markers(view);
assert.match(board, /Atomics Aftermath in Arrakeen: permanent shipment ban/);
assert.doesNotMatch(board, /hidden Terror token in Arrakeen/);
}
});
+198
View File
@@ -0,0 +1,198 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction, createGame, newPlayer, normalizeAutomaticGame, viewGame,
type Action, type Game,
} from '../game/engine';
import { placeTerror } from '../game/moritani-terror';
import { botArrivalBlock } from '../game/bot-arrival';
import { botActions } from '../game/bots';
import { atomicsFixtureGame } from './fixture-moritani-atomics';
const seat = (g: Game, id: string) => g.players.find(p => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const ship: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 };
const move: Action = { type: 'move', from: 'imperial_basin:10', territory: 'arrakeen', sector: 10, amount: 2 };
function rejected(g: Game, id: string, action: Action, pattern?: RegExp) {
const before = structuredClone(g);
if (pattern) assert.throws(() => applyAction(g, id, action), pattern);
else assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
function fixture(advanced = false, ally = true, target = 'arrakeen') {
const g = createGame('ATOMIC01', newPlayer('m', 'Moritani', 'moritani'), advanced);
g.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides'));
const { token } = atomicsFixtureGame(g,
{ moritani: 'm', entrant: 'e', ally: 'a' },
{ advanced, allied: ally, target });
return { g, token };
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} Atomics destroys exact groups, discards excess at random and persists Aftermath`, () => {
const { g, token } = fixture(advanced);
const before = structuredClone(g);
const mCards = seat(g, 'm').hand.map(card => card.id);
const aCards = seat(g, 'a').hand.map(card => card.id);
let state = applyAction(g, 'e', ship);
assert.equal(state.pendingTerrorEntry?.stage, 'offer');
const owner = viewGame(state, 'm');
assert.equal(owner.terrorEntry?.kind, 'atomics');
assert.equal(owner.terrorEntry?.canReveal, true);
assert.equal('kind' in (viewGame(state, 'e').terrorEntry ?? {}), false);
assert.equal(viewGame(state, 'e').moritaniAtomics, null);
state = applyAction(reload(state), 'm', { type: 'decision', reveal: true });
state = normalizeAutomaticGame(reload(state));
assert.equal(state.moritaniAtomics?.territory, 'arrakeen');
assert.equal(state.moritaniAtomics?.allyAtActivation, 'a');
assert.equal(state.moritaniTerror!.tokens.find(row => row.id === token)!.status, 'removed');
assert.equal(state.pendingTerrorEntry ?? null, null);
assert.equal(state.pendingTreacheryDiscard ?? null, null);
assert.equal(seat(state, 'e').forces['arrakeen:10'] ?? 0, 0);
assert.equal(seat(state, 'm').forces['arrakeen:10'] ?? 0, 0);
assert.equal(seat(state, 'a').forces['arrakeen:10'] ?? 0, 0);
assert.equal(seat(state, 'm').forces['carthag:11'], 1);
assert.equal(seat(state, 'e').tanks, advanced ? 3 : 2);
assert.equal(seat(state, 'e').elites?.tanks ?? 0, advanced ? 1 : 0);
assert.equal(seat(state, 'm').tanks, 0);
assert.equal(seat(state, 'a').tanks, 2);
for (const id of ['m', 'a']) {
assert.equal(seat(state, id).hand.length, 3);
assert.equal(viewGame(state, id).players.find(p => p.id === id)!.handLimit, 3);
assert.equal(seat(state, id).atomicsHandLimitPenalty, true);
}
assert.equal(state.discard.filter(card => mCards.includes(card.id)).length, 1);
assert.equal(state.discard.filter(card => aCards.includes(card.id)).length, 1);
assert.equal(before.moritaniAtomics ?? null, null);
assert.equal(seat(before, 'e').reserves, advanced ? 19 : 20);
for (const id of ['m', 'e', 'a'])
assert.equal(viewGame(state, id).moritaniAtomics?.territory, 'arrakeen');
rejected(state, 'm', { type: 'decision', reveal: true });
});
}
void test('Aftermath blocks every ordinary shipment, including Fremen reinforcement, but not movement', () => {
const { g } = fixture(false, false);
let state = applyAction(g, 'e', ship);
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
assert.equal(viewGame(state, 'e').moritaniAtomics?.territory, 'arrakeen');
state.active = 'a';
seat(state, 'a').spice = 20;
rejected(state, 'a', ship, /Atomics Aftermath/);
assert.match(String(botArrivalBlock(viewGame(state, 'a'), ship)), /Atomics Aftermath/);
seat(state, 'a').forces = { 'imperial_basin:10': 2 };
seat(state, 'a').reserves = 18;
seat(state, 'a').shipped = true;
const moved = applyAction(reload(state), 'a', move);
assert.equal(seat(moved, 'a').forces['arrakeen:10'], 2);
assert.equal(moved.moritaniAtomics?.territory, 'arrakeen');
});
void test('unallied Atomics destroys Moritani forces but reduces only its own hand', () => {
const { g } = fixture(false, false);
seat(g, 'a').forces = {};
seat(g, 'a').reserves = 20;
seat(g, 'm').forces = { 'arrakeen:10': 1, 'carthag:11': 1 };
seat(g, 'm').reserves = 18;
let state = applyAction(g, 'e', ship);
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
assert.equal(state.moritaniAtomics?.allyAtActivation, null);
assert.equal(seat(state, 'm').tanks, 1);
assert.equal(seat(state, 'm').forces['carthag:11'], 1);
assert.equal(seat(state, 'm').hand.length, 3);
assert.equal(seat(state, 'a').atomicsHandLimitPenalty, undefined);
assert.equal(viewGame(state, 'a').players.find(p => p.id === 'a')!.handLimit, 4);
});
void test('Harkonnen ally loses one of its eight hand slots and one random physical card', () => {
const g = createGame('ATOMHARK', newPlayer('m', 'Moritani', 'moritani'));
g.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Harkonnen', 'harkonnen'));
atomicsFixtureGame(g, { moritani: 'm', entrant: 'e', ally: 'a' });
seat(g, 'a').hand.push(...g.deck.splice(0, 4));
const before = seat(g, 'a').hand.map(card => card.id);
let state = applyAction(g, 'e', ship);
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
assert.equal(seat(state, 'a').hand.length, 7);
assert.equal(viewGame(state, 'a').players.find(p => p.id === 'a')!.handLimit, 7);
assert.equal(state.discard.filter(card => before.includes(card.id)).length, 1);
});
void test('Fremen free reinforcement into Aftermath is rejected without spending its shipment', () => {
const { g } = fixture(false, false, 'sietch_tabr');
g.players.push(newPlayer('f', 'Fremen', 'fremen'));
g.order.push('f');
g.movementRemaining!.push('f');
const arrival: Action = { type: 'ship', territory: 'sietch_tabr', sector: 14, amount: 1 };
let state = applyAction(g, 'e', arrival);
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
state.active = 'f';
seat(state, 'f').spice = 20;
rejected(state, 'f', arrival, /Atomics Aftermath/);
assert.match(String(botArrivalBlock(viewGame(state, 'f'), arrival)), /Atomics Aftermath/);
assert.equal(seat(state, 'f').shipped, false);
});
void test('Sneak Attack remains a free reserve entry into an Atomics Aftermath stronghold', () => {
const { g } = fixture(false, false);
let state = applyAction(g, 'e', ship);
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
const token = state.moritaniTerror!.tokens.find(row => row.kind === 'sneakAttack')!;
state.moritaniTerror = placeTerror(state.moritaniTerror!, token.id, 'arrakeen', state.turn);
seat(state, 'e').forces = { 'imperial_basin:10': 1 };
seat(state, 'e').reserves--;
seat(state, 'e').moved = 0;
state.active = 'e';
const entered = applyAction(state, 'e', {
type: 'move', from: 'imperial_basin:10', amount: 1,
territory: 'arrakeen', sector: 10,
});
assert.equal(entered.pendingTerrorEntry?.stage, 'offer');
const revealed = applyAction(entered, 'm', { type: 'decision', reveal: true });
assert.equal(revealed.pendingTerrorEntry?.stage, 'sneakAttack');
const done = applyAction(revealed, 'm', { type: 'decision', amount: 1 });
assert.equal(seat(done, 'm').forces['arrakeen:10'], 1);
assert.equal(done.moritaniAtomics?.territory, 'arrakeen');
});
void test('later Moritani alliance change and forged hand penalties reject atomically', () => {
const { g } = fixture(false, true);
let state = applyAction(g, 'e', ship);
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
state.phase = 1;
state.nexus = true;
state.spiceWindow = null;
state.spiceResolution = null;
rejected(state, 'm', { type: 'alliance', target: null }, /Atomics/);
for (const mutate of [
(game: Game) => { delete seat(game, 'a').atomicsHandLimitPenalty; },
(game: Game) => { game.moritaniAtomics = { ...game.moritaniAtomics!, allyAtActivation: 'e' }; },
(game: Game) => { game.moritaniTerror!.tokens.find(row => row.kind === 'atomics')!.status = 'placed'; },
]) {
const damaged = reload(state);
mutate(damaged);
rejected(damaged, 'e', { type: 'ready' });
}
});
void test('a later Nexus filters impossible Moritani offers before bots can stall', () => {
const { g } = fixture(false, false);
let state = applyAction(g, 'e', ship);
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
state.phase = 1;
state.nexus = true;
state.spiceWindow = null;
state.spiceResolution = null;
state.ready = [];
state.allianceOffers = { m: 'e' };
for (const p of state.players) state.deck.push(...p.hand.splice(0));
seat(state, 'm').bot = 'Medium';
seat(state, 'e').bot = 'Medium';
rejected(state, 'e', { type: 'alliance', target: 'm' }, /Atomics/);
rejected(state, 'm', { type: 'alliance', target: 'e' }, /Atomics/);
assert.deepEqual(botActions(viewGame(state, 'm'))[0], { type: 'ready' });
assert.equal(botActions(viewGame(state, 'e')).some(action =>
action.type === 'alliance' && action.target === 'm'), false);
const withdrawn = applyAction(state, 'm', { type: 'alliance' });
assert.equal(withdrawn.allianceOffers.m, undefined);
assert.equal(withdrawn.players.find(p => p.id === 'm')!.ally, null);
});
+82
View File
@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { RoomsClock } from '../db/rooms';
import { viewGame, type Action } from '../game/engine';
import { atomicsFixtureGame } from './fixture-moritani-atomics';
import { unitStore } from './fixture-nexus-room-store';
const clock: RoomsClock = { now: () => 45000, sleep: async () => {} };
const shipment: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 };
void test('Atomics persists one reveal, random overflow, custody and public Aftermath across concurrent SQL workers', async t => {
const store = unitStore();
t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom('Atomics SQL', 'moritani', false, []);
const code = made.view.code;
const entrant = await store.rooms.joinRoom(code, 'Emperor', 'emperor');
const ally = await store.rooms.joinRoom(code, 'Atreides', 'atreides');
const tokens = [made.token, entrant.token!, ally.token!];
const auth = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const [m, e, a] = auth.map(seat => seat.playerId);
const g = await store.rooms.readRoom(code);
const { token } = atomicsFixtureGame(g, { moritani: m, entrant: e, ally: a });
const originalM = g.players.find(p => p.id === m)!.hand.map(card => card.id);
const originalA = g.players.find(p => p.id === a)!.hand.map(card => card.id);
g.code = code;
g.version = (await store.rooms.readRoom(code)).version;
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
store.writes.length = 0;
const rooms = store.restart();
const initialM = await rooms.readSeatView(code, auth[0]);
const initialE = await rooms.readSeatView(code, auth[1]);
assert.equal(initialM.moritaniAtomics, null);
assert.equal(initialE.moritaniAtomics, null);
assert.equal('kind' in initialE.moritaniTerror!.tokens.find(row => row.id === token)!, false);
await store.restart().act(code, auth[1], g.version, shipment, clock);
const offered = await store.restart().readRoom(code);
assert.equal(offered.pendingTerrorEntry?.stage, 'offer');
assert.equal(viewGame(offered, m).terrorEntry?.kind, 'atomics');
assert.equal('kind' in (viewGame(offered, e).terrorEntry ?? {}), false);
const reveal: Action = { type: 'decision', reveal: true };
const outcomes = await Promise.allSettled([
store.restart().act(code, auth[0], offered.version, reveal, clock),
store.restart().act(code, auth[0], offered.version, reveal, clock),
]);
assert.equal(outcomes.filter(result => result.status === 'fulfilled').length, 1);
assert.equal(outcomes.filter(result => result.status === 'rejected').length, 1);
const saved = await store.restart().readRoom(code);
assert.equal(saved.moritaniAtomics?.territory, 'arrakeen');
assert.equal(saved.moritaniAtomics?.allyAtActivation, a);
assert.equal(saved.moritaniTerror!.tokens.find(row => row.id === token)!.status, 'removed');
assert.equal(saved.pendingTerrorEntry, null);
assert.equal(saved.pendingTreacheryDiscard ?? null, null);
assert.equal(saved.players.find(p => p.id === e)!.tanks, 2);
assert.equal(saved.players.find(p => p.id === a)!.tanks, 2);
assert.equal(saved.players.find(p => p.id === m)!.hand.length, 3);
assert.equal(saved.players.find(p => p.id === a)!.hand.length, 3);
assert.equal(saved.discard.filter(card => originalM.includes(card.id)).length, 1);
assert.equal(saved.discard.filter(card => originalA.includes(card.id)).length, 1);
for (let i = 0; i < tokens.length; i++) {
const authFresh = await store.restart().authenticate(code, tokens[i]);
const view = await store.restart().readSeatView(code, authFresh);
assert.deepEqual(view, viewGame(saved, authFresh.playerId));
assert.equal(view.moritaniAtomics?.territory, 'arrakeen');
assert.equal(view.players.find(p => p.id === m)!.handLimit, 3);
assert.equal(view.players.find(p => p.id === a)!.handLimit, 3);
}
const beforeReplay = store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code);
await assert.rejects(store.restart().act(code, auth[0], saved.version, reveal, clock));
assert.deepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), beforeReplay);
let later = saved;
for (const index of [1, 0]) {
await store.restart().act(code, auth[index], later.version, { type: 'endMovement' }, clock);
later = await store.restart().readRoom(code);
}
assert.equal(later.active, a);
const row = store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code);
await assert.rejects(store.restart().act(code, auth[2], later.version, shipment, clock), /Atomics Aftermath/);
await store.restart().continueRoomAutomatic(code, clock);
assert.deepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), row);
});
+125
View File
@@ -0,0 +1,125 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
atomicsAllianceStatus,
atomicsEffectiveHandLimit,
atomicsShipmentBlocked,
quoteMoritaniAtomics,
type AtomicsPlayer,
type AtomicsAftermath,
} from '../game/moritani-atomics';
import type { TerrorToken } from '../game/moritani-terror';
const token: TerrorToken = {
id: 'terror-opaque', kind: 'atomics', status: 'placed', location: 'arrakeen',
};
const moritani: AtomicsPlayer = {
id: 'm', faction: 'moritani', ally: null, forces: {}, tanks: 0,
handSize: 4, baseHandLimit: 4,
};
const quote = (players: readonly AtomicsPlayer[], aftermath: AtomicsAftermath | null = null) =>
quoteMoritaniAtomics({ token, territory: 'arrakeen', players,
moritaniId: 'm', turn: 3, aftermath });
void test('no ally at activation: all owners lose typed forces in the territory, not elsewhere', () => {
const players: AtomicsPlayer[] = [
{ ...moritani, forces: { 'arrakeen:10': 2, 'carthag:11': 3 } },
{ id: 'ix', faction: 'ixians', ally: null, forces: {
'arrakeen:10': 4, 'carthag:11': 2,
}, elites: { reserves: 0, tanks: 0, revived: 0,
forces: { 'arrakeen:10': 2 } }, tanks: 1,
handSize: 5, baseHandLimit: 4 },
{ id: 'bg', faction: 'beneGesserit', ally: null,
forces: { 'arrakeen:10': 1, 'carthag:11': 2 },
advisors: { arrakeen: {} }, tanks: 0, handSize: 4, baseHandLimit: 4 },
{ id: 'zero', faction: 'fremen', ally: null,
forces: { 'arrakeen:10': 0 }, tanks: 0,
handSize: 0, baseHandLimit: 4 },
];
const before = structuredClone(players);
const plan = quote(players);
assert.deepEqual(plan.casualties, [
{ playerId: 'm', location: 'arrakeen:10', normal: 2, elite: 0, advisor: 0 },
{ playerId: 'ix', location: 'arrakeen:10', normal: 2, elite: 2, advisor: 0 },
{ playerId: 'bg', location: 'arrakeen:10', normal: 1, elite: 0, advisor: 1 },
]);
assert.deepEqual(plan.handReductions,
[{ playerId: 'm', limit: 3, randomDiscards: 1 }]);
assert.deepEqual(plan.aftermath, { territory: 'arrakeen', turn: 3,
moritaniId: 'm', allyAtActivation: null, alliancePolicy: 'unresolved' });
assert.deepEqual(players, before);
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'ix', null, 4), 4);
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'm', null, 4), 3);
assert.equal(atomicsAllianceStatus(plan.aftermath, null), 'activation-alliance');
assert.equal(atomicsAllianceStatus(plan.aftermath, 'newAlly'), 'clarification-required');
assert.throws(() => atomicsEffectiveHandLimit(plan.aftermath, 'm', 'newAlly', 4),
/alliance change/);
});
void test('activation ally is reduced with Moritani; counts exceed-one overflow without selecting cards', () => {
const plan = quote([
{ ...moritani, ally: 'choam', handSize: 6, baseHandLimit: 4 },
{ id: 'choam', faction: 'choam', ally: 'm', tanks: 0,
forces: { 'arrakeen:10': 1 }, handSize: 6, baseHandLimit: 5 },
{ id: 'other', faction: 'harkonnen', ally: null, tanks: 0,
forces: {}, handSize: 9, baseHandLimit: 8 },
]);
assert.deepEqual(plan.handReductions, [
{ playerId: 'm', limit: 3, randomDiscards: 3 },
{ playerId: 'choam', limit: 4, randomDiscards: 2 },
]);
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'choam', 'choam', 5), 4);
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'other', 'choam', 8), 8);
for (const next of [null, 'other']) {
assert.equal(atomicsAllianceStatus(plan.aftermath, next), 'clarification-required');
assert.throws(() => atomicsEffectiveHandLimit(plan.aftermath, 'other', next, 8),
/alliance change/);
}
});
void test('Aftermath blocks shipment by territory regardless of faction or sector, not other territories', () => {
const aftermath = quote([moritani]).aftermath;
assert.equal(atomicsShipmentBlocked(aftermath, 'arrakeen'), true);
assert.equal(atomicsShipmentBlocked(aftermath, 'carthag'), false);
assert.equal(atomicsShipmentBlocked(null, 'arrakeen'), false);
assert.equal(atomicsShipmentBlocked(aftermath, 'arrakeen:10'), false);
});
void test('placement, existing Aftermath, alliance inconsistency and malformed physical custody fail closed', () => {
const aftermath = quote([moritani]).aftermath;
assert.throws(() => quote([moritani], aftermath), /already on the board/);
for (const wrong of [
{ ...token, status: 'removed', location: null } as TerrorToken,
{ ...token, kind: 'robbery' } as TerrorToken,
{ ...token, location: 'hidden_mobile_stronghold' } as TerrorToken,
{ ...token, location: 'carthag' } as TerrorToken,
]) assert.throws(() => quoteMoritaniAtomics({ token: wrong,
territory: 'arrakeen', moritaniId: 'm', turn: 3,
aftermath: null, players: [moritani] }),
/still-placed token/);
assert.throws(() => quoteMoritaniAtomics({ token, territory: 'carthag',
moritaniId: 'm', turn: 3, aftermath: null, players: [moritani] }),
/entered ordinary stronghold/);
assert.throws(() => quote([{ ...moritani, ally: 'missing' }]), /mutual ally/);
assert.throws(() => quote([{ ...moritani, forces: { 'arrakeen:11': 1 } }]),
/valid typed forces/);
assert.throws(() => quote([{ ...moritani, forces: { 'arrakeen:10': 1 },
elites: { reserves: 0, tanks: 0, revived: 0,
forces: { 'arrakeen:10': 2 } } }]), /valid typed forces/);
assert.throws(() => quote([{ ...moritani, noField: {
tokens: [{ id: 'zero', value: 0 }], lastShipped: null,
deployed: { tokenId: 'zero', controller: 'm',
location: { territory: 'arrakeen', sector: 10 } },
} }]), /concealed No-Field/);
});
void test('a token at a storm-exposed stronghold still quotes all occupied counters', () => {
const stormExposed = quoteMoritaniAtomics({
token: { ...token, location: 'tueks_sietch' }, territory: 'tueks_sietch', players: [
{ ...moritani, forces: { 'tueks_sietch:5': 1, 'arrakeen:10': 1 } },
], moritaniId: 'm', turn: 4, aftermath: null,
});
assert.deepEqual(stormExposed.casualties,
[{ playerId: 'm', location: 'tueks_sietch:5', normal: 1, elite: 0, advisor: 0 }]);
assert.equal(stormExposed.aftermath.territory, 'tueks_sietch');
});