Connect BG Nexus fresh and storm advisor conversion

This commit is contained in:
Quintine committed 2026-10-15 04:40:46 +11:00
1 parent 82f1aae51b
commit 985cf85e12
21 files changed
+936 -316

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { type SpiceCard } from '../game/cards';
import { validateNexusCards } from '../game/nexus-cards';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type NexusAdvisorFreshFixture = {
initial: Game;
afterSetup: Game;
game: Game;
owner: string;
foreign: string;
canceler: string;
card: string;
territory: string;
location: string;
event: string;
declaration: Action;
batchDeclaration: Action;
ordinaryAction: Action;
actions: StrongholdFactionsNativeStep[];
beforeForeignShipment: Game;
afterForeignShipment: Game;
beforeAccompaniment: Game;
afterAccompaniment: Game;
beforeReinforcement: Game;
scenario: {
turn: number;
storm: number;
lockedTurn: number;
advisors: number;
opposingFighters: number;
alliance: [string, string];
ordinaryTarget: string;
undealtOrder: string[];
};
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
export function nexusAdvisorFreshPlayer(game: Game, actor: string) {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing original advisor seat ${actor}`);
return player;
}
function entropy<T>(producer: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return producer(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function nativeStep(game: Game): StrongholdFactionsNativeStep {
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision', take: game.turn === 2 } };
// Preserve original advisors through the earlier mandatory declaration.
if (decision?.kind === 'advisorBattle' || decision?.kind === 'advisor' || decision?.kind === 'intrusion')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (game.status === 'setup' && game.setupStage === 'prediction')
return { actor: 'bg', action: { type: 'predict', faction: 'emperor', turn: 10 } };
if (game.status === 'setup' && game.setupStage === 'forces' && !nexusAdvisorFreshPlayer(game, 'bg').advisorSetup)
return { actor: 'bg', action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
}
const step = nextStrongholdFactionsNativeStep(game);
assert.ok(step, 'The genuine advisor fixture reached an unexpected private battle plan');
return step;
}
function run(game: Game, step: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]): Game {
actions.push(structuredClone(step));
return entropy(() => applyAction(game, step.actor, step.action));
}
function advance(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
game = run(game, nativeStep(game), actions);
}
throw Error('Native advisor progression did not reach its requested boundary');
}
/** Finish only original acknowledgement/decision windows, never another movement turn. */
export function settleNexusAdvisorFresh(game: Game): Game {
return advance(game, clean, []);
}
/** Reorder undealt Spice only. Previously blown cards retain their discard custody. */
function orderSpice(game: Game, kinds: ('land' | 'worm')[], order: string[]): void {
const front: SpiceCard[] = [];
for (const kind of kinds) {
const index = game.spiceDeck.findIndex(card => kind === 'worm' ? 'worm' in card :
'territory' in card && !['imperial_basin', 'arrakeen', 'tueks_sietch'].includes(card.territory));
assert.ok(index >= 0, `Missing original undealt ${kind}`);
const card = game.spiceDeck.splice(index, 1)[0];
front.push(card);
order.push('territory' in card ? card.territory : 'worm');
}
game.spiceDeck.unshift(...front);
}
/** Every force, payment, lock, alliance, card deal and continuation is native.
* The only staged values are physical undealt deck order and producer entropy. */
export function createNexusAdvisorFreshFixture(): NexusAdvisorFreshFixture {
const owner = 'bg', foreign = 'guild', canceler = foreign;
const territory = 'shield_wall', location = 'shield_wall:9';
const actions: StrongholdFactionsNativeStep[] = [], undealtOrder: string[] = [];
let game = createGame('NEXUSADVISORFRESH', newPlayer(owner, 'Bene Gesserit', 'beneGesserit'), true, []);
joinGame(game, newPlayer(foreign, 'Guild', 'guild'));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
game.nexusCards = { cards: null, phase: null };
for (const player of game.players) game = run(game, { actor: player.id, action: { type: 'ready' } }, actions);
game = entropy(() => initializeNexusGameForAudit(game));
assert.equal(game.status, 'setup');
assert.ok(game.players.every(p => p.hand.length === 0));
// The original starting deal consumes one card per seat, in roster order.
const karamaIndex = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(karamaIndex >= 0);
const card = game.deck[karamaIndex].id;
[game.deck[1], game.deck[karamaIndex]] = [game.deck[karamaIndex], game.deck[1]];
undealtOrder.push(`Starting deal ${card} to ${canceler}`);
const initial = structuredClone(game);
game = advance(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
assert.ok(nexusAdvisorFreshPlayer(game, canceler).hand.some(held => held.id === card));
game = advance(game, state => state.phase === 1 && clean(state), actions);
orderSpice(game, ['land', 'land'], undealtOrder);
game = advance(game, state => state.phase === 5 && state.active === 'atreides' && clean(state), actions);
// A genuine nearby occupied destination makes the later ordinary flip action
// geometrically legal; only its fresh advisor stance disallows fighter entry.
game = run(game, { actor: 'atreides', action: { type: 'ship', territory: 'imperial_basin', sector: 10, amount: 1 } }, actions);
game = advance(game, clean, actions);
game = advance(game, state => state.turn === 2 && state.phase === 1 && clean(state), actions);
orderSpice(game, ['worm', 'land', 'land'], undealtOrder);
game = advance(game, state => state.nexus === true && !state.spiceWindow, actions);
game = run(game, { actor: 'emperor', action: { type: 'alliance', target: 'atreides' } }, actions);
game = run(game, { actor: 'atreides', action: { type: 'alliance', target: 'emperor' } }, actions);
game = advance(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
for (const [actor, face] of [[owner, 'beneGesserit'], [foreign, 'guild']] as const) {
const cards = game.nexusCards!.cards!;
const index = cards.deck.indexOf(face);
assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
undealtOrder.push(`Closing Nexus ${face} to ${actor}`);
game = run(game, { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }, actions);
}
game = advance(game, state => state.phase === 5 && state.active === foreign && clean(state), actions);
const beforeForeignShipment = structuredClone(game);
game = run(game, { actor: foreign, action: { type: 'ship', territory, sector: 9, amount: 1 } }, actions);
game = advance(game, state => state.decision?.kind === 'advisor' && !state.response && !state.phaseOpening, actions);
const afterForeignShipment = structuredClone(game), beforeAccompaniment = structuredClone(game);
game = run(game, { actor: owner, action: { type: 'decision', accept: true, accompany: true, amount: 1 } }, actions);
game = advance(game, clean, actions);
const afterAccompaniment = structuredClone(game);
game = run(game, { actor: foreign, action: { type: 'endMovement' } }, actions);
game = advance(game, state => state.phase === 5 && state.active === owner && clean(state), actions);
const beforeReinforcement = structuredClone(game);
game = run(game, { actor: owner, action: { type: 'ship', territory, sector: 9, amount: 1 } }, actions);
game = advance(game, clean, actions);
const player = nexusAdvisorFreshPlayer(game, owner);
assert.equal(player.advisors?.[territory]?.lockedTurn, game.turn);
assert.equal(player.forces[location], 2);
assert.equal(game.nexusCards!.cards!.hands[owner], 'beneGesserit');
assert.equal(player.ally, null);
validateNexusCards(game.nexusCards!.cards!, game.players);
const event = viewGame(game, owner).nexusAdvisors!.offer!.event;
return {
initial, afterSetup, game, owner, foreign, canceler, card, territory, location, event,
declaration: { type: 'nexusAdvisors', event, territories: [territory] },
batchDeclaration: { type: 'nexusAdvisors', event, territories: ['arrakeen', territory] },
ordinaryAction: { type: 'move', from: location, territory: 'imperial_basin', sector: 10, amount: 1, fighters: true },
actions, beforeForeignShipment, afterForeignShipment, beforeAccompaniment, afterAccompaniment, beforeReinforcement,
scenario: { turn: game.turn, storm: game.storm, lockedTurn: player.advisors![territory].lockedTurn!,
advisors: player.forces[location], opposingFighters: nexusAdvisorFreshPlayer(game, foreign).forces[location],
alliance: ['emperor', 'atreides'], ordinaryTarget: 'imperial_basin:10', undealtOrder },
};
}
+230
View File
@@ -0,0 +1,230 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
type Action, type Game, type Player,
} from '../game/engine';
import { gameDistance, splitLocation, territory } from '../game/board';
import type { FactionId } from '../game/catalog';
import { validateNexusCards } from '../game/nexus-cards';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { nexusBorrowedVoiceInventory } from './fixture-nexus-borrowed-voice';
export type NexusAdvisorStormStep = { actor: string; action: Action };
export type NexusAdvisorStormFixture = {
initial: Game;
afterSetup: Game;
afterShipment: Game;
beforeStorm: Game;
afterStorm: Game;
game: Game;
owner: string;
opponent: string;
canceler: string;
cancelerCard: string;
territory: string;
storm: number;
separated: boolean;
physicalFacts: {
sectors: number[];
groups: Record<string, number>;
opponentGroups: Record<string, number>;
originalStance: { lockedTurn?: number };
unblockedDistance: number;
blockedDistance: number;
stormSource: Game['stormMovementSource'];
};
actions: NexusAdvisorStormStep[];
staging: string[];
scenario: Record<string, unknown>;
};
export function nexusAdvisorStormPlayer(game: Game, id: string): Player {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player, `Missing original storm seat ${id}`);
return player;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Entropy affects original producers only. No persisted hand, board, wallet,
* phase, casualty, alliance, response or source receipt is staged. */
function identityRandom<T>(run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
type Driver = {
owner: string;
opponent: string;
canceler: string;
territory: string;
accompanySector: number;
reinforcementSector: number;
declinedAdvisorBattle: boolean;
};
function nextStep(game: Game, driver: Driver): NexusAdvisorStormStep | null {
if (game.status === 'setup' && game.setupStage === 'prediction')
return { actor: driver.owner, action: { type: 'predict', faction: 'emperor', turn: 10 } };
if (game.status === 'setup' && game.setupStage === 'forces' &&
!nexusAdvisorStormPlayer(game, driver.owner).advisorSetup)
return { actor: driver.owner, action: { type: 'advisorSetup', territory: 'polar_sink', sector: 0 } };
const decision = game.decision;
if (decision?.kind === 'advisorBattle') {
driver.declinedAdvisorBattle = true;
return { actor: decision.player, action: { type: 'decision', accept: false } };
}
if (decision?.kind === 'advisor') {
const accompany = game.turn === 1 && decision.shipment === driver.opponent;
return { actor: decision.player, action: accompany
? { type: 'decision', accept: true, accompany: true, sector: driver.accompanySector, amount: 1 }
: { type: 'decision', accept: false } };
}
if (decision?.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision', take: false } };
if (!clean(game)) return nextStrongholdFactionsNativeStep(game);
if (game.nexusCards?.phase?.stage === 'drawing') {
const phase = game.nexusCards.phase;
const actor = phase.eligible.find(id => !phase.done.includes(id));
if (actor) return { actor, action: { type: 'nexusCardChoice', turn: phase.turn,
card: game.nexusCards.cards!.hands[actor],
choice: game.nexusCards.cards!.hands[actor] === null ? 'draw' : 'keep', ownRedraws: 0 } };
}
if (game.status === 'playing' && game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return { actor, action: { type: 'stormDial', amount: 0 } };
}
if (game.turn === 1 && game.phase === 3 && game.auction &&
!nexusAdvisorStormPlayer(game, driver.canceler).hand.some(card => card.effect === 'karama') &&
game.auction.index === 0 && game.auction.bid === 0 && game.auction.active === driver.canceler)
return { actor: driver.canceler, action: { type: 'bid', amount: 1 } };
if (game.turn === 1 && game.phase === 5 && game.active &&
[driver.owner, driver.opponent].includes(game.active) &&
!nexusAdvisorStormPlayer(game, game.active).shipped)
return { actor: game.active, action: { type: 'ship', territory: driver.territory,
sector: game.active === driver.owner ? driver.reinforcementSector : driver.accompanySector,
amount: 1, elite: 0, allyPayment: 0,
...(game.active === driver.owner ? { advisors: true } : {}) } };
return nextStrongholdFactionsNativeStep(game);
}
function advance(game: Game, until: (state: Game) => boolean,
driver: Driver, actions: NexusAdvisorStormStep[]): Game {
for (let count = 0; count < 2600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original chronology must remain playable');
const next = nextStep(game, driver);
assert.ok(next, 'The native storm chronology must not require an invented battle plan');
actions.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
}
throw Error('Original advisor storm chronology did not reach its requested boundary');
}
function frontUndealt<T>(deck: T[], matches: (card: T) => boolean): void {
const index = deck.findIndex(matches);
assert.ok(index >= 0, 'The requested physical card must remain undealt');
deck.unshift(deck.splice(index, 1)[0]);
}
function build(separated: boolean): NexusAdvisorStormFixture {
const owner = 'storm-bene-gesserit', opponent = 'storm-emperor', canceler = 'storm-guild';
const harkonnen = 'storm-harkonnen';
const site = separated ? 'false_wall_east' : 'arrakeen';
const accompanySector = separated ? 5 : 10, reinforcementSector = separated ? 9 : 10;
const actions: NexusAdvisorStormStep[] = [], staging: string[] = [];
let lobby = createGame('NEXUSADVISORSTORM', newPlayer(opponent, 'Emperor', 'emperor'), true);
joinGame(lobby, newPlayer(owner, 'Bene Gesserit', 'beneGesserit'));
joinGame(lobby, newPlayer(canceler, 'Guild', 'guild'));
joinGame(lobby, newPlayer(harkonnen, 'Harkonnen', 'harkonnen'));
lobby.nexusCards ??= { cards: null, phase: null };
for (const player of lobby.players) if (!player.ready)
lobby = applyAction(lobby, player.id, { type: 'ready' });
const initial = structuredClone(lobby);
let game = initializeNexusGameForAudit(lobby);
const driver: Driver = { owner, opponent, canceler, territory: site,
accompanySector, reinforcementSector, declinedAdvisorBattle: false };
game = advance(game, state => state.status === 'playing', driver, actions);
const afterSetup = structuredClone(game);
game = advance(game, state => state.turn === 1 && state.phase === 2 && clean(state), driver, actions);
frontUndealt(game.deck, card => card.effect === 'karama');
frontUndealt(game.spiceDeck, card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
staging.push('Ordered one original undealt Karama for the real first auction and one original undealt Shai-Hulud for the real turn-two Nexus.');
game = advance(game, state => state.turn === 1 && state.phase === 5 && clean(state) &&
nexusAdvisorStormPlayer(state, owner).shipped, driver, actions);
const afterShipment = structuredClone(game);
assert.equal(nexusAdvisorStormPlayer(game, owner).forces[`${site}:${accompanySector}`], separated ? 1 : 2);
assert.equal(nexusAdvisorStormPlayer(game, owner).forces[`${site}:${reinforcementSector}`], separated ? 1 : 2);
assert.deepEqual(nexusAdvisorStormPlayer(game, owner).advisors?.[site], { lockedTurn: 1 });
const cancelerCard = nexusAdvisorStormPlayer(game, canceler).hand.find(card => card.effect === 'karama');
assert.ok(cancelerCard, 'The Guild must have bought the original Karama');
game = advance(game, state => state.turn === 2 && state.nexus && !state.spiceWindow && clean(state), driver, actions);
assert.equal(game.storm, 2, 'The real turn-two one-sector storm card must move the original storm from one to two');
for (const entry of [
{ actor: opponent, action: { type: 'alliance', target: canceler } },
{ actor: canceler, action: { type: 'alliance', target: opponent } },
] satisfies NexusAdvisorStormStep[]) {
actions.push(structuredClone(entry));
game = applyAction(game, entry.actor, entry.action);
}
game = advance(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state), driver, actions);
const phase = game.nexusCards!.phase!, cards = game.nexusCards!.cards!;
assert.deepEqual([...phase.eligible].sort(), [owner, harkonnen].sort());
const wanted: Record<string, FactionId> = { [owner]: 'beneGesserit', [harkonnen]: 'harkonnen' };
const front = phase.eligible.map(id => wanted[id]);
cards.deck = [...front, ...cards.deck.filter(card => !front.includes(card))];
staging.push('Ordered the conserved undealt Nexus deck for the actual unallied Bene Gesserit and Harkonnen draws; neither hand is assigned.');
const storm = separated ? 7 : 10;
game = advance(game, state => state.turn === storm - 1 && state.phase === 8 && clean(state), driver, actions);
const beforeStorm = structuredClone(game);
assert.equal(game.nexusCards!.cards!.hands[owner], 'beneGesserit');
assert.ok(driver.declinedAdvisorBattle, 'The native ordinary advisor battle opportunity must be declined before the storm');
game = advance(game, state => state.turn === storm && state.phase === 0 && state.stormPending !== null && clean(state), driver, actions);
const stormSource = structuredClone(game.stormMovementSource);
assert.ok(stormSource);
assert.equal(stormSource.kind, 'card');
assert.equal(stormSource.turn, storm);
assert.equal(stormSource.distance, 1);
assert.equal(game.stormPending, 1, 'Identity producer entropy draws the real printed one-sector storm card');
game = advance(game, state => state.turn === storm && state.phase === 1 && clean(state), driver, actions);
const afterStorm = structuredClone(game);
game = advance(game, state => state.turn === storm && state.phase === 5 && state.active === owner && clean(state), driver, actions);
assert.equal(game.storm, storm, 'Only the original storm producer establishes the blocked geometry');
assert.deepEqual(nexusAdvisorStormPlayer(game, owner).forces, nexusAdvisorStormPlayer(beforeStorm, owner).forces);
assert.deepEqual(nexusAdvisorStormPlayer(game, opponent).forces, nexusAdvisorStormPlayer(beforeStorm, opponent).forces);
assert.equal(nexusAdvisorStormPlayer(game, owner).ally, null);
assert.equal(game.nexusCards!.cards!.hands[owner], 'beneGesserit');
assert.equal(nexusAdvisorStormPlayer(game, owner).shipped, false);
assert.equal(nexusAdvisorStormPlayer(game, owner).moved, 0);
if (separated) assert.equal(territory(site).type, 'rock');
else assert.ok(!game.shieldWallDestroyed, 'The intact Shield Wall must shelter Arrakeen from real storm casualties');
const from = `${site}:${reinforcementSector}`, to = `${site}:${accompanySector}`;
const blocked = (key: string) => splitLocation(key).sector === storm;
const physicalFacts = {
sectors: [...territory(site).sectors],
groups: structuredClone(nexusAdvisorStormPlayer(game, owner).forces),
opponentGroups: structuredClone(nexusAdvisorStormPlayer(game, opponent).forces),
originalStance: structuredClone(nexusAdvisorStormPlayer(game, owner).advisors![site]),
unblockedDistance: gameDistance(game, from, to),
blockedDistance: gameDistance(game, from, to, blocked),
stormSource,
};
assert.equal(physicalFacts.unblockedDistance, 0);
assert.ok(physicalFacts.blockedDistance > 0);
nexusBorrowedVoiceInventory(game);
validateNexusCards(game.nexusCards!.cards!, game.players);
const scenario = { code: game.code, advanced: game.advanced, turn: game.turn, phase: game.phase,
owner, opponent, canceler, cancelerCard: cancelerCard.id, territory: site, storm, separated,
physicalFacts, heldNexusCard: game.nexusCards!.cards!.hands[owner], staging: [...staging] };
return { initial, afterSetup, afterShipment, beforeStorm, afterStorm, game, owner, opponent,
canceler, cancelerCard: cancelerCard.id, territory: site, storm, separated, physicalFacts,
actions, staging, scenario };
}
export function createNexusAdvisorStormFixture(options: { separated?: boolean } = {}): NexusAdvisorStormFixture {
return identityRandom(() => build(options.separated ?? false));
}
+3 -1
View File
@@ -185,7 +185,9 @@ export function createNexusFremenProtectionFixture(
'Original scalar producer lotteries keep every printed shuffle order; native UUID entropy is untouched.',
'Only the undealt physical Spice Deck is ordered: turn-one land, the real empty turn-two worm, the turn-two sand blow the group ships into, the next accepted real worm, then the replacement draw.',
'Only the undealt physical Nexus Deck is ordered so the real closing draws give the unallied holder the physical Fremen card and the other unallied seat the physical Bene Gesserit card; no hand is assigned.',
'Every storm dialer chooses the printed minimum (0 on turn one, otherwise 1), an original legal dial that keeps the real storm on Meridian at turn one and well clear of the shipped sand source afterwards',
advanced
? 'Advanced retains the original storm-card producer after the first Storm; no card distance, storm sector or phase is assigned.'
: 'Basic storm dialers choose the native minimum: zero on turn one and one on later turns. No storm sector or phase is assigned.',
advanced
? 'The physically dealt Bene Gesserit seat documents the absent-Fremen case: it holds an irrelevant Nexus face and accepts the native free accompanied advisor arrival at the same sector.'
: 'No Bene Gesserit is seated; the fourth seat is Harkonnen and holds an irrelevant Nexus face at no other location.',
+1 -159
View File
@@ -1,113 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type GameView } from '../game/engine';
import { NexusAdvisors } from '../components/nexus-advisors';
import {
nexusAdvisorAction,
nexusAdvisorCanAct,
} from '../game/nexus-advisor-options';
import {
nexusAdvisorFixture,
beginNexusAdvisorFlip,
holdAdvisorKarama,
} from './fixture-nexus-advisors';
import { nexusAdvisorFixture } from './fixture-nexus-advisors';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/image'
? 'vinext/shims/image'
: specifier === 'next/link'
? 'vinext/shims/link'
: specifier,
context,
);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries(
[...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((match) => [
match[1],
match[1],
]),
);
return {
format: 'module',
source: `export default ${JSON.stringify(classes)}`,
shortCircuit: true,
};
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const html = (game: GameView, busy = false) =>
renderToStaticMarkup(createElement(NexusAdvisors, { game, busy, act() {} }));
void test('advisor controls name whole territories and counts with an explicit empty initial selection', () => {
const v = viewGame(nexusAdvisorFixture(), 'p');
const offer = v.nexusAdvisors!.offer!;
const before = structuredClone(v);
const text = html(v);
assert.match(text, /Pasty Mesa: 3 advisors/);
assert.match(text, /Arrakeen: 2 advisors/);
assert.equal((text.match(/class="decision-checkbox min-h-11"/g) ?? []).length, offer.territories.length);
assert.match(text, /Select all available/);
assert.match(text, /0 territories selected/);
assert.match(text, /across all sectors/);
assert.match(
text,
/costs no spice and uses no shipment or movement allowance/,
);
assert.doesNotMatch(text, /checked=""/);
assert.match(text, /disabled=""[^>]*>Convert selected advisors/);
const selected = offer.territories
.filter((choice) => !choice.blocked)
.map((choice) => choice.territory);
assert.ok(selected.length >= 2);
assert.deepEqual(nexusAdvisorAction(v, offer.event, selected), {
type: 'nexusAdvisors',
event: offer.event,
territories: selected,
});
assert.deepEqual(v, before);
assert.match(html(v, true), /disabled=""/);
});
void test('shared advisor selection binds event, unique whole groups and projected source boundary reasons', () => {
const v = viewGame(nexusAdvisorFixture(), 'p');
const offer = v.nexusAdvisors!.offer!;
const place = offer.territories.find(
(choice) => choice.territory === 'arrakeen',
)!;
assert.ok(nexusAdvisorAction(v, offer.event, ['arrakeen']));
for (const choices of [
[],
['arrakeen', 'arrakeen'],
['not-a-territory'],
['arrakeen:10'],
])
assert.equal(nexusAdvisorAction(v, offer.event, choices), null);
assert.equal(nexusAdvisorAction(v, 'stale', ['arrakeen']), null);
// Consumer-only projections prove reasons are displayed rather than guessed
// from a source rule or the current territory's board graphics.
place.blocked =
'Newly accompanied advisors await the same-turn conversion ruling.';
offer.territories.find(
(choice) => choice.territory === 'pasty_mesa',
)!.blocked = 'Conversion in storm awaits its timing ruling.';
assert.equal(nexusAdvisorAction(v, offer.event, ['arrakeen']), null);
assert.match(html(v), /Newly accompanied advisors await/);
assert.match(html(v), /Conversion in storm awaits/);
offer.blocked = 'Finish the current movement response first.';
assert.equal(nexusAdvisorCanAct(v), false);
assert.match(html(v), /Finish the current movement response first/);
});
void test('advisor selection rejects other actors, Basic, wrong phase and all interrupted windows', () => {
const original = viewGame(nexusAdvisorFixture(), 'p');
@@ -169,59 +67,3 @@ void test('advisor selection rejects other actors, Basic, wrong phase and all in
}
});
void test('public pending territory set explains one Karama cancellation without exposing the private offer', () => {
const initial = nexusAdvisorFixture();
holdAdvisorKarama(initial);
const g = beginNexusAdvisorFlip(initial, ['arrakeen', 'pasty_mesa']);
assert.equal(g.response?.kind, 'nexusAdvisorFlip');
for (const id of ['p', 'q', 'r']) {
const v = viewGame(g, id);
assert.ok(v.nexusAdvisors!.pending);
Object.defineProperty(v.nexusAdvisors!, 'offer', {
get() {
throw new Error('Read offer during public pending response');
},
});
for (const player of v.players)
Object.defineProperty(player, 'hand', {
get() {
throw new Error('Read private hand');
},
});
const text = html(v);
assert.match(text, /Arrakeen, Pasty Mesa/);
assert.match(text, /Karama can cancel the entire selection/);
assert.doesNotMatch(text, /checkbox|Convert selected advisors/);
}
});
void test('actual response panel names Nexus conversion and offers Karama without printing its internal event', () => {
const initial = nexusAdvisorFixture();
holdAdvisorKarama(initial);
const g = beginNexusAdvisorFlip(initial, ['arrakeen', 'pasty_mesa']);
const v = viewGame(g, 'q');
// Real component rendering with CSS selector names preserved above; this is
// structural response verification, not a browser or visual acceptance claim.
const text = renderToStaticMarkup(
createElement(GameTable, {
game: v,
send: async () => {},
onExit() {},
busy: false,
}),
);
assert.match(text, /Bene Gesserit Nexus advisor conversion/);
assert.match(text, /Cancel with Karama/);
assert.match(text, /Allow this power/);
assert.doesNotMatch(text, /nexusAdvisors/);
const circleTitles = [
...text.matchAll(/<title>(Player circle [^<]+)<\/title>/g),
].map((match) => match[1]);
assert.equal(circleTitles.length, 6);
assert.ok(
circleTitles.every((title) =>
/^Player circle [1-6], sector \d+: .+/.test(title),
),
);
assert.ok(circleTitles.some((title) => title.includes('Bene Gesserit')));
});
+132
View File
@@ -0,0 +1,132 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { distance, splitLocation } from '../game/board';
import { fighterCount } from '../game/advisors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { nexusAdvisorAction } from '../game/nexus-advisor-options';
import {
createNexusAdvisorFreshFixture, nexusAdvisorFreshPlayer, settleNexusAdvisorFresh,
} from './fixture-nexus-advisor-fresh';
/** Stance/card changes must not alter physical pools, resources or the native turn. */
function unchangedFrame(game: Game) {
return {
status: game.status, turn: game.turn, phase: game.phase, active: game.active,
order: game.order, remaining: game.movementRemaining, spice: game.spice,
storm: game.storm, hajr: game.hajr, guildTimingGranted: game.guildTimingGranted,
guildTimingLocked: game.guildTimingLocked,
players: game.players.map(player => ({
id: player.id, forces: player.forces, reserves: player.reserves, tanks: player.tanks,
elites: player.elites, spice: player.spice, shipped: player.shipped, moved: player.moved,
})),
};
}
void test('fresh Cunning spends its physical card once and resumes the original shipment/movement turn in place', () => {
const fixture = createNexusAdvisorFreshFixture();
const { owner, territory, location } = fixture;
const frame = structuredClone(unchangedFrame(fixture.game));
const offer = viewGame(fixture.game, owner).nexusAdvisors!.offer!;
assert.equal(offer.blocked, null);
assert.deepEqual(offer.territories.find(group => group.territory === territory), { territory, count: 2, blocked: null });
const declaration = nexusAdvisorAction(viewGame(fixture.game, owner), offer.event, [territory]);
const pending = applyAction(fixture.game, owner, declaration!);
assert.equal(pending.response!.kind, 'nexusAdvisorFlip');
assert.equal(nexusAdvisorFreshPlayer(pending, owner).advisors![territory].lockedTurn, pending.turn);
assert.equal(pending.nexusCards!.cards!.hands[owner], null);
assert.equal(pending.nexusCards!.cards!.discard.filter(face => face === 'beneGesserit').length, 1);
assert.deepEqual(unchangedFrame(pending), frame);
assert.throws(() => applyAction(pending, owner, fixture.declaration));
const allowed = settleNexusAdvisorFresh(pending);
assert.equal(nexusAdvisorFreshPlayer(allowed, owner).advisors?.[territory], undefined);
assert.deepEqual(nexusAdvisorFreshPlayer(allowed, owner).advisors?.arrakeen, {});
assert.equal(nexusAdvisorFreshPlayer(allowed, owner).forces[location], 2);
assert.equal(fighterCount(nexusAdvisorFreshPlayer(allowed, owner), territory), 2);
assert.equal(allowed.nexusAdvisorHistory!.at(-1)!.stage, 'completed');
assert.deepEqual(unchangedFrame(allowed), frame);
assert.equal(allowed.nexusCards!.cards!.discard.filter(face => face === 'beneGesserit').length, 1);
assert.throws(() => applyAction(allowed, owner, fixture.declaration));
assert.equal(viewGame(allowed, owner).nexusAdvisors!.offer, null);
const continued = settleNexusAdvisorFresh(applyAction(allowed, owner, { type: 'endMovement' }));
assert.equal(nexusAdvisorFreshPlayer(continued, owner).forces[location], 2);
});
void test('original Karama cancels the entire selected batch and preserves the current-turn lock and native continuation', () => {
const fixture = createNexusAdvisorFreshFixture();
const frame = structuredClone(unchangedFrame(fixture.game));
const stances = structuredClone(nexusAdvisorFreshPlayer(fixture.game, fixture.owner).advisors);
const pending = applyAction(fixture.game, fixture.owner, fixture.batchDeclaration);
assert.deepEqual(pending.nexusAdvisorHistory!.at(-1)!.receipt.selections.map(group => group.territory), ['arrakeen', fixture.territory]);
const canceled = settleNexusAdvisorFresh(applyAction(pending, fixture.canceler, { type: 'card', card: fixture.card, mode: 'cancel' }));
assert.equal(canceled.nexusAdvisorHistory!.at(-1)!.stage, 'canceled');
assert.deepEqual(nexusAdvisorFreshPlayer(canceled, fixture.owner).advisors, stances);
assert.equal(nexusAdvisorFreshPlayer(canceled, fixture.owner).advisors![fixture.territory].lockedTurn, canceled.turn);
assert.equal(fighterCount(nexusAdvisorFreshPlayer(canceled, fixture.owner), fixture.territory), 0);
assert.deepEqual(unchangedFrame(canceled), frame);
assert.equal(canceled.nexusCards!.cards!.hands[fixture.owner], null);
assert.equal(canceled.nexusCards!.cards!.discard.filter(face => face === 'beneGesserit').length, 1);
assert.ok(!nexusAdvisorFreshPlayer(canceled, fixture.canceler).hand.some(card => card.id === fixture.card));
assert.equal(canceled.discard.filter(card => card.id === fixture.card).length, 1);
assert.throws(() => applyAction(canceled, fixture.owner, fixture.declaration));
assert.throws(() => applyAction(canceled, fixture.owner, fixture.ordinaryAction), /advisors cannot flip|New advisors cannot become fighters/i);
const continued = settleNexusAdvisorFresh(applyAction(canceled, fixture.owner, { type: 'endMovement' }));
assert.notEqual(continued.active, fixture.owner);
assert.ok(!continued.movementRemaining!.includes(fixture.owner));
assert.deepEqual(nexusAdvisorFreshPlayer(continued, fixture.owner).advisors, stances);
});
void test('ordinary fighter arrival remains illegal for the fresh group but its same native action works after Cunning', () => {
const fixture = createNexusAdvisorFreshFixture();
const { game, owner, location, ordinaryAction } = fixture;
assert.equal(distance(location, fixture.scenario.ordinaryTarget, key => splitLocation(key).sector === game.storm), 1);
assert.equal(nexusAdvisorFreshPlayer(game, 'atreides').forces[fixture.scenario.ordinaryTarget], 1);
assert.equal(nexusAdvisorFreshPlayer(game, owner).forces[fixture.scenario.ordinaryTarget], undefined);
assert.throws(() => applyAction(game, owner, ordinaryAction), /advisors cannot flip|New advisors cannot become fighters/i);
const converted = settleNexusAdvisorFresh(applyAction(game, owner, fixture.declaration));
const moved = settleNexusAdvisorFresh(applyAction(converted, owner, ordinaryAction));
const player = nexusAdvisorFreshPlayer(moved, owner);
assert.equal(player.forces[location], 1);
assert.equal(player.forces[fixture.scenario.ordinaryTarget], 1);
assert.equal(player.advisors?.imperial_basin, undefined);
assert.equal(player.moved, nexusAdvisorFreshPlayer(game, owner).moved + 1);
assert.equal(player.spice, nexusAdvisorFreshPlayer(game, owner).spice);
});
void test('fresh native Cunning rejects other actors, stale events, extra keys and duplicate whole territories atomically', () => {
const fixture = createNexusAdvisorFreshFixture();
const before = structuredClone(fixture.game);
const rejected = [
{ actor: fixture.foreign, action: fixture.declaration },
{ actor: fixture.owner, action: { ...fixture.declaration, event: `${fixture.event}:stale` } },
{ actor: fixture.owner, action: { ...fixture.declaration, amount: 1 } },
{ actor: fixture.owner, action: { ...fixture.declaration, territories: [fixture.territory, fixture.territory] } },
];
for (const step of rejected) {
assert.throws(() => applyAction(fixture.game, step.actor, step.action));
assert.deepEqual(fixture.game, before);
}
const view = viewGame(fixture.game, fixture.owner);
assert.equal(nexusAdvisorAction(view, `${fixture.event}:stale`, [fixture.territory]), null);
assert.equal(nexusAdvisorAction(view, fixture.event, [fixture.territory, fixture.territory]), null);
});
void test('all four unchanged policies Use the favorable actual fresh group and complete the same native response and turn', () => {
const fixture = createNexusAdvisorFreshFixture();
for (const difficulty of DIFFICULTIES) {
const view = viewGame(fixture.game, fixture.owner);
view.players.find(player => player.id === fixture.owner)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action, `${difficulty} must produce a legal active-turn action`);
const used = applyAction(fixture.game, fixture.owner, action);
assert.equal(used.response!.kind, 'nexusAdvisorFlip', difficulty);
const allowed = settleNexusAdvisorFresh(used);
assert.equal(fighterCount(nexusAdvisorFreshPlayer(allowed, fixture.owner), fixture.territory), 2, difficulty);
assert.equal(nexusAdvisorFreshPlayer(allowed, fixture.owner).advisors?.[fixture.territory], undefined, difficulty);
assert.deepEqual(unchangedFrame(allowed), unchangedFrame(fixture.game), difficulty);
const continued = settleNexusAdvisorFresh(applyAction(allowed, fixture.owner, { type: 'endMovement' }));
assert.notEqual(continued.active, fixture.owner, difficulty);
}
});
+169
View File
@@ -0,0 +1,169 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { fighterCount } from '../game/advisors';
import { gameDistance, splitLocation, territory } from '../game/board';
import { quoteBattleBoard } from '../game/board-resolution-quote';
import { quoteAdvisorBattleOffer } from '../game/movement-phase-quote';
import { nexusAdvisorAction } from '../game/nexus-advisor-options';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import {
createNexusAdvisorStormFixture, nexusAdvisorStormPlayer,
type NexusAdvisorStormFixture,
} from './fixture-nexus-advisor-storm';
function declaration(f: NexusAdvisorStormFixture, game = f.game): Action {
const view = viewGame(game, f.owner);
const offer = view.nexusAdvisors!.offer!;
const action = nexusAdvisorAction(view, offer.event, [f.territory]);
assert.ok(action, 'The real storm-held advisor territory is a legal whole-territory Cunning selection');
return action;
}
function resources(game: Game) {
return game.players.map(player => ({ id: player.id, spice: player.spice,
reserves: player.reserves, tanks: player.tanks, forces: player.forces,
elites: player.elites, shipped: player.shipped, moved: player.moved,
revived: player.revived, ally: player.ally }));
}
function settle(game: Game): Game {
for (let count = 0; count < 80; count++) {
if (!game.response && !game.decision && !game.phaseOpening) return game;
const step = nextStrongholdFactionsNativeStep(game);
assert.ok(step, 'Only original responses and decisions may settle the declaration');
game = applyAction(game, step.actor, step.action);
}
throw Error('Original storm advisor response did not settle');
}
function rejectsImmutably(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function assertStormGeometry(f: NexusAdvisorStormFixture, game: Game): void {
const from = `${f.territory}:${f.separated ? 9 : 10}`;
const to = `${f.territory}:${f.separated ? 5 : 10}`;
assert.equal(gameDistance(game, from, to), 0);
assert.ok(gameDistance(game, from, to, key => splitLocation(key).sector === game.storm) > 0);
assert.equal(quoteBattleBoard(game).battles.some(battle => battle.territory === f.territory &&
[battle.attacker, battle.defender].includes(f.owner)), false);
}
function ordinaryStormMove(f: NexusAdvisorStormFixture): Action {
return { type: 'move', forces: { [`${f.territory}:${f.separated ? 9 : 10}`]: 1 },
eliteForces: { [`${f.territory}:${f.separated ? 9 : 10}`]: 0 },
territory: f.separated ? f.territory : 'polar_sink', sector: f.separated ? 5 : 0 };
}
for (const separated of [false, true]) {
const label = separated ? 'real rock groups separated by the storm' : 'real shielded Arrakeen groups under the storm';
void test(`Cunning changes only stance for ${label}, without granting movement or battle`, () => {
const f = createNexusAdvisorStormFixture({ separated });
const original = structuredClone(f.game);
assert.equal(original.advanced, true);
assert.deepEqual(nexusAdvisorStormPlayer(f.afterStorm, f.owner).forces,
nexusAdvisorStormPlayer(f.beforeStorm, f.owner).forces);
assert.deepEqual(nexusAdvisorStormPlayer(f.afterStorm, f.opponent).forces,
nexusAdvisorStormPlayer(f.beforeStorm, f.opponent).forces);
assert.equal(territory(f.territory).type, separated ? 'rock' : 'stronghold');
assert.deepEqual(nexusAdvisorStormPlayer(original, f.owner).advisors![f.territory], { lockedTurn: 1 });
assert.equal(nexusAdvisorStormPlayer(original, f.owner).forces[`${f.territory}:${separated ? 9 : 10}`], separated ? 1 : 2);
assert.equal(fighterCount(nexusAdvisorStormPlayer(original, f.opponent), f.territory), 1);
assert.equal(quoteAdvisorBattleOffer(original).territories.includes(f.territory), false);
assertStormGeometry(f, original);
rejectsImmutably(original, f.owner, ordinaryStormMove(f));
rejectsImmutably(original, f.owner, { ...ordinaryStormMove(f), territory: f.territory,
sector: separated ? 5 : 10, fighters: true });
rejectsImmutably(original, f.owner, { type: 'ship', territory: f.territory,
sector: original.storm, amount: 1, elite: 0, allyPayment: 0 });
const action = declaration(f);
rejectsImmutably(original, f.opponent, action);
rejectsImmutably(original, f.owner, { ...action, event: `${String(action.event)}-stale` });
let game = applyAction(original, f.owner, action);
assert.equal(game.response?.kind, 'nexusAdvisorFlip');
const receipt = structuredClone(game.nexusAdvisorHistory!.at(-1)!.receipt);
assert.equal(receipt.owner, f.owner);
assert.equal(receipt.turn, original.turn);
assert.deepEqual(receipt.selections, [{ territory: f.territory, count: 2,
locations: separated
? [{ location: `${f.territory}:5`, count: 1 }, { location: `${f.territory}:9`, count: 1 }]
: [{ location: `${f.territory}:10`, count: 2 }],
noField: null, stance: { lockedTurn: 1 } }]);
assert.deepEqual(resources(game), resources(original));
assert.deepEqual(nexusAdvisorStormPlayer(game, f.owner).advisors,
nexusAdvisorStormPlayer(original, f.owner).advisors);
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'beneGesserit').length, 1);
rejectsImmutably(game, f.owner, action);
game = settle(game);
assert.equal(game.nexusAdvisorHistory!.at(-1)!.stage, 'completed');
assert.equal(game.nexusAdvisorHistory!.length, 1);
assert.deepEqual(game.nexusAdvisorHistory!.at(-1)!.receipt, receipt);
const expected = structuredClone(nexusAdvisorStormPlayer(original, f.owner).advisors!);
delete expected[f.territory];
assert.deepEqual(nexusAdvisorStormPlayer(game, f.owner).advisors, expected);
assert.deepEqual(resources(game), resources(original));
assert.deepEqual(game.players.map(player => player.hand), original.players.map(player => player.hand));
assert.equal(fighterCount(nexusAdvisorStormPlayer(game, f.owner), f.territory), 2);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'beneGesserit').length, 1);
assert.equal(game.turn, original.turn);
assert.equal(game.phase, 5);
assert.equal(game.active, f.owner);
assert.equal(game.response, null);
assertStormGeometry(f, game);
rejectsImmutably(game, f.owner, ordinaryStormMove(f));
rejectsImmutably(game, f.owner, action);
const sinkBefore = nexusAdvisorStormPlayer(game, f.owner).forces['polar_sink:0'] ?? 0;
game = applyAction(game, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0,
amount: 1, elite: 0, allyPayment: 0 });
game = settle(game);
assert.equal(nexusAdvisorStormPlayer(game, f.owner).shipped, true);
assert.equal(nexusAdvisorStormPlayer(game, f.owner).moved, 0);
assert.equal(nexusAdvisorStormPlayer(game, f.owner).forces['polar_sink:0'], sinkBefore + 1);
game = applyAction(game, f.owner, { type: 'endMovement' });
assert.notEqual(game.active, f.owner);
});
void test(`the real auction Karama cancels the original batch for ${label}`, () => {
const f = createNexusAdvisorStormFixture({ separated });
const original = structuredClone(f.game), action = declaration(f);
let game = applyAction(original, f.owner, action);
game = applyAction(game, f.canceler, { type: 'card', card: f.cancelerCard, mode: 'cancel' });
game = settle(game);
assert.equal(game.nexusAdvisorHistory!.at(-1)!.stage, 'canceled');
assert.equal(game.nexusAdvisorHistory!.length, 1);
assert.deepEqual(resources(game), resources(original));
assert.deepEqual(game.players.map(player => player.advisors), original.players.map(player => player.advisors));
assert.equal(game.discard.filter(card => card.id === f.cancelerCard).length, 1);
assert.equal(nexusAdvisorStormPlayer(game, f.canceler).hand.some(card => card.id === f.cancelerCard), false);
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'beneGesserit').length, 1);
assert.equal(game.active, f.owner);
assert.equal(game.phase, 5);
assert.equal(fighterCount(nexusAdvisorStormPlayer(game, f.owner), f.territory), 0);
assert.equal(quoteAdvisorBattleOffer(game).territories.includes(f.territory), false);
assertStormGeometry(f, game);
rejectsImmutably(game, f.owner, ordinaryStormMove(f));
rejectsImmutably(game, f.owner, action);
game = applyAction(game, f.owner, { type: 'endMovement' });
assert.notEqual(game.active, f.owner);
});
void test(`all four legal bot policies can use Cunning for ${label}`, () => {
const f = createNexusAdvisorStormFixture({ separated });
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(f.game, f.owner);
view.players.find(player => player.id === f.owner)!.bot = difficulty;
const actions = botActions(view);
const action = actions.find(candidate => candidate.type === 'nexusAdvisors');
assert.ok(action, `${difficulty} selects the genuine two-advisor versus one-fighter offer`);
let game = applyAction(f.game, f.owner, action);
assert.equal(game.response?.kind, 'nexusAdvisorFlip');
game = settle(game);
assert.equal(fighterCount(nexusAdvisorStormPlayer(game, f.owner), f.territory), 2);
assert.deepEqual(resources(game), resources(f.game));
assertStormGeometry(f, game);
}
});
}
-45
View File
@@ -78,48 +78,6 @@ void test('Basic and automatically released lone advisors yield no choice; Homew
);
assert.throws(() => quoteNexusAdvisors(c, 'g'));
});
void test('fresh advisor lock and storm interference expose explicit unresolved reasons while clear sectors remain usable', () => {
const fresh = context();
fresh.players[0].advisors!.hagga_basin.lockedTurn = 3;
assert.match(
quoteNexusAdvisors(fresh, 'b').territories.find(
(t) => t.territory === 'hagga_basin',
)!.blocked!,
/same-turn.*unresolved/,
);
assert.throws(
() => createNexusAdvisors(fresh, 'b', 'x', ['arrakeen', 'hagga_basin']),
/unresolved/,
);
const storm = context();
storm.storm = 10;
assert.match(
quoteNexusAdvisors(storm, 'b').territories.find(
(t) => t.territory === 'arrakeen',
)!.blocked!,
/storm.*ruling/,
);
assert.equal(
quoteNexusAdvisors(storm, 'b').territories.find(
(t) => t.territory === 'hagga_basin',
)!.blocked,
null,
);
});
void test('a storm separating hostile sectors blocks only the affected conversion rather than requiring battle contact everywhere', () => {
const c = context();
c.players[0].forces = { 'pasty_mesa:5': 2 };
c.players[0].advisors = { pasty_mesa: {} };
c.players[1].forces = { 'pasty_mesa:8': 1 };
c.storm = 7;
assert.match(quoteNexusAdvisors(c, 'b').territories[0].blocked!, /storm/);
c.storm = 18;
assert.equal(quoteNexusAdvisors(c, 'b').territories[0].blocked, null);
c.players[0].forces = { 'polar_sink:0': 2 };
c.players[0].advisors = { polar_sink: {} };
c.players[1].forces = { 'polar_sink:0': 1 };
assert.equal(quoteNexusAdvisors(c, 'b').territories[0].blocked, null);
});
void test('ordinary allied occupation and a third stronghold faction block conversion using public No-Field presence', () => {
const allied = context();
allied.players[0].ally = 'g';
@@ -231,9 +189,6 @@ void test('empty, duplicate, foreign and malformed selections reject immutably a
(g: NexusAdvisorContext) => {
g.players[2].forces = { 'arrakeen:10': 1 };
},
(g: NexusAdvisorContext) => {
g.storm = 10;
},
]) {
const altered = reload(c);
change(altered);
-18
View File
@@ -172,24 +172,6 @@ void test('Cunning belongs to the current native Advanced shipment and movement
);
});
void test('fresh advisor locks and storm-locked strongholds remain guarded without partially converting an allowed group', () => {
for (const scenario of ['lock', 'storm']) {
const g = nexusAdvisorFixture();
if (scenario === 'lock')
nexusAdvisorPlayer(g).advisors!.arrakeen.lockedTurn = g.turn;
else g.storm = 10;
const offer = viewGame(g, 'p').nexusAdvisors!.offer!;
assert.ok(
offer.territories.find((t) => t.territory === 'arrakeen')!.blocked,
);
assert.equal(
offer.territories.find((t) => t.territory === 'pasty_mesa')!.blocked,
null,
);
nexusReject(g, 'p', action(g));
assert.ok(nexusAdvisorPlayer(g).advisors!.pasty_mesa);
}
});
void test('a third occupying faction blocks stronghold conversion while leaving other advisor groups available', () => {
const g = nexusAdvisorFixture();