Resume storm traversal after surviving advisor conversion

Preserve current-sector losses and original Fremen casualty priority before the mandatory BG counter. Bind public sector facts to a private continuation and resume only the remaining storm once.
This commit is contained in:
Quintine committed 2026-10-24 21:19:18 +11:00
1 parent 9959f1b347
commit d66b594770
13 files changed
+869 -17

No files matched your search

+168 -1
View File
@@ -13,6 +13,7 @@ import {
type AutomaticAdvisorBattle,
type AutomaticAdvisorHomeworldTransport,
type AutomaticAdvisorFaceDance,
type AutomaticAdvisorStorm,
} from '../game/advisor-automatic-flip';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
@@ -22,6 +23,7 @@ type GamontSource = Extract<AutomaticAdvisorFlipSource, { producer: 'gamont' }>;
type BattleSource = Extract<AutomaticAdvisorFlipSource, { producer: 'battleLosses' }>;
type HomeworldSource = Extract<AutomaticAdvisorFlipSource, { producer: 'homeworldTransport' }>;
type FaceDanceSource = Extract<AutomaticAdvisorFlipSource, { producer: 'faceDance' }>;
type StormSource = Extract<AutomaticAdvisorFlipSource, { producer: 'storm' }>;
function context(): AutomaticAdvisorFlipContext {
return {
@@ -72,7 +74,7 @@ void test('quotes exclude held, occupied and physically empty advisor groups whi
ctx.players[1].forces = { ...ctx.players[1].forces, 'arrakeen:10': 1 };
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), []);
for (const patch of [
{ advanced: false }, { status: 'lobby' }, { phase: 0 }, { phase: 4 }, { phase: 7 },
{ advanced: false }, { status: 'lobby' }, { status: 'setup', phase: 0 }, { phase: 1 }, { phase: 4 }, { phase: 7 },
]) {
assert.deepEqual(quoteAutomaticAdvisorFlips({ ...context(), ...patch }), []);
}
@@ -1005,3 +1007,168 @@ void test('completed Face Dance validation never rereads availability, current d
}
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
});
function storm() {
const ctx = context();
ctx.phase = 0;
ctx.storm = 8;
ctx.stormResolution = { from: 8, distance: 2, traversed: 1, pending: [] };
ctx.players[0].forces = { 'oh_gap:10': 2 };
ctx.players[1].forces = {};
ctx.players[2].forces = {};
const receipt: AutomaticAdvisorStorm = {
event: 'public-current-sector-storm-event', from: 8, distance: 2, traversed: 1, sector: 9,
};
const source: StormSource = {
event: 'automatic-after-current-sector-casualties', owner: 'b', territory: 'oh_gap',
turn: ctx.turn, phase: 0, forces: { 'oh_gap:10': 2 }, producer: 'storm', storm: receipt,
};
ctx.players[0].advisors = { oh_gap: { lockedTurn: 4, aloneFlipHold: source.event } };
return { ctx, source };
}
void test('the five-field public storm source binds current Oh Gap sector nine while surviving advisors remain in sector ten', () => {
const original = storm();
const before = structuredClone(original);
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
assert.deepEqual(original, before);
assert.deepEqual(Object.keys(original.source).sort(), ['event', 'forces', 'owner', 'phase', 'producer', 'storm', 'territory', 'turn']);
assert.deepEqual(Object.keys(original.source.storm).sort(), ['distance', 'event', 'from', 'sector', 'traversed']);
const quiet = storm().ctx;
quiet.players[0].advisors = { oh_gap: { lockedTurn: 4 } };
assert.deepEqual(quoteAutomaticAdvisorFlips(quiet), [
{ player: 'b', territory: 'oh_gap', forces: { 'oh_gap:10': 2 } },
]);
quiet.status = 'setup';
assert.deepEqual(quoteAutomaticAdvisorFlips(quiet), []);
assert.equal(advisorStanceAfterSettlement({ advanced: true, players: quiet.players }, 'b', 'oh_gap'), undefined);
for (const from of [0, 18]) {
const wrapped = storm();
wrapped.ctx.storm = from;
wrapped.source.storm = { ...wrapped.source.storm, from, distance: 40, traversed: 9, sector: 9 };
wrapped.ctx.stormResolution = { from, distance: 40, traversed: 9, pending: [] };
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(wrapped.ctx, wrapped.source));
}
});
void test('storm conversion rejects stale public cursor, unfinished current-sector casualties and malformed traversal bounds', () => {
const changes: ((ctx: AutomaticAdvisorFlipContext, source: StormSource) => void)[] = [
(ctx) => { ctx.storm = 9; },
(ctx) => { delete ctx.storm; },
(ctx) => { ctx.stormResolution = null; },
(ctx) => { delete ctx.stormResolution; },
(ctx) => { ctx.stormResolution!.from = 9; },
(ctx) => { ctx.stormResolution!.distance = 3; },
(ctx) => { ctx.stormResolution!.traversed = 2; },
(ctx) => { ctx.stormResolution!.pending = ['private-fremen-casualty-key']; },
(ctx) => { ctx.status = 'setup'; },
(ctx) => { ctx.turn++; },
(_ctx, source) => { source.storm.event = ''; },
(_ctx, source) => { source.storm.from = -1; },
(_ctx, source) => { source.storm.from = 19; },
(_ctx, source) => { source.storm.from = 8.5; },
(_ctx, source) => { source.storm.distance = 0; },
(_ctx, source) => { source.storm.distance = 41; },
(_ctx, source) => { source.storm.distance = 2.5; },
(_ctx, source) => { source.storm.traversed = 0; },
(_ctx, source) => { source.storm.traversed = 3; },
(_ctx, source) => { source.storm.traversed = 1.5; },
(_ctx, source) => { source.storm.sector = 0; },
(_ctx, source) => { source.storm.sector = 19; },
(_ctx, source) => { source.storm.sector = 10; },
];
for (const change of changes) {
const original = storm();
change(original.ctx, original.source);
const before = structuredClone(original);
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
assert.deepEqual(original, before);
}
const { ctx, source } = storm();
for (const malformed of [
{ ...source, storm: null },
{ ...source, storm: { ...source.storm, sector: '9' } },
{ ...source, storm: { ...source.storm, forecast: 2 } },
{ ...source, protected: true },
]) {
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as unknown as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
}
});
void test('protected Imperial Basin and unrelated or unknown territories cannot supply a storm occurrence', () => {
const imperial = storm();
imperial.source.territory = 'imperial_basin';
imperial.source.forces = { 'imperial_basin:10': 2 };
imperial.ctx.players[0].forces = { 'imperial_basin:10': 2 };
imperial.ctx.players[0].advisors = { imperial_basin: { aloneFlipHold: imperial.source.event } };
assert.throws(() => validateAutomaticAdvisorFlip(imperial.ctx, imperial.source), AutomaticAdvisorFlipError);
imperial.ctx.shieldWallDestroyed = true;
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(imperial.ctx, imperial.source));
for (const territory of ['arrakeen', 'missing']) {
const invalid = storm();
invalid.source.territory = territory;
invalid.source.forces = { [`${territory}:10`]: 2 };
invalid.ctx.players[0].forces = { [`${territory}:10`]: 2 };
invalid.ctx.players[0].advisors = { [territory]: { aloneFlipHold: invalid.source.event } };
invalid.ctx.shieldWallDestroyed = true;
assert.throws(() => validateAutomaticAdvisorFlip(invalid.ctx, invalid.source), AutomaticAdvisorFlipError);
}
});
void test('later storm traversal cannot reuse the old occurrence or restore an advisor group killed in its next sector', () => {
const { ctx, source } = storm();
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
ctx.stormResolution!.traversed = 2;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
ctx.players[0].forces = {};
const before = structuredClone({ ctx, source });
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), []);
assert.equal(advisorStanceAfterSettlement(ctx, 'b', 'oh_gap'), undefined);
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
assert.deepEqual({ ctx, source }, before);
});
void test('storm public validation never reads forecasts, protection choices, private cards or pending Fremen keys', () => {
const { ctx, source } = storm();
for (const field of ['stormCard', 'stormCardKnown', 'stormForecast', 'discoveryChoices', 'pendingAutomaticAdvisorStorm']) {
Object.defineProperty(ctx, field, {
get() { throw new Error(`Storm advisor rule read private or future ${field}`); },
});
}
for (const field of ['protected', 'choamProtected', 'discoveryChoices', 'forecast']) {
Object.defineProperty(ctx.stormResolution, field, {
get() { throw new Error(`Storm advisor rule read private cursor ${field}`); },
});
}
for (const player of ctx.players) {
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'stormForecast']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Storm advisor rule read ${player.id} private ${field}`); },
});
}
}
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
const pending = ['private-fremen-key'];
Object.defineProperty(pending, '0', {
get() { throw new Error('Storm advisor rule inspected a pending Fremen key'); },
});
ctx.stormResolution!.pending = pending;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
});
void test('storm stays phase-zero-only while every existing producer keeps its original receipt and phase', () => {
for (const original of [held(), transported(), gamont(false), battleResult(), homeworldTransport(), faceDance()]) {
const before = JSON.stringify(original.source);
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
assert.equal(JSON.stringify(original.source), before);
original.ctx.phase = 0;
original.source.phase = 0;
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
}
for (const phase of [5, 6, 8]) {
const { ctx, source } = storm();
ctx.phase = phase;
source.phase = phase;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
}
});
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { isAdvisor, fighterCount } from '../game/advisors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createAdvisorStormFlipFixture as fixture, actAdvisorStormFlip as act,
commitAdvisorStormFlip as commit, finishAdvisorStormFlip as finish,
advisorStormFlipPlayer as player, advisorStormFlipInventory as inventory,
} from './fixture-advisor-storm-flip';
function allow(g: Game) {
for (let count = 0; g.response?.automaticAdvisorFlip && count < 30; count++) {
const actor = g.players.find(p => !g.response!.passed.includes(p.id)); assert.ok(actor);
g = act(g, actor.id, { type: 'passResponse' });
}
assert.ok(!g.response?.automaticAdvisorFlip); return g;
}
function rejected(g: Game, actor: string, action: Action) {
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
}
const physical = (g: Game) => g.players.map(p => ({ id: p.id, forces: p.forces,
reserves: p.reserves, tanks: p.tanks, spice: p.spice }));
for (const continues of [false, true]) for (const canceled of [false, true]) {
void test(`native ${continues ? 'continuing' : 'ending'} storm ${canceled ? 'prevention' : 'allowance'} preserves current losses and resumes only remaining sectors`, () => {
const f = fixture({ continues }), before = structuredClone(f.game), pending = commit(f.game, f).game;
assert.deepEqual(f.game, before);
assert.equal(pending.phase, 0); assert.equal(pending.storm, f.from);
assert.equal(pending.stormResolution?.traversed, 3); assert.deepEqual(pending.stormResolution?.pending, []);
assert.equal(pending.decision, null);
const source = pending.response?.automaticAdvisorFlip; assert.ok(source?.producer === 'storm');
assert.equal(source.storm.from, f.from); assert.equal(source.storm.distance, f.distance);
assert.equal(source.storm.traversed, 3); assert.equal(source.storm.sector, 9);
assert.deepEqual(Object.keys(source.storm).sort(), ['distance', 'event', 'from', 'sector', 'traversed']);
assert.deepEqual(pending.stormMovementSource, f.stormSource);
assert.equal(player(pending, f.holder).forces[f.outsiderLocation], undefined);
assert.equal(player(pending, f.holder).tanks, player(before, f.holder).tanks + 1);
assert.equal(player(pending, f.bg).forces[f.location], 1);
assert.equal(fighterCount(player(pending, f.bg), f.territory), 0);
assert.equal(pending.pendingAutomaticAdvisorStorm?.ready, true);
assert.ok(viewGame(pending, f.holder).responseControls?.cancelCards.includes(f.karama));
assert.ok(!JSON.stringify(viewGame(pending, f.holder).response).includes('cursor'));
const committed = physical(pending);
for (let read = 0; read < 3; read++) {
const stable = normalizeAutomaticGame(pending);
assert.deepEqual(stable.stormResolution, pending.stormResolution);
assert.deepEqual(physical(stable), committed);
assert.equal(fighterCount(player(stable, f.bg), f.territory), 0);
}
let done = canceled
? act(pending, f.holder, { type: 'card', card: f.karama, mode: 'cancel' }) : allow(pending);
assert.equal(done.pendingAutomaticAdvisorStorm, undefined); assert.equal(done.pendingAutomaticAdvisorFlips, undefined);
done = finish(done);
assert.equal(done.phase, 1); assert.equal(done.storm, continues ? 10 : 9);
assert.equal(done.stormResolution, null); assert.equal(done.stormMovementSource, undefined);
assert.equal(player(done, f.holder).tanks, player(before, f.holder).tanks + 1);
assert.equal(player(done, f.holder).forces[f.outsiderLocation], undefined);
assert.equal(player(done, f.bg).forces[f.location] ?? 0, continues ? 0 : 1);
assert.equal(player(done, f.bg).tanks, player(before, f.bg).tanks + (continues ? 1 : 0));
assert.equal(isAdvisor(player(done, f.bg), f.territory), !continues && canceled);
assert.equal(done.log.filter(entry => entry.text === `Storm moved ${f.distance} sectors to sector ${continues ? 10 : 9}.`).length, 1);
assert.equal(done.discard.filter(c => c.id === f.karama).length, canceled ? 1 : 0);
assert.deepEqual(inventory(done), f.originalInventory);
});
}
void test('a native same-sector storm destroys both counters without manufacturing a conversion', () => {
const f = fixture({ sameSector: true }), done = finish(commit(f.game, f).game);
assert.equal(done.pendingAutomaticAdvisorStorm, undefined); assert.equal(done.pendingAutomaticAdvisorFlips, undefined);
assert.equal(done.response?.automaticAdvisorFlip, undefined);
assert.equal(player(done, f.bg).forces[f.location], undefined);
assert.equal(player(done, f.bg).tanks, player(f.game, f.bg).tanks + 1);
assert.equal(player(done, f.holder).tanks, player(f.game, f.holder).tanks + 1);
assert.ok(player(done, f.holder).hand.some(c => c.id === f.karama));
assert.equal(done.phase, 1); assert.equal(done.storm, 9);
assert.deepEqual(inventory(done), f.originalInventory);
});
void test('a changed private storm cursor rejects before a conversion counter spends its card', () => {
const f = fixture(), pending = commit(f.game, f).game;
pending.pendingAutomaticAdvisorStorm!.cursor = 'different-original-sector';
rejected(pending, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
assert.ok(player(pending, f.holder).hand.some(c => c.id === f.karama));
assert.equal(player(pending, f.holder).tanks, player(f.game, f.holder).tanks + 1);
});
void test('all four existing profiles resolve ending and continuing storm conversions legally', () => {
for (const continues of [false, true]) for (const profile of DIFFICULTIES) {
const f = fixture({ continues }), pending = commit(f.game, f).game, own = viewGame(pending, f.holder);
own.players.find(p => p.id === f.holder)!.bot = profile;
const actions = botActions(own); assert.ok(actions.length);
const done = finish(allow(act(pending, f.holder, actions[0])));
assert.equal(done.pendingAutomaticAdvisorStorm, undefined);
assert.equal(done.phase, 1); assert.equal(done.storm, continues ? 10 : 9);
assert.equal(player(done, f.holder).tanks, player(pending, f.holder).tanks);
assert.deepEqual(inventory(done), f.originalInventory);
}
});
+235
View File
@@ -0,0 +1,235 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import {
actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory,
advisorPreparationPlayer, nextAdvisorPreparationStep,
} from './fixture-advisor-preparation';
export type AdvisorStormFlipStep = { actor: string; action: Action };
export type AdvisorStormFlipTransition = { before: Game; steps: AdvisorStormFlipStep[]; after: Game };
export const actAdvisorStormFlip = actAdvisorPreparation;
export const advisorStormFlipClean = advisorPreparationClean;
export const advisorStormFlipInventory = advisorPreparationInventory;
export const advisorStormFlipPlayer = advisorPreparationPlayer;
/** Native first Storm uses real 2/3 dials. Setup's empty Oh Gap group becomes
* fighters normally; later original Intrusion really changes it to advisors. */
export function nextAdvisorStormFlip(game: Game): AdvisorStormFlipStep | null {
if (game.response?.automaticAdvisorFlip) return null;
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.status === 'setup' && game.setupStage === 'forces' &&
!advisorStormFlipPlayer(game, 'beneGesserit').advisorSetup)
return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'oh_gap', sector: 10 } };
if (game.status === 'playing' && game.turn === 1 && game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return { actor, action: { type: 'stormDial', amount: game.stormDialers.indexOf(actor) === 0 ? 2 : 3 } };
}
if (game.decision?.kind === 'intrusion')
return { actor: game.decision.player, action: { type: 'decision', accept: true } };
if (game.decision?.kind === 'advisorBattle')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
}
return nextAdvisorPreparationStep(game);
}
export function advanceAdvisorStormFlip(game: Game, until: (state: Game) => boolean,
steps: AdvisorStormFlipStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine Storm chronology ended before its requested boundary');
const next = nextAdvisorStormFlip(game);
assert.ok(next, `Choose the original advisor conversion at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actAdvisorStormFlip(game, next.actor, next.action, steps);
}
throw Error('The native advisor Storm source did not reach its finite boundary');
}
export function passAdvisorStormFlip(game: Game, steps: AdvisorStormFlipStep[] = []): Game {
for (let count = 0; game.response && !game.response.automaticAdvisorFlip && count < 60; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actAdvisorStormFlip(game, actor.id, { type: 'passResponse' }, steps);
}
assert.ok(!game.response || game.response.automaticAdvisorFlip); return game;
}
/** Select just the six original Storm Cards' native Fisher-Yates shuffle on
* the real last Mentat readiness action. No Storm Card or clock is assigned. */
function drawOriginalStormCard(game: Game, step: AdvisorStormFlipStep, distance: 3 | 4,
steps: AdvisorStormFlipStep[]) {
assert.equal(game.phase, 8); assert.equal(game.turn, 1); assert.ok(advisorStormFlipClean(game));
assert.equal(game.ready.length, game.players.length - 1); assert.equal(step.action.type, 'ready');
const original = [1, 2, 3, 4, 5, 6], order = [distance, ...original.filter(value => value !== distance)];
const current = [...original], scalars: number[] = [];
for (let index = current.length - 1; index > 0; index--) {
const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index);
scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1)));
[current[index], current[from]] = [current[from], current[index]];
}
assert.deepEqual(current, order);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) {
assert.ok(cursor < scalars.length, 'Only the original turn-two Storm shuffle consumes selected entropy');
array[0] = scalars[cursor++];
} else Reflect.apply(native, crypto, [array]);
return array;
};
try {
const next = applyAction(game, step.actor, step.action);
assert.equal(cursor, scalars.length, 'The real six-card Storm shuffle consumes its original permutation once');
steps.push(structuredClone(step)); return { game: next, original, order, scalars };
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Public Advanced BG/Atreides/Emperor base33, no Fremen or optional module.
* Original setup, first dials, paid shipment and Intrusion earn a split-sector
* peaceful group. Stop before the last actual turn-two Storm Ready action. */
export function createAdvisorStormFlipFixture(options: { continues?: boolean; sameSector?: boolean } = {}) {
const continues = options.continues ?? false, distance: 3 | 4 = continues ? 4 : 3;
const sameSector = options.sameSector ?? false;
const bg = 'beneGesserit', holder = 'atreides', emperor = 'emperor';
const territory = 'oh_gap', location = sameSector ? 'oh_gap:9' : 'oh_gap:10', outsiderLocation = 'oh_gap:9';
let game = createGame('ADVISORSTORMFLIP', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
const initial = structuredClone(game), steps: AdvisorStormFlipStep[] = [];
const act = (actor: string, action: Action) => { game = actAdvisorStormFlip(game, actor, action, steps); };
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorStormFlip(game, until, steps); };
const transition = (run: () => void): AdvisorStormFlipTransition => {
const before = structuredClone(game), start = steps.length; run();
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
};
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.deepEqual(game.expansions, []);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => !player.hand.length));
const originalInventory = advisorStormFlipInventory(game); assert.equal(originalInventory.length, 33);
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
assert.equal(ordinary.length, 2);
const selected = [ordinary[0], karama, ordinary[1]];
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game);
let advisorSetup: AdvisorStormFlipTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextAdvisorStormFlip(game); assert.ok(next);
const before = next.action.type === 'advisorSetup' ? structuredClone(game) : null;
if (before && sameSector) next.action = { type: 'advisorSetup', territory, sector: 9 };
act(next.actor, next.action);
if (before) advisorSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) };
}
assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
const afterSetup = structuredClone(game);
assert.equal(game.storm, 1);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
assert.equal(advisorStormFlipPlayer(game, bg).forces[location], 1);
assert.ok(!advisorStormFlipPlayer(game, bg).advisors?.[territory]);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
const firstStorm = transition(() => {
reach(state => state.turn === 1 && state.phase === 1 && state.storm === 6);
});
const firstStormDialSteps = firstStorm.steps.filter(step => step.action.type === 'stormDial');
assert.deepEqual(firstStormDialSteps.map(step => step.action.amount), [2, 3]);
reach(state => state.turn === 1 && state.phase === 5 && state.active === bg && advisorStormFlipClean(state));
const beforeMovement = structuredClone(game);
const bgEnd = transition(() => {
act(bg, { type: 'endMovement' });
reach(state => state.phase === 5 && state.active === holder && advisorStormFlipClean(state));
});
const shipment = transition(() => {
act(holder, { type: 'ship', territory, sector: 9, amount: 1, elite: 0, allyPayment: 0 });
reach(state => !state.phaseOpening && !state.response && state.decision?.kind === 'intrusion');
});
const beforeIntrusion = structuredClone(game);
const intrusion = transition(() => {
act(bg, { type: 'decision', accept: true });
assert.equal(game.response?.kind, 'advisorFlip'); assert.equal(game.response?.advisors, true);
reach(advisorStormFlipClean);
});
assert.equal(advisorStormFlipPlayer(shipment.before, holder).spice - advisorStormFlipPlayer(game, holder).spice, 2);
assert.equal(advisorStormFlipPlayer(game, holder).forces[outsiderLocation], 1);
assert.equal(advisorStormFlipPlayer(game, bg).forces[location], 1);
assert.ok(advisorStormFlipPlayer(game, bg).advisors?.[territory]);
const afterIntrusion = structuredClone(game);
reach(state => state.turn === 1 && state.phase === 8 && advisorStormFlipClean(state));
const mentat = structuredClone(game);
const mentatReadiness = transition(() => {
for (const player of game.players.slice(0, -1)) act(player.id, { type: 'ready' });
});
const drawStep: AdvisorStormFlipStep = { actor: game.players.at(-1)!.id, action: { type: 'ready' } };
const beforeDraw = structuredClone(game), drawStart = steps.length;
const draw = drawOriginalStormCard(game, drawStep, distance, steps); game = draw.game;
const stormDraw: AdvisorStormFlipTransition = {
before: beforeDraw, steps: structuredClone(steps.slice(drawStart)), after: structuredClone(game),
};
assert.equal(game.turn, 2); assert.equal(game.phase, 0); assert.equal(game.storm, 6);
assert.equal(game.stormPending, distance); assert.equal(game.stormMovementSource?.kind, 'card');
assert.ok(advisorStormFlipClean(game)); assert.equal(game.ready.length, 0);
const stormSource = structuredClone(game.stormMovementSource);
const stormCardLog = structuredClone(game.log.findLast(entry => entry.component?.kind === 'stormCard' && entry.component.distance === distance));
assert.ok(stormCardLog, 'The original native Storm Card reveal is actually logged');
const beforeStormReadiness = structuredClone(game);
const stormReadiness = transition(() => {
for (const player of game.players.slice(0, -1)) act(player.id, { type: 'ready' });
});
const triggerActor = game.players.at(-1)!.id, action: Action = { type: 'ready' };
assert.equal(game.ready.length, 2); assert.ok(!game.ready.includes(triggerActor));
assert.equal(game.stormResolution, null);
assert.equal(advisorStormFlipPlayer(game, holder).forces[outsiderLocation], 1);
assert.equal(advisorStormFlipPlayer(game, bg).forces[location], 1);
assert.ok(advisorStormFlipPlayer(game, bg).advisors?.[territory]);
assert.ok(advisorStormFlipPlayer(game, holder).hand.some(card => card.id === karama.id));
assert.deepEqual(advisorStormFlipInventory(game), originalInventory);
return { game, source: structuredClone(game), beforeAction: structuredClone(game), initial, lobby, setup,
beforeStartingDeal, afterSetup, advisorSetup, firstStorm, firstStormDialSteps,
beforeMovement, bgEnd, shipment, beforeIntrusion, intrusion, afterIntrusion,
mentat, mentatReadiness, beforeDraw, drawStep, stormDraw, beforeStormReadiness, stormReadiness,
originalStormCards: draw.original, stormCardOrder: draw.order, stormCardScalars: draw.scalars,
stormSource, stormCardLog, continues, sameSector, bg, owner: bg, holder, atreides: holder, emperor,
karama: karama.id, card: karama.id, territory, location, outsiderLocation, from: 6, distance,
triggerActor, actor: triggerActor, action,
triggerStep: { actor: triggerActor, action } satisfies AdvisorStormFlipStep,
steps, actions: steps, sourceActionCount: steps.length, stepCount: steps.length,
originalInventory, originalSpice, dealOrder, spiceOrder,
staging: [
`Actual public Advanced BG/Atreides/Emperor ready/startPreview, original base33 and no Fremen or optional module. Original prediction/private Traitor choices and actual advisorSetup place BG’s original counter in empty exposed Oh Gap${sameSector ? 9 : 10}, which native setup normally makes a fighter.`,
'Select only original scalar shuffle draws, three original unopened Treachery positions and two original unopened ordinary Spice positions. BG/Emperor receive ordinary cards; Atreides receives its original Karama. No played hand, force, phase, wallet, Tanks or stance is installed.',
'Actually submit first native secret Storm dials two and three. Original Storm moves from sector1 to6 without crossing BG10. Both Spice blows, Charity, normal Bidding and Revival execute normally; actual original circle order finishes Emperor before BG’s movement turn.',
`BG actually ends movement; Atreides pays two spice to ship one original reserve to Oh Gap9. BG actually accepts the resulting original Intrusion, all standard advisorFlip responses allow it, and separate Spiritual Advisors accompaniment is declined. A1 at sector9 and BG’s original peaceful advisor1 at sector${sameSector ? 9 : 10} remain physical neighbours in one territory.`,
'Actual remaining movement, peaceful Battle completion, Collection and first Mentat readiness run normally. Scope exactly the native six-card Storm shuffle on the real last Mentat Ready to reveal three/four, with its original source signature and real Storm Card log; no Storm Pending/Card/phase/forecast is assigned.',
sameSector
? 'Actually submit two turn-two Storm Ready actions, stopping before traversal from6 through9 destroys both colocated counters without a surviving advisor conversion.'
: 'Actually submit only two of the three turn-two Storm Ready actions. Stop before the final original Ready triggers traversal from6: three sectors ends9 with BG10 safe; four sectors continues through10. The original counter, casualties, traversal cursor and later Spice reveal remain caller-owned outcomes.',
],
};
}
export type AdvisorStormFlipFixture = ReturnType<typeof createAdvisorStormFlipFixture>;
/** Submit only the final real Ready. The new conversion response is untouched. */
export function commitAdvisorStormFlip(source: Game, fixture: AdvisorStormFlipFixture,
steps: AdvisorStormFlipStep[] = []) {
const before = structuredClone(source), start = steps.length;
const game = actAdvisorStormFlip(source, fixture.triggerActor, fixture.action, steps);
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)),
resolution: structuredClone(game.stormResolution), source: structuredClone(game.stormMovementSource) };
}
/** Call after the caller's conversion choice. Stop as soon as native traversal
* opens phase1; its ordinary windows may remain, and no Spice is revealed. */
export function finishAdvisorStormFlip(source: Game, steps: AdvisorStormFlipStep[] = []): Game {
return advanceAdvisorStormFlip(source, game => game.phase === 1, steps);
}
+61
View File
@@ -226,6 +226,67 @@ void test('printed counter-cancellation of BG storm protection restores genuine
assert.equal(player(g, 'f').elites?.tanks, 1);
assert.equal(g.stormResolution, null);
});
void test('controlled storm staging holds alone advisors until the original mixed Fremen casualties finish', () => {
for (const prevented of [false, true]) {
const f = fixture();
// Controlled conserved placement, not an earned native setup journey:
// the existing mixed Red Chasm group still owns the real casualty choice.
const bg = player(f.g, 'b'), emperor = player(f.g, 'e');
bg.forces['the_minor_erg:8'] = 1;
bg.reserves--;
bg.advisors = { the_minor_erg: {} };
emperor.forces['the_minor_erg:7'] = 1;
emperor.reserves--;
let g = pass(storm(f), 'stormProtection');
assert.equal(player(g, 'e').forces['the_minor_erg:7'], undefined);
assert.equal(player(g, 'e').tanks, 1);
assert.equal(g.decision?.kind, 'stormLosses');
if (g.decision?.kind !== 'stormLosses') assert.fail('The original Fremen casualty choice must remain first.');
assert.equal(g.decision.key, 'red_chasm:7');
assert.equal(g.decision.amount, 2);
assert.equal(g.response, null, 'The held conversion must not interrupt mandatory Fremen losses.');
const held = g.pendingAutomaticAdvisorFlips?.find(source =>
source.owner === 'b' && source.territory === 'the_minor_erg');
assert.ok(held, 'The storm must capture the already-alone group before exposing the casualty choice.');
assert.equal(held.producer, 'storm');
assert.equal(held.phase, 0);
assert.deepEqual(held.forces, { 'the_minor_erg:8': 1 });
assert.equal(player(g, 'b').advisors?.the_minor_erg?.aloneFlipHold, held.event);
assert.equal(g.stormResolution?.traversed, 1);
assert.equal(g.storm, 6);
assert.deepEqual(g.stormResolution?.pending, [], 'The last casualty choice has already popped its queue item.');
assert.equal(g.pendingAutomaticAdvisorStorm?.ready, false, 'An unresolved casualty choice is not a ready sector.');
g = act(g, 'f', { type: 'decision', elite: 1 });
assert.equal(g.decision, null);
assert.equal(g.response?.kind, 'advisorFlip');
assert.equal(g.response?.automaticAdvisorFlip?.event, held.event);
assert.equal(g.pendingAutomaticAdvisorStorm?.ready, true);
assert.deepEqual(g.stormResolution?.pending, []);
assert.equal(g.stormResolution?.traversed, 1);
assert.equal(player(g, 'f').forces['red_chasm:7'], 2);
assert.equal(player(g, 'f').elites?.forces['red_chasm:7'], 1);
assert.equal(player(g, 'f').tanks, 2);
assert.equal(player(g, 'f').elites?.tanks, 1);
const physical = g.players.map(p => ({ id: p.id, forces: structuredClone(p.forces),
reserves: p.reserves, tanks: p.tanks, elites: structuredClone(p.elites) }));
g = prevented ? cancel(g, f, false) : pass(g, 'advisorFlip');
assert.notEqual(g.response?.kind, 'advisorFlip');
assert.equal(g.storm, 7);
assert.equal(g.stormResolution, null);
assert.equal(g.phase, 1);
assert.ok(!g.pendingAutomaticAdvisorFlips?.length);
assert.deepEqual(g.players.map(p => ({ id: p.id, forces: structuredClone(p.forces),
reserves: p.reserves, tanks: p.tanks, elites: structuredClone(p.elites) })), physical,
'Finishing the conversion must not repeat any storm casualty or physical transfer.');
assert.equal(!!player(g, 'b').advisors?.the_minor_erg, prevented);
if (prevented)
assert.equal(player(g, 'b').advisors?.the_minor_erg?.aloneFlipHold, held.event);
assert.equal(g.discard.filter(card => card.id === f.printed).length, Number(prevented));
}
});
void test('both forms cancel only a newly shipped typed Fremen cohort in the storm, leaving earlier occupants intact', () => {
for (const bg of [false, true]) {
const f = fixture();