Implement Moritani Nexus placement and preserve later Terror custody
This commit is contained in:
1 parent
290259bac2
commit
ae66668e2e
27 files changed
+2208
-73
No files matched your search
@@ -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;
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user