Connect native Fremen Nexus effects across selected modules

Retain actual Cunning appearances, paid Karama and typed remote riding with optional Sandmaster collection and source-parent HMS entry. Preserve Betrayal movement sources and apply its declared worm restriction to Great Maker reserve riding without suppressing votes or casualties. Completed Maker history no longer blocks a later ordinary offer.

Validation: frozen 14-file86/86, types/lint/build, nine finite original-action/rendered-control programmes, four minimal legal policies. Discovery destination proof remains focused quotes pending actual arrival; full combinations, assurance and deployed acceptance remain required. No saved-game, restart or deployment operation.
This commit is contained in:
Quintine committed 2026-10-18 11:35:59 +11:00
1 parent bb9d292c4c
commit b4157aaa9b
20 files changed
+1349 -41

No files matched your search

+247
View File
@@ -0,0 +1,247 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { distance, location, splitLocation, TERRITORIES, territory } from '../game/board';
import { nativeShipmentSources } from '../game/homeworld-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import {
actE3IndependentDuke, e3IndependentDukeClean, nextE3IndependentDukeStep,
} from './fixture-e3-independent-nexus-duke';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type FremenBetrayalModulesStep = { actor: string; action: Action };
export type FremenBetrayalModulesOptions = {
advanced?: boolean;
homeworlds?: boolean;
skills?: boolean;
tech?: boolean;
strongholds?: boolean;
expansions?: Game['expansions'];
program?: 'movement' | 'worm';
/** Earn city ornithopters through a real free reinforcement and board move. */
city?: boolean;
/** The real Nexus between Advanced piles may end the native alliance before
* an empty second-pile appearance after the completed Great Maker. */
emptyAfterMaker?: boolean;
};
export const fremenBetrayalModulesClean = e3IndependentDukeClean;
export const fremenBetrayalModulesPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const actFremenBetrayalModules = actE3IndependentDuke;
export function advanceFremenBetrayalModules(game: Game, until: (state: Game) => boolean,
actions: FremenBetrayalModulesStep[] = []): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Fremen programme ended before its owned boundary');
const step = nextE3IndependentDukeStep(game);
assert.ok(step, `Complete the real ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
game = actFremenBetrayalModules(game, step.actor, step.action, actions);
}
throw Error('The original Fremen Betrayal programme did not reach its finite boundary');
}
/** Original lobby selections, original private setup choices and original
* typed starting placement. The scalar producer only orders unopened decks. */
function originalSetup(options: FremenBetrayalModulesOptions) {
const discovery = options.program === 'worm';
let game = createGame('FREMENBETRAYALMODULES', newPlayer('fremen', 'Fremen', 'fremen'),
options.advanced ?? true, options.expansions ?? (discovery ? [] : ['ix', 'choam', 'ecaz']));
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: FremenBetrayalModulesStep[] = [];
const act = (actor: string, action: Action) => { game = actFremenBetrayalModules(game, actor, action, actions); };
for (const selection of ['nexusCards', ...(discovery ? ['discoveries'] : ['ecazTreachery']),
...(options.skills !== false ? ['leaderSkills'] : [])])
act(game.host, { type: 'moduleOption', module: selection, enabled: true });
for (const [type, enabled] of [['homeworlds', options.homeworlds ?? true], ['techTokens', options.tech ?? true],
['strongholdCards', options.strongholds ?? game.advanced]] as const)
if (enabled) act(game.host, { type, enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, game.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
const source = location('sietch_tabr', territory('sietch_tabr').sectors[0]);
for (let count = 0; game.status === 'setup' && count < 180; count++) {
let step: FremenBetrayalModulesStep | undefined;
if (game.setupStage === 'forces' && fremenBetrayalModulesPlayer(game, 'fremen').reserves === 20)
step = { actor: 'fremen', action: { type: 'fremenSetup', placements: { [source]: 10 },
...(game.advanced ? { elitePlacements: { [source]: 1 } } : {}) } };
else for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { step = { actor: player.id, action }; break; }
}
assert.ok(step, 'Original private Fremen setup must expose a legal next control');
act(step.actor, step.action);
}
assert.equal(game.status, 'playing');
return { lobby, setup, game, actions, source };
}
/** Choose a position only while each physical Spice Card remains unopened. */
function orderSpice(game: Game, choices: string[]) {
const front: Game['spiceDeck'] = [];
for (const choice of choices) {
const at = game.spiceDeck.findIndex(card => choice === 'worm'
? 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed
: choice === 'maker' ? 'worm' in card && !!card.greatMaker
: 'territory' in card && !card.discovery && card.territory === choice);
assert.ok(at >= 0, `The original ${choice} Spice Card must remain unopened`);
front.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...front);
}
/** Genuine first setup, turn-two worm, reciprocal alliance and closing draw.
* Movement keeps its original board source. Worm mode uses two actual paid/
* free typed shipments before the next pre-blow window; no played state edits. */
export function createFremenBetrayalModulesFixture(options: FremenBetrayalModulesOptions = {}) {
const program = options.program ?? 'movement';
const source = withClassicDiscoveryNexusLottery(() => originalSetup({ ...options, program }));
let game = structuredClone(source.game);
const actions = [...source.actions], afterSetup = structuredClone(game);
const act = (actor: string, action: Action) => { game = actFremenBetrayalModules(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceFremenBetrayalModules(game, until, actions); };
const shipments: { before: Game; step: FremenBetrayalModulesStep; after: Game }[] = [];
const ship = (actor: string, to: string, sector: number, amount: number, elite: number) => {
const sources = game.homeworlds ? nativeShipmentSources(viewGame(game, actor), amount, elite) : undefined;
assert.ok(!game.homeworlds || sources);
const step: FremenBetrayalModulesStep = { actor, action: { type: 'ship', territory: to, sector,
amount, elite, allyPayment: 0, ...(sources ? { homeworldSources: sources } : {}) } };
const before = structuredClone(game); act(actor, step.action); reach(fremenBetrayalModulesClean);
shipments.push({ before, step, after: structuredClone(game) });
};
orderSpice(game, ['broken_land', ...(game.advanced ? ['south_mesa'] : [])]);
let cityMove: FremenBetrayalModulesStep | undefined;
if (options.city) {
reach(state => state.phase === 5 && state.active === 'fremen' && fremenBetrayalModulesClean(state));
ship('fremen', 'hagga_basin', 12, 1, 0);
cityMove = { actor: 'fremen', action: { type: 'move', from: 'hagga_basin:12', amount: 1,
elite: 0, territory: 'carthag', sector: 11 } };
act(cityMove.actor, cityMove.action); reach(fremenBetrayalModulesClean);
}
reach(state => state.turn === 2 && state.phase === 1 && fremenBetrayalModulesClean(state));
const afterFirstTurn = structuredClone(game);
orderSpice(game, ['worm', 'red_chasm', ...(game.advanced
? [options.emptyAfterMaker ? 'sihaya_ridge' : 'hagga_basin'] : [])]);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && fremenBetrayalModulesClean(state));
const beforeAlliance = structuredClone(game);
act('fremen', { type: 'alliance', target: 'emperor' }); act('emperor', { type: 'alliance', target: 'fremen' });
reach(state => state.nexusCards?.phase?.stage === 'drawing' && fremenBetrayalModulesClean(state));
const cards = game.nexusCards!.cards!, at = cards.deck.indexOf('fremen'); assert.ok(at >= 0);
cards.deck.unshift(cards.deck.splice(at, 1)[0]);
const beforeDraw = structuredClone(game), draw: FremenBetrayalModulesStep = { actor: 'guild', action: {
type: 'nexusCardChoice', turn: game.turn, card: cards.hands.guild, choice: 'draw', ownRedraws: 0,
} };
act(draw.actor, draw.action);
const afterDraw = structuredClone(game);
if (program === 'worm') {
assert.ok(game.advanced, 'This bounded Great Maker programme uses both real Advanced spice piles');
const shipped = new Set<string>();
for (let count = 0; shipped.size < 2 && count < 500; count++) {
if (game.phase === 5 && fremenBetrayalModulesClean(game) && game.active &&
['guild', 'fremen'].includes(game.active) && !shipped.has(game.active)) {
const actor = game.active;
if (actor === 'guild') ship(actor, 'red_chasm', 7, 1, 0);
else ship(actor, 'hagga_basin', 12, 2, 1);
shipped.add(actor);
} else {
const step = nextE3IndependentDukeStep(game); assert.ok(step);
act(step.actor, step.action);
}
}
assert.equal(shipped.size, 2);
reach(state => state.turn === 3 && state.phase === 1 && fremenBetrayalModulesClean(state));
orderSpice(game, ['maker', 'habbanya_erg', 'worm', 'oh_gap']);
} else reach(state => state.phase === 5 && state.active === 'fremen' && fremenBetrayalModulesClean(state));
const offer = viewGame(game, 'guild').nexusFremenBetrayal;
assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The genuine card needs its original advance-declaration window');
assert.equal(offer.mode, program === 'worm' ? 'worm' : 'movement');
const play: Action = { type: 'nexusFremenBetrayal', event: offer.event };
return { ...source, game, program, actions, afterSetup, afterFirstTurn, beforeAlliance, beforeDraw, draw,
afterDraw, shipments, cityMove, offer, play, emptyAfterMaker: options.emptyAfterMaker ?? false,
owner: 'guild', rider: 'fremen', victim: 'guild',
staging: ['Only original undealt Spice/Nexus positions and scalar setup lotteries are selected; all setup, alliances, native source-qualified shipments and moves are actual actions.'] };
}
export type FremenBetrayalModulesFixture = ReturnType<typeof createFremenBetrayalModulesFixture>;
/** An empty printed destination at the requested actual board distance. */
export function fremenBetrayalModulesMove(game: Game, source: string, range: number): Action {
const candidate = TERRITORIES.filter(row => row.type === 'sand' || row.type === 'rock')
.flatMap(row => row.sectors.map(sector => location(row.id, sector)))
.find(to => game.players.every(player => !Object.entries(player.forces).some(([key, count]) =>
count > 0 && splitLocation(key).territory === splitLocation(to).territory)) &&
distance(source, to, key => splitLocation(key).sector === game.storm) === range);
assert.ok(candidate, `An empty printed range-${range} destination must exist from the original source`);
const to = splitLocation(candidate), own = fremenBetrayalModulesPlayer(game, 'fremen');
return { type: 'move', from: source, amount: 1, elite: (own.elites?.forces[source] ?? 0) > 0 ? 1 : 0,
territory: to.territory, sector: to.sector };
}
/** Complete the actual restricted move and the remainder of its native turn. */
export function finishFremenBetrayalMovement(fixture: FremenBetrayalModulesFixture, range = 1) {
const actions: FremenBetrayalModulesStep[] = [];
let game = actFremenBetrayalModules(structuredClone(fixture.game), fixture.owner, fixture.play, actions);
const spent = structuredClone(game), move = fremenBetrayalModulesMove(game, fixture.source, range);
game = actFremenBetrayalModules(game, fixture.rider, move, actions);
game = advanceFremenBetrayalModules(game, fremenBetrayalModulesClean, actions);
const moved = structuredClone(game);
game = advanceFremenBetrayalModules(game, state => state.turn > fixture.game.turn &&
state.phase === 1 && fremenBetrayalModulesClean(state), actions);
return { game, spent, moved, move, actions };
}
/** Real Great Maker votes, native reserve ride in the unspent control, both
* replacement blows and the ordinary typed native survivor. No board ride is
* added to the Great Maker and no legal action is synthesized from a save. */
export function finishFremenBetrayalWorm(fixture: FremenBetrayalModulesFixture, spend = true) {
assert.equal(fixture.program, 'worm');
const actions: FremenBetrayalModulesStep[] = [], votes: FremenBetrayalModulesStep[] = [],
rides: { before: Game; action: Action; after: Game }[] = [], ordinaryRides: FremenBetrayalModulesStep[] = [],
cunningOffers: Game[] = [];
let game = structuredClone(fixture.game);
if (spend) game = actFremenBetrayalModules(game, fixture.owner, fixture.play, actions);
const spent = structuredClone(game);
for (let count = 0; count < 650; count++) {
if (game.phase === 2 && fremenBetrayalModulesClean(game))
return { game, spent, actions, votes, rides, ordinaryRides, cunningOffers };
const before = structuredClone(game), decision = game.decision;
let step: FremenBetrayalModulesStep | null;
if (fixture.emptyAfterMaker && game.nexus && !game.spiceWindow && !game.spiceResolution &&
fremenBetrayalModulesClean(game) && fremenBetrayalModulesPlayer(game, fixture.rider).ally) {
step = { actor: fixture.rider, action: { type: 'alliance', target: null } };
} else if (!game.response && !game.phaseOpening && decision?.kind === 'nexusFremenCunningOffer') {
cunningOffers.push(structuredClone(game));
step = { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
} else if (!game.response && !game.phaseOpening && decision?.kind === 'greatMakerVote') {
step = { actor: decision.player, action: { type: 'decision', event: decision.event, yes: true } };
votes.push(structuredClone(step));
} else if (!game.response && !game.phaseOpening && decision?.kind === 'greatMakerRide') {
const action = greatMakerRideAction(viewGame(game, fixture.rider), 'polar_sink', 0, 1, game.advanced ? 1 : 0);
assert.ok(action, 'The unspent control uses the original typed reserve-ride control');
step = { actor: fixture.rider, action };
} else step = nextE3IndependentDukeStep(game);
assert.ok(step, 'The native Great Maker continuation must keep its own legal next choice');
if (decision?.kind === 'wormRide') ordinaryRides.push(structuredClone(step));
game = actFremenBetrayalModules(game, step.actor, step.action, actions);
if (decision?.kind === 'greatMakerRide') rides.push({ before, action: step.action, after: structuredClone(game) });
}
throw Error('The original Great Maker/worm sequence did not reach Bidding preparation');
}
/** Four finite legal declarations, without difficulty-strength calibration. */
export function smokeFremenBetrayalModules(options: FremenBetrayalModulesOptions = {}) {
const fixture = createFremenBetrayalModulesFixture(options);
return (['Easy', 'Medium', 'Hard', 'Brutal'] as const).map(difficulty => {
const view = viewGame(fixture.game, fixture.owner);
view.players.find(player => player.id === fixture.owner)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'nexusFremenBetrayal'); assert.ok(action, difficulty);
const game = actFremenBetrayalModules(structuredClone(fixture.game), fixture.owner, action);
const native = fremenBetrayalModulesPlayer(game, fixture.rider);
return { difficulty, phase: game.phase, mode: fixture.offer.mode,
restricted: fixture.offer.mode === 'worm' ? native.fremenNexusWormBlockedTurn === game.turn
: native.fremenNexusMovementBlockedTurn === game.turn,
spent: game.nexusCards!.cards!.discard.filter(card => card === 'fremen').length };
});
}
+261
View File
@@ -0,0 +1,261 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { territory } from '../game/board';
import { nativeShipmentSources } from '../game/homeworld-options';
import { sandmasterCunningCollection } from '../game/sandmaster-worm';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { skillOfferRolls } from './fixture-independent-card-modules';
import { nextPairedChoamSkillsStep } from './fixture-paired-choam-nexus-skills';
export type FremenCunningModulesStep = { actor: string; action: Action };
export type FremenCunningModulesOptions = {
advanced?: boolean;
expansions?: Game['expansions'];
factions?: readonly FactionId[];
skills?: boolean;
homeworlds?: boolean;
tech?: boolean;
strongholds?: boolean;
hmsAtSource?: boolean;
};
export const fremenCunningModulesPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const fremenCunningModulesClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingGuildBetrayal &&
!game.pendingNexusIxianReplacement && !game.pendingNexusIxianBetrayal && !game.pendingNexusHarkonnenBetrayal;
/** Scope the real undealt skill lottery; all remaining scalar shuffles retain
* their original order. UUIDs and all other random byte arrays stay native. */
function originalLottery<T>(rolls: readonly number[], operation: () => T): T {
const native = crypto.getRandomValues.bind(crypto), descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actFremenCunningModules(game: Game, actor: string, action: Action, actions?: FremenCunningModulesStep[]): Game {
actions?.push({ actor, action: structuredClone(action) });
return originalLottery([], () => applyAction(game, actor, action));
}
/** Original no-spend/no-movement progression stops at owned Terror, Ambassador
* and the remote ride. The real first empty appearance has no held Fremen card
* and is declined; no private Nexus entitlement is manufactured for it. */
export function nextFremenCunningModulesStep(game: Game): FremenCunningModulesStep | null {
if (game.pendingGuildBetrayal) {
const frame = game.pendingGuildBetrayal, actor = frame.required.find(actor => !frame.passed.includes(actor)); assert.ok(actor);
return { actor, action: { type: 'guildBetrayalPass', event: frame.invoice.event } };
}
if (game.pendingNexusIxianReplacement) return { actor: game.pendingNexusIxianReplacement.buyer,
action: { type: 'nexusIxianReplacementPass', event: game.pendingNexusIxianReplacement.event } };
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'nexusFremenCunningRide' || decision?.kind === 'moritaniTerror' || decision?.kind === 'ecazAmbassador') return null;
if (decision?.kind === 'nexusFremenCunningOffer') return { actor: decision.player,
action: { type: 'decision', event: decision.event, accept: false } };
if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player,
action: { type: 'decision', event: decision.event, allow: true } };
if (decision?.kind === 'homeworldDefense') return { actor: decision.player,
action: { type: 'decision', event: decision.event, use: false } };
if (decision?.kind === 'moritaniPlacement' || decision?.kind === 'ecazPlacement' ||
decision?.kind === 'homeworldRevivalDeployment' || decision?.kind === 'caladanReinforcement' || decision?.kind === 'grummanCollection')
return { actor: decision.player, action: { type: 'decision', event: 'event' in decision ? decision.event : undefined, decline: true } };
if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } };
if (decision?.kind === 'wormRide' || decision?.kind === 'wormPlacement' || decision?.kind === 'advisor' ||
decision?.kind === 'intrusion' || decision?.kind === 'advisorBattle')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'leaderSkillVisibility') return { actor: decision.player,
action: { type: 'leaderSkillVisibility', event: decision.event, hide: false } };
if (decision?.kind === 'mentatQuestion') return { actor: decision.player,
action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'moritaniSetup') return { actor: decision.player,
action: { type: 'decision', territory: 'pasty_mesa', sector: 5 } };
}
return nextPairedChoamSkillsStep(game);
}
export function advanceFremenCunningModules(game: Game, until: (state: Game) => boolean,
actions: FremenCunningModulesStep[] = []): Game {
for (let count = 0; count < 2400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine Fremen Cunning chronology ended early');
const next = nextFremenCunningModulesStep(game);
assert.ok(next, `Choose the original ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
game = actFremenCunningModules(game, next.actor, next.action, actions);
}
throw Error('The genuine Fremen Cunning chronology did not reach its boundary');
}
/** Move original unopened physical Spice Cards only. Never assign a discard
* target, board pile, appearance identity or historical worm record. */
function orderOriginalSpice(game: Game, entries: readonly string[]): void {
const front: Game['spiceDeck'] = [];
for (const entry of entries) {
const index = game.spiceDeck.findIndex(card => entry === 'worm'
? 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper
: 'territory' in card && card.territory === entry && !card.discovery);
assert.ok(index >= 0, `Use original unopened Spice Card ${entry}`);
front.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...front);
}
const quietTraining: Partial<Record<FactionId, LeaderSkillId>> = {
fremen: 'sandmaster', guild: 'killer-medic', emperor: 'master-of-assassins',
ixians: 'prana-bindu-adept', choam: 'swordmaster-of-ginaz', ecaz: 'warmaster',
};
/** Ordinary host module selections and private setup choices. The all14 case
* chooses a genuinely offered Sandmaster and eligible living Fremen trainer.
* Only unopened lotteries are ordered; no post-deal inventory is replaced. */
export function createFremenCunningModulesFixture(options: FremenCunningModulesOptions = {}) {
const factions = options.factions ?? ['fremen', 'guild', 'emperor'];
assert.equal(factions[0], 'fremen');
assert.ok(factions.includes('guild') && factions.includes('emperor'));
let game = createGame('FREMENCUNNINGMODULES', newPlayer('fremen', 'Fremen', 'fremen'),
options.advanced ?? false, options.expansions ?? ['ecaz']);
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: FremenCunningModulesStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => { game = actFremenCunningModules(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceFremenCunningModules(game, until, actions); };
for (const selection of ['ecazTreachery', 'nexusCards', ...(options.skills ? ['leaderSkills'] : [])])
act(game.host, { type: 'moduleOption', module: selection, enabled: true });
for (const [type, enabled] of [['homeworlds', options.homeworlds], ['techTokens', options.tech], ['strongholdCards', options.strongholds]] as const)
if (enabled) act(game.host, { type, enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game), start: FremenCunningModulesStep = { actor: game.host,
action: game.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } };
actions.push(structuredClone(start));
game = originalLottery(options.skills ? skillOfferRolls(game, quietTraining) : [], () => applyAction(game, start.actor, start.action));
const offered = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 300; count++) {
if (game.setupStage === 'leaderSkills' && !game.decision) {
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
const wanted = quietTraining[fremenCunningModulesPlayer(game, actor).faction];
const skill = wanted && own.offer!.cards.includes(wanted) ? wanted : own.offer!.cards.find(card => !own.unavailableSkills?.[card]);
const leader = own.eligibleLeaders[0];
assert.ok(skill && !own.unavailableSkills?.[skill] && leader);
act(actor, { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id });
continue;
}
if (game.setupStage === 'forces' && fremenCunningModulesPlayer(game, 'fremen').reserves === 20) {
act('fremen', { type: 'fremenSetup', placements: { [`sietch_tabr:${territory('sietch_tabr').sectors[0]}`]: 10 } });
continue;
}
const next = nextFremenCunningModulesStep(game); assert.ok(next); act(next.actor, next.action);
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const trainer = game.leaderSkills?.assignments.find(assignment => assignment.owner === 'fremen' && assignment.skill === 'sandmaster')?.leader ?? null;
assert.ok(!options.skills || trainer);
orderOriginalSpice(game, game.advanced ? ['broken_land', 'rock_outcroppings'] : ['broken_land']);
const counterIndex = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(counterIndex >= 0);
const counterCard = game.deck[counterIndex].id;
game.deck.unshift(game.deck.splice(counterIndex, 1)[0]);
let counterBid: { before: Game; step: FremenCunningModulesStep } | null = null;
let hmsPlacement: { before: Game; step: FremenCunningModulesStep; after: Game } | null = null;
for (let count = 0; count < 700; count++) {
if (fremenCunningModulesPlayer(game, 'guild').hand.some(card => card.id === counterCard)) break;
let next: FremenCunningModulesStep | null;
if (options.hmsAtSource && !game.response && !game.phaseOpening &&
game.decision?.kind === 'mobileStronghold' && game.decision.placement) {
const before = structuredClone(game), step: FremenCunningModulesStep = {
actor: game.decision.player, action: { type: 'decision', location: 'wind_pass:14' } };
act(step.actor, step.action);
hmsPlacement = { before, step, after: structuredClone(game) };
continue;
}
if (!game.response && !game.phaseOpening && game.decision?.kind === 'ixAuction' &&
game.ixAuction?.cards.some(card => card.id === counterCard)) {
const returned = game.ixAuction.cards.find(card => card.id !== counterCard); assert.ok(returned);
act(game.decision.player, { type: 'decision', card: returned.id, position: 'bottom' });
continue;
}
if (fremenCunningModulesClean(game) && game.auction?.cards[game.auction.index]?.id === counterCard) {
next = { actor: game.auction.active, action: game.auction.active === 'guild' && game.auction.bidder !== 'guild'
? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' } };
if (next.action.type === 'bid') counterBid = { before: structuredClone(game), step: structuredClone(next) };
} else next = nextFremenCunningModulesStep(game);
assert.ok(next, 'The original paid Karama must pass through its native bank auction');
act(next.actor, next.action);
}
assert.ok(counterBid && fremenCunningModulesPlayer(game, 'guild').hand.some(card => card.id === counterCard));
assert.ok(!options.hmsAtSource || hmsPlacement);
const counterPurchase = { ...counterBid, after: structuredClone(game) };
reach(state => state.turn === 1 && state.phase === 5 && state.active === 'fremen' && fremenCunningModulesClean(state));
const elite = game.advanced ? 2 : 0, homeworldSources = game.homeworlds ? nativeShipmentSources(viewGame(game, 'fremen'), 3, elite) : undefined;
assert.ok(!game.homeworlds || homeworldSources);
const shipmentBefore = structuredClone(game), shipmentStep: FremenCunningModulesStep = { actor: 'fremen', action: {
type: 'ship', territory: 'wind_pass', sector: 14, amount: 3, elite, allyPayment: 0,
...(homeworldSources ? { homeworldSources } : {}) } };
act(shipmentStep.actor, shipmentStep.action); reach(fremenCunningModulesClean);
const shipmentAfter = structuredClone(game);
assert.equal(fremenCunningModulesPlayer(game, 'fremen').forces['wind_pass:14'], 3);
reach(state => state.turn === 2 && state.phase === 1 && fremenCunningModulesClean(state));
orderOriginalSpice(game, game.advanced ? ['worm', 'red_chasm', 'south_mesa'] : ['worm', 'red_chasm']);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && fremenCunningModulesClean(state));
const beforeAlliance = structuredClone(game);
act('guild', { type: 'alliance', target: 'emperor' });
act('emperor', { type: 'alliance', target: 'guild' });
reach(state => state.nexusCards?.phase?.stage === 'drawing' && fremenCunningModulesClean(state));
const deck = game.nexusCards!.cards!.deck, index = deck.indexOf('fremen'); assert.ok(index >= 0);
deck.unshift(deck.splice(index, 1)[0]);
const drawBefore = structuredClone(game), drawStep: FremenCunningModulesStep = { actor: 'fremen', action: {
type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
act(drawStep.actor, drawStep.action);
const drawAfter = structuredClone(game);
reach(state => state.turn === 3 && state.phase === 1 && fremenCunningModulesClean(state));
orderOriginalSpice(game, game.advanced ? ['worm', 'hagga_basin', 'habbanya_erg'] : ['worm', 'hagga_basin']);
let beforeWorm = structuredClone(game), producer: FremenCunningModulesStep | null = null;
for (let count = 0; count < 150; count++) {
if (!game.response && !game.phaseOpening && game.decision?.kind === 'nexusFremenCunningOffer') break;
beforeWorm = structuredClone(game); producer = nextFremenCunningModulesStep(game); assert.ok(producer);
act(producer.actor, producer.action);
}
assert.ok(producer && game.decision?.kind === 'nexusFremenCunningOffer' && game.nexusFremenCunningOffer);
assert.equal(game.nexusFremenCunningOffer.occurrence.territory, 'red_chasm');
staging.push('Original host/module/setup actions, undealt skill and scalar shuffles, actual one-spice Guild Karama purchase, native free typed Fremen reinforcement, real empty worm and Guild/Emperor alliance, actual own Fremen closing draw, then a distinct real empty worm before devouring. Only unopened physical Treachery, Spice and Nexus order is selected.');
return { initial, lobby, offered, afterSetup, game, actions, staging, owner: 'fremen', trainer,
source: 'wind_pass', sourceKey: 'wind_pass:14', destination: 'hagga_basin', destinationSector: 12,
shipment: { before: shipmentBefore, step: shipmentStep, after: shipmentAfter },
counterCard, counterPurchase, hmsPlacement,
beforeAlliance, draw: { before: drawBefore, step: drawStep, after: drawAfter },
beforeWorm, producer, occurrence: structuredClone(game.nexusFremenCunningOffer.occurrence) };
}
export type FremenCunningModulesFixture = ReturnType<typeof createFremenCunningModulesFixture>;
/** Actual spend/response/devouring/Nexus/remote selection, then its native
* arrival continuation. Optional collection debits only the newly earned
* replacement-Blow destination pile before any arrival reaction. */
export function completeFremenCunningModules(fixture: FremenCunningModulesFixture,
options: { collect?: boolean; pile?: string; destination?: { territory: string; sector: number } } = {}) {
const actions = fixture.actions, owner = fixture.owner, event = fixture.occurrence.event;
let game = fixture.game;
const beforeSpend = structuredClone(game), spend: FremenCunningModulesStep = { actor: owner,
action: { type: 'decision', event, accept: true } };
game = actFremenCunningModules(game, owner, spend.action, actions);
const spentResponse = structuredClone(game);
game = advanceFremenCunningModules(game, state => !state.response && !state.phaseOpening && state.decision?.kind === 'nexusFremenCunningRide', actions);
const beforeRide = structuredClone(game), player = fremenCunningModulesPlayer(game, owner);
const total = player.forces[fixture.sourceKey], elite = player.elites?.forces[fixture.sourceKey] ?? 0;
assert.equal(total, 3);
const destination = options.destination ?? { territory: fixture.destination, sector: fixture.destinationSector };
const collection = options.collect ? sandmasterCunningCollection(game, owner, fixture.source,
destination.territory, destination.sector, options.pile) : null;
if (options.collect) assert.ok(collection && !collection.blocked && collection.key);
const ride: FremenCunningModulesStep = { actor: owner, action: { type: 'decision', event, accept: true,
source: fixture.source, forces: { [fixture.sourceKey]: { normal: total - elite, elite } },
territory: destination.territory, sector: destination.sector,
...(options.collect !== undefined ? { sandmasterCollect: options.collect } : {}),
...(options.collect ? { sandmasterPile: collection!.key! } : {}) } };
game = actFremenCunningModules(game, owner, ride.action, actions);
const afterRide = structuredClone(game);
game = advanceFremenCunningModules(game, fremenCunningModulesClean, actions);
return { game, actions, beforeSpend, spend, spentResponse, beforeRide, ride, afterRide, collection };
}
+176
View File
@@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { location } from '../game/board';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import {
actFremenBetrayalModules, advanceFremenBetrayalModules, createFremenBetrayalModulesFixture,
finishFremenBetrayalMovement, finishFremenBetrayalWorm, fremenBetrayalModulesClean,
fremenBetrayalModulesMove, fremenBetrayalModulesPlayer as player, smokeFremenBetrayalModules,
type FremenBetrayalModulesFixture,
} from './fixture-fremen-betrayal-modules';
function originalCard(f: FremenBetrayalModulesFixture) {
assert.equal(f.beforeDraw.nexusCards!.cards!.hands.guild, null);
assert.equal(f.afterDraw.nexusCards!.cards!.hands.guild, 'fremen');
assert.equal(player(f.afterDraw, 'guild').ally, null);
assert.equal(player(f.afterDraw, 'fremen').ally, 'emperor');
assert.equal(player(f.afterDraw, 'emperor').ally, 'fremen');
assert.ok(f.actions.some(step => step.actor === 'guild' && step.action.type === 'nexusCardChoice'));
}
function spentCard(game: Game, mode: 'worm' | 'movement') {
assert.equal(game.nexusCards!.cards!.hands.guild, null);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
assert.equal(game.nexusFremenBetrayalHistory!.at(-1)!.mode, mode);
}
function nativePool(game: Game, id: string) {
return homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
.find(group => group.id === id)!.forces.fremen;
}
for (const advanced of [false, true]) {
void test(`original ${advanced ? 'Advanced typed' : 'Basic'} selected-family World/training Fremen Betrayal suppresses native range and preserves a real ordinary move`, () => {
const f = createFremenBetrayalModulesFixture({ advanced });
originalCard(f);
assert.deepEqual(f.game.expansions, ['ix', 'choam', 'ecaz']);
assert.ok(f.game.homeworlds && f.game.leaderSkills && f.game.techTokens);
assert.equal(!!f.game.strongholdCards, advanced);
assert.equal(f.game.active, f.rider);
assert.equal(player(f.game, f.rider).moved, 0);
assert.equal(player(f.game, f.rider).forces[f.source], 10);
const long = fremenBetrayalModulesMove(f.game, f.source, 2);
let native = actFremenBetrayalModules(structuredClone(f.game), f.rider, long);
native = advanceFremenBetrayalModules(native, fremenBetrayalModulesClean);
assert.equal(player(native, f.rider).moved, 1, 'The original unspent native movement reaches range two');
const { game, spent, moved, move } = finishFremenBetrayalMovement(f);
spentCard(spent, 'movement');
assert.equal(player(spent, f.rider).fremenNexusMovementBlockedTurn, spent.turn);
assert.deepEqual(player(spent, f.rider).forces, player(f.game, f.rider).forces,
'The advance declaration moves no original board counter');
assert.deepEqual(spent.homeworlds, f.game.homeworlds);
assert.throws(() => actFremenBetrayalModules(spent, f.rider, long), /more than 1 territories away/i);
assert.equal(player(moved, f.rider).moved, 1);
assert.equal(player(moved, f.rider).forces[f.source], 9);
const to = location(String(move.territory), Number(move.sector));
assert.equal(player(moved, f.rider).forces[to], 1);
if (advanced) {
assert.equal(player(moved, f.rider).elites!.forces[f.source] ?? 0, 0);
assert.equal(player(moved, f.rider).elites!.forces[to], 1, 'The actual starting Fedaykin moves as its original starred counter');
}
assert.equal(player(moved, f.rider).reserves, player(f.game, f.rider).reserves);
assert.equal(player(moved, f.rider).spice, player(f.game, f.rider).spice);
assert.equal(moved.pendingFremenMove ?? null, null);
assert.equal(moved.response ?? null, null, 'The ordinary range-one move opens no native advantage cancellation');
assert.equal(game.turn, f.game.turn + 1);
assert.equal(viewGame(game, f.rider).players.find(row => row.id === f.rider)!.fremenNexusMovementBlocked, false);
spentCard(game, 'movement');
});
}
void test('original free Fremen reinforcement and city entry preserve independent ornithopter range under selected-module Betrayal', () => {
const f = createFremenBetrayalModulesFixture({ city: true });
originalCard(f);
assert.equal(f.shipments[0].step.actor, f.rider);
assert.equal(f.shipments[0].step.action.territory, 'hagga_basin');
assert.ok(f.shipments[0].step.action.homeworldSources);
assert.equal(player(f.shipments[0].after, f.rider).reserves,
player(f.shipments[0].before, f.rider).reserves - 1);
assert.equal(player(f.shipments[0].after, f.rider).spice,
player(f.shipments[0].before, f.rider).spice, 'The printed native reinforcement is free');
assert.equal(f.cityMove!.action.from, 'hagga_basin:12');
assert.equal(f.cityMove!.action.territory, 'carthag');
assert.equal(player(f.game, f.rider).forces['carthag:11'], 1);
const { spent, moved, move } = finishFremenBetrayalMovement(f, 3);
spentCard(spent, 'movement');
assert.equal(player(moved, f.rider).forces['carthag:11'], 1);
assert.equal(player(moved, f.rider).forces[location(String(move.territory), Number(move.sector))], 1);
assert.equal(moved.pendingFremenMove ?? null, null, 'City range does not borrow the suppressed native Fremen advantage');
});
void test('original World/training Great Maker votes and ordinary native worm destruction continue while advance Betrayal prevents both ride sources', () => {
const f = createFremenBetrayalModulesFixture({ program: 'worm' });
originalCard(f);
assert.equal(f.game.turn, 3);
assert.equal(f.game.phase, 1);
assert.ok(f.game.discoveries && f.game.homeworlds && f.game.leaderSkills);
assert.equal(player(f.game, f.victim).forces['red_chasm:7'], 1);
assert.equal(player(f.game, f.rider).forces['hagga_basin:12'], 2);
assert.equal(player(f.game, f.rider).elites!.forces['hagga_basin:12'], 1);
const victim = player(f.game, f.victim), rider = player(f.game, f.rider);
const { game, spent, votes, rides, ordinaryRides, cunningOffers } = finishFremenBetrayalWorm(f);
spentCard(spent, 'worm');
assert.equal(player(spent, f.rider).fremenNexusWormBlockedTurn, spent.turn);
assert.deepEqual(player(spent, f.rider).forces, rider.forces);
assert.equal(votes.length, f.game.players.length);
assert.deepEqual(votes.map(step => step.actor), game.greatMaker!.order);
assert.ok(game.greatMaker!.votes.every(vote => vote.yes));
assert.equal(game.greatMaker!.stage, 'complete');
assert.equal(rides.length, 0, 'A current restriction skips the Great Maker reserve-ride control');
assert.equal(ordinaryRides.length, 0, 'The later actual native Hagga worm grants no board ride');
assert.equal(cunningOffers.length, 0);
assert.deepEqual(game.wormRides, []);
assert.equal(player(game, f.victim).forces['red_chasm:7'] ?? 0, 0);
assert.equal(player(game, f.victim).tanks, victim.tanks + 1, 'The genuine paid victim is devoured despite the restriction');
assert.equal(game.spice['red_chasm:7'] ?? 0, 0);
assert.equal(game.spice['hagga_basin:12'] ?? 0, 0);
assert.equal(player(game, f.rider).forces['hagga_basin:12'], 2, 'Native worm survival remains in effect');
assert.equal(player(game, f.rider).elites!.forces['hagga_basin:12'], 1);
assert.equal(player(game, f.rider).reserves, rider.reserves);
assert.equal(player(game, f.rider).elites!.reserves, rider.elites!.reserves);
assert.deepEqual(nativePool(game, 'homeworld:fremen'), nativePool(f.game, 'homeworld:fremen'));
assert.ok(game.spiceDiscard[0].some(card => 'worm' in card && !!card.greatMaker));
assert.ok(game.spiceDiscard[0].some(card => 'territory' in card && card.territory === 'habbanya_erg'));
assert.ok(game.spiceDiscard[1].some(card => 'territory' in card && card.territory === 'oh_gap'));
assert.equal(game.nexusCards!.phase!.occurred, true);
assert.equal(game.nexusCards!.phase!.stage, 'complete');
assert.equal(game.phase, 2, 'Both replacement blows and original Nexus finish before charity');
spentCard(game, 'worm');
});
void test('the same original unspent Great Maker offers and completes a typed native reserve ride before normal worm riding', () => {
const f = createFremenBetrayalModulesFixture({ program: 'worm' });
const { game, rides, ordinaryRides } = finishFremenBetrayalWorm(f, false);
assert.equal(rides.length, 1);
assert.equal(rides[0].before.decision!.kind, 'greatMakerRide');
assert.equal(rides[0].action.amount, 1);
assert.equal(rides[0].action.elite, 1);
const before = player(rides[0].before, f.rider), after = player(rides[0].after, f.rider);
assert.equal(after.reserves, before.reserves - 1);
assert.equal(after.elites!.reserves, before.elites!.reserves - 1);
assert.equal(after.forces['polar_sink:0'], 1);
assert.equal(after.elites!.forces['polar_sink:0'], 1);
assert.equal(after.spice, before.spice);
assert.equal(after.moved, before.moved);
assert.equal(after.shipped, before.shipped);
assert.equal(ordinaryRides.length, 1, 'The distinct occupied Hagga appearance retains its normal later ride control');
assert.equal(game.nexusCards!.cards!.hands.guild, 'fremen');
assert.equal(game.nexusFremenBetrayalHistory?.length ?? 0, 0);
assert.equal(game.phase, 2);
});
void test('a genuine second-pile empty worm offers native Cunning after the Great Maker completes and the real Nexus ends its alliance', () => {
const f = createFremenBetrayalModulesFixture({ program: 'worm', emptyAfterMaker: true });
const { game, cunningOffers, rides, actions } = finishFremenBetrayalWorm(f, false);
assert.equal(rides.length, 1);
assert.equal(cunningOffers.length, 1);
const offered = cunningOffers[0];
assert.equal(offered.greatMaker!.stage, 'complete');
assert.equal(offered.nexusFremenCunningOffer!.occurrence.territory, 'sihaya_ridge');
assert.equal(offered.nexusFremenCunningOffer!.occurrence.initiallyEmpty, true);
assert.equal(player(offered, f.rider).ally, null);
assert.equal(offered.nexusCards!.cards!.hands.guild, 'fremen');
assert.equal(offered.nexusCards!.cards!.hands.fremen, null, 'The public native offer does not disclose or require the private held face');
assert.ok(actions.some(step => step.actor === f.rider && step.action.type === 'alliance' && step.action.target === null));
assert.equal(game.nexusFremenCunningOffer ?? null, null);
assert.equal(game.phase, 2);
});
void test('all four minimal policies retain genuine selected-module movement and Discovery worm declarations', () => {
for (const program of ['movement', 'worm'] as const) {
const report = smokeFremenBetrayalModules({ program });
assert.equal(report.length, 4);
assert.ok(report.every(result => result.restricted && result.spent === 1));
assert.ok(report.every(result => result.mode === program));
}
});
+150
View File
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { botActions } from '../game/bots';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { presenceAt } from '../game/force-presence';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
import {
createFremenCunningModulesFixture, completeFremenCunningModules,
fremenCunningModulesPlayer as player,
} from './fixture-fremen-cunning-modules';
function physicalNexus(game: Game): void {
const cards = game.nexusCards!.cards!;
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(), [...NEXUS_FACTIONS].sort());
}
for (const expansions of [['ix'], ['choam']] as const) void test(`Basic selected ${expansions[0]} independent/Nexus uses its actual drawn Fremen Cunning after an empty appearance and real closing Nexus`, () => {
const fixture = createFremenCunningModulesFixture({ expansions: [...expansions] });
assert.ok(fixture.afterSetup.ecazTreachery && fixture.afterSetup.nexusCards);
assert.deepEqual(fixture.afterSetup.expansions, [...expansions]);
assert.equal(fixture.counterPurchase.step.actor, 'guild');
assert.equal(fixture.counterPurchase.step.action.type, 'bid');
assert.equal(player(fixture.counterPurchase.after, 'guild').spice, player(fixture.counterPurchase.before, 'guild').spice - 1);
assert.ok(player(fixture.counterPurchase.after, 'guild').hand.some(card => card.id === fixture.counterCard && card.effect === 'karama'));
assert.equal(player(fixture.shipment.after, 'fremen').reserves, player(fixture.shipment.before, 'fremen').reserves - 3);
assert.equal(player(fixture.shipment.after, 'fremen').spice, player(fixture.shipment.before, 'fremen').spice);
assert.equal(player(fixture.draw.after, 'guild').ally, 'emperor');
assert.equal(player(fixture.draw.after, 'emperor').ally, 'guild');
assert.equal(player(fixture.draw.after, 'fremen').ally, null);
assert.equal(fixture.draw.after.nexusCards!.cards!.hands.fremen, 'fremen');
assert.equal(fixture.occurrence.initiallyEmpty, true);
assert.equal(fixture.occurrence.origin, 'natural');
assert.equal(fixture.occurrence.pile, 0);
assert.equal(fixture.occurrence.turn, 3);
assert.ok(fixture.game.players.every(player => presenceAt(player, fixture.occurrence.territory) === 0));
assert.equal(fixture.game.spice['red_chasm:7'], 8, 'The accepted appearance is captured before the original devouring');
const done = completeFremenCunningModules(fixture), game = done.game;
assert.equal(done.spentResponse.response?.kind, 'nexusFremenCunning');
assert.equal(done.spentResponse.nexusCards!.cards!.hands.fremen, null);
assert.equal(done.spentResponse.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
assert.equal(player(done.spentResponse, 'fremen').forces[fixture.sourceKey], 3);
assert.equal(done.spentResponse.spice['red_chasm:7'], 8);
assert.equal(done.beforeRide.spice['red_chasm:7'] ?? 0, 0);
assert.equal(done.beforeRide.spice['hagga_basin:12'], 6);
assert.equal(done.beforeRide.nexus, true);
assert.deepEqual([...done.beforeRide.ready].sort(), done.beforeRide.players.map(player => player.id).sort());
assert.equal(done.beforeRide.decision?.kind, 'nexusFremenCunningRide');
assert.equal(done.beforeRide.decision?.player, 'fremen');
assert.equal(done.beforeRide.nexusCards?.phase?.stage, 'spice', 'Original closing-card choices follow the post-negotiation rides');
assert.equal(game.nexusFremenCunningRides!.find(ride => ride.occurrence.event === fixture.occurrence.event)!.stage, 'complete');
assert.equal(player(game, 'fremen').forces[fixture.sourceKey] ?? 0, 0);
assert.equal(player(game, 'fremen').forces['hagga_basin:12'], 3);
assert.equal(player(game, 'fremen').moved, player(done.beforeRide, 'fremen').moved);
assert.equal(player(game, 'fremen').reserves, player(done.beforeRide, 'fremen').reserves);
assert.equal(player(game, 'fremen').spice, player(done.beforeRide, 'fremen').spice);
assert.equal(game.spice['hagga_basin:12'], 6);
const canceled = applyAction(done.spentResponse, 'guild', { type: 'card', mode: 'cancel', card: fixture.counterCard });
assert.equal(canceled.nexusFremenCunningRides!.find(ride => ride.occurrence.event === fixture.occurrence.event)!.stage, 'complete');
assert.equal(player(canceled, 'fremen').forces[fixture.sourceKey], 3);
assert.equal(player(canceled, 'fremen').forces['hagga_basin:12'] ?? 0, 0);
assert.equal(canceled.nexusCards!.cards!.hands.fremen, null);
assert.equal(canceled.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
assert.equal(canceled.discard.filter(card => card.id === fixture.counterCard).length, 1);
assert.equal(canceled.spice['red_chasm:7'] ?? 0, 0, 'Canceling the extra ride preserves the original worm destruction');
physicalNexus(game);
});
void test('Advanced registered Ixians/CHOAM/Ecaz with selected independent decks, World/all14/phase retains typed remote riding and genuine Sandmaster collection', () => {
const fixture = createFremenCunningModulesFixture({ advanced: true, expansions: ['ix', 'choam', 'ecaz'],
factions: ['fremen', 'guild', 'emperor', 'ixians', 'choam', 'ecaz'],
skills: true, homeworlds: true, tech: true, strongholds: true });
assert.ok(fixture.afterSetup.ecazTreachery && fixture.afterSetup.nexusCards && fixture.afterSetup.homeworlds &&
fixture.afterSetup.leaderSkills && fixture.afterSetup.techTokens && fixture.afterSetup.strongholdCards);
assert.equal(fixture.afterSetup.players.length, 6);
assert.ok(fixture.trainer);
const assignment = fixture.afterSetup.leaderSkills!.assignments.find(assignment => assignment.owner === 'fremen'); assert.ok(assignment);
assert.equal(assignment.skill, 'sandmaster');
assert.equal(assignment.leader, fixture.trainer);
assert.ok(fixture.actions.some(step => step.actor === 'fremen' && step.action.type === 'leaderSkill' && step.action.skill === 'sandmaster'));
assert.equal(player(fixture.shipment.after, 'fremen').elites!.reserves, player(fixture.shipment.before, 'fremen').elites!.reserves - 2);
assert.equal(player(fixture.game, 'fremen').elites!.forces[fixture.sourceKey], 2);
for (const difficulty of DIFFICULTIES) {
const own = viewGame(fixture.game, 'fremen'); own.players.find(player => player.id === 'fremen')!.bot = difficulty;
const action = botActions(own)[0]; assert.ok(action);
const spent = applyAction(fixture.game, 'fremen', action);
assert.equal(spent.nexusCards!.cards!.hands.fremen, null);
assert.equal(spent.response?.kind, 'nexusFremenCunning');
}
const done = completeFremenCunningModules(fixture, { collect: true });
assert.equal(done.collection!.leader, fixture.trainer);
assert.equal(done.collection!.key, 'hagga_basin:12');
assert.equal(done.collection!.before, 6);
assert.equal(player(done.afterRide, 'fremen').spice, player(done.beforeRide, 'fremen').spice + 1);
assert.equal(done.afterRide.spice['hagga_basin:12'], 5);
assert.equal(player(done.afterRide, 'fremen').forces['hagga_basin:12'], 3);
assert.equal(player(done.afterRide, 'fremen').elites!.forces['hagga_basin:12'], 2);
assert.equal(player(done.afterRide, 'fremen').forces[fixture.sourceKey] ?? 0, 0);
assert.equal(player(done.afterRide, 'fremen').elites!.forces[fixture.sourceKey] ?? 0, 0);
assert.equal(player(done.afterRide, 'fremen').moved, player(done.beforeRide, 'fremen').moved);
assert.equal(player(done.afterRide, 'fremen').shipped, player(done.beforeRide, 'fremen').shipped);
assert.equal(player(done.afterRide, 'fremen').reserves, player(done.beforeRide, 'fremen').reserves);
for (const [key, amount] of Object.entries(done.beforeRide.spice))
if (key !== 'hagga_basin:12') assert.equal(done.afterRide.spice[key], amount, 'A remote ride collects from its destination alone');
const invalidPile: Action = { ...done.ride.action, sandmasterPile: 'wind_pass:14' };
assert.throws(() => applyAction(done.beforeRide, 'fremen', invalidPile), /pile|spice|Sandmaster/i);
assert.throws(() => applyAction(done.beforeRide, 'fremen', { ...done.ride.action, sandmasterCollect: 'yes' } as unknown as Action), /Sandmaster|collect/i);
for (const collect of [false, undefined]) {
const plain = { ...done.ride.action };
delete plain.sandmasterCollect; delete plain.sandmasterPile;
const result = applyAction(done.beforeRide, 'fremen', { ...plain, ...(collect === undefined ? {} : { sandmasterCollect: collect }) });
assert.equal(player(result, 'fremen').forces['hagga_basin:12'], 3);
assert.equal(player(result, 'fremen').elites!.forces['hagga_basin:12'], 2);
assert.equal(player(result, 'fremen').spice, player(done.beforeRide, 'fremen').spice);
for (const [key, amount] of Object.entries(done.beforeRide.spice))
assert.equal(result.spice[key], amount, 'Declining collection leaves each existing pile unchanged');
assert.equal(result.spice['habbanya_erg:16'], 8,
'The original second Advanced spice pile continues after the first-pile remote ride');
}
for (const difficulty of DIFFICULTIES) {
const own = viewGame(done.beforeRide, 'fremen'); own.players.find(player => player.id === 'fremen')!.bot = difficulty;
const action = botActions(own)[0]; assert.ok(action);
const result = applyAction(done.beforeRide, 'fremen', action);
assert.equal(result.nexusFremenCunningRides!.find(ride => ride.occurrence.event === fixture.occurrence.event)!.stage, 'complete');
}
physicalNexus(done.game);
});
void test('Advanced actual Ixian first-Storm HMS placement at the native desert source admits the typed Cunning arrival into its interior', () => {
const fixture = createFremenCunningModulesFixture({ advanced: true, expansions: ['ix', 'choam', 'ecaz'],
factions: ['fremen', 'guild', 'emperor', 'ixians', 'choam', 'ecaz'],
skills: true, homeworlds: true, tech: true, strongholds: true, hmsAtSource: true });
const placement = fixture.hmsPlacement; assert.ok(placement);
assert.equal(placement.before.mobileStronghold!.location, null);
assert.equal(placement.after.mobileStronghold!.location, 'wind_pass:14');
assert.equal(placement.step.actor, 'ixians');
assert.equal(fixture.game.mobileStronghold!.location, 'wind_pass:14');
const done = completeFremenCunningModules(fixture, { destination: { territory: MOBILE_STRONGHOLD, sector: 0 } });
assert.equal(player(done.afterRide, 'fremen').forces[MOBILE_LOCATION], 3);
assert.equal(player(done.afterRide, 'fremen').elites!.forces[MOBILE_LOCATION], 2);
assert.equal(player(done.afterRide, 'fremen').forces[fixture.sourceKey] ?? 0, 0);
assert.equal(done.afterRide.mobileStronghold!.location, 'wind_pass:14');
assert.deepEqual(player(done.afterRide, 'ixians').forces, player(done.beforeRide, 'ixians').forces);
assert.deepEqual(player(done.afterRide, 'ixians').elites, player(done.beforeRide, 'ixians').elites);
assert.equal(player(done.afterRide, 'fremen').moved, player(done.beforeRide, 'fremen').moved);
assert.equal(player(done.afterRide, 'fremen').shipped, player(done.beforeRide, 'fremen').shipped);
assert.equal(player(done.afterRide, 'fremen').reserves, player(done.beforeRide, 'fremen').reserves);
assert.equal(player(done.afterRide, 'fremen').spice, player(done.beforeRide, 'fremen').spice);
physicalNexus(done.game);
});
@@ -0,0 +1,172 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
import { clearNewlyRevealed, createDiscoveryState, placeDiscovery, revealDiscoveryToken } from '../game/discoveries';
import type { Game } from '../game/engine';
import { greatMakerSignature } from '../game/great-maker';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import {
captureFremenCunningOccurrence, fremenCunningDestinationBlock, fremenCunningPublicDestinationBlock,
quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection,
type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection,
} from '../game/nexus-fremen-cunning';
import { nexusFixture } from './fixture-nexus-cards';
/** Pure quote position. The engine fixture lane owns genuine appearance,
* card acquisition and paid native arrivals; these tests do not certify setup. */
function position() {
const game = nexusFixture({ advanced: true });
game.turn = 2; game.phase = 1; game.storm = 0;
for (const player of game.players) {
player.forces = {};
if (player.elites) player.elites.forces = {};
}
const fremen = game.players.find(player => player.faction === 'fremen')!;
fremen.forces = { 'wind_pass:14': 3, 'wind_pass:15': 2, 'carthag:11': 1, 'polar_sink:0': 1 };
fremen.elites!.forces = { 'wind_pass:14': 1, 'wind_pass:15': 2 };
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('fremen');
assert.ok(index >= 0);
cards.hands[fremen.id] = cards.deck.splice(index, 1)[0];
return { game, fremen, other: game.players[1], third: game.players[2] };
}
function appearance(game: Game): FremenCunningOccurrence {
return captureFremenCunningOccurrence(game, {
event: 'accepted-empty-worm', territory: 'hagga_basin', origin: 'natural', pile: 0,
});
}
function authorize(game: Game, owner: string, occurrence: FremenCunningOccurrence): FremenCunningRideAuthorization {
const cards = game.nexusCards!.cards!;
assert.equal(cards.hands[owner], 'fremen');
cards.hands[owner] = null; cards.discard.push('fremen');
return { event: occurrence.event, owner, turn: occurrence.turn };
}
function revealLocation(game: Game) {
const supply = createDiscoveryState(() => 0.25);
const hiereg = supply.tokens.filter(token => token.type === 'hiereg');
const chosen = hiereg.findIndex(token => token.face === 'jacurutu-sietch');
assert.ok(chosen >= 0);
const hidden = placeDiscovery(supply, 'discovery-hagga-basin', () => (chosen + 0.25) / hiereg.length);
const token = hidden.tokens.find(token => token.face === 'jacurutu-sietch')!;
game.discoveryEnabled = true;
game.discoveries = clearNewlyRevealed(revealDiscoveryToken(hidden, token.id, game.turn - 1).state);
return { hidden, token: token.id };
}
const selected: Omit<FremenCunningSelection, 'destination'> = {
source: 'wind_pass', forces: {
'wind_pass:14': { normal: 1, elite: 1 }, 'wind_pass:15': { normal: 0, elite: 1 },
},
};
void test('revealed room-local Discovery accepts the original typed desert subset with World/all14/phase modules', () => {
const { game, fremen } = position();
const { hidden } = revealLocation(game);
game.expansions = ['ix', 'choam', 'ecaz']; game.ecazTreachery = true;
game.homeworlds = { custody: createHomeworldCustody(homeworldContext(game)) };
game.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
game.techTokens = {} as NonNullable<Game['techTokens']>;
game.strongholdCards = {} as NonNullable<Game['strongholdCards']>;
fremen.forces['jacurutu-sietch:0'] = 1;
game.mobileStronghold = { location: 'wind_pass:14' };
fremen.forces[MOBILE_LOCATION] = 1;
const occurrence = appearance(game);
assert.deepEqual(quoteFremenCunning(game, fremen.id, occurrence)!.sources, [{
territory: 'wind_pass', sectors: {
'wind_pass:14': { normal: 2, elite: 1 }, 'wind_pass:15': { normal: 0, elite: 2 },
},
}], 'Reserves, World custody, rock, strongholds and nested territories are not desert sources.');
const authorization = authorize(game, fremen.id, occurrence);
const destination = { territory: 'jacurutu-sietch', sector: 0 };
assert.equal(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source, destination), null);
assert.equal(fremenCunningDestinationBlock(game, fremen.id, occurrence, selected.source, destination), null);
const before = structuredClone(fremen);
assert.deepEqual(validateFremenCunningSelection(game, fremen.id, occurrence,
{ ...selected, destination }, authorization), { total: 3, elite: 2 });
assert.deepEqual(fremen, before);
assert.throws(() => validateFremenCunningSelection(game, fremen.id, occurrence,
{ ...selected, source: 'homeworld:fremen', destination }, authorization), /one occupied desert/);
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source,
{ territory: 'homeworld:atreides', sector: 0 })!, /Arrakis board sector/);
game.discoveries = hidden;
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source, destination)!, /Arrakis board sector/);
assert.throws(() => validateFremenCunningSelection(game, fremen.id, occurrence,
{ ...selected, destination }, authorization), /Arrakis board sector/);
});
void test('nested Discovery destinations retain public faction capacity, advisor stance and native Ecaz coalition rules', () => {
const { game, fremen, other, third } = position();
revealLocation(game);
const destination = { territory: 'jacurutu-sietch', sector: 0 };
other.forces['jacurutu-sietch:0'] = 1; third.forces['jacurutu-sietch:0'] = 1;
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, 'wind_pass', destination)!, /three occupying factions/);
other.faction = 'beneGesserit'; other.advisors = { 'jacurutu-sietch': {} };
assert.equal(fremenCunningPublicDestinationBlock(game, fremen.id, 'wind_pass', destination), null);
delete other.advisors;
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, 'wind_pass', destination)!, /three occupying factions/,
'The same BG counters fill a faction slot after changing to fighters.');
other.faction = 'ecaz'; other.ally = third.id; third.ally = other.id;
assert.equal(fremenCunningPublicDestinationBlock(game, fremen.id, 'wind_pass', destination), null,
'Ecaz and its real reciprocal ally share one destination faction.');
other.ally = null; third.ally = null;
fremen.ally = other.id; other.ally = fremen.id;
assert.equal(fremenCunningPublicDestinationBlock(game, fremen.id, 'wind_pass', destination), null,
'An actual Fremen/Ecaz alliance retains the native shared-occupation exception.');
other.faction = 'emperor';
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, 'wind_pass', destination)!, /occupied by your ally/);
});
void test('HMS entry uses the chosen actual desert source, not the initially empty worm territory', () => {
const { game, fremen } = position();
game.mobileStronghold = { location: 'wind_pass:14' };
const occurrence = appearance(game), authorization = authorize(game, fremen.id, occurrence);
const destination = { territory: MOBILE_STRONGHOLD, sector: 0 };
assert.notEqual(occurrence.territory, selected.source);
assert.equal(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source, destination), null);
assert.deepEqual(validateFremenCunningSelection(game, fremen.id, occurrence,
{ ...selected, destination }, authorization), { total: 3, elite: 2 });
game.mobileStronghold.location = 'hagga_basin:12';
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source, destination)!, /territory it points at/);
assert.throws(() => validateFremenCunningSelection(game, fremen.id, occurrence,
{ ...selected, destination }, authorization), /territory it points at/);
game.mobileStronghold.location = 'wind_pass:99';
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source, destination)!, /territory it points at/);
game.mobileStronghold.location = null;
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source, destination)!, /Arrakis board sector/);
});
void test('storm sectors and multi-territory or non-desert source substitutions remain illegal with dynamic destinations', () => {
const { game, fremen } = position();
revealLocation(game);
const occurrence = appearance(game), authorization = authorize(game, fremen.id, occurrence);
const destination = { territory: 'jacurutu-sietch', sector: 0 };
game.storm = 14;
assert.deepEqual(quoteFremenCunningRide(game, fremen.id, occurrence, authorization)!.sources[0].sectors,
{ 'wind_pass:15': { normal: 0, elite: 2 } });
assert.throws(() => validateFremenCunningSelection(game, fremen.id, occurrence,
{ ...selected, destination }, authorization), /available typed physical forces/);
game.storm = 11;
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source,
{ territory: 'carthag', sector: 11 })!, /storm/);
assert.throws(() => validateFremenCunningSelection(game, fremen.id, occurrence,
{ ...selected, forces: { ...selected.forces, 'carthag:11': { normal: 1, elite: 0 } }, destination },
authorization), /available typed physical forces/);
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source,
{ territory: 'jacurutu-sietch', sector: 1 })!, /Arrakis board sector/);
});
void test('a completed earlier Great Maker does not manufacture or suppress a later ordinary empty occurrence', () => {
const { game, fremen, other } = position();
game.greatMaker = { event: 'earlier-maker', turn: game.turn, pile: 0, index: 0,
territory: 'red_chasm', stage: 'complete', order: [...game.order],
votes: game.order.map(player => ({ player, yes: false })), ridesBefore: [], arrival: null, signature: '' };
game.greatMaker.signature = greatMakerSignature(game.greatMaker);
const empty = appearance(game);
assert.ok(quoteFremenCunning(game, fremen.id, empty));
other.forces['hagga_basin:12'] = 1;
const occupied = captureFremenCunningOccurrence(game, {
event: 'later-occupied-worm', territory: 'hagga_basin', origin: 'additional', pile: 1,
});
delete other.forces['hagga_basin:12'];
assert.equal(quoteFremenCunning(game, fremen.id, occupied), null,
'Completed Maker custody does not turn a post-devour empty board into a qualifying occurrence.');
});