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:
1 parent
bb9d292c4c
commit
b4157aaa9b
20 files changed
+1349
-41
No files matched your search
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
Reference in new issue
Block a user