Apply ruled Grumman return and Cunning placement
This commit is contained in:
1 parent
7994026a70
commit
2efa69d10f
24 files changed
+1246
-219
No files matched your search
@@ -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() : [] } };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in new issue
Block a user