From 985cf85e12a78714a8b6a465dea9b8b63f15153a Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Thu, 15 Oct 2026 04:40:46 +1100
Subject: [PATCH] Connect BG Nexus fresh and storm advisor conversion
---
components/game-table.tsx | 30 ++-
components/nexus-advisors.tsx | 5 +
docs/CURRENT_STATUS.md | 19 ++
docs/IMPLEMENTATION_STATUS.md | 46 +++++
docs/NEXUS_ADVISOR_RUNTIME.md | 55 +++++-
docs/NEXUS_BENE_GESSERIT_RULES.md | 38 +++-
docs/NEXUS_CARD_RULES.md | 12 +-
docs/RULE_DECISIONS.md | 1 +
game/bot-mobility.ts | 20 +-
game/engine.ts | 2 +-
game/nexus-advisors.ts | 69 +------
game/planetologist-movement.ts | 2 +-
game/reference.ts | 12 +-
tests/fixture-nexus-advisor-fresh.ts | 183 +++++++++++++++++
tests/fixture-nexus-advisor-storm.ts | 230 ++++++++++++++++++++++
tests/fixture-nexus-fremen-protection.ts | 4 +-
tests/nexus-advisor-controls.test.ts | 160 +--------------
tests/nexus-advisor-fresh-runtime.test.ts | 132 +++++++++++++
tests/nexus-advisor-storm-runtime.test.ts | 169 ++++++++++++++++
tests/nexus-advisors-quote.test.ts | 45 -----
tests/nexus-advisors.test.ts | 18 --
21 files changed, 936 insertions(+), 316 deletions(-)
create mode 100644 tests/fixture-nexus-advisor-fresh.ts
create mode 100644 tests/fixture-nexus-advisor-storm.ts
create mode 100644 tests/nexus-advisor-fresh-runtime.test.ts
create mode 100644 tests/nexus-advisor-storm-runtime.test.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index be51b584..89bd69c9 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -95,6 +95,7 @@ import { NexusHarkonnenBetrayal } from './nexus-harkonnen-betrayal';
import { OrnithopterMovement } from './ornithopter-movement';
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
import { PlanetologistMovement } from './planetologist-movement';
+import { selectedOriginElites } from '@/game/planetologist-movement';
import { SandmasterMovement } from './sandmaster-movement';
import { SandmasterWormChoice } from './sandmaster-worm';
import { sandmasterWormCollection } from '@/game/sandmaster-worm';
@@ -177,7 +178,7 @@ import { SmugglerShipmentChoice } from './smuggler-shipment';
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyQuote } from '@/game/nexus-guild-secret-ally-options';
import { nexusRicheseAction, nexusRicheseQuote } from '@/game/nexus-richese-options';
import { guildTransportQuote } from '@/game/transport-quote';
-import { fremenReserveEntry, botGroundMoveAllowed } from '@/game/bot-mobility';
+import { fremenReserveEntry, botGroundMoveAllowed, botMovementRange, groundMoveRouteDistance } from '@/game/bot-mobility';
import { isAlliedShipmentVisit } from '@/game/allied-separation';
import { location as boardLocation } from '@/game/board';
import { fighterCount, isAdvisor } from '@/game/advisors';
@@ -814,6 +815,15 @@ export function GameTable({
count > 0 &&
!botGroundMoveAllowed(g, me, key, boardLocation(selected, sector), 0),
);
+ const movementTarget = boardLocation(selected, sector);
+ const selectedMovementElite = combineSectors
+ ? selectedOriginElites(physicalDraftEntries, eliteForces, splitLocation(source).territory)
+ : eliteAmount;
+ const ordinaryMovementRange = botMovementRange(g, me, selectedMovementElite);
+ const ordinaryMovementRouteBlocked = physicalDraftEntries.some(([key, count]) =>
+ count > 0 && groundMoveRouteDistance(g, me, key, movementTarget) > ordinaryMovementRange) ||
+ (!!includesNoField && !!ownNoField && groundMoveRouteDistance(g, me,
+ boardLocation(ownNoField.location.territory, ownNoField.location.sector), movementTarget) > ordinaryMovementRange);
const act = (a: Action) => {
// Control of one's own seat is independent of every gameplay response.
if (a.type === 'setAutopilot') { void send(a); return; }
@@ -4674,6 +4684,7 @@ export function GameTable({
ordinaryMovementAction,
!source ||
canceledFremenRouteBlocked ||
+ ordinaryMovementRouteBlocked ||
selectedDestinationInStorm ||
(me.moved ?? 0) >= (me.movesAllowed ?? 1) ||
(includesNoField
@@ -4681,9 +4692,11 @@ export function GameTable({
: (me.forces[source] ?? 0) === 0),
selectedDestinationInStorm
? 'movement-storm-unavailable'
- : canceledFremenRouteBlocked
- ? 'movement-range-unavailable'
- : undefined,
+ : ordinaryMovementRouteBlocked
+ ? 'movement-route-unavailable'
+ : canceledFremenRouteBlocked
+ ? 'movement-range-unavailable'
+ : undefined,
)}
{canceledFremenRouteBlocked &&
!selectedDestinationInStorm && (
@@ -4697,6 +4710,13 @@ export function GameTable({
territory, or use available ornithopters.
)}
+ {ordinaryMovementRouteBlocked && !selectedDestinationInStorm && (
+
+ Movement unavailable: this force group has no unblocked
+ ground route within its ordinary range. Choose a reachable
+ source or destination outside the storm and blocked strongholds.
+
+ )}
{selectedDestinationInStorm && (
= (me.movesAllowed ?? 1)
? 'No ordinary movement remains this turn.'
+ : ordinaryMovementRouteBlocked
+ ? 'Choose an unblocked source and route outside the storm.'
: canceledFremenRouteBlocked
? 'Choose an ordinary route within your available range.'
: selectedDestinationInStorm
diff --git a/components/nexus-advisors.tsx b/components/nexus-advisors.tsx
index d54c0b33..0c328993 100644
--- a/components/nexus-advisors.tsx
+++ b/components/nexus-advisors.tsx
@@ -79,6 +79,11 @@ function AdvisorSelection({ game, act, busy }: Props) {
This spends your Nexus card once. Karama may cancel the entire
selection. It costs no spice and uses no shipment or movement allowance.
+
+ Provisional card exception: Cunning may convert freshly accompanied or
+ storm-bound advisors in place. Ordinary flipping, storm movement and
+ battle restrictions remain unchanged.
+
{offer.blocked && {offer.blocked}
}
{
+ const loc = splitLocation(key);
+ return (loc.sector !== 0 && loc.sector === g.storm) ||
+ strongholdPathBlocked(g.players, p.id, loc.territory, advisors);
+ });
+}
export function botGroundMoveAllowed(
g: GameView,
p: Seat,
@@ -121,16 +130,7 @@ export function botGroundMoveAllowed(
)
)
return false;
- const advisors = isAdvisor(p, source.territory);
- return (
- gameDistance(g, from, to, (key) => {
- const loc = splitLocation(key);
- return (
- (loc.sector !== 0 && loc.sector === g.storm) ||
- strongholdPathBlocked(g.players, p.id, loc.territory, advisors)
- );
- }) <= botMovementRange(g, p, elite, planetologist)
- );
+ return groundMoveRouteDistance(g, p, from, to) <= botMovementRange(g, p, elite, planetologist);
}
/** Fremen reinforcement radius is static territory geometry, not a ground route. */
export function fremenReserveEntry(to: string) {
diff --git a/game/engine.ts b/game/engine.ts
index e8403e6d..5726c26a 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -3585,7 +3585,7 @@ function playNexusAdvisors(g: Game, p: Player, action: Action) {
(g.nexusAdvisorHistory ??= []).push(record);
g.nexusAdvisorLast = {event: receipt.event, stage: record.stage};
g.response = {kind: 'nexusAdvisorFlip', owner: p.id, intent: receipt.event, passed: []};
- log(g, `${p.name} spent Bene Gesserit Nexus Cunning to convert all advisors in ${receipt.selections.map(s => territory(s.territory).name).join(', ')}. Karama may prevent this entire conversion.`,
+ log(g, `${p.name} spent Bene Gesserit Nexus Cunning to convert all advisors in ${receipt.selections.map(s => territory(s.territory).name).join(', ')}. Karama may prevent this entire conversion. Provisional card exception: fresh and storm-bound advisors may change stance in place; ordinary movement and battle restrictions remain.`,
{faction: p.faction, name: 'Nexus advisor conversion declared'});
}
function finishNexusAdvisors(g: Game, canceled: boolean) {
diff --git a/game/nexus-advisors.ts b/game/nexus-advisors.ts
index a3abd335..7596f78c 100644
--- a/game/nexus-advisors.ts
+++ b/game/nexus-advisors.ts
@@ -1,8 +1,6 @@
import {
- gameDistance,
gameTerritories,
location,
- MOBILE_LOCATION,
splitLocation,
territory,
validLocation,
@@ -12,8 +10,7 @@ import {
type BoardContext,
type BoardSeat,
} from './board-resolution-quote';
-import { presenceAt, presenceByLocation } from './force-presence';
-import { fighterCount } from './advisors';
+import { presenceAt } from './force-presence';
import { territoryEntryBlock } from './occupancy';
import { ecazOccupancyIdentity } from './ecaz-occupy';
@@ -126,54 +123,6 @@ function selection(player: BoardSeat, to: string): NexusAdvisorSelection {
: { lockedTurn: player.advisors![to].lockedTurn },
};
}
-function stormBlocked(
- context: NexusAdvisorContext,
- players: readonly BoardSeat[],
- owner: BoardSeat,
- to: string,
-): boolean {
- if (context.storm === 0 || territory(to).type === 'polar') return false;
- const inStorm = (key: string) =>
- splitLocation(
- key === MOBILE_LOCATION
- ? (context.mobileStronghold?.location ?? key)
- : key,
- ).sector === context.storm;
- const keys = (p: BoardSeat) =>
- Object.entries(presenceByLocation(p))
- .filter(
- ([key, count]) => count > 0 && splitLocation(key).territory === to,
- )
- .map(([key]) => key);
- const own = keys(owner);
- if (own.some(inStorm)) return true;
- // Only storm-caused separation is an unresolved interaction. No general
- // requirement to be in a battle-capable territory is imposed by this card.
- for (const other of players) {
- if (
- other.id === owner.id ||
- other.id === owner.ally ||
- !fighterCount(other, to)
- )
- continue;
- const theirs = keys(other);
- if (
- own.some((x) =>
- theirs.some((y) => x === y || gameDistance(context, x, y) === 0),
- ) &&
- !own.some((x) =>
- theirs.some(
- (y) =>
- !inStorm(x) &&
- !inStorm(y) &&
- (x === y || gameDistance(context, x, y, inStorm) === 0),
- ),
- )
- )
- return true;
- }
- return false;
-}
export function quoteNexusAdvisors(
context: NexusAdvisorContext,
owner: string,
@@ -192,17 +141,11 @@ export function quoteNexusAdvisors(
release.player === owner && release.territory === site.id,
),
)
- .map((site) => {
- const original = selection(player, site.id);
- let blocked = territoryEntryBlock(settled.players, owner, site.id);
- if (!blocked && player.advisors![site.id].lockedTurn === context.turn)
- blocked =
- 'Whether Nexus Cunning overrides the same-turn advisor lock is unresolved.';
- if (!blocked && stormBlocked(context, settled.players, player, site.id))
- blocked =
- 'Nexus advisor conversion involving storm-blocked forces awaits a ruling.';
- return { territory: site.id, count: original.count, blocked };
- });
+ .map((site) => ({
+ territory: site.id,
+ count: presenceAt(player, site.id),
+ blocked: territoryEntryBlock(settled.players, owner, site.id),
+ }));
return { territories };
}
function signature(receipt: Omit): string {
diff --git a/game/planetologist-movement.ts b/game/planetologist-movement.ts
index 38232882..d9bd7354 100644
--- a/game/planetologist-movement.ts
+++ b/game/planetologist-movement.ts
@@ -41,7 +41,7 @@ export function planetologistRange(
/** Cyborgs accompany selected sectors of their own origin, never another origin. */
export function selectedOriginElites(
- group: readonly [string, number][],
+ group: readonly (readonly [string, number])[],
elites: Readonly>,
origin: string,
): number {
diff --git a/game/reference.ts b/game/reference.ts
index 0591df29..079b9397 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4564,7 +4564,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Ixian Betrayal has a separate fresh native-Ixian ixian-betrayal profile for both original Bidding inspection/draw and Advanced Technology attempts. Native counters resolve first; then every publicly possible unallied rival held-Nexus seat acknowledges uniformly before the effect. A legal Use spends one physical Ixian Nexus to deny only that attempt; all-pass preserves the native effect once. No completed-card undo or whole-phase ban is added. This bounded ordering/privacy is an inference, not universal publisher priority; see its bounded verified native/private/recovery/phone evidence and unchanged Richese/module/public/deployment gates.',
'With Tleilaxu absent, its actual unallied Nexus holder may use a quiet Revival control to return one mandatory own dead leader free and up to five exact physical counters for one bank spice each. The provisional policy uses the ordinary leader slot and expands the shared ordinary ceiling to at least five, counting prior returns and retaining larger native/Recruits allowance. Only the unopened roster trigger is waived: repeated deaths, actual stars/Cyborgs and special custody retain canonical rules, and ordinary cycle history is not fabricated. Zero forces is legal; force-only use is not. Spend the physical card once; native Tleilaxu income and cancellation are not imported.',
'In Advanced play, Bene Gesserit Cunning converts selected whole advisor territories during your own Shipment and Movement action. All your counters in each selected territory become fighters together across sectors. It spends the Nexus card once, with no spice, shipment or movement cost.',
- 'One Karama response covers the entire declared territory set. Cancellation keeps every selected group as advisors and leaves the Nexus card spent. Existing territory restrictions still apply; fresh accompanied advisors and storm-related conversion remain unavailable pending their rulings.',
+ 'One Karama response covers the entire declared territory set. Cancellation preserves every original advisor group and lock while leaving the Nexus spent. Under the explicit recommended in-place card exception, Cunning may include freshly accompanied or storm-bound/separated territories. This is provisional, not publisher clarification. Ordinary flipping/arrival, allied capacity, storm movement/casualties and battle geometry remain unchanged; broader module acceptance remains open.',
'With BG absent, every fresh Nexus battle asks both combatants in attacker order before Prescience, Nexus inspection or sealed plans. Continue preserves the card. The actual unallied BG-card holder may spend it to require or forbid one canonical type against the opponent. Under the explicit provisional recommendation, original Karama can cancel this named Voice advantage; the Nexus stays spent, only Voice is removed and native preparation continues. Existing in-flight preparation is not retrofitted. Native compliance and special/mode restrictions remain; this does not complete other unfinished effects.',
'Bene Gesserit Betrayal can prevent the native Voice while the ordinary pre-plan response is open. In Nexus games every seat responds even without a canceling card, so the window does not reveal the hidden holder. An unallied opponent spends one physical Bene Gesserit Nexus card to remove only that declared Voice; Prescience and plan commitments continue. Truthtrance has priority. This path does not grant borrowed Voice.',
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
@@ -4597,11 +4597,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Existing physical effects, Tleilaxu shared revival and borrowed Voice remain connected. Absent-Fremen protection now binds one original pre-devour occurrence and public threatened held-Nexus storm order; only the actual unallied holder spends. It preserves that owner only and resumes native other casualties, spice removal, Great Maker vote and replacement once. No extra Karama provider, ride or native identity is invented under the visible provisional policy. Wider custody and combinations remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts'] },
- { area: 'Player controls', status: 'Partial', detail: 'Existing lifecycle, Tleilaxu and borrowed Voice controls remain. Worm protection adds owner-only neutral Continue or actual-holder Use before devouring, with a visible independent-operation policy. The original Accept spice blow and Great Maker vote remain available after settlement. Nexus table guidance now lists connected effects and supply-only Moritani Cunning honestly. Remaining combinations and complete modes are open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx'] },
- { area: 'AI', status: 'Partial', detail: 'All four minimal legal profiles retain existing effects, Tleilaxu combined returns and borrowed Voice, and now answer only their own worm protection offer: actual holder Use or irrelevant-face Pass. Original later worm/Nexus and vote policies continue. This is legal participation, not strategy or difficulty calibration; both remain deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
- { area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve faces retain remaining custody, inquiry, native nested Cunning, fresh advisor/storm and combination questions. Source documents explicitly distinguish cancellable named borrowed Voice from independent absent-Fremen protection with neutral pre-destruction timing. Neither is publisher clarification or universal reaction priority. Existing Richese/Guild contracts and complete-mode gates remain.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Protection related9-file union86/86 and actual390px GameTable pass. Genuine setup, worm/alliance/closing draw, paid safe Hagga shipment and free Advanced advisor produce the next real worm; Use/Pass, source rejection, four policies and original Great Maker vote/replacement are exercised. Neutral Continue leaves groups intact; Use preserves Emperor1/reserves19/Tanks0/spice10, devours BG advisor1, clears four spice, spends one Nexus and accepts the resumed Sihaya blow. No overflow/errors. Earlier Voice/Tleilaxu evidence remains bounded; wider modules, assurance and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Existing physical effects, Tleilaxu revival, borrowed Voice and independent worm protection remain connected. Native BG Cunning now permits fresh and storm-bound/separated whole advisor territories under the visible provisional card exception. Exact original stance/groups, physical card and one Karama response remain; ordinary flip/arrival, capacity, movement, casualties and battle geometry are unchanged. Dead uncertainty helper is removed and quotes avoid receipt allocations. Wider custody and combinations remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Existing connected controls remain. Cunning offers actual fresh/storm whole groups with a visible in-place interpretation, native allowance or whole-batch cancellation. Live proof repaired an existing ordinary Move button mismatch using projected route geometry/native range; storm-origin and over-range cross-storm routes now disable with accessible explanation, without engine relaxation. Original movement/turn handover remains. Complete modes and wider module controls are open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'All four unchanged minimal legal profiles retain existing effects and now execute actual favorable two-advisor versus one-fighter Cunning for fresh, storm-bound and separated sources through the shared quote. Original response and movement/turn continuation remain legal. Projected geometry is shared with corrected controls; no new strategy or calibration is claimed, and both remain deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
+ { area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve faces retain remaining custody, inquiry, native nested Cunning and combination questions. Source documents distinguish cancellable named Voice, independent protection and the recommended in-place fresh/storm Cunning exceptions without claiming publisher answers. Actual Advanced card-distance chronology and native ordinary restrictions are explicit. Existing Richese/Guild contracts and complete-mode gates remain.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Cunning related10-file union65/65 and types/lint pass. Genuine current-turn Shield Wall2, sheltered Arrakeen2 under storm10 and rock False Wall East5/9 separated by storm7 exercise allow/cancel, immutable rejection, exact custody, original geometry and four legal profiles. Three real390px surfaces prove fresh conversion/fighter move/Battle handover, storm stance-only allowance and entire separated-batch Karama cancellation. A live offered but engine-rejected storm move is corrected to disabled route/range guidance. Costs/pools/usage unchanged by conversion; no overflow/errors. Wider modules, assurance and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md'] },
],
},
{
diff --git a/tests/fixture-nexus-advisor-fresh.ts b/tests/fixture-nexus-advisor-fresh.ts
new file mode 100644
index 00000000..986f15ff
--- /dev/null
+++ b/tests/fixture-nexus-advisor-fresh.ts
@@ -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(producer: () => T): T {
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ crypto.getRandomValues = (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 },
+ };
+}
diff --git a/tests/fixture-nexus-advisor-storm.ts b/tests/fixture-nexus-advisor-storm.ts
new file mode 100644
index 00000000..2dc3acce
--- /dev/null
+++ b/tests/fixture-nexus-advisor-storm.ts
@@ -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;
+ opponentGroups: Record;
+ originalStance: { lockedTurn?: number };
+ unblockedDistance: number;
+ blockedDistance: number;
+ stormSource: Game['stormMovementSource'];
+ };
+ actions: NexusAdvisorStormStep[];
+ staging: string[];
+ scenario: Record;
+};
+
+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(run: () => T): T {
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ crypto.getRandomValues = (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(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 = { [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));
+}
diff --git a/tests/fixture-nexus-fremen-protection.ts b/tests/fixture-nexus-fremen-protection.ts
index d8159b4b..acc72ba5 100644
--- a/tests/fixture-nexus-fremen-protection.ts
+++ b/tests/fixture-nexus-fremen-protection.ts
@@ -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.',
diff --git a/tests/nexus-advisor-controls.test.ts b/tests/nexus-advisor-controls.test.ts
index 58c1e41a..6cc99abc 100644
--- a/tests/nexus-advisor-controls.test.ts
+++ b/tests/nexus-advisor-controls.test.ts
@@ -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(/(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')));
-});
diff --git a/tests/nexus-advisor-fresh-runtime.test.ts b/tests/nexus-advisor-fresh-runtime.test.ts
new file mode 100644
index 00000000..4d82283c
--- /dev/null
+++ b/tests/nexus-advisor-fresh-runtime.test.ts
@@ -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);
+ }
+});
diff --git a/tests/nexus-advisor-storm-runtime.test.ts b/tests/nexus-advisor-storm-runtime.test.ts
new file mode 100644
index 00000000..398dd103
--- /dev/null
+++ b/tests/nexus-advisor-storm-runtime.test.ts
@@ -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);
+ }
+ });
+}
diff --git a/tests/nexus-advisors-quote.test.ts b/tests/nexus-advisors-quote.test.ts
index 5c3ec226..745d32a5 100644
--- a/tests/nexus-advisors-quote.test.ts
+++ b/tests/nexus-advisors-quote.test.ts
@@ -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);
diff --git a/tests/nexus-advisors.test.ts b/tests/nexus-advisors.test.ts
index 9d712b43..3ecd4859 100644
--- a/tests/nexus-advisors.test.ts
+++ b/tests/nexus-advisors.test.ts
@@ -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();