Respect storm sector for rides into mobile stronghold
Verify and publish checkpoint container / container (push) Failing after 10m28s
Verify and publish checkpoint container / container (push) Failing after 10m28s
This commit is contained in:
1 parent
0d55333d5d
commit
ac1f32c859
11 files changed
+324
-3
No files matched your search
@@ -140,6 +140,7 @@ import { SandmasterMovement } from './sandmaster-movement';
|
||||
import { SandmasterWormChoice } from './sandmaster-worm';
|
||||
import { sandmasterWormCollection, sandmasterCunningCollection } from '@/game/sandmaster-worm';
|
||||
import { fremenCunningPublicDestinationBlock } from '@/game/nexus-fremen-cunning';
|
||||
import { wormRideDestinationBlock } from '@/game/worm-ride-destination';
|
||||
import { ResidualPoison, BattleLeaderOpportunity } from './residual-poison';
|
||||
import { PortableSnooper } from './portable-snooper';
|
||||
import {
|
||||
@@ -634,6 +635,8 @@ export function GameTable({
|
||||
? { ...baseSandmasterRide, key: selectedRidePile.key, before: selectedRidePile.before } : baseSandmasterRide;
|
||||
const cunningDestinationBlocked = g.decision?.kind === 'nexusFremenCunningRide' && source
|
||||
? fremenCunningPublicDestinationBlock(g, me.id, source, { territory: selected, sector }) : null;
|
||||
const wormDestinationBlocked = g.decision?.kind === 'wormRide'
|
||||
? wormRideDestinationBlock(g, selected, sector) : null;
|
||||
const [exchangeCards, setExchangeCards] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
@@ -4181,6 +4184,7 @@ export function GameTable({
|
||||
</label>
|
||||
))}
|
||||
{destination}
|
||||
{wormDestinationBlocked && <p className="notice">{wormDestinationBlocked}</p>}
|
||||
{me.elites &&
|
||||
Object.entries(rideForces)
|
||||
.filter(
|
||||
@@ -4218,7 +4222,7 @@ export function GameTable({
|
||||
(g.decision as { territory: string }).territory,
|
||||
),
|
||||
),
|
||||
})}
|
||||
}, !!wormDestinationBlocked)}
|
||||
{actionButton('Stay in this territory', {
|
||||
type: 'decision',
|
||||
accept: false,
|
||||
|
||||
@@ -17,6 +17,17 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Storm and worm rides into the mobile stronghold — 29 October. Prototyped /
|
||||
Partial.** Ordinary, Cunning and Great Maker worm rides now check the mobile
|
||||
stronghold's actual pointing sector rather than treating its synthetic interior
|
||||
sector as storm-free. Human controls show the reason and bots omit the blocked
|
||||
choice. Polar Sink and shelter for existing occupants remain unchanged. The
|
||||
11-file related batch passes 45 cases, along with type checking, lint and the
|
||||
production build. Cunning and Great Maker use actual
|
||||
encounters with staged storm/marker positions, while the ordinary ride uses a
|
||||
conserved staged decision. The table check is server-rendered, not hydrated or
|
||||
deployed acceptance. [Source and interpretation](RULE_DECISIONS.md#worm-rides-into-the-mobile-stronghold--29-october-2026).
|
||||
|
||||
**Basic Richese No-Field interruptions — 29 October. Prototyped / Partial.**
|
||||
In supported Basic native Ix/CHOAM games the owner can reveal a deployed
|
||||
No-Field during an Ix opening and selected economic decisions without replacing
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Rule decision index
|
||||
|
||||
## Worm rides into the mobile stronghold — 29 October 2026
|
||||
|
||||
The [publisher FAQ, p. 5](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
|
||||
allows Fremen to ride directly into the Hidden Mobile Stronghold when the
|
||||
destination sector is clear of storm. The game represents its interior as
|
||||
sector zero; this implementation maps that check to the stronghold's actual
|
||||
pointing sector. That mapping is an online interpretation of the FAQ, while
|
||||
forces already inside retain the printed storm shelter. Ordinary, Cunning and
|
||||
Great Maker rides share this destination check; Polar Sink remains clear.
|
||||
[Shared quote](../game/worm-ride-destination.ts),
|
||||
[runtime cases](../tests/worm-ride-mobile-storm-runtime.test.ts).
|
||||
|
||||
## Basic leaderless battle announcement — 29 October 2026
|
||||
|
||||
The [publisher base rulebook, printed p. 10](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ import { isAlliedShipmentVisit } from './allied-separation';
|
||||
import { discoveryBotActions } from './discovery-options';
|
||||
import { normalAuctionHandBlock } from './normal-auction';
|
||||
import { fremenRevivalGrantBlock } from './fremen-revival-grant';
|
||||
import { wormRideDestinationBlock } from './worm-ride-destination';
|
||||
import { atreidesFullPlanAction } from './atreides-full-plan';
|
||||
import { liveMovementPromises } from './movement-promises';
|
||||
import { ecazOccupyDialChoices, ecazOccupyOwnProfile, ecazOccupyPolicyActions } from './ecaz-occupy-options';
|
||||
@@ -3055,7 +3056,7 @@ function policyActions(g: GameView): Action[] {
|
||||
.filter(
|
||||
(to) =>
|
||||
to.t !== d.territory && botEntryAllowed(g, me, to.t, to.s, 'move') &&
|
||||
!botWormRideArrivalBlock(g, to.t, to.s),
|
||||
!wormRideDestinationBlock(g, to.t, to.s) && !botWormRideArrivalBlock(g, to.t, to.s),
|
||||
)
|
||||
.slice(0, 16)
|
||||
.map(
|
||||
|
||||
+10
-1
@@ -665,6 +665,7 @@ import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, c
|
||||
import { quoteAdvisorPreparationWindow, quoteAdvisorPreparation, validateAdvisorPreparation, projectAdvisorPreparationWindow, AdvisorPreparationError, type AdvisorPreparationWindow, type AdvisorPreparationSource } from './advisor-preparation-window';
|
||||
import { quoteFremenRevivalGrant, validateFremenRevivalGrant, projectFremenRevivalGrant, projectFremenGroupRevivalGrant, FremenRevivalGrantError, type FremenRevivalGrantSource } from './fremen-revival-grant';
|
||||
import { richeseRevealInterruptSupported } from './richese-reveal-interruption';
|
||||
import { wormRideDestinationBlock } from './worm-ride-destination';
|
||||
import { splitOrdinaryPayments, type OrdinaryPayment } from './ordinary-payment-plan';
|
||||
import {
|
||||
charityAmount,
|
||||
@@ -14655,7 +14656,11 @@ function greatMakerRideOptions(g: Game, owner: string) {
|
||||
if (frame?.stage !== 'ride' || p.faction !== 'fremen' ||
|
||||
p.fremenNexusWormBlockedTurn === g.turn) return null;
|
||||
const destinations = gameTerritories(g).flatMap(t => t.sectors.flatMap(sector => {
|
||||
try { allowedEntry(g,p,t.id,sector); return [{territory:t.id,name:t.name,sector}]; }
|
||||
try {
|
||||
allowedEntry(g,p,t.id,sector);
|
||||
if (wormRideDestinationBlock(g,t.id,sector)) return [];
|
||||
return [{territory:t.id,name:t.name,sector}];
|
||||
}
|
||||
catch (error) { if (error instanceof RuleError) return []; throw error; }
|
||||
}));
|
||||
return {max:p.reserves,eliteMax:p.elites?.reserves ?? 0,destinations};
|
||||
@@ -14758,6 +14763,8 @@ function decideGreatMakerRide(g: Game, p: Player, action: Action) {
|
||||
const amount = integer(action.amount,1,p.reserves,'Riding forces');
|
||||
const elite = eliteChoice(amount,p.reserves,p.elites?.reserves ?? 0,action.elite);
|
||||
allowedEntry(g,p,to,sector);
|
||||
const stormBlock = wormRideDestinationBlock(g,to,sector);
|
||||
requireRule(!stormBlock, stormBlock ?? 'That worm-ride destination is in storm.');
|
||||
withdrawNativeReserves(g,p,amount,elite);
|
||||
place(p,to,sector,amount,elite);
|
||||
frame.arrival = {territory:to,sector,amount,elite};
|
||||
@@ -45334,6 +45341,8 @@ function applyActionInner(
|
||||
'Choose another destination territory.',
|
||||
);
|
||||
allowedEntry(g, p, to, sector);
|
||||
const stormBlock = wormRideDestinationBlock(g, to, sector);
|
||||
requireRule(!stormBlock, stormBlock ?? 'That worm-ride destination is in storm.');
|
||||
const sandmaster = action.sandmasterCollect
|
||||
? sandmasterWormCollection(g, p.id, to, sector, decision, action.sandmasterPile as string | undefined) : null;
|
||||
if (action.sandmasterCollect) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Game, GameView, Player } from './engine';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { nexusCardMode, validateNexusCards } from './nexus-cards';
|
||||
import { territoryEntryBlock } from './occupancy';
|
||||
import { wormRideDestinationBlock } from './worm-ride-destination';
|
||||
|
||||
/** The engine allocates a distinct event for each accepted worm appearance, even
|
||||
* when two worms visit the same territory. Capture before any devouring. */
|
||||
@@ -137,6 +138,8 @@ export function fremenCunningPublicDestinationBlock(
|
||||
return 'Ride into the mobile stronghold only from the territory it points at.';
|
||||
}
|
||||
if (sector !== 0 && sector === g.storm) return 'That sector is in storm.';
|
||||
const stormBlock = wormRideDestinationBlock(g, to, sector);
|
||||
if (stormBlock) return stormBlock;
|
||||
return territoryEntryBlock(g.players, owner, to);
|
||||
}
|
||||
|
||||
|
||||
@@ -2135,6 +2135,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Before later storm dials or card reveals, occupied Ixians may keep the current position or declare a route of up to three territories. Use the route selector to name each sector. The destination must be a non-stronghold territory. The route cannot begin, end or pass through the current storm sector. Occupants stay inside throughout relocation.',
|
||||
'Choose the spice collected in each visited sector, including the starting sector. This preview provisionally permits any whole amount up to the available pile and two per eligible force inside, separately in each sector. Foreign fighters count, a concealed No-Field counts as one, and advisors do not count. Older moves without selected amounts retain their Ixian-only automatic limit. A public Karama response resolves before relocation or collection. The printed rule does not detail partial allocations or all passenger interactions.',
|
||||
'Forces inside are separate from forces outside. Enter and leave through the pointing sector, counting the stronghold as one more territory of movement. A storm in that pointing sector blocks ordinary entry or exit. Direct shipping inside is restricted to Ixians; advanced Bene Gesserit may accompany their shipment.',
|
||||
'Fremen may ride a worm directly into the mobile stronghold when its pointing sector is clear of the storm. Its sheltered occupants remain protected while that sector is in storm.',
|
||||
'Worms and storms do not kill the occupants. Battles wait while the pointing sector is in storm; otherwise normal battles and the two-faction capacity apply inside. The sole controller counts it toward victory, even when that controller is not Ixian. Capturing it does not grant another faction permission to move it.',
|
||||
'In advanced play, Ixians may spend special Karama once during their own Shipment and Movement turn to relocate up to two territories while their forces occupy it. This preserves their ordinary shipment and troop movement. Setup and bidding technology now have decision controls; expansion starts and remaining interaction audits are unfinished.',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MOBILE_STRONGHOLD, splitLocation, validLocation } from './board';
|
||||
|
||||
/** The mobile stronghold's interior is encoded as sector 0, but a worm ride
|
||||
* enters through its actual pointed sector. Ordinary sectors and the Polar
|
||||
* Sink keep their existing independent entry checks. */
|
||||
export function wormRideDestinationBlock(
|
||||
g: {storm: number; mobileStronghold?: {location: string | null} | null},
|
||||
territory: string,
|
||||
_sector: number,
|
||||
): string | null {
|
||||
if (territory !== MOBILE_STRONGHOLD || !g.mobileStronghold?.location ||
|
||||
!Number.isSafeInteger(g.storm) || g.storm < 1 || g.storm > 18) return null;
|
||||
const pointer = g.mobileStronghold.location;
|
||||
const parts = pointer.split(':');
|
||||
if (parts.length !== 2) return null;
|
||||
const at = splitLocation(pointer);
|
||||
if (!validLocation(at.territory, at.sector) || at.sector < 1) return null;
|
||||
return at.sector === g.storm
|
||||
? 'The Hidden Mobile Stronghold points into the storm; a worm ride cannot enter it.'
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { MOBILE_STRONGHOLD } from '../game/board';
|
||||
import { wormRideDestinationBlock as block } from '../game/worm-ride-destination';
|
||||
|
||||
void test('the storm at the real pointer blocks riding into the sector-zero mobile interior', () => {
|
||||
for (const location of ['arsunt:11', 'arrakeen:10', 'imperial_basin:9']) {
|
||||
const storm = Number(location.split(':')[1]);
|
||||
const game = {storm, mobileStronghold:{location}}, before = structuredClone(game);
|
||||
assert.match(block(game, MOBILE_STRONGHOLD, 0)!, /points into the storm/);
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(block({...game, storm:storm === 18 ? 1 : storm + 1}, MOBILE_STRONGHOLD, 0), null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('ordinary territories and the Polar Sink keep their existing entry checks', () => {
|
||||
const game = {storm:11, mobileStronghold:{location:'arsunt:11'}};
|
||||
assert.equal(block(game, 'arsunt', 11), null);
|
||||
assert.equal(block(game, 'polar_sink', 0), null);
|
||||
assert.equal(block({storm:0, mobileStronghold:{location:'polar_sink:0'}}, MOBILE_STRONGHOLD, 0), null);
|
||||
});
|
||||
|
||||
void test('an absent or malformed marker is left to the existing placement validation', () => {
|
||||
assert.equal(block({storm:11}, MOBILE_STRONGHOLD, 0), null);
|
||||
assert.equal(block({storm:11, mobileStronghold:null}, MOBILE_STRONGHOLD, 0), null);
|
||||
assert.equal(block({storm:11, mobileStronghold:{location:null}}, MOBILE_STRONGHOLD, 0), null);
|
||||
for (const location of ['missing', 'arsunt:11:extra', 'unknown:11', 'arsunt:not-a-sector'])
|
||||
assert.equal(block({storm:11, mobileStronghold:{location}}, MOBILE_STRONGHOLD, 0), null);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { createGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { MOBILE_STRONGHOLD, location, territory } from '../game/board';
|
||||
|
||||
// SSR cannot operate a select. Seed exactly the state normally set by the
|
||||
// destination/sector and force controls while still rendering real GameTable.
|
||||
// Derive hook indices from declaration names so unrelated inserted hooks do not
|
||||
// change the selected state. This does not claim a hydrated browser interaction.
|
||||
const file = resolve('components/game-table.tsx');
|
||||
const sourceText = readFileSync(file, 'utf8');
|
||||
const names = [...sourceText.matchAll(/const\s+\[\s*([\w$]+)\s*,[^\]]+\]\s*=\s*useState/g)].map(match => match[1]);
|
||||
const indices = Object.fromEntries(['selectedId', 'sector', 'rideForces'].map(name => {
|
||||
const index = names.indexOf(name); assert.ok(index >= 0, `Missing ${name} table state`); return [name, index];
|
||||
}));
|
||||
const reactUrl = pathToFileURL(resolve('node_modules/react/index.js')).href;
|
||||
const mockReact = `import {useState as original} from ${JSON.stringify(reactUrl)};
|
||||
export function useState(initial) {
|
||||
const config = globalThis.__duneWormRideHarness;
|
||||
const index = config.next++;
|
||||
if (index === config.indices.selectedId) initial = config.destination;
|
||||
if (index === config.indices.sector) initial = 0;
|
||||
if (index === config.indices.rideForces) initial = {[config.source]: 4};
|
||||
return original(initial);
|
||||
}`;
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
if (specifier === 'react' && context.parentURL?.endsWith('/components/game-table.tsx'))
|
||||
return {url:`data:text/javascript,${encodeURIComponent(mockReact)}`, shortCircuit:true};
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' :
|
||||
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
|
||||
},
|
||||
});
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
aliases.deregister();
|
||||
|
||||
const sourceSector = territory('the_great_flat').sectors[0];
|
||||
const sourceLocation = location('the_great_flat', sourceSector);
|
||||
/** Staged Basic Ix worm decision, matching the focused runtime fixture; only
|
||||
* forces, source, storm and mobile placement are staged, not a played journey. */
|
||||
function fixture(storm: number): Game {
|
||||
const game = createGame('WORMHMSUI', 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:'arsunt:11'}});
|
||||
for (const player of game.players) Object.assign(player, {hand:[], forces:{}, reserves:20, spice:10});
|
||||
game.players[0].forces = {[sourceLocation]:4}; game.players[0].reserves = 16;
|
||||
return game;
|
||||
}
|
||||
function render(game: Game) {
|
||||
const config = {next:0, indices, source:sourceLocation, destination:MOBILE_STRONGHOLD};
|
||||
(globalThis as unknown as {__duneWormRideHarness: typeof config}).__duneWormRideHarness = config;
|
||||
try {
|
||||
return renderToStaticMarkup(createElement(GameTable, {game:viewGame(game,'f'),busy:false,
|
||||
send:async () => { assert.fail('Server rendering must not send a decision.'); },onExit() {}}));
|
||||
} finally {
|
||||
Reflect.deleteProperty(globalThis, '__duneWormRideHarness');
|
||||
}
|
||||
}
|
||||
function rideButton(html: string) {
|
||||
const match = html.match(/<button\b[^>]*>Ride Shai-Hulud/); assert.ok(match, 'The original Ride button renders');
|
||||
return match[0];
|
||||
}
|
||||
|
||||
void test('selected mobile interior blocks a worm ride through a storm-covered pointer with a readable reason', () => {
|
||||
const game = fixture(11), before = structuredClone(game), html = render(game);
|
||||
assert.match(html, /Hidden Mobile Stronghold points into the storm; a worm ride cannot enter it\./);
|
||||
assert.match(rideButton(html), /\bdisabled=""/);
|
||||
assert.match(html, /<option value="hidden_mobile_stronghold" selected="">/);
|
||||
assert.match(html, /<option value="0" selected="">Inside stronghold/);
|
||||
assert.match(html, new RegExp(`From sector ${sourceSector} · 4 forces`));
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('the same selected mobile interior remains actionable when the pointer sector is clear', () => {
|
||||
const game = fixture(12), before = structuredClone(game), html = render(game);
|
||||
assert.doesNotMatch(html, /Hidden Mobile Stronghold points into the storm; a worm ride cannot enter it\./);
|
||||
assert.doesNotMatch(rideButton(html), /\bdisabled=""/);
|
||||
assert.match(html, /<option value="hidden_mobile_stronghold" selected="">/);
|
||||
assert.match(html, /<option value="0" selected="">Inside stronghold/);
|
||||
assert.match(html, new RegExp(`From sector ${sourceSector} · 4 forces`));
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user