Implement Moritani Nexus placement and preserve later Terror custody

This commit is contained in:
Quintine committed 2026-09-10 12:39:23 +10:00
1 parent 290259bac2
commit ae66668e2e
27 files changed
+2208 -73

No files matched your search

+274
View File
@@ -0,0 +1,274 @@
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeNexusGameForAudit,
joinGame,
newPlayer,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { botActions } from '../game/bots';
import { drawNexusCard } from '../game/nexus-cards';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { TERROR_KINDS, type TerrorKind } from '../game/moritani-terror';
import { TERRITORIES } from '../game/board';
import { nexusTraitorInventory } from './fixture-nexus-traitors';
import {
enterNexusSpice,
finishNexusSpice,
orderNexusSpice,
nexusAllow,
nexusReload,
} from './fixture-nexus-cards';
export type NexusMoritaniFixture = {
g: Game;
owner: string;
target: string;
observer: string;
karama?: string;
};
/** Final faction/seat/module choices precede genuine initialization. The later
* conserved position is staged before actual Collection -> Mentat actions. */
export function nexusMoritaniFixture(
options: {
seatIds?: [string, string, string];
advanced?: boolean;
homeworlds?: boolean;
native?: number;
karama?: boolean;
stack?: boolean;
} = {},
): NexusMoritaniFixture {
const [owner, target, observer] = options.seatIds ?? ['p', 'q', 'r'];
let g = createGame(
'MORITANINEXUS',
newPlayer(owner, 'Moritani', 'moritani'),
options.advanced ?? false,
[],
);
joinGame(g, newPlayer(target, 'Guild', 'guild'));
joinGame(g, newPlayer(observer, 'Fremen', 'fremen'));
if (options.homeworlds)
g = applyAction(g, owner, { type: 'homeworlds', enabled: true });
g.nexusCards = { cards: null, phase: null };
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeNexusGameForAudit(g);
for (let i = 0; g.status === 'setup' && i < 80; i++) {
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 a = botActions(v)[0];
if (a) {
next = applyAction(g, p.id, a);
break;
}
}
assert.ok(next, `Setup stalled at ${g.setupStage}/${g.decision?.kind}`);
g = next;
}
assert.equal(g.status, 'playing');
for (const p of g.players) g.deck.push(...p.hand.splice(0));
g = enterNexusSpice(g);
orderNexusSpice(g, ['land', 'land']);
g = finishNexusSpice(g);
for (const p of g.players) {
Object.assign(p, {
forces: {},
reserves: 20,
tanks: 0,
spice: 20,
shipped: true,
moved: 0,
});
if (p.elites)
Object.assign(p.elites, { forces: {}, reserves: 3, tanks: 0 });
}
// Complete the first genuine Mentat opportunity before staging turn two.
// Stack fixtures actually place Sabotage at that first opportunity.
Object.assign(g, {
phase: 5,
active: target,
movementRemaining: [target],
order: [owner, target, observer],
ready: [],
response: null,
decision: null,
phaseOpening: null,
spice: {},
storm: 18,
});
g = applyAction(g, target, { type: 'endMovement' });
if (g.decision?.kind === 'grummanCollection')
g = applyAction(g, owner, {
type: 'decision',
event: g.grummanCollection!.event,
decline: true,
});
for (const id of g.order) g = applyAction(g, id, { type: 'ready' });
assert.equal(g.decision?.kind, 'moritaniPlacement');
g = options.stack
? nexusAllow(
applyAction(g, owner, {
type: 'decision',
token: nexusMoritaniToken(g, 'sabotage').id,
territory: 'arrakeen',
}),
)
: applyAction(g, owner, { type: 'decision', decline: true });
let nextSpiceOrdered = false;
for (let step = 0; g.phase !== 2 && step < 80; step++) {
if (g.phase === 1 && !nextSpiceOrdered) {
orderNexusSpice(g, ['land', 'land']);
nextSpiceOrdered = true;
}
g = automaticStep(g);
}
assert.equal(g.turn, 2);
assert.equal(g.phase, 2);
for (const p of g.players) {
g.deck.push(...p.hand.splice(0));
Object.assign(p, {
forces: {},
reserves: 20,
tanks: 0,
spice: 20,
shipped: true,
moved: 0,
});
if (p.elites)
Object.assign(p.elites, { forces: {}, reserves: 3, tanks: 0 });
}
const native = options.native ?? 8;
g.players[0].reserves = native;
g.players[0].forces = { 'polar_sink:0': 20 - native };
const cards = g.nexusCards!.cards!;
cards.deck = [
'moritani',
...cards.deck.filter((card) => card !== 'moritani'),
];
g.nexusCards!.cards = drawNexusCard(cards, owner, g.players, () => 0);
const karama =
options.karama === false
? undefined
: holdNexusMoritaniCard(g, target, 'karama').id;
Object.assign(g, {
turn: 2,
phase: 5,
active: target,
movementRemaining: [target],
order: [owner, target, observer],
ready: [],
response: null,
decision: null,
phaseOpening: null,
spice: {},
storm: 18,
});
g = applyAction(g, target, { type: 'endMovement' });
assert.equal(g.phase, 7);
if (g.decision?.kind === 'grummanCollection')
g = applyAction(g, owner, {
type: 'decision',
event: g.grummanCollection!.event,
decline: true,
});
for (const id of g.order) g = applyAction(g, id, { type: 'ready' });
assert.equal(g.phase, 8);
assert.equal(g.decision?.kind, 'moritaniPlacement');
nexusMoritaniInventory(g);
return { g, owner, target, observer, karama };
}
export function holdNexusMoritaniCard(g: Game, owner: string, kind: string) {
const i = g.deck.findIndex((c) => c.kind === kind || c.effect === kind);
assert.ok(i >= 0);
const card = g.deck.splice(i, 1)[0];
g.players.find((p) => p.id === owner)!.hand.push(card);
return card;
}
function automaticStep(g: Game): Game {
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) return applyAction(g, p.id, action);
}
throw new Error(
`Phase progression stalled at ${g.phase}/${g.decision?.kind}/${g.response?.kind}`,
);
}
export const nexusMoritaniToken = (g: Game, kind: TerrorKind) =>
g.moritaniTerror!.tokens.find((token) => token.kind === kind)!;
export function nexusMoritaniRequest(
f: NexusMoritaniFixture,
kind: TerrorKind = 'robbery',
territory = 'red_chasm',
g = f.g,
): Action {
return {
type: 'decision',
token: nexusMoritaniToken(g, kind).id,
territory,
nexus: viewGame(g, f.owner).nexusMoritani!.event,
};
}
export function nexusMoritaniInventory(g: Game) {
nexusTraitorInventory(g);
if (g.homeworlds) homeworldGameIntegrity(g);
assert.deepEqual(
g.moritaniTerror!.tokens.map((t) => t.kind).sort(),
[...TERROR_KINDS].sort(),
);
assert.equal(new Set(g.moritaniTerror!.tokens.map((t) => t.id)).size, 6);
}
/** Advance actual phase boundaries with legal bot choices before shipping;
* signed placement and Nexus phase history are retained unchanged. */
export function nexusMoritaniMovement(state: Game): Game {
let g = nexusReload(state);
const target = g.players.find((p) => p.faction === 'guild')!;
// Finish actual Mentat, Storm, Spice Blow, Charity, Bidding and Revival.
// In particular the Nexus phase receipt is created by the real next Storm.
for (let step = 0; g.phase !== 5 && step < 160; step++) {
let next: Game | undefined;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) {
next = applyAction(g, p.id, action);
break;
}
}
assert.ok(
next,
`Next-turn progression stalled at ${g.phase}/${g.decision?.kind}/${g.response?.kind}`,
);
g = next;
}
assert.equal(g.phase, 5);
for (let step = 0; g.active !== target.id && step < g.players.length; step++)
g = applyAction(g, g.active!, { type: 'endMovement' });
assert.equal(g.active, target.id);
assert.equal(g.players.find((p) => p.id === target.id)!.shipped, false);
nexusMoritaniInventory(g);
return g;
}
export function nexusMoritaniArrival(
state: Game,
territory: string,
amount = 3,
): Game {
let g = nexusMoritaniMovement(state);
const target = g.players.find((p) => p.faction === 'guild')!;
const sector = TERRITORIES.find((t) => t.id === territory)!.sectors.find(
(s) => s !== g.storm,
)!;
g = applyAction(g, target.id, { type: 'ship', territory, sector, amount });
nexusMoritaniInventory(g);
return g;
}
export const nexusMoritaniAllow = nexusAllow;
export const nexusMoritaniReload = nexusReload;
+11 -10
View File
@@ -1,3 +1,4 @@
import { nexusMoritaniFixture, nexusMoritaniRequest, nexusMoritaniAllow, nexusMoritaniMovement } from './fixture-nexus-moritani';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
@@ -258,22 +259,22 @@ void test('normal movement triggers after moving forces once, while an internal
const done = decide(reload(entered), { decline: true });
assert.deepEqual(done.players, entered.players);
rejected(done, 'e', action);
// Ordinary strongholds each have one sector. This injected historical location exercises
// the general same-territory guard independently of present placement restrictions.
const internal = fixture();
internal.moritaniTerror!.tokens.find(
(token) => token.status === 'placed',
)!.location = 'imperial_basin';
player(internal, 'e').forces = { 'imperial_basin:10': 3 };
player(internal, 'e').reserves = 17;
const shifted = applyAction(internal, 'e', {
// A real Cunning supply placement earns the otherwise unavailable desert
// location. Moving between its sectors is still not a new territory entry.
const f = nexusMoritaniFixture();
const placed = nexusMoritaniAllow(applyAction(f.g,f.owner,
nexusMoritaniRequest(f,'robbery','imperial_basin')));
const internal = nexusMoritaniMovement(placed);
internal.storm = 18;
Object.assign(player(internal,f.target), {forces:{'imperial_basin:10':3},reserves:17,shipped:true,moved:0});
const shifted = applyAction(internal, f.target, {
...action,
territory: 'imperial_basin',
sector: 11,
});
assert.equal(shifted.pendingTerrorEntry ?? null, null);
assert.equal(shifted.decision, null);
assert.equal(player(shifted, 'e').forces['imperial_basin:11'], 2);
assert.equal(player(shifted, f.target).forces['imperial_basin:11'], 2);
});
void test('overlapping Guild income and Bene Gesserit arrival reactions reject the entire shipment atomically', () => {
+1 -1
View File
@@ -123,7 +123,7 @@ void test('the owned face highlights its mode from the public roster and all thr
void test('controls expose public counts and waiting players, disable while busy, and offer no effect play action', () => {
const view = fixture();
const html = markup(view, true);
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor and CHOAM Cunning/);
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM and Moritani Cunning/);
assert.match(html, /other unfinished effects remain unavailable/);
assert.doesNotMatch(html, /other card effects are not playable yet/);
assert.match(html, /10 in deck · 0 discarded · 2 held/);
+213
View File
@@ -0,0 +1,213 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import {
nexusMoritaniFixture,
nexusMoritaniToken,
nexusMoritaniRequest,
nexusMoritaniAllow,
nexusMoritaniReload,
nexusMoritaniArrival,
holdNexusMoritaniCard,
} from './fixture-nexus-moritani';
function rejectCorruption(g: Game) {
const before = nexusMoritaniReload(g);
for (const p of g.players) assert.throws(() => viewGame(g, p.id));
assert.throws(() => normalizeAutomaticGame(g));
assert.throws(() => applyAction(g, g.players[0].id, { type: 'advanceBots' }));
assert.deepEqual(g, before);
}
function placed(kind: 'robbery' | 'sabotage' = 'robbery') {
const f = nexusMoritaniFixture();
holdNexusMoritaniCard(f.g, f.target, 'shield');
const g = nexusMoritaniAllow(
applyAction(f.g, f.owner, nexusMoritaniRequest(f, kind)),
);
return { f, g };
}
void test('completed placement binds its physical face, and ordinary later relocation retires its live Cunning location', () => {
const { f, g: initial } = placed();
const swapped = nexusMoritaniReload(initial);
const first = nexusMoritaniToken(swapped, 'robbery'),
other = nexusMoritaniToken(swapped, 'sabotage');
[first.kind, other.kind] = [other.kind, first.kind];
rejectCorruption(swapped);
let g = nexusMoritaniArrival(initial, 'red_chasm');
g = applyAction(g, f.owner, { type: 'decision', decline: true });
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 id = nexusMoritaniToken(g, 'robbery').id;
g = nexusMoritaniAllow(
applyAction(g, f.owner, {
type: 'decision',
token: id,
territory: 'carthag',
}),
);
assert.equal(nexusMoritaniToken(g, 'robbery').location, 'carthag');
assert.equal(g.nexusMoritaniLocations?.[id], undefined);
for (const p of g.players) assert.doesNotThrow(() => viewGame(g, p.id));
const stale = nexusMoritaniReload(g);
nexusMoritaniToken(stale, 'robbery').location = 'red_chasm';
rejectCorruption(stale);
});
void test('a genuine Cunning arrival cannot downgrade to legacy by deleting only its original arrival signature', () => {
const { g } = placed();
const arrived = nexusMoritaniArrival(g, 'red_chasm');
assert.ok(arrived.pendingTerrorEntry?.nexusPlacements);
assert.equal(arrived.pendingTerrorEntry.candidates, undefined);
for (const mode of ['signature', 'count', 'both'] as const) {
const bad = nexusMoritaniReload(arrived);
delete bad.pendingTerrorEntry!.entrySignature;
if (mode === 'count') bad.pendingTerrorEntry!.amount = 100;
if (mode === 'both') delete bad.pendingTerrorEntry!.nexusPlacements;
rejectCorruption(bad);
}
});
void test('Truthtrance preserves the original pending Cunning placement and its cancellation response', () => {
const f = nexusMoritaniFixture();
const truth = holdNexusMoritaniCard(f.g, f.observer, 'truthtrance');
let g = applyAction(f.g, f.owner, nexusMoritaniRequest(f));
const response = structuredClone(g.response),
history = structuredClone(g.nexusMoritaniHistory);
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(nexusMoritaniReload(g), f.owner, {
type: 'truthAnswer',
answer: 'no',
});
assert.deepEqual(g.response, response);
assert.deepEqual(g.nexusMoritaniHistory, history);
g = nexusMoritaniAllow(g);
assert.equal(nexusMoritaniToken(g, 'robbery').location, 'red_chasm');
assert.equal(g.discard.filter((c) => c.id === truth.id).length, 1);
});
void test('a live Cunning placement rejects missing parent response, pending marker or changed original supply', () => {
const f = nexusMoritaniFixture();
const declared = applyAction(f.g, f.owner, nexusMoritaniRequest(f));
for (const edit of [
(g: Game) => {
g.response = null;
},
(g: Game) => {
delete g.pendingMoritaniPlacement!.nexusEvent;
},
(g: Game) => {
delete g.nexusMoritaniLast;
},
(g: Game) => {
delete g.nexusMoritaniHistory;
},
(g: Game) => {
nexusMoritaniToken(g, 'sabotage').status = 'removed';
},
]) {
const bad = nexusMoritaniReload(declared);
edit(bad);
rejectCorruption(bad);
}
});
void test('a genuine non-stronghold Sabotage discard retains its original placement proof after revelation and restores exactly once', () => {
const { f, g } = placed('sabotage');
const arrived = nexusMoritaniArrival(g, 'red_chasm');
const originalHandCount = arrived.players.find((p) => p.id === f.target)!.hand
.length;
const observed: {
applyActionInner?: (g: Game, id: string, action: Action) => Game;
} = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
'\nexport { applyActionInner };\n',
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports: observed,
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
crypto,
structuredClone,
TextEncoder,
JSON,
},
);
const pending = nexusMoritaniReload(
observed.applyActionInner!(arrived, f.owner, {
type: 'decision',
reveal: true,
}),
);
const continuation = pending.pendingTreacheryDiscard!.continuation;
assert.equal(continuation.kind, 'terrorDiscard');
if (continuation.kind !== 'terrorDiscard')
throw new Error('Missing genuine Sabotage discard');
assert.ok(continuation.entry.nexusPlacements);
assert.equal(nexusMoritaniToken(pending, 'sabotage').status, 'removed');
for (const p of pending.players)
assert.doesNotThrow(() => viewGame(pending, p.id));
const finished = normalizeAutomaticGame(nexusMoritaniReload(pending));
assert.equal(finished.pendingTreacheryDiscard, null);
assert.deepEqual(finished.discard, pending.discard);
assert.equal(finished.discard.length, 1);
assert.equal(
finished.players.find((p) => p.id === f.target)!.hand.length,
originalHandCount - 1,
);
assert.deepEqual(
normalizeAutomaticGame(nexusMoritaniReload(finished)),
nexusMoritaniReload(finished),
);
for (const edit of [
(bad: Game) => {
delete bad.nexusMoritaniHistory;
},
(bad: Game) => {
const child = bad.pendingTreacheryDiscard!.continuation;
if (child.kind === 'terrorDiscard') delete child.entry.nexusPlacements;
},
(bad: Game) => {
const child = bad.pendingTreacheryDiscard!.continuation;
if (child.kind === 'terrorDiscard')
child.entry.nexusPlacements![child.entry.token] = 'different-placement';
},
]) {
const bad = nexusMoritaniReload(pending);
edit(bad);
rejectCorruption(bad);
}
});
+58
View File
@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { nexusMoritaniBotActions } from '../game/nexus-moritani-options';
import {
nexusMoritaniFixture,
nexusMoritaniInventory,
} from './fixture-nexus-moritani';
void test('all four Moritani profiles use Cunning for a public opposing Arrakis group and keep token custody until allowance', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const f = nexusMoritaniFixture();
// Conserved public target position, staged before the real declaration.
const target = f.g.players.find((player) => player.id === f.target)!;
target.reserves -= 3;
target.forces['red_chasm:7'] = 3;
const v = viewGame(f.g, f.owner);
v.players.find((player) => player.id === f.owner)!.bot = profile;
const before = structuredClone(v);
const action = botActions(v)[0];
assert.deepEqual([action], nexusMoritaniBotActions(v));
assert.equal(action.territory, 'red_chasm');
assert.equal(action.nexus, v.nexusMoritani!.event);
const pending = applyAction(f.g, f.owner, action);
assert.equal(pending.response?.kind, 'moritaniPlacement');
assert.deepEqual(pending.moritaniTerror, f.g.moritaniTerror);
nexusMoritaniInventory(pending);
assert.deepEqual(v, before);
}
});
void test('Cunning policy reads only public opposing armies, supports an existing stack and retains ordinary fallback', () => {
const f = nexusMoritaniFixture({ stack: true });
const target = f.g.players.find((player) => player.id === f.target)!;
target.reserves -= 2;
target.forces['arrakeen:10'] = 2;
const v = viewGame(f.g, f.owner);
for (const p of v.players.filter((player) => player.id !== f.owner))
for (const key of ['hand', 'traitors', 'spice'])
Object.defineProperty(p, key, {
get() {
throw new Error('Private opponent data read');
},
});
assert.equal(nexusMoritaniBotActions(v)[0].territory, 'arrakeen');
v.nexusMoritani!.blocked = 'Not available now';
assert.deepEqual(nexusMoritaniBotActions(v), []);
const ordinary = viewGame(f.g, f.owner);
ordinary.nexusMoritani = null;
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
ordinary.players.find((player) => player.id === f.owner)!.bot = profile;
const action = botActions(ordinary)[0];
assert.equal(action.type, 'decision');
assert.equal(action.nexus, undefined);
nexusMoritaniInventory(applyAction(f.g, f.owner, action));
}
});
+166
View File
@@ -0,0 +1,166 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type GameView } from '../game/engine';
import {
MoritaniPlacement,
MoritaniTerrorSupply,
} from '../components/moritani-terror';
import {
moritaniPlacementChoices,
nexusMoritaniAction,
nexusMoritaniCanAct,
} from '../game/nexus-moritani-options';
import {
nexusMoritaniFixture,
nexusMoritaniToken,
nexusMoritaniInventory,
} from './fixture-nexus-moritani';
const html = (game: GameView, busy = false) =>
renderToStaticMarkup(
createElement(MoritaniPlacement, { game, busy, act() {} }),
);
void test('genuine Basic and Advanced Mentat keeps native relocation choices and offers explicit Cunning supply placement into Arrakis or a stack', () => {
for (const advanced of [false, true]) {
const f = nexusMoritaniFixture({ advanced, stack: true });
const v = viewGame(f.g, f.owner),
before = structuredClone(v);
const token = nexusMoritaniToken(f.g, 'robbery').id;
const native = moritaniPlacementChoices(v, false, token);
const cunning = moritaniPlacementChoices(v, true, token);
assert.ok(native.tokens.some((candidate) => candidate.status === 'placed'));
assert.ok(
cunning.tokens.every((candidate) => candidate.status === 'available'),
);
for (const destination of ['red_chasm', 'polar_sink', 'arrakeen']) {
assert.equal(native.destinations.includes(destination), false);
assert.ok(cunning.destinations.includes(destination));
const action = nexusMoritaniAction(
v,
v.nexusMoritani!.event,
token,
destination,
);
assert.deepEqual(action, {
type: 'decision',
token,
territory: destination,
nexus: v.nexusMoritani!.event,
});
const pending = applyAction(f.g, f.owner, action!);
assert.equal(pending.response?.kind, 'moritaniPlacement');
assert.equal(nexusMoritaniToken(pending, 'robbery').status, 'available');
nexusMoritaniInventory(pending);
}
assert.ok(!cunning.destinations.includes('hidden_mobile_stronghold'));
assert.ok(
!cunning.destinations.some((destination) =>
destination.startsWith('homeworld:'),
),
);
const markup = html(v);
assert.match(markup, /type="checkbox"/);
assert.doesNotMatch(markup, /checked=""/);
assert.match(markup, /decision-checkbox min-h-11/);
assert.match(markup, /Use Moritani Nexus Cunning/);
assert.match(
markup,
/Relocation, Grumman use, Homeworlds and the Hidden Mobile Stronghold are unavailable/,
);
assert.match(markup, /Destination stronghold/);
assert.match(html(v, true), /disabled=""/);
assert.match(
renderToStaticMarkup(createElement(MoritaniTerrorSupply, { game: v })),
/Inspect Robbery/,
);
assert.deepEqual(v, before);
}
});
void test('Moritani shared Cunning action binds current private offer, available token and destination without accepting relocation or stale events', () => {
const f = nexusMoritaniFixture({ stack: true });
const v = viewGame(f.g, f.owner),
offer = v.nexusMoritani!;
const token = nexusMoritaniToken(f.g, 'robbery').id;
assert.ok(nexusMoritaniCanAct(v));
assert.equal(nexusMoritaniAction(v, 'stale', token, 'red_chasm'), null);
assert.equal(
nexusMoritaniAction(
v,
offer.event,
nexusMoritaniToken(f.g, 'sabotage').id,
'red_chasm',
),
null,
);
assert.equal(
nexusMoritaniAction(v, offer.event, 'missing', 'red_chasm'),
null,
);
assert.equal(
nexusMoritaniAction(v, offer.event, token, 'hidden_mobile_stronghold'),
null,
);
for (const edit of [
(g: GameView) => {
g.phase = 7;
},
(g: GameView) => {
g.turn++;
},
(g: GameView) => {
g.decision = null;
},
(g: GameView) => {
g.decision = { kind: 'moritaniPlacement', player: f.target };
},
(g: GameView) => {
g.nexusCards!.card = null;
},
(g: GameView) => {
g.players.find((p) => p.id === f.owner)!.ally = f.target;
},
(g: GameView) => {
g.automaticContinuationPending = true;
},
(g: GameView) => {
g.nexusMoritani!.blocked = 'Finish the current interaction.';
},
]) {
const blocked = structuredClone(v);
edit(blocked);
assert.equal(
nexusMoritaniAction(blocked, offer.event, token, 'red_chasm'),
null,
);
}
const blocked = structuredClone(v);
blocked.nexusMoritani!.blocked =
'This combination awaits its placement ruling.';
assert.match(html(blocked), /combination awaits its placement ruling/);
});
void test('nonowners cannot render or read private Cunning supply even from an accidentally broad view', () => {
const f = nexusMoritaniFixture({ stack: true });
for (const id of [f.target, f.observer]) {
const v = viewGame(f.g, id);
assert.equal(v.nexusMoritani, null);
assert.equal(html(v), '');
Object.defineProperty(v, 'nexusMoritani', {
get() {
throw new Error('Opponent Nexus offer read');
},
});
assert.equal(nexusMoritaniCanAct(v), false);
assert.deepEqual(moritaniPlacementChoices(v, true).tokens, []);
assert.equal(nexusMoritaniAction(v, 'event', 'token', 'red_chasm'), null);
const markup = renderToStaticMarkup(
createElement(MoritaniTerrorSupply, { game: v }),
);
assert.match(markup, /Hidden Terror/);
assert.doesNotMatch(markup, /Inspect Sabotage/);
}
});
+207
View File
@@ -0,0 +1,207 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import {
nexusMoritaniFixture,
nexusMoritaniToken,
nexusMoritaniRequest,
nexusMoritaniAllow,
nexusMoritaniReload,
nexusMoritaniInventory,
nexusMoritaniArrival,
holdNexusMoritaniCard,
} from './fixture-nexus-moritani';
function reject(g: Game, id: string, a: Action) {
const before = nexusMoritaniReload(g);
assert.throws(() => applyAction(g, id, a));
assert.deepEqual(g, before);
}
function stable(g: Game) {
nexusMoritaniInventory(g);
const before = nexusMoritaniReload(g);
assert.deepEqual(normalizeAutomaticGame(before), before);
for (const p of g.players)
assert.deepEqual(viewGame(nexusMoritaniReload(g), p.id), viewGame(g, p.id));
}
void test('Moritani Cunning places a real available token in sand, rock, Polar Sink or an existing stack in Basic and Advanced', () => {
for (const advanced of [false, true])
for (const territory of [
'red_chasm',
'sihaya_ridge',
'polar_sink',
'arrakeen',
]) {
const f = nexusMoritaniFixture({
advanced,
stack: territory === 'arrakeen',
}),
before = nexusMoritaniReload(f.g),
token = nexusMoritaniToken(f.g, 'robbery').id;
const action = nexusMoritaniRequest(f, 'robbery', territory);
let g = applyAction(f.g, f.owner, action);
assert.equal(g.response?.kind, 'moritaniPlacement');
assert.equal(nexusMoritaniToken(g, 'robbery').status, 'available');
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'moritani').length,
1,
);
assert.deepEqual(g.moritaniTerror, before.moritaniTerror);
stable(g);
g = nexusMoritaniAllow(nexusMoritaniReload(g));
assert.equal(nexusMoritaniToken(g, 'robbery').location, territory);
assert.equal(nexusMoritaniToken(g, 'robbery').status, 'placed');
assert.equal(g.moritaniTerror!.placementTurn, g.turn);
assert.deepEqual(
g.moritaniTerror!.tokens.filter((t) => t.id !== token),
before.moritaniTerror!.tokens.filter((t) => t.id !== token),
);
assert.deepEqual(
g.players.map((p) => [p.spice, p.forces, p.reserves, p.tanks]),
before.players.map((p) => [p.spice, p.forces, p.reserves, p.tanks]),
);
if (territory === 'arrakeen')
assert.equal(
g.moritaniTerror!.tokens.filter((t) => t.location === 'arrakeen')
.length,
2,
);
reject(g, f.owner, action);
stable(g);
}
});
void test('printed Karama consumes the Mentat opportunity while preserving supply, previous placement and physical counters', () => {
const f = nexusMoritaniFixture({ stack: true }),
before = nexusMoritaniReload(f.g),
action = nexusMoritaniRequest(f, 'robbery', 'arrakeen');
let g = applyAction(f.g, f.owner, action);
g = applyAction(nexusMoritaniReload(g), f.target, {
type: 'card',
mode: 'cancel',
card: f.karama,
});
assert.equal(g.pendingMoritaniPlacement, null);
assert.equal(g.response, null);
assert.deepEqual(g.moritaniTerror!.tokens, before.moritaniTerror!.tokens);
assert.equal(g.moritaniTerror!.placementTurn, g.turn);
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
assert.equal(g.discard.filter((c) => c.id === f.karama).length, 1);
assert.deepEqual(
g.players.map((p) => p.spice),
before.players.map((p) => p.spice),
);
reject(g, f.owner, action);
stable(g);
});
void test('Moritani Cunning offers every static territory without reading token kinds for another seat; hidden declarations stay equivalent', () => {
const f = nexusMoritaniFixture(),
a = f.g,
b = nexusMoritaniReload(a);
const tokenA = nexusMoritaniToken(a, 'robbery'),
tokenB = b.moritaniTerror!.tokens.find((t) => t.id === tokenA.id)!,
other = nexusMoritaniToken(b, 'sabotage');
[tokenB.kind, other.kind] = [other.kind, tokenB.kind];
for (const id of [f.target, f.observer])
assert.deepEqual(viewGame(a, id), viewGame(b, id));
const offer = viewGame(a, f.owner).nexusMoritani!;
assert.equal(offer.blocked, null);
assert.equal(offer.tokens.length, 6);
assert.equal(offer.destinations.length, TERRITORIES.length);
const action = nexusMoritaniRequest(f, 'robbery', 'red_chasm');
const pendingA = applyAction(a, f.owner, action),
pendingB = applyAction(b, f.owner, action);
for (const id of [f.target, f.observer]) {
assert.deepEqual(viewGame(pendingA, id), viewGame(pendingB, id));
assert.equal(viewGame(pendingA, id).nexusMoritani, null);
assert.equal(
Object.hasOwn(viewGame(pendingA, id), 'nexusMoritaniHistory'),
false,
);
}
});
void test('Moritani Cunning rejects stale event, foreign ownership, relocation, unknown supply, Homeworlds and mobile stronghold without spending Nexus', () => {
const f = nexusMoritaniFixture({ stack: true }),
action = nexusMoritaniRequest(f);
for (const bad of [
{ ...action, nexus: 'old' },
{ ...action, token: 'missing' },
{ ...action, token: nexusMoritaniToken(f.g, 'sabotage').id },
{ ...action, territory: 'grumman' },
{ ...action, territory: 'hidden_mobile_stronghold' },
{ ...action, territory: 'unknown' },
])
reject(f.g, f.owner, bad);
reject(f.g, f.target, action);
assert.equal(f.g.nexusCards!.cards!.hands[f.owner], 'moritani');
const noNexus = nexusMoritaniReload(f.g);
noNexus.nexusCards!.cards!.deck.push('moritani');
noNexus.nexusCards!.cards!.hands[f.owner] = null;
reject(noNexus, f.owner, action);
});
void test('Nexus Mentat placement preserves genuine previous Grumman decline or expiry, pays no Collection income and applies the low entry threshold later', () => {
for (const native of [7, 8]) {
const f = nexusMoritaniFixture({ homeworlds: true, native, stack: true }),
receipt = structuredClone(f.g.grummanCollection);
assert.equal(receipt?.outcome, native === 7 ? 'expired' : 'decline');
let g = nexusMoritaniAllow(
applyAction(f.g, f.owner, nexusMoritaniRequest(f)),
);
assert.deepEqual(g.grummanCollection, receipt);
assert.equal(g.players[0].spice, 20);
g = nexusMoritaniArrival(g, 'red_chasm', 2);
if (native === 7) assert.equal(g.pendingTerrorEntry ?? null, null);
else assert.equal(g.pendingTerrorEntry?.stage, 'offer');
stable(g);
}
});
for (const kind of ['robbery', 'sabotage', 'sneakAttack'] as const)
void test(`Nexus ${kind} placed outside a stronghold triggers its existing effect on a later real shipment`, () => {
const f = nexusMoritaniFixture();
const victimCard = holdNexusMoritaniCard(f.g, f.target, 'shield');
let g = nexusMoritaniAllow(
applyAction(f.g, f.owner, nexusMoritaniRequest(f, kind, 'sihaya_ridge')),
);
g = nexusMoritaniArrival(g, 'sihaya_ridge');
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
assert.equal(viewGame(g, f.owner).terrorEntry?.kind, kind);
for (const id of [f.target, f.observer])
assert.equal(viewGame(g, id).terrorEntry?.kind, undefined);
const before = nexusMoritaniReload(g);
g = applyAction(nexusMoritaniReload(g), f.owner, {
type: 'decision',
reveal: true,
});
if (kind === 'robbery') {
g = applyAction(g, f.owner, { type: 'decision', choice: 'spice' });
const taken = Math.ceil(before.players[1].spice / 2);
assert.equal(g.players[0].spice, before.players[0].spice + taken);
assert.equal(g.players[1].spice, before.players[1].spice - taken);
} else if (kind === 'sabotage') {
assert.equal(g.pendingTerrorEntry?.stage, 'gift');
g = applyAction(g, f.owner, { type: 'decision', decline: true });
assert.ok(before.players[1].hand.some((c) => c.id === victimCard.id));
assert.equal(g.players[1].hand.length, before.players[1].hand.length - 1);
assert.equal(g.discard.length, before.discard.length + 1);
assert.ok(
before.players[1].hand.some((c) => c.id === g.discard.at(-1)!.id),
);
} else {
assert.equal(g.pendingTerrorEntry?.stage, 'sneakAttack');
g = applyAction(g, f.owner, { type: 'decision', amount: 3 });
assert.equal(g.players[0].forces['sihaya_ridge:9'], 3);
assert.equal(g.players[0].reserves, before.players[0].reserves - 3);
assert.equal(g.players[0].spice, before.players[0].spice);
}
assert.equal(nexusMoritaniToken(g, kind).status, 'removed');
assert.equal(g.pendingTerrorEntry, null);
assert.equal(g.players[1].forces['sihaya_ridge:9'], 3);
reject(g, f.owner, { type: 'decision', reveal: true });
stable(g);
});
+322
View File
@@ -0,0 +1,322 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { unitStore } from './fixture-nexus-room-store';
import { viewGame, type Game, type Action } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import {
nexusMoritaniFixture,
nexusMoritaniRequest,
nexusMoritaniToken,
nexusMoritaniInventory,
nexusMoritaniMovement,
} from './fixture-nexus-moritani';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture() {
const store = unitStore(),
made = await store.rooms.createRoom('Moritani SQL', 'moritani', false, []),
code = made.view.code,
tokens = [made.token];
for (const faction of ['guild', 'fremen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
),
ids = auths.map((auth) => auth.playerId) as [string, string, string];
const f = nexusMoritaniFixture({
seatIds: ids,
stack: true,
homeworlds: true,
});
f.g.code = code;
f.g.version = (await store.rooms.readRoom(code)).version;
const save = (g: Game) =>
store.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
save(f.g);
store.writes.length = 0;
return { ...store, ...f, code, tokens, auths, ids, save };
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
const row = (f: Fixture) =>
f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(f.code);
async function act(f: Fixture, seat: number, action: Action) {
const g = await f.restart().readRoom(f.code);
await f.restart().act(f.code, f.auths[seat], g.version, action, clock);
return f.restart().readRoom(f.code);
}
async function restored(f: Fixture, g: Game) {
const before = row(f),
rooms = f.restart();
for (const [i, token] of f.tokens.entries()) {
const auth = await rooms.authenticate(f.code, token),
v = await rooms.readSeatView(f.code, auth);
assert.deepEqual(v, viewGame(g, f.ids[i]));
assert.equal(Object.hasOwn(v, 'nexusMoritaniHistory'), false);
if (i !== 0) {
assert.equal(v.nexusMoritani, null);
for (const token of v.moritaniTerror!.tokens.filter(
(t) => t.status === 'placed',
))
assert.equal('kind' in token, false);
}
}
await rooms.continueRoomAutomatic(f.code, clock);
assert.deepEqual(row(f), before);
nexusMoritaniInventory(g);
}
void test('SQLite lost acknowledgment restores hidden supply placement and preserves genuine Grumman history after allowance or cancellation', async () => {
for (const canceled of [false, true])
for (const territory of [
'red_chasm',
'sihaya_ridge',
'polar_sink',
'arrakeen',
]) {
const f = await fixture();
try {
const request = nexusMoritaniRequest(f, 'robbery', territory),
source = structuredClone(f.g.moritaniTerror),
grumman = structuredClone(f.g.grummanCollection);
await f.rooms.act(f.code, f.auths[0], f.g.version, request, clock);
const pending = await f.restart().readRoom(f.code);
assert.equal(pending.nexusMoritaniLast?.stage, 'pending');
assert.deepEqual(pending.moritaniTerror, source);
await restored(f, pending);
const done = await act(
f,
1,
canceled
? { type: 'card', mode: 'cancel', card: f.karama }
: { type: 'passResponse' },
);
assert.equal(
done.nexusMoritaniLast?.stage,
canceled ? 'canceled' : 'complete',
);
assert.equal(done.nexusMoritaniHistory!.length, 1);
assert.equal(
nexusMoritaniToken(done, 'robbery').status,
canceled ? 'available' : 'placed',
);
assert.equal(
nexusMoritaniToken(done, 'robbery').location,
canceled ? null : territory,
);
assert.equal(done.moritaniTerror!.placementTurn, done.turn);
assert.deepEqual(done.grummanCollection, grumman);
assert.equal(done.players[0].spice, 20);
assert.equal(
done.nexusCards!.cards!.discard.filter((card) => card === 'moritani')
.length,
1,
);
await restored(f, done);
const before = row(f);
await assert.rejects(
f.restart().act(f.code, f.auths[0], done.version, request, clock),
);
assert.deepEqual(row(f), before);
} finally {
f.sqlite.close();
}
}
});
async function compete(
f: Fixture,
seat: number,
version: number,
actions: Action[],
) {
let arrivals = 0,
release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const timer = setTimeout(release, 2000);
const results = await Promise.allSettled(
actions.map((action) =>
f.restart().act(f.code, f.auths[seat], version, action, clock),
),
);
clearTimeout(timer);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
assert.deepEqual(
f.writes.map((w) => w.changes).sort((a, b) => a - b),
[0, 1],
);
return f.restart().readRoom(f.code);
}
void test('SQLite concurrent Moritani declarations and competing final Karama/pass each commit exactly once', async () => {
const f = await fixture();
try {
const request = nexusMoritaniRequest(f),
pending = await compete(f, 0, f.g.version, [request, request]);
assert.equal(pending.version, f.g.version + 1);
assert.equal(pending.nexusMoritaniHistory!.length, 1);
assert.equal(nexusMoritaniToken(pending, 'robbery').status, 'available');
await restored(f, pending);
f.writes.length = 0;
const done = await compete(f, 1, pending.version, [
{ type: 'passResponse' },
{ type: 'card', mode: 'cancel', card: f.karama },
]);
assert.equal(done.version, pending.version + 1);
assert.equal(done.nexusMoritaniHistory!.length, 1);
const canceled = done.nexusMoritaniLast!.stage === 'canceled';
assert.equal(
nexusMoritaniToken(done, 'robbery').status,
canceled ? 'available' : 'placed',
);
assert.equal(
done.discard.filter((c) => c.id === f.karama).length,
canceled ? 1 : 0,
);
assert.equal(
done.nexusCards!.cards!.discard.filter((c) => c === 'moritani').length,
1,
);
await restored(f, done);
} finally {
f.sqlite.close();
}
});
void test('SQLite damaged Moritani original supply, target, response or completion proof rejects both settlement choices and every private read without writes', async () => {
const f = await fixture();
try {
const pending = await act(f, 0, nexusMoritaniRequest(f));
const changes: ((g: Game) => void)[] = [
(g) => {
g.pendingMoritaniPlacement!.token = 'unknown';
},
(g) => {
g.pendingMoritaniPlacement!.territory = 'sihaya_ridge';
},
(g) => {
g.pendingMoritaniPlacement!.turn++;
},
(g) => {
g.pendingMoritaniPlacement!.nexusEvent = 'changed';
},
(g) => {
g.response!.owner = f.target;
},
(g) => {
g.response = null;
},
(g) => {
g.moritaniTerror!.tokens[0].id = g.moritaniTerror!.tokens[1].id;
},
(g) => {
nexusMoritaniToken(g, 'robbery').status = 'removed';
},
(g) => {
g.nexusMoritaniHistory![0].stage = 'complete';
},
(g) => {
delete g.nexusMoritaniLast;
},
(g) => {
delete g.nexusMoritaniHistory;
},
(g) => {
g.nexusMoritaniHistory![0].before = '{}';
},
];
for (const [index, change] of changes.entries()) {
const bad = structuredClone(pending);
change(bad);
f.save(bad);
f.writes.length = 0;
const before = row(f);
for (const action of [
{ type: 'passResponse' },
{ type: 'card', mode: 'cancel', card: f.karama },
] as Action[])
await assert.rejects(
f.restart().act(f.code, f.auths[1], bad.version, action, clock),
`mutation ${index}`,
);
for (const auth of f.auths)
await assert.rejects(
f.restart().readSeatView(f.code, auth),
`read mutation ${index}`,
);
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
}
} finally {
f.sqlite.close();
}
});
void test('SQLite later nonstronghold entry preserves the original Nexus placement proof through reveal, private choice and replay rejection', async () => {
const f = await fixture();
try {
await act(f, 0, nexusMoritaniRequest(f, 'robbery', 'sihaya_ridge'));
const placed = await act(f, 1, { type: 'passResponse' });
// Complete genuine phase boundaries, then persist before the real SQL shipment.
// No original receipt or seat identity is rewritten.
const movement = nexusMoritaniMovement(placed);
f.save(movement);
f.writes.length = 0;
const arrival = await act(f, 1, {
type: 'ship',
territory: 'sihaya_ridge',
sector: 9,
amount: 3,
});
assert.equal(arrival.pendingTerrorEntry?.stage, 'offer');
await restored(f, arrival);
const original = JSON.stringify(arrival.pendingTerrorEntry);
const revealed = await act(f, 0, { type: 'decision', reveal: true });
assert.equal(revealed.pendingTerrorEntry?.stage, 'robbery');
await restored(f, revealed);
const beforeSpice = revealed.players[0].spice,
taken = Math.ceil(revealed.players[1].spice / 2);
const done = await act(f, 0, { type: 'decision', choice: 'spice' });
assert.equal(done.players[0].spice, beforeSpice + taken);
assert.equal(done.pendingTerrorEntry, null);
assert.equal(done.nexusMoritaniLast?.stage, 'complete');
await restored(f, done);
const before = row(f);
await assert.rejects(
f
.restart()
.act(
f.code,
f.auths[0],
done.version,
{ type: 'decision', choice: 'spice' },
clock,
),
);
assert.deepEqual(row(f), before);
const bad = structuredClone(arrival);
bad.pendingTerrorEntry!.territory = 'red_chasm';
assert.notEqual(JSON.stringify(bad.pendingTerrorEntry), original);
f.save(bad);
f.writes.length = 0;
await assert.rejects(
f
.restart()
.act(
f.code,
f.auths[0],
bad.version,
{ type: 'decision', reveal: true },
clock,
),
);
assert.equal(f.writes.length, 0);
} finally {
f.sqlite.close();
}
});
+263
View File
@@ -0,0 +1,263 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { TERRITORIES, MOBILE_STRONGHOLD } from '../game/board';
import {
createTerrorState,
placeTerror,
type TerrorState,
} from '../game/moritani-terror';
import {
createNexusMoritani,
validateNexusMoritani,
quoteNexusMoritaniPlacement,
type NexusMoritaniContext,
type NexusMoritaniReceipt,
} from '../game/nexus-moritani';
const context: NexusMoritaniContext = {
turn: 3,
players: [
{ id: 'm', faction: 'moritani' },
{ id: 'a', faction: 'atreides' },
],
};
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
const fresh = () => createTerrorState(() => 0.3);
void test('Moritani Cunning places one supply token in every static Arrakis territory without mutating the source', () => {
const state = fresh(),
before = clone(state),
id = state.tokens[0].id;
state.supplyEpoch = 4;
before.supplyEpoch = 4;
for (const territory of TERRITORIES) {
const receipt = createNexusMoritani(context, 'm', id, territory.id);
validateNexusMoritani(context, clone(receipt));
const placed = quoteNexusMoritaniPlacement(state, id, territory.id, 3);
assert.equal(placed.tokens[0].location, territory.id);
assert.equal(placed.tokens[0].status, 'placed');
assert.equal(placed.placementTurn, 3);
assert.equal(placed.supplyEpoch, 4);
assert.deepEqual(placed.tokens.slice(1), state.tokens.slice(1));
placed.tokens[1].status = 'removed';
assert.deepEqual(state, before);
}
assert.throws(
() => placeTerror(state, id, 'polar_sink', 3),
/ordinary strongholds/,
);
});
void test('Moritani Cunning stacks physical tokens but cannot reuse the Mentat allowance or enhance a relocation', () => {
let state = fresh();
state = quoteNexusMoritaniPlacement(
state,
state.tokens[0].id,
'hagga_basin',
1,
);
state = quoteNexusMoritaniPlacement(
state,
state.tokens[1].id,
'hagga_basin',
2,
);
assert.equal(
state.tokens.filter((t) => t.location === 'hagga_basin').length,
2,
);
const before = clone(state);
for (const turn of [1, 2, 0, -1, 2.5, NaN])
assert.throws(
() =>
quoteNexusMoritaniPlacement(
state,
state.tokens[2].id,
'polar_sink',
turn,
),
/Moritani Nexus/,
);
assert.throws(
() =>
quoteNexusMoritaniPlacement(state, state.tokens[0].id, 'polar_sink', 3),
/relocation/,
);
assert.deepEqual(state, before);
// Its custody is ordinary placed custody; a later unenhanced move may leave it.
const moved = placeTerror(state, state.tokens[0].id, 'carthag', 3);
assert.equal(moved.tokens[1].location, 'hagga_basin');
assert.equal(moved.tokens[0].location, 'carthag');
});
void test('Moritani placement rejects invalid physical inventory and excluded destinations without mutations', () => {
const state = fresh();
const edits: ((s: TerrorState) => void)[] = [
(s) => {
s.tokens.pop();
},
(s) => {
s.tokens[1].id = s.tokens[0].id;
},
(s) => {
s.tokens[1].kind = s.tokens[0].kind;
},
(s) => {
s.tokens[0].status = 'removed';
},
(s) => {
s.tokens[0].location = 'arrakeen';
},
(s) => {
s.tokens[1].status = 'placed';
s.tokens[1].location = MOBILE_STRONGHOLD;
},
(s) => {
s.tokens[1].status = 'extortion';
s.tokens[1].kind = 'robbery';
},
(s) => {
s.supplyEpoch = -1;
},
(s) => {
Object.assign(s, { extraToken: true });
},
];
for (const edit of edits) {
const bad = clone(state);
edit(bad);
const before = clone(bad);
assert.throws(
() =>
quoteNexusMoritaniPlacement(bad, state.tokens[0].id, 'polar_sink', 3),
/Moritani Nexus/,
);
assert.deepEqual(bad, before);
}
for (const destination of [
MOBILE_STRONGHOLD,
'homeworld:moritani',
'arrakeen:10',
'',
'__proto__',
]) {
assert.throws(
() =>
quoteNexusMoritaniPlacement(state, state.tokens[0].id, destination, 3),
/Moritani Nexus/,
);
assert.throws(
() => createNexusMoritani(context, 'm', state.tokens[0].id, destination),
/Moritani Nexus/,
);
}
});
void test('Moritani historical receipts preserve original identity after later turns without reading hidden custody or alliances', () => {
const receipt = createNexusMoritani(
context,
'm',
'physical-token',
'hagga_basin',
);
const later = {
turn: 9,
players: [...context.players].reverse().map((p) =>
Object.defineProperties(
{ ...p },
{
hand: {
get() {
throw new Error('hidden hand read');
},
},
ally: {
get() {
throw new Error('later alliance read');
},
},
moritaniTerror: {
get() {
throw new Error('later custody read');
},
},
},
),
),
};
validateNexusMoritani(later, clone(receipt));
assert.equal(
receipt.event,
JSON.stringify(['nexusMoritani', 3, 'm', 'physical-token', 'hagga_basin']),
);
assert.notEqual(
receipt.event,
createNexusMoritani(context, 'm', 'other-token', 'hagga_basin').event,
);
});
void test('Moritani receipt rejects deleted or changed source evidence and foreign native owners', () => {
const receipt = createNexusMoritani(
context,
'm',
'physical-token',
'hagga_basin',
);
const edits: ((r: NexusMoritaniReceipt) => void)[] = [
(r) => {
r.token = 'other';
},
(r) => {
r.territory = 'polar_sink';
},
(r) => {
r.turn = 4;
},
(r) => {
r.owner = 'a';
},
(r) => {
r.roster.pop();
},
(r) => {
r.signature = '';
},
(r) => {
r.event = 'other';
},
(r) => {
Object.assign(r, { source: 'grumman', phase: 7 });
},
(r) => {
delete (r as Partial<NexusMoritaniReceipt>).source;
},
(r) => {
Object.assign(r, { stage: 'complete' });
},
];
for (const edit of edits) {
const bad = clone(receipt);
edit(bad);
const before = clone(bad);
assert.throws(() => validateNexusMoritani(context, bad), /Moritani Nexus/);
assert.deepEqual(bad, before);
}
assert.throws(
() => validateNexusMoritani({ ...context, turn: 2 }, receipt),
/Moritani Nexus/,
);
assert.throws(
() => createNexusMoritani(context, 'a', 'token', 'polar_sink'),
/Moritani Nexus/,
);
assert.throws(
() =>
createNexusMoritani(
{ ...context, players: [...context.players, context.players[0]] },
'm',
'token',
'polar_sink',
),
/roster/,
);
});