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
@@ -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