import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { MOBILE_LOCATION, MOBILE_STRONGHOLD, location, territory } from '../game/board'; import { fremenCunningPublicDestinationBlock } from '../game/nexus-fremen-cunning'; import { greatMakerRideAction } from '../game/great-maker-options'; import { createFremenCunningModulesFixture, completeFremenCunningModules } from './fixture-fremen-cunning-modules'; import { advanceNativeHomeworld, createNativeHomeworldDiscoveryProgramme } from './fixture-homeworld-native-e1e2-modules'; const source = location('the_great_flat', territory('the_great_flat').sectors[0]); const ride = (destination: string): Action => ({ type: 'decision', accept: true, territory: destination, sector: 0, forces: { [source]: 4 } }); /** Conserved staged supplement to the real action path: Fremen, Ixians, and * Emperor use Basic Ix, with a placed mobile stronghold and worm decision. * The worm, placement and earlier chronology are staged, not replayed. */ function fixture(pointer = 'arsunt:11', storm = 11): Game { const game = createGame('WORMHMS', newPlayer('f', 'Fremen', 'fremen'), false, ['ix']); game.players.push(newPlayer('i', 'Ixians', 'ixians'), newPlayer('e', 'Emperor', 'emperor')); Object.assign(game, { status: 'playing', phase: 1, turn: 2, storm, active: 'f', order: ['f', 'i', 'e'], spiceSequence: { pile: 1, skipped: [] }, wormRides: [], decision: { kind: 'wormRide', player: 'f', territory: 'the_great_flat' }, mobileStronghold: { location: pointer } }); for (const player of game.players) Object.assign(player, { hand: [], forces: {}, reserves: 20, spice: 10 }); game.players[0].forces = { [source]: 4 }; game.players[0].reserves = 16; return game; } void test('a worm ride cannot enter the mobile stronghold through its storm-covered pointer', () => { const game = fixture(), before = structuredClone(game); assert.throws(() => applyAction(game, 'f', ride(MOBILE_STRONGHOLD)), /storm/i); assert.deepEqual(game, before, 'rejection preserves the pending ride and all counters'); const sink = applyAction(game, 'f', ride('polar_sink')); assert.equal(sink.players[0].forces['polar_sink:0'], 4); assert.equal(sink.players[0].reserves, 16); }); void test('a clear pointed sector permits the ride, including a pointer at Polar Sink', () => { for (const game of [fixture('arsunt:11', 12), fixture('polar_sink:0', 11)]) { const next = applyAction(game, 'f', ride(MOBILE_STRONGHOLD)); assert.equal(next.players[0].forces[MOBILE_LOCATION], 4); assert.equal(next.players[0].forces[source] ?? 0, 0); assert.equal(next.players[0].reserves, 16); assert.equal(next.mobileStronghold?.location, game.mobileStronghold?.location); } }); void test('all four bot profiles omit storm-blocked mobile arrivals and offer legal rides', () => { for (const difficulty of DIFFICULTIES) { for (const game of [fixture(), fixture('arsunt:11', 12)]) { const view = viewGame(game, 'f'); view.players.find(player => player.id === 'f')!.bot = difficulty; const before = structuredClone(view), actions = botActions(view); assert.deepEqual(view, before); const accepted = actions.filter(action => action.type === 'decision' && action.accept === true); assert.ok(accepted.length, `${difficulty} needs a legal ride`); assert.ok(actions.some(action => action.type === 'decision' && action.accept === false)); if (game.storm === 11) assert.ok(accepted.every(action => action.territory !== MOBILE_STRONGHOLD), difficulty); for (const action of actions) assert.doesNotThrow(() => applyAction(game, 'f', action), `${difficulty}: ${JSON.stringify(action)}`); } } }); void test('the native Fremen Cunning ride uses the same public storm quote for the placed Ixian stronghold', () => { const native = createFremenCunningModulesFixture({ expansions: ['ix', 'ecaz'], factions: ['fremen', 'guild', 'emperor', 'ixians'], hmsAtSource: true }); assert.equal(native.hmsPlacement?.after.mobileStronghold?.location, 'wind_pass:14'); const game = completeFremenCunningModules(native).beforeRide; assert.equal(game.decision?.kind, 'nexusFremenCunningRide'); assert.equal(game.mobileStronghold?.location, 'wind_pass:14'); // Stage a conserved move within Wind Pass so the ride source remains clear // while the stronghold's original pointing sector is under the storm. The // worm, Cunning card and Ix placement come from the native chronology. const staged = structuredClone(game), rider = staged.players.find(p => p.id === 'fremen')!; const oldSource = native.sourceKey, newSource = 'wind_pass:16'; const count = rider.forces[oldSource], elite = rider.elites?.forces[oldSource] ?? 0; assert.equal(count, 3); assert.equal(rider.forces[newSource] ?? 0, 0); delete rider.forces[oldSource]; rider.forces[newSource] = count; if (rider.elites && elite) { delete rider.elites.forces[oldSource]; rider.elites.forces[newSource] = elite; } const blocked = structuredClone(staged), clear = structuredClone(staged); blocked.storm = 14; clear.storm = 15; const destination = { territory: MOBILE_STRONGHOLD, sector: 0 }; assert.match(fremenCunningPublicDestinationBlock(blocked, 'fremen', 'wind_pass', destination) ?? '', /storm/i); assert.equal(fremenCunningPublicDestinationBlock(clear, 'fremen', 'wind_pass', destination), null); assert.match(fremenCunningPublicDestinationBlock(viewGame(blocked, 'fremen'), 'fremen', 'wind_pass', destination) ?? '', /storm/i); assert.equal(fremenCunningPublicDestinationBlock(viewGame(clear, 'fremen'), 'fremen', 'wind_pass', destination), null); assert.ok(blocked.decision?.kind === 'nexusFremenCunningRide'); const action: Action = { type: 'decision', event: blocked.decision.event, accept: true, source: 'wind_pass', forces: { [newSource]: { normal: count - elite, elite } }, territory: MOBILE_STRONGHOLD, sector: 0 }; const before = structuredClone(blocked); assert.throws(() => applyAction(blocked, 'fremen', action), /storm/i); assert.deepEqual(blocked, before); const next = applyAction(clear, 'fremen', action); const moved = next.players.find(p => p.id === 'fremen')!; assert.equal(moved.forces[MOBILE_LOCATION], count); assert.equal(moved.forces[newSource] ?? 0, 0); assert.equal(moved.elites?.forces[MOBILE_LOCATION] ?? 0, elite); }); void test('a native Great Maker ride hides the storm-covered mobile destination and rejects it at execution', () => { const native = createNativeHomeworldDiscoveryProgramme({ maker: true, face: 'cistern', tech: true }); let game = advanceNativeHomeworld(native.game, state => state.decision?.kind === 'greatMakerVote'); while (game.decision?.kind === 'greatMakerVote') game = applyAction(game, game.decision.player, { type: 'decision', event: game.decision.event, yes: false }); assert.equal(game.decision?.kind, 'greatMakerRide'); assert.ok(game.mobileStronghold?.location); // The native encounter and votes are real. This comparison stages only the // already-placed stronghold pointer and storm to isolate the shared gate. const blocked = structuredClone(game), clear = structuredClone(game); blocked.mobileStronghold!.location = 'arsunt:11'; blocked.storm = 11; clear.mobileStronghold!.location = 'arsunt:11'; clear.storm = 12; const blockedView = viewGame(blocked, 'fremen'), clearView = viewGame(clear, 'fremen'); assert.equal(blockedView.greatMaker!.ride!.destinations.some(d => d.territory === MOBILE_STRONGHOLD), false); assert.equal(greatMakerRideAction(blockedView, MOBILE_STRONGHOLD, 0, 1, 0), null); assert.ok(blocked.decision?.kind === 'greatMakerRide'); const forbidden: Action = { type: 'decision', event: blocked.decision.event, accept: true, territory: MOBILE_STRONGHOLD, sector: 0, amount: 1, elite: 0 }; const before = structuredClone(blocked); assert.throws(() => applyAction(blocked, 'fremen', forbidden)); assert.deepEqual(blocked, before); assert.ok(clearView.greatMaker!.ride!.destinations.some(d => d.territory === MOBILE_STRONGHOLD)); const allowed = greatMakerRideAction(clearView, MOBILE_STRONGHOLD, 0, 1, 0); assert.ok(allowed); const next = applyAction(clear, 'fremen', allowed); assert.equal(next.players.find(p => p.id === 'fremen')!.forces[MOBILE_LOCATION], 1); });