Apply ruled Grumman return and Cunning placement

This commit is contained in:
Quintine committed 2026-10-14 08:26:05 +11:00
1 parent 7994026a70
commit 2efa69d10f
24 files changed
+1246 -219

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeHomeworldGameForAudit, initializeNexusGameForAudit,
joinGame, newPlayer, type Action, type Game,
} from '../game/engine';
import { territory } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { TERROR_KINDS } from '../game/moritani-terror';
import { nextNativeE3HomeworldStep } from './fixture-homeworld-native-e3-modules';
export type RuledGrummanStep = { actor: string; action: Action };
export type RuledGrummanFixture = {
game: Game;
owner: string;
placed: string;
placedTerritory: string;
initial: Game;
afterSetup: Game;
beforePlacement: Game;
afterPlacement: Game;
previousCollection: Game;
beforeCollection: Game;
drawing: Game | null;
afterDraw: Game | null;
census: { treachery: string[]; terror: string[]; nexus: string[] };
actions: RuledGrummanStep[];
};
/** Select only the original undealt lotteries, retaining all native stock. */
function initialize(game: Game, nexus: boolean): Game {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return nexus ? initializeNexusGameForAudit(game) : initializeHomeworldGameForAudit(game); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Move only still-unplayed Spice cards; never empty or redeal the discard. */
function frontSpice(game: Game, kinds: ('land' | 'worm')[]): void {
for (const [position, kind] of kinds.entries()) {
const index = game.spiceDeck.findIndex((card, at) => at >= position &&
(kind === 'land' ? 'territory' in card : 'worm' in card));
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
}
export function assertRuledGrummanInventory(game: Game): void {
homeworldGameIntegrity(game);
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((a, b) => a + b, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((a, b) => a + b, 0), 5);
}
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id).sort(),
treacheryDeck(game.expansions).map(c => c.id).sort());
assert.deepEqual(game.moritaniTerror!.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort());
assert.equal(new Set(game.moritaniTerror!.tokens.map(t => t.id)).size, 6);
if (game.nexusCards) validateNexusCards(game.nexusCards.cards!, game.players);
}
/** Genuine setup → first Collection → Mentat placement → next Collection.
* No force, population, phase, turn, wallet, hand or earned receipt is staged.
* The Nexus variant plays an actual second-turn worm and original card draw. */
export function createRuledGrummanFixture(options: { advanced?: boolean; nexus?: boolean } = {}): RuledGrummanFixture {
const owner = 'moritani', placedTerritory = 'arrakeen';
let game = createGame('RULEDGRUMMAN', newPlayer(owner, 'Moritani', 'moritani'), options.advanced ?? false, options.nexus ? ['ecaz'] : []);
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
game = applyAction(game, owner, { type: 'homeworlds', enabled: true });
if (options.nexus) game.nexusCards = { cards: null, phase: null };
const initial = structuredClone(game), actions: RuledGrummanStep[] = [];
function act(step: RuledGrummanStep): void {
actions.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
}
for (const player of game.players) act({ actor: player.id, action: { type: 'ready' } });
game = initialize(game, !!options.nexus);
let afterSetup: Game | undefined, previousCollection: Game | undefined;
let beforePlacement: Game | undefined, afterPlacement: Game | undefined, beforeCollection: Game | undefined;
let drawing: Game | null = null, afterDraw: Game | null = null, placed: string | undefined;
let spiceTurn = 0;
for (let count = 0; count < 500; count++) {
if (game.status === 'playing' && !afterSetup) {
afterSetup = structuredClone(game);
if (options.nexus) {
const deck = game.nexusCards!.cards!.deck;
const at = deck.indexOf('moritani'); assert.ok(at >= 0);
deck.unshift(deck.splice(at, 1)[0]);
}
}
if (game.status === 'playing' && game.phase === 0 && spiceTurn !== game.turn) {
frontSpice(game, options.nexus && game.turn === 2 ? ['worm', 'land', 'land'] : ['land', 'land']);
spiceTurn = game.turn;
}
if (options.nexus && game.nexus && !game.spiceWindow && !game.spiceResolution &&
!game.response && !game.phaseOpening && !game.decision && !game.players.find(p => p.id === 'guild')!.ally) {
act({ actor: 'guild', action: { type: 'alliance', target: 'emperor' } });
act({ actor: 'emperor', action: { type: 'alliance', target: 'guild' } });
}
if (game.turn === 2 && game.decision?.kind === 'grummanCollection' && !game.response && !game.phaseOpening) break;
if (game.turn === 2 && game.phase === 5) beforeCollection = structuredClone(game);
const decision = game.decision;
let step: RuledGrummanStep | null;
if (!game.response && !game.phaseOpening && decision?.kind === 'moritaniSetup') {
step = { actor: owner, action: { type: 'decision', territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0] } };
} else if (!game.response && !game.phaseOpening && decision?.kind === 'grummanCollection') {
previousCollection = structuredClone(game);
step = { actor: owner, action: { type: 'decision', event: game.grummanCollection!.event, decline: true } };
} else if (!game.response && !game.phaseOpening && decision?.kind === 'moritaniPlacement' && game.turn === 1) {
beforePlacement = structuredClone(game);
placed = game.moritaniTerror!.tokens.find(t => t.kind === 'robbery')!.id;
step = { actor: owner, action: { type: 'decision', token: placed, territory: placedTerritory } };
} else if (!game.response && !game.phaseOpening && !decision && game.nexusCards?.phase?.stage === 'drawing') {
const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id)); assert.ok(actor);
if (actor === owner) drawing = structuredClone(game);
step = { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: game.nexusCards.cards!.hands[actor] ?? null,
choice: actor === owner ? 'draw' : 'keep', ownRedraws: 0 } };
} else step = nextNativeE3HomeworldStep(game);
assert.ok(step, `Native Grumman programme stalled at ${game.status}/${game.turn}/${game.phase}/${decision?.kind}`);
act(step);
if (beforePlacement && !afterPlacement && game.moritaniTerror!.tokens.some(t => t.id === placed && t.status === 'placed'))
afterPlacement = structuredClone(game);
if (drawing && !afterDraw && game.nexusCards!.cards!.hands[owner] === 'moritani') afterDraw = structuredClone(game);
}
assert.equal(game.turn, 2); assert.equal(game.phase, 7); assert.equal(game.decision?.kind, 'grummanCollection');
assert.ok(afterSetup && previousCollection && beforePlacement && afterPlacement && beforeCollection && placed);
assert.equal(game.players.find(p => p.id === owner)!.reserves, 14);
assert.ok(!game.players.find(p => p.id === owner)!.ally);
assert.equal(game.moritaniTerror!.placementTurn, 1);
assert.equal(game.moritaniTerror!.tokens.find(t => t.id === placed)!.location, placedTerritory);
assert.ok(game.moritaniTerror!.tokens.some(t => t.status === 'available'));
if (options.nexus) {
assert.ok(drawing && afterDraw, `Native Nexus draw missing: ${JSON.stringify({ phase: game.nexusCards?.phase, discards: game.spiceDiscard, actions: actions.filter(s => s.action.type === 'nexusCardChoice') })}`);
assert.equal(game.nexusCards!.cards!.hands[owner], 'moritani');
}
assertRuledGrummanInventory(game);
return { game, owner, placed, placedTerritory, initial, afterSetup, previousCollection, beforePlacement, afterPlacement,
beforeCollection, drawing, afterDraw, actions,
census: { treachery: treacheryDeck(game.expansions).map(c => c.id).sort(), terror: [...TERROR_KINDS].sort(),
nexus: options.nexus ? [...NEXUS_FACTIONS].sort() : [] } };
}
+3 -27
View File
@@ -43,7 +43,9 @@ function fixture(): GameView {
event: 'collection-1',
player: 'm',
blocked: null,
removeBlocked: 'Removing a token awaits a custody ruling.',
removeBlocked: 'No placed Terror token is available to return.',
removable: [],
cunning: null,
tokens: [
{ id: 'terror-1', kind: 'robbery' },
{ id: 'terror-2', kind: 'sabotage' },
@@ -98,32 +100,6 @@ void test('public map markers group hidden Terror stacks without reading token f
assert.doesNotMatch(html, /available|removed/);
});
void test('Grumman controls require an explicit available-token addition before the four-spice reward', () => {
const view = fixture();
const before = structuredClone(view);
assert.deepEqual(grummanCollectionChoice(view, 'terror-2', 'carthag'), {
blocked: null,
action: {
type: 'decision',
event: 'collection-1',
mode: 'add',
token: 'terror-2',
territory: 'carthag',
},
});
const html = markup(view);
assert.match(html, /Your Terror token/);
assert.match(html, /Robbery/);
assert.match(html, /Sabotage/);
assert.match(html, /only after the addition resolves/);
assert.match(html, /Add token and collect 4 spice/);
assert.match(
html,
/Removal unavailable: Removing a token awaits a custody ruling/,
);
assert.doesNotMatch(html, /<button[^>]*>Remove/);
assert.deepEqual(view, before);
});
void test('stale tokens or destinations cannot add; blocked or exhausted collection can still be declined', () => {
const view = fixture();
+1 -2
View File
@@ -122,7 +122,7 @@ void test('low seven waits without income and expires on Mentat; a current high
stable(declined);
});
void test('owner event, physical availability, destination and pending removal rulings reject before token or spice mutation', () => {
void test('owner event, physical availability and destination reject before token or spice mutation', () => {
const g = enterGrummanCollection(grummanCollectionFixture());
const event = g.grummanCollection!.event;
const token = grummanToken(g, 'sabotage').id;
@@ -135,7 +135,6 @@ void test('owner event, physical availability, destination and pending removal r
territory: 'arrakeen',
},
{ event, mode: 'add', token, territory: 'carthag' },
{ event, mode: 'remove', token: grummanToken(g, 'robbery').id },
{ event, decline: true, token },
])
grummanReject(g, 'm', { type: 'decision', ...action });
+1 -7
View File
@@ -264,16 +264,10 @@ void test('production SQL lost Grumman add response preserves four spice and ind
} finally { f.sqlite.close(); }
});
void test('production SQL guarded removal and declined Grumman choice never pay or change token custody', async () => {
void test('production SQL declined Grumman choice never pays or changes token custody', async () => {
const f = await persisted();
try {
const g = await opened(f);
const before = snapshot(f);
await assert.rejects(f.rooms.act(f.code, f.auths[0], g.version, {
type: 'decision', event: g.grummanCollection!.event, mode: 'remove', token: token(g, 'robbery').id,
}, clock));
assert.deepEqual(snapshot(f), before);
assert.equal(f.writes.length, 0);
const done = await act(f, g, f.ids[0], { type: 'decision', event: g.grummanCollection!.event, decline: true });
assert.equal(done.grummanCollection!.outcome, 'decline');
assert.deepEqual(done.moritaniTerror, g.moritaniTerror);
+12 -7
View File
@@ -137,7 +137,7 @@ void test('offers need available supply and an existing ordinary stronghold toke
assert.match(quoteGrummanCollection(g, 'm').blocked!, /no available/);
});
void test('decline gives zero with cloned unchanged custody; unresolved removal never spends or reveals a token', () => {
void test('decline gives zero with cloned unchanged custody; removal requires current high population and a placed token', () => {
for (const native of [7, 8]) {
const g = fixture(native);
const before = JSON.stringify(g);
@@ -145,12 +145,17 @@ void test('decline gives zero with cloned unchanged custody; unresolved removal
assert.equal(declined.amount, 0);
assert.deepEqual(declined.state, g.moritaniTerror);
assert.notEqual(declined.state, g.moritaniTerror);
assert.match(quoteGrummanCollection(g, 'm').removeBlocked, /custody zone/);
for (const token of ['terror-1', 'terror-2', 'atomics-aftermath', 'absent'])
assert.throws(
() => quoteGrummanCollectionAction(g, 'm', { mode: 'remove', token }),
/custody zone/,
);
assert.equal(quoteGrummanCollection(g, 'm').removeBlocked === null, native >= 8);
for (const token of ['terror-2', 'atomics-aftermath', 'absent'])
assert.throws(() => quoteGrummanCollectionAction(g, 'm', { mode: 'remove', token }));
if (native < 8)
assert.throws(() => quoteGrummanCollectionAction(g, 'm', { mode: 'remove', token: 'terror-1' }));
else {
const returned = quoteGrummanCollectionAction(g, 'm', { mode: 'remove', token: 'terror-1' }, () => 0);
assert.equal(returned.amount, 4);
assert.equal(returned.state.tokens.filter(token => token.status === 'available').length, 6);
assert.equal(returned.state.placementTurn, 2);
}
assert.equal(JSON.stringify(g), before);
}
});
+132
View File
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { grummanCollectionChoice } from '../game/grumman-collection-options';
import { assertRuledGrummanInventory, createRuledGrummanFixture } from './fixture-ruled-grumman';
import { nextNativeE3HomeworldStep } from './fixture-homeworld-native-e3-modules';
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
function advance(state: Game, until: (game: Game) => boolean): Game {
let game = state;
for (let count = 0; count < 900; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
if (game.decision?.kind === 'moritaniPlacement' && !game.response && !game.phaseOpening)
game = applyAction(game, game.decision.player, {type:'decision',decline:true});
else {
const step = nextNativeE3HomeworldStep(game);
assert.ok(step);
game = applyAction(game,step.actor,step.action);
}
}
throw Error('Native Grumman Cunning programme missed its boundary');
}
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(()=>applyAction(game,actor,action));
assert.deepEqual(game,before);
}
function cunningFixture(advanced = false) {
const f = createRuledGrummanFixture({advanced,nexus:true});
const token = f.game.moritaniTerror!.tokens.find(t=>t.status==='available'&&t.kind==='sabotage')!;
const view = viewGame(f.game,f.owner);
const action = grummanCollectionChoice(view,token.id,'polar_sink','add',true).action;
assert.ok(action);
return {...f,token,action};
}
void test('native high-Grumman Cunning adds from supply to an empty static territory using its actual ordinary prerequisite',()=>{
for (const advanced of [false,true]) {
const f = cunningFixture(advanced);
const before = f.game;
const wallet = before.players.find(p=>p.id===f.owner)!.spice;
const armies = before.players.map(p=>({id:p.id,forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites}));
reject(before,'guild',f.action);
reject(before,f.owner,{...f.action,nexus:'old-cunning-opportunity'});
reject(before,f.owner,{...f.action,token:f.placed});
reject(before,f.owner,{...f.action,territory:'hidden_mobile_stronghold'});
reject(before,f.owner,{...f.action,territory:'homeworld:moritani'});
const game = applyAction(before,f.owner,f.action);
assert.equal(game.players.find(p=>p.id===f.owner)!.spice,wallet+4);
assert.deepEqual(game.players.map(p=>({id:p.id,forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites})),armies);
assert.equal(game.moritaniTerror!.placementTurn,before.moritaniTerror!.placementTurn);
assert.deepEqual(game.moritaniTerror!.tokens.find(t=>t.id===f.token.id),{...f.token,status:'placed',location:'polar_sink'});
assert.deepEqual(game.moritaniTerror!.tokens.find(t=>t.id===f.placed),before.moritaniTerror!.tokens.find(t=>t.id===f.placed));
const receipt = game.nexusMoritaniHistory!.at(-1)!.receipt;
assert.equal(receipt.source,'grumman');
if(receipt.source!=='grumman')throw Error('Expected the original Grumman source');
assert.equal(receipt.sourceEvent,before.grummanCollection!.event);
assert.equal(receipt.prerequisite,f.placedTerritory);
assert.equal(game.nexusCards!.cards!.hands[f.owner],null);
assert.equal(game.nexusCards!.cards!.discard.filter(card=>card==='moritani').length,1);
assert.equal(game.grummanCollection!.nexusEvent,receipt.event);
for(const p of game.players.filter(p=>p.id!==f.owner)){
const view = viewGame(game,p.id);
assert.ok(view.moritaniTerror!.tokens.every(t=>!('kind' in t)));
assert.equal(Object.hasOwn(view,'nexusMoritaniHistory'),false);
}
reject(game,f.owner,f.action);
assertRuledGrummanInventory(game);
}
});
void test('a Grumman Cunning location authorizes a later real entry and its unrevealed return retires only that location',()=>{
const f = cunningFixture();
let game = applyAction(f.game,f.owner,f.action);
game = advance(game,g=>g.turn===3&&g.phase===5&&g.active==='guild'&&clean(g));
const beforeShipment = structuredClone(game);
const payer = game.players.find(p=>p.id==='guild')!;
game = applyAction(game,'guild',{type:'ship',territory:'polar_sink',sector:0,amount:1,elite:0,
homeworldSources:{'homeworld:guild':{normal:1,elite:0}}});
assert.equal(game.pendingTerrorEntry!.token,f.token.id);
assert.equal(game.pendingTerrorEntry!.territory,'polar_sink');
assert.equal(game.players.find(p=>p.id==='guild')!.spice,payer.spice-1);
assert.equal(game.players.find(p=>p.id==='guild')!.forces['polar_sink:0'],1);
assert.equal(game.players.find(p=>p.id==='guild')!.reserves,payer.reserves-1);
game = applyAction(game,f.owner,{type:'decision',decline:true});
assert.equal(game.moritaniTerror!.tokens.find(t=>t.id===f.token.id)!.status,'placed');
assert.equal(game.players.find(p=>p.id==='guild')!.spice,beforeShipment.players.find(p=>p.id==='guild')!.spice-1);
game = advance(game,g=>g.turn===3&&g.decision?.kind==='grummanCollection');
const balance = game.players.find(p=>p.id===f.owner)!.spice;
game = applyAction(game,f.owner,{type:'decision',event:game.grummanCollection!.event,mode:'remove',token:f.token.id});
assert.equal(game.players.find(p=>p.id===f.owner)!.spice,balance+4);
assert.equal(game.nexusMoritaniLocations![f.token.id],undefined);
assert.equal(game.moritaniTerror!.tokens.some(t=>t.id===f.token.id),false);
assert.equal(game.moritaniTerror!.tokens.find(t=>t.id===f.placed)!.location,f.placedTerritory);
assert.equal(game.moritaniTerror!.tokens.find(t=>t.kind===f.token.kind)!.status,'available');
assertRuledGrummanInventory(game);
});
void test('held Cunning cannot manufacture a Grumman addition after the only board token actually returned',()=>{
const f = createRuledGrummanFixture({nexus:true});
let game = applyAction(f.game,f.owner,{type:'decision',event:f.game.grummanCollection!.event,mode:'remove',token:f.placed});
game = advance(game,g=>g.turn===3&&g.decision?.kind==='grummanCollection');
assert.equal(game.nexusCards!.cards!.hands[f.owner],'moritani');
const view = viewGame(game,f.owner), offer = view.grummanCollection!;
assert.ok(offer.cunning!.blocked);
assert.equal(offer.cunning!.prerequisite,null);
const token = offer.tokens[0].id;
assert.equal(grummanCollectionChoice(view,token,'polar_sink','add',true).action,null);
reject(game,f.owner,{type:'decision',event:offer.event,mode:'add',token,territory:'polar_sink',nexus:offer.cunning!.event});
const declined = applyAction(game,f.owner,{type:'decision',event:offer.event,decline:true});
assert.equal(declined.players.find(p=>p.id===f.owner)!.spice,game.players.find(p=>p.id===f.owner)!.spice);
assert.equal(declined.nexusCards!.cards!.hands[f.owner],'moritani');
});
void test('all existing legal policies resolve the owned Grumman Cunning source without reading hidden opposing faces',()=>{
const f = cunningFixture();
for(const difficulty of DIFFICULTIES){
const view = viewGame(f.game,f.owner);
view.players.find(p=>p.id===f.owner)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action);
const done = applyAction(f.game,f.owner,action);
assert.equal(done.players.find(p=>p.id===f.owner)!.spice,f.game.players.find(p=>p.id===f.owner)!.spice+4);
assert.equal(done.grummanCollection!.stage,'complete');
assert.equal(done.nexusCards!.cards!.hands[f.owner],null);
assert.equal(done.moritaniTerror!.placementTurn,f.game.moritaniTerror!.placementTurn);
assertRuledGrummanInventory(done);
}
});
+133
View File
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { quoteGrummanCollection, quoteGrummanCollectionAction } from '../game/grumman-collection';
import { grummanCollectionSignature, validateGrummanCollection } from '../game/grumman-collection-return';
import { placeTerror, projectTerror, revealTerror, TERROR_KINDS } from '../game/moritani-terror';
import { nextNativeE3HomeworldStep } from './fixture-homeworld-native-e3-modules';
import { assertRuledGrummanInventory, createRuledGrummanFixture } from './fixture-ruled-grumman';
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('native Grumman removal returns the unrevealed face to rotated supply, credits four spice once and preserves Mentat use', () => {
for (const advanced of [false, true]) {
const f = createRuledGrummanFixture({ advanced });
const original = structuredClone(f.game);
const prior = f.game.moritaniTerror!;
const face = prior.tokens.find(t => t.id === f.placed)!.kind;
const owner = f.game.players.find(p => p.id === f.owner)!;
const request: Action = { type: 'decision', event: f.game.grummanCollection!.event, mode: 'remove', token: f.placed };
reject(f.game, 'guild', request);
for (const token of [prior.tokens.find(t => t.status === 'available')!.id, 'missing'])
reject(f.game, f.owner, { ...request, token });
let game = applyAction(f.game, f.owner, request);
assert.deepEqual(f.game, original);
assert.equal(game.players.find(p => p.id === f.owner)!.spice, owner.spice + 4);
assert.equal(game.grummanCollection!.stage, 'complete');
assert.equal(game.grummanCollection!.outcome, 'remove');
assert.equal(game.grummanCollection!.token, f.placed);
assert.equal(game.grummanCollection!.territory, f.placedTerritory);
assert.equal(game.moritaniTerror!.placementTurn, prior.placementTurn);
assert.equal(game.moritaniTerror!.supplyEpoch, (prior.supplyEpoch ?? 0) + 1);
assert.deepEqual(game.moritaniTerror!.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort());
assert.ok(game.moritaniTerror!.tokens.every(t => t.status === 'available' && t.location === null));
assert.ok(game.moritaniTerror!.tokens.every(t => !prior.tokens.some(old => old.id === t.id)));
assert.deepEqual(projectTerror(game.moritaniTerror!, false).tokens, []);
assertRuledGrummanInventory(game);
validateGrummanCollection(game);
reject(game, f.owner, request);
reject(game, f.owner, { ...request, mode: 'add', token: game.moritaniTerror!.tokens[0].id, territory: f.placedTerritory });
for (let count = 0; game.decision?.kind !== 'moritaniPlacement' && count < 100; count++) {
const step = nextNativeE3HomeworldStep(game); assert.ok(step);
game = applyAction(game, step.actor, step.action);
}
assert.equal(game.decision?.kind, 'moritaniPlacement');
const returned = game.moritaniTerror!.tokens.find(t => t.kind === face)!;
assert.notEqual(returned.id, f.placed);
game = applyAction(game, f.owner, { type: 'decision', token: returned.id, territory: 'carthag' });
for (let count = 0; game.response && count < 20; count++) {
const step = nextNativeE3HomeworldStep(game); assert.ok(step);
game = applyAction(game, step.actor, step.action);
}
assert.equal(game.moritaniTerror!.placementTurn, 2);
const redeployed = projectTerror(game.moritaniTerror!, false).tokens.find(t => t.id === returned.id)!;
assert.deepEqual(redeployed, { id: returned.id, location: 'carthag', status: 'placed' });
assertRuledGrummanInventory(game);
assert.ok(!JSON.stringify(viewGame(game, 'guild').moritaniTerror).includes(f.placed));
}
});
void test('return quote remains available without ADD supply and preserves other placements and spent faces', () => {
const f = createRuledGrummanFixture();
const game = structuredClone(f.game);
// Pure custody input: all faces are physically placed, so no addition exists.
game.moritaniTerror = placeTerror(game.moritaniTerror!, game.moritaniTerror!.tokens.find(t => t.kind === 'sabotage')!.id, 'carthag', 2);
for (const token of game.moritaniTerror.tokens)
if (token.status === 'available') { token.status = 'placed'; token.location = 'carthag'; }
const quote = quoteGrummanCollection(game, f.owner);
assert.ok(quote.blocked);
assert.equal(quote.removeBlocked, null);
assert.equal(quote.removable.length, 6);
quote.removable[0].location = 'not-a-location';
assert.notEqual(game.moritaniTerror.tokens[0].location, 'not-a-location');
const before = structuredClone(game);
const result = quoteGrummanCollectionAction(game, f.owner, { mode: 'remove', token: f.placed }, () => 0);
assert.equal(result.amount, 4);
assert.equal(result.state.placementTurn, 2);
assert.deepEqual(result.state.tokens.filter(t => t.status === 'placed'), before.moritaniTerror!.tokens.filter(t => t.id !== f.placed));
assert.equal(result.state.tokens.filter(t => t.status === 'available').length, 1);
assert.deepEqual(game, before);
for (const kind of ['robbery', 'extortion'] as const) {
const source = structuredClone(f.game);
const selected = source.moritaniTerror!.tokens.find(t => t.kind === kind)!;
if (selected.status === 'available') source.moritaniTerror = placeTerror(source.moritaniTerror!, selected.id, 'carthag', 2);
source.moritaniTerror = revealTerror(source.moritaniTerror!, selected.id);
const invalid = structuredClone(source);
assert.throws(() => quoteGrummanCollectionAction(source, f.owner, { mode: 'remove', token: selected.id }, () => 0));
assert.deepEqual(source, invalid);
if (kind === 'extortion') {
const returned = quoteGrummanCollectionAction(source, f.owner, { mode: 'remove', token: f.placed }, () => 0);
assert.deepEqual(returned.state.tokens.find(t => t.id === selected.id), source.moritaniTerror.tokens.find(t => t.id === selected.id));
}
}
});
void test('stale rotated IDs, inactive Grumman, low native population and foreign owners cannot quote a return', () => {
const f = createRuledGrummanFixture();
const action = { mode: 'remove', token: f.placed } as const;
const result = quoteGrummanCollectionAction(f.game, f.owner, action, () => 0);
const returned = { ...f.game, moritaniTerror: result.state };
assert.throws(() => quoteGrummanCollectionAction(returned, f.owner, action, () => 0));
for (const context of [
{ ...f.game, homeworlds: undefined },
{ ...f.game, players: f.game.players.map(p => p.id === f.owner ? { ...p, reserves: 7 } : p) },
]) {
const before = structuredClone(context);
assert.ok(quoteGrummanCollection(context, f.owner).removeBlocked);
assert.deepEqual(quoteGrummanCollection(context, f.owner).removable, []);
assert.throws(() => quoteGrummanCollectionAction(context, f.owner, action, () => 0));
assert.deepEqual(context, before);
}
assert.throws(() => quoteGrummanCollectionAction(f.game, 'guild', action, () => 0));
assert.throws(() => quoteGrummanCollectionAction(f.game, f.owner, { ...action, token: 'missing' }, () => 0));
});
void test('native removal historical frame binds its original ID and territory without requiring a live token', () => {
const f = createRuledGrummanFixture();
const game = applyAction(f.game, f.owner, { type: 'decision', event: f.game.grummanCollection!.event, mode: 'remove', token: f.placed });
assert.ok(!game.moritaniTerror!.tokens.some(t => t.id === f.placed));
validateGrummanCollection(game);
for (const changes of [
{ territory: 'homeworld:moritani' }, { territory: 'mobile_stronghold' }, { token: undefined },
{ nexusEvent: 'unearned' }, { outcome: 'decline' as const },
]) {
const bad = structuredClone(game);
Object.assign(bad.grummanCollection!, changes);
bad.grummanCollection!.signature = grummanCollectionSignature(bad.grummanCollection!);
assert.throws(() => validateGrummanCollection(bad));
}
});
-4
View File
@@ -66,10 +66,6 @@ void test('genuine Basic and Advanced Mentat keeps native relocation choices and
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(
+225
View File
@@ -0,0 +1,225 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { TERRITORIES, MOBILE_STRONGHOLD } from '../game/board';
import {
createTerrorState,
placeTerror,
revealTerror,
type TerrorState,
} from '../game/moritani-terror';
import {
createNexusMoritani,
validateNexusMoritani,
quoteNexusMoritaniPlacement,
quoteNexusMoritaniGrummanPlacement,
type NexusMoritaniContext,
type NexusMoritaniGrummanSource,
type NexusMoritaniReceipt,
} from '../game/nexus-moritani';
const context: NexusMoritaniContext = {
turn: 3,
players: [
{ id: 'm', faction: 'moritani' },
{ id: 'a', faction: 'atreides' },
],
};
const source: NexusMoritaniGrummanSource = {
source: 'grumman',
sourceEvent: JSON.stringify(['grummanCollection', 3, 'm']),
prerequisite: 'arrakeen',
};
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
const fresh = () => createTerrorState(() => 0.3);
const eligible = (turn = 1) => {
const state = fresh();
return placeTerror(state, state.tokens[0].id, 'arrakeen', turn);
};
const grummanEvent = (receipt: Extract<NexusMoritaniReceipt, { source: 'grumman' }>) =>
JSON.stringify([
'nexusMoritaniGrumman',
receipt.turn,
receipt.owner,
receipt.token,
receipt.territory,
receipt.sourceEvent,
receipt.prerequisite,
]);
void test('Grumman Cunning receipts bind the native source and ordinary prerequisite to their event and signature', () => {
const receipt = createNexusMoritani(context, 'm', 'supply-token', 'polar_sink', source);
assert.equal(receipt.source, 'grumman');
if (receipt.source !== 'grumman') throw new Error('Expected Grumman receipt');
assert.equal(receipt.phase, 7);
assert.equal(receipt.event, grummanEvent(receipt));
validateNexusMoritani({ ...context, turn: 8 }, clone(receipt));
assert.notEqual(
receipt.event,
createNexusMoritani(context, 'm', 'supply-token', 'polar_sink', {
...source,
sourceEvent: 'another-native-operation',
}).event,
);
assert.notEqual(
receipt.event,
createNexusMoritani(context, 'm', 'supply-token', 'polar_sink', {
...source,
prerequisite: 'carthag',
}).event,
);
const edits: ((r: Extract<NexusMoritaniReceipt, { source: 'grumman' }>) => void)[] = [
(r) => { r.sourceEvent = 'other-source'; },
(r) => { r.prerequisite = 'carthag'; },
(r) => { r.sourceEvent = 'other-source'; r.event = grummanEvent(r); },
(r) => { r.prerequisite = 'carthag'; r.event = grummanEvent(r); },
(r) => { r.token = 'other-token'; },
(r) => { r.territory = 'hagga_basin'; },
(r) => { r.turn = 2; },
(r) => { r.owner = 'a'; },
(r) => { Object.assign(r, { source: 'mentat', phase: 8 }); },
(r) => { Object.assign(r, { phase: 8 }); },
(r) => { delete (r as Partial<Extract<NexusMoritaniReceipt, { source: 'grumman' }>>).sourceEvent; },
(r) => { delete (r as Partial<Extract<NexusMoritaniReceipt, { source: 'grumman' }>>).prerequisite; },
(r) => { r.signature = ''; },
];
for (const edit of edits) {
const bad: Extract<NexusMoritaniReceipt, { source: 'grumman' }> = clone(receipt);
edit(bad);
const before: typeof bad = clone(bad);
assert.throws(() => validateNexusMoritani(context, bad));
assert.deepEqual(bad, before);
}
assert.throws(() => validateNexusMoritani({ ...context, turn: 2 }, receipt));
for (const prerequisite of ['polar_sink', MOBILE_STRONGHOLD, 'homeworld:moritani', ''])
assert.throws(() => createNexusMoritani(context, 'm', 'token', 'polar_sink', {
...source,
prerequisite,
}));
assert.throws(() => createNexusMoritani(context, 'm', 'token', 'polar_sink', {
...source,
sourceEvent: '',
}));
});
void test('Existing Mentat evidence authenticates independently of the new Collection source', () => {
const legacy: NexusMoritaniReceipt = {
version: 1,
event: JSON.stringify(['nexusMoritani', 3, 'm', 'token', 'hagga_basin']),
source: 'mentat',
phase: 8,
turn: 3,
owner: 'm',
token: 'token',
territory: 'hagga_basin',
roster: [{ id: 'a', faction: 'atreides' }, { id: 'm', faction: 'moritani' }],
signature: '',
};
legacy.signature = JSON.stringify([
1, legacy.event, 'mentat', 8, 3, 'm', 'token', 'hagga_basin',
[['a', 'atreides'], ['m', 'moritani']],
]);
validateNexusMoritani(context, legacy);
const current = createNexusMoritani(context, 'm', 'token', 'hagga_basin');
assert.equal(current.event, legacy.event);
assert.equal(current.signature, legacy.signature);
validateNexusMoritani(context, current);
assert.throws(() => validateNexusMoritani(context, {
...legacy,
source: 'grumman',
phase: 7,
sourceEvent: source.sourceEvent,
prerequisite: source.prerequisite,
}));
});
void test('Grumman Cunning broadens an available ordinary addition to every printed static territory, including empty desert and stacks', () => {
const state = eligible();
const before = clone(state);
const id = state.tokens[1].id;
for (const territory of TERRITORIES) {
const placed = quoteNexusMoritaniGrummanPlacement(state, id, territory.id, 'arrakeen');
assert.equal(placed.tokens[1].status, 'placed');
assert.equal(placed.tokens[1].location, territory.id);
assert.equal(placed.placementTurn, state.placementTurn);
assert.deepEqual(placed.tokens.filter((token) => token.id !== id), state.tokens.filter((token) => token.id !== id));
if (territory.id === 'arrakeen')
assert.equal(placed.tokens.filter((token) => token.location === 'arrakeen').length, 2);
}
assert.deepEqual(state, before);
const desert = quoteNexusMoritaniGrummanPlacement(state, id, 'hagga_basin', 'arrakeen');
const stacked = quoteNexusMoritaniGrummanPlacement(desert, desert.tokens[2].id, 'hagga_basin', 'arrakeen');
assert.equal(stacked.tokens.filter((token) => token.location === 'hagga_basin').length, 2);
});
void test('Grumman Cunning requires an actually eligible ordinary stronghold before broadening the destination', () => {
const empty = fresh();
const desertOnly = quoteNexusMoritaniPlacement(empty, empty.tokens[0].id, 'hagga_basin', 1);
const ordinary = eligible();
for (const [state, prerequisite] of [
[empty, 'arrakeen'],
[desertOnly, 'arrakeen'],
[desertOnly, 'hagga_basin'],
[ordinary, 'carthag'],
[ordinary, MOBILE_STRONGHOLD],
[ordinary, 'homeworld:moritani'],
] as const) {
const before = clone(state);
assert.throws(() => quoteNexusMoritaniGrummanPlacement(state, state.tokens[1].id, 'polar_sink', prerequisite));
assert.deepEqual(state, before);
}
});
void test('Grumman Cunning cannot create supply, reuse revealed tokens or enhance relocation, and rejects excluded destinations', () => {
const state = eligible();
const revealed = revealTerror(state, state.tokens[0].id);
for (const [stock, id] of [
[state, 'invented-token'],
[state, state.tokens[0].id],
[revealed, revealed.tokens[0].id],
] as const) {
const before = clone(stock);
assert.throws(() => quoteNexusMoritaniGrummanPlacement(stock, id, 'polar_sink', 'arrakeen'));
assert.deepEqual(stock, before);
}
for (const territory of [MOBILE_STRONGHOLD, 'homeworld:moritani', 'arrakeen:10', '', '__proto__']) {
assert.throws(() => quoteNexusMoritaniGrummanPlacement(state, state.tokens[1].id, territory, 'arrakeen'));
assert.throws(() => createNexusMoritani(context, 'm', state.tokens[1].id, territory, source));
}
const edits: ((stock: TerrorState) => void)[] = [
(stock) => { stock.tokens.pop(); },
(stock) => { stock.tokens[2].id = stock.tokens[1].id; },
(stock) => { stock.tokens[2].kind = stock.tokens[1].kind; },
(stock) => { stock.tokens[1].location = 'arrakeen'; },
(stock) => { stock.supplyEpoch = -1; },
];
for (const edit of edits) {
const bad = clone(state);
edit(bad);
const before = clone(bad);
assert.throws(() => quoteNexusMoritaniGrummanPlacement(bad, state.tokens[1].id, 'polar_sink', 'arrakeen'));
assert.deepEqual(bad, before);
}
});
void test('Grumman Cunning preserves the physical faces, other placements, supply epoch and independent Mentat allowance', () => {
let state = eligible();
state = quoteNexusMoritaniPlacement(state, state.tokens[1].id, 'hagga_basin', 2);
state.supplyEpoch = 4;
const before = clone(state);
const placed = quoteNexusMoritaniGrummanPlacement(state, state.tokens[2].id, 'polar_sink', 'arrakeen');
assert.deepEqual(placed.tokens.map(({ id, kind }) => ({ id, kind })), state.tokens.map(({ id, kind }) => ({ id, kind })));
assert.deepEqual(placed.tokens.filter((token) => token.id !== state.tokens[2].id), state.tokens.filter((token) => token.id !== state.tokens[2].id));
assert.equal(placed.supplyEpoch, 4);
assert.equal(placed.placementTurn, 2);
const mentat = quoteNexusMoritaniPlacement(placed, placed.tokens[3].id, 'carthag', 3);
assert.equal(mentat.placementTurn, 3);
assert.equal(mentat.tokens[2].location, 'polar_sink');
placed.tokens[0].location = 'carthag';
placed.tokens[4].status = 'removed';
assert.deepEqual(state, before);
const alreadySpent = eligible(3);
const collection = quoteNexusMoritaniGrummanPlacement(alreadySpent, alreadySpent.tokens[1].id, 'hagga_basin', 'arrakeen');
assert.equal(collection.placementTurn, 3);
assert.throws(() => quoteNexusMoritaniPlacement(collection, collection.tokens[2].id, 'carthag', 3));
});