Offer Karama against mandatory advisor ground conversions

Keep completed moves while allowing native advisor-to-fighter conversion to be prevented after empty arrival or the last outsider's ground departure. Bind each queued response to its actual whole group and movement. Shared settlement honors the explicit provisional continuously-alone cancellation duration; setup remains automatic and non-ground producers remain open.

Validation: 16 related files199/199, types/lint/build, five native move/response/GameTable SSR journeys and eight legal existing-profile paths. Native34/36-action public Advanced sources earn original setup/counters/Karama. Existing standard controls render; no new component-callback, browser/deployed or full-mode certificate. No saved-room/D1/restart/deployment operation.
This commit is contained in:
Quintine committed 2026-10-24 19:44:12 +11:00
1 parent 1a505525a8
commit 3aca64412f
15 files changed
+874 -34

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { fighterCount, isAdvisor } from '../game/advisors';
import { quoteBattleBoard, quoteSpiceCollection } from '../game/board-resolution-quote';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createAdvisorAutomaticFlipFixture as fixture,
actAdvisorAutomaticFlip as act,
advisorAutomaticFlipPlayer as player,
advisorAutomaticFlipInventory as inventory,
passAdvisorAutomaticFlip as pass,
finishAdvisorAutomaticFlip as finish,
advanceAdvisorAutomaticFlip as advance,
advisorAutomaticFlipClean as clean,
} from './fixture-advisor-automatic-flip';
const cancel = (g: Game, f: ReturnType<typeof fixture>) => act(g, f.holder,
{ type: 'card', card: f.card, mode: 'cancel' });
function rejected(g: Game, actor: string, action: Action) {
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
for (const kind of ['arrival', 'departure'] as const) {
void test(`native ${kind} commits physical ground movement before its mandatory conversion response`, () => {
const f = fixture({ kind }), before = structuredClone(f.game);
const pending = act(f.game, f.actor, f.action);
assert.deepEqual(f.game, before);
assert.equal(pending.response?.kind, 'advisorFlip');
assert.equal(pending.response?.automaticAdvisorFlip?.territory, f.territory);
assert.equal(pending.response?.automaticAdvisorFlip?.mover, f.actor);
assert.equal(player(pending, f.actor).moved, player(before, f.actor).moved + 1);
assert.equal(player(pending, f.actor).forces[f.from], undefined);
assert.equal(player(pending, f.actor).forces[f.to], f.amount);
assert.equal(isAdvisor(player(pending, f.bg), f.territory), true);
assert.equal(fighterCount(player(pending, f.bg), f.territory), 0);
assert.ok(player(pending, f.holder).hand.some(c => c.id === f.card));
const allowed = pass(pending);
assert.equal(isAdvisor(player(allowed, f.bg), f.territory), false);
assert.equal(fighterCount(player(allowed, f.bg), f.territory), 1);
assert.equal(allowed.pendingAutomaticAdvisorFlips, undefined);
assert.equal(player(allowed, f.actor).moved, player(pending, f.actor).moved);
assert.deepEqual(inventory(allowed), f.originalInventory);
assert.ok(finish(allowed).phase >= 6);
});
void test(`native ${kind} cancellation preserves advisors and the original committed movement through phase continuation`, () => {
const f = fixture({ kind }), pending = act(f.game, f.actor, f.action);
const stopped = cancel(pending, f), hold = player(stopped, f.bg).advisors?.[f.territory]?.aloneFlipHold;
assert.ok(hold);
assert.equal(stopped.response, null);
assert.equal(stopped.pendingAutomaticAdvisorFlips, undefined);
assert.equal(player(stopped, f.actor).moved, 1);
assert.equal(player(stopped, f.actor).forces[f.to], f.amount);
assert.equal(stopped.discard.filter(c => c.id === f.card).length, 1);
for (let read = 0; read < 3; read++) {
const normalized = normalizeAutomaticGame(stopped);
assert.equal(isAdvisor(player(normalized, f.bg), f.territory), true);
const own = viewGame(normalized, f.bg).players.find(p => p.id === f.bg)!;
assert.equal(isAdvisor(own, f.territory), true);
assert.ok(!quoteBattleBoard(normalized).released.some(r => r.player === f.bg && r.territory === f.territory));
const collection = quoteSpiceCollection(normalized).receipts.find(r => r.player === f.bg)!;
assert.equal(collection.strongholds, 0);
assert.equal(collection.collected, 0);
}
const continued = finish(stopped);
assert.ok(continued.phase >= 6);
assert.equal(player(continued, f.bg).advisors?.[f.territory]?.aloneFlipHold, hold);
assert.equal(fighterCount(player(continued, f.bg), f.territory), 0);
assert.deepEqual(inventory(continued), f.originalInventory);
rejected(stopped, f.holder, { type: 'card', card: f.card, mode: 'cancel' });
});
}
void test('an actual outsider return ends the continuously-alone suppression without changing advisor stance', () => {
const f = fixture({ kind: 'arrival' });
let g = cancel(act(f.game, f.actor, f.action), f);
assert.ok(player(g, f.bg).advisors?.[f.territory]?.aloneFlipHold);
g = advance(g, state => state.phase === 5 && state.active === f.holder && clean(state));
g = act(g, f.holder, { ...f.action, amount: 10 });
assert.equal(player(g, f.holder).forces[f.to], 10);
assert.equal(isAdvisor(player(g, f.bg), f.territory), true);
assert.equal(player(g, f.bg).advisors?.[f.territory]?.aloneFlipHold, undefined);
assert.equal(g.pendingAutomaticAdvisorFlips, undefined);
assert.deepEqual(inventory(g), f.originalInventory);
});
void test('a changed mandatory source rejects before Karama disposal or a second movement', () => {
const f = fixture(), pending = act(f.game, f.actor, f.action);
const changed = structuredClone(pending);
changed.response!.automaticAdvisorFlip = { ...changed.response!.automaticAdvisorFlip!, territory: 'arrakeen' };
rejected(changed, f.holder, { type: 'card', card: f.card, mode: 'cancel' });
assert.ok(player(changed, f.holder).hand.some(c => c.id === f.card));
assert.equal(player(changed, f.actor).moved, 1);
});
void test('four existing profiles can resolve both native mandatory conversions legally', () => {
for (const kind of ['arrival', 'departure'] as const) for (const profile of DIFFICULTIES) {
const f = fixture({ kind }), pending = act(f.game, f.actor, f.action);
const own = viewGame(pending, f.holder);
own.players.find(p => p.id === f.holder)!.bot = profile;
const action = botActions(own)[0]; assert.ok(action);
const settled = pass(act(pending, f.holder, action));
assert.equal(settled.pendingAutomaticAdvisorFlips, undefined);
assert.equal(player(settled, f.actor).moved, 1);
assert.deepEqual(inventory(settled), f.originalInventory);
assert.ok(finish(settled).phase >= 6);
}
});
+188
View File
@@ -0,0 +1,188 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
advisorStanceAfterSettlement,
AutomaticAdvisorFlipError,
quoteAutomaticAdvisorFlips,
validateAutomaticAdvisorFlip,
type AdvisorSettlementContext,
type AutomaticAdvisorFlipContext,
type AutomaticAdvisorFlipSource,
} from '../game/advisor-automatic-flip';
function context(): AutomaticAdvisorFlipContext {
return {
advanced: true, status: 'playing', turn: 4, phase: 5,
players: [
{
id: 'b', faction: 'beneGesserit', moved: 0,
forces: { 'imperial_basin:9': 2, 'imperial_basin:10': 1, 'arrakeen:10': 1, 'carthag:11': 2, 'hagga_basin:12': 0 },
advisors: { imperial_basin: { lockedTurn: 4 }, arrakeen: {}, hagga_basin: {} },
},
{ id: 'a', faction: 'atreides', moved: 1, forces: { 'carthag:11': 3 } },
{ id: 'r', faction: 'richese', moved: 0, forces: {} },
],
};
}
function held() {
const ctx = context();
const choice = quoteAutomaticAdvisorFlips(ctx).find((entry) => entry.territory === 'imperial_basin')!;
const source: AutomaticAdvisorFlipSource = {
event: 'automatic-after-original-ground-move', owner: choice.player,
territory: choice.territory, turn: ctx.turn, phase: ctx.phase,
producer: 'groundMovement', mover: 'a', move: 1, forces: choice.forces,
};
ctx.players[0].advisors = {
...ctx.players[0].advisors,
imperial_basin: { ...ctx.players[0].advisors!.imperial_basin, aloneFlipHold: source.event },
};
return { ctx, source };
}
void test('the phase-five quote finds whole physical advisor groups that became alone without using fresh locks as immunity', () => {
const ctx = context();
const before = structuredClone(ctx);
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), [
{ player: 'b', territory: 'imperial_basin', forces: { 'imperial_basin:9': 2, 'imperial_basin:10': 1 } },
{ player: 'b', territory: 'arrakeen', forces: { 'arrakeen:10': 1 } },
]);
assert.deepEqual(ctx, before);
const quote = quoteAutomaticAdvisorFlips(ctx);
quote[0].forces['imperial_basin:9'] = 99;
assert.equal(ctx.players[0].forces['imperial_basin:9'], 2);
assert.equal(quoteAutomaticAdvisorFlips(ctx)[0].forces['imperial_basin:9'], 2);
});
void test('quotes exclude held, occupied and physically empty advisor groups while setup and other phases stay automatic', () => {
const { ctx } = held();
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: 6 },
]) {
assert.deepEqual(quoteAutomaticAdvisorFlips({ ...context(), ...patch }), []);
}
const setup = context();
setup.phase = 0;
assert.equal(advisorStanceAfterSettlement(setup, 'b', 'imperial_basin'), undefined);
assert.equal(advisorStanceAfterSettlement(setup, 'b', 'hagga_basin'), undefined);
});
void test('settlement uses only advanced and players, retains a held occurrence and clears that hold when another faction returns', () => {
const { ctx, source } = held();
const board: AdvisorSettlementContext = { advanced: ctx.advanced, players: ctx.players };
const original = structuredClone(ctx);
const stance = advisorStanceAfterSettlement(board, ctx.players[0], 'imperial_basin');
assert.deepEqual(stance, { lockedTurn: 4, aloneFlipHold: source.event });
assert.notEqual(stance, ctx.players[0].advisors!.imperial_basin);
assert.equal(advisorStanceAfterSettlement(board, 'b', 'arrakeen'), undefined);
assert.deepEqual(ctx, original);
ctx.players[1].forces = { ...ctx.players[1].forces, 'imperial_basin:11': 1 };
assert.deepEqual(advisorStanceAfterSettlement(board, 'b', 'imperial_basin'), { lockedTurn: 4 });
assert.equal(ctx.players[0].advisors!.imperial_basin.aloneFlipHold, source.event);
const basic = { ...board, advanced: false };
assert.deepEqual(advisorStanceAfterSettlement(basic, 'b', 'imperial_basin'), { lockedTurn: 4, aloneFlipHold: source.event });
});
void test('a continuous alone hold survives later phases and turns, while emptiness and a newly moved group do not inherit it', () => {
const { ctx, source } = held();
ctx.phase = 7;
ctx.turn++;
assert.deepEqual(advisorStanceAfterSettlement(ctx, 'b', 'imperial_basin'), { lockedTurn: 4, aloneFlipHold: source.event });
ctx.phase = 5;
assert.equal(quoteAutomaticAdvisorFlips(ctx).some((entry) => entry.territory === 'imperial_basin'), false);
ctx.players[0].forces = { 'hagga_basin:12': 3 };
ctx.players[0].advisors = { ...ctx.players[0].advisors, hagga_basin: { lockedTurn: 4 } };
assert.equal(advisorStanceAfterSettlement(ctx, 'b', 'imperial_basin'), undefined);
assert.equal(advisorStanceAfterSettlement(ctx, 'b', 'hagga_basin'), undefined);
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), [
{ player: 'b', territory: 'hagga_basin', forces: { 'hagga_basin:12': 3 } },
]);
});
void test('a concealed No-Field supplies public occupation without exposing its physical identity or printed value', () => {
const { ctx } = held();
ctx.players[2].noField = { deployed: { location: { territory: 'imperial_basin', sector: 9 } } };
Object.defineProperty(ctx.players[2].noField, 'tokens', {
get() { throw new Error('Automatic conversion read No-Field inventory'); },
});
Object.defineProperty(ctx.players[2].noField.deployed, 'value', {
get() { throw new Error('Automatic conversion read No-Field value'); },
});
assert.deepEqual(advisorStanceAfterSettlement(ctx, 'b', 'imperial_basin'), { lockedTurn: 4 });
assert.equal(quoteAutomaticAdvisorFlips(ctx).some((entry) => entry.territory === 'imperial_basin'), false);
});
void test('the response validates its original held event and exact physical group regardless of map insertion order', () => {
const { ctx, source } = held();
const before = structuredClone({ ctx, source });
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, {
...source, forces: { 'imperial_basin:10': 1, 'imperial_basin:9': 2 },
}));
assert.deepEqual({ ctx, source }, before);
delete ctx.players[0].advisors;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
});
void test('changed window, owner, occurrence, group, occupation and movement sources throw the named rule error', () => {
const changes: ((ctx: AutomaticAdvisorFlipContext, source: AutomaticAdvisorFlipSource) => void)[] = [
(ctx) => { ctx.advanced = false; },
(ctx) => { ctx.status = 'lobby'; },
(ctx) => { ctx.phase = 4; },
(ctx) => { ctx.turn++; },
(_ctx, source) => { source.phase = 4; },
(_ctx, source) => { source.owner = 'a'; },
(_ctx, source) => { source.owner = 'missing'; },
(_ctx, source) => { source.territory = 'arrakeen'; },
(_ctx, source) => { source.event = 'other-occurrence'; },
(ctx) => { ctx.players[0].advisors = { imperial_basin: { aloneFlipHold: 'other-occurrence' } }; },
(ctx) => { ctx.players[0].forces = { 'imperial_basin:9': 3 }; },
(ctx) => { ctx.players[0].forces = {}; },
(ctx) => { ctx.players[1].forces = { 'imperial_basin:11': 1 }; },
(ctx) => { ctx.players[2].noField = { deployed: { location: { territory: 'imperial_basin', sector: 10 } } }; },
(_ctx, source) => { source.forces = {}; },
(_ctx, source) => { source.forces = { 'imperial_basin:9': 2 }; },
(_ctx, source) => { source.forces['imperial_basin:9'] = 1; },
(_ctx, source) => { source.forces['arrakeen:10'] = 1; },
(_ctx, source) => { source.forces['imperial_basin:9'] = 0; },
(_ctx, source) => { source.forces['imperial_basin:9'] = 1.5; },
(_ctx, source) => { source.move = 0; },
(_ctx, source) => { source.move = 2; },
(_ctx, source) => { source.mover = 'missing'; },
(ctx) => { ctx.players[1].moved = 2; },
(ctx) => { delete ctx.players[1].moved; },
(_ctx, source) => { (source as { producer: string }).producer = 'shipment'; },
];
for (const change of changes) {
const { ctx, source } = held();
change(ctx, source);
const before = structuredClone({ ctx, source });
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), (error: unknown) =>
error instanceof AutomaticAdvisorFlipError && error.name === 'AutomaticAdvisorFlipError');
assert.deepEqual({ ctx, source }, before);
}
const { ctx, source } = held();
for (const malformed of [null, [], { ...source, extra: true }, { ...source, forces: null }, { ...source, event: '' }]) {
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
}
});
void test('quotes, settlement and response matching do not inspect private cards, leaders or spice', () => {
const { ctx, source } = held();
const before = ctx.players.map((player) => structuredClone({ forces: player.forces, advisors: player.advisors }));
for (const player of ctx.players) {
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Automatic advisor rule read ${player.id} private ${field}`); },
});
}
}
assert.equal(quoteAutomaticAdvisorFlips(ctx).some((entry) => entry.territory === 'arrakeen'), true);
assert.deepEqual(advisorStanceAfterSettlement({ advanced: ctx.advanced, players: ctx.players }, 'b', 'imperial_basin'), {
lockedTurn: 4, aloneFlipHold: source.event,
});
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
assert.deepEqual(ctx.players.map((player) => ({ forces: player.forces, advisors: player.advisors })), before);
});
+9 -3
View File
@@ -199,7 +199,7 @@ void test('moving fighters into existing advisors matches their stance, and the
assert.equal(isAdvisor(g.players[1], 'arrakeen'), false);
assert.equal(fighterCount(g.players[1], 'arrakeen'), 3);
});
void test('advisors moving to an empty territory become fighters; optional occupied-territory flips can be canceled after movement', () => {
void test('empty and occupied advisor arrivals offer conversion cancellation after the committed movement', () => {
const g = fixture();
g.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!];
army(g, 'a', { 'arrakeen:10': 1 });
@@ -217,8 +217,14 @@ void test('advisors moving to an empty territory become fighters; optional occup
assert.equal(isAdvisor(allow(moved).players[1], 'imperial_basin'), false);
g.players[2].forces = {};
const empty = move(g, 'b', 'arrakeen:10', 'imperial_basin', 10, 2);
assert.equal(isAdvisor(empty.players[1], 'imperial_basin'), false);
assert.equal(empty.response, null);
assert.equal(isAdvisor(empty.players[1], 'imperial_basin'), true);
assert.equal(empty.response?.kind, 'advisorFlip');
assert.ok(empty.response?.automaticAdvisorFlip);
assert.equal(isAdvisor(allow(empty).players[1], 'imperial_basin'), false);
const held = cancel(empty);
assert.equal(isAdvisor(held.players[1], 'imperial_basin'), true);
assert.ok(held.players[1].advisors?.imperial_basin.aloneFlipHold);
assert.equal(held.players[1].moved, 1);
});
void test('intrusion without advisor accompaniment restores the same decision and private views', () => {
const g = fixture();
+147
View File
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import {
actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory,
advisorPreparationPlayer, nextAdvisorPreparationStep,
} from './fixture-advisor-preparation';
export type AdvisorAutomaticFlipStep = { actor: string; action: Action };
export type AdvisorAutomaticFlipTransition = {
before: Game; steps: AdvisorAutomaticFlipStep[]; after: Game;
};
export const actAdvisorAutomaticFlip = actAdvisorPreparation;
export const advisorAutomaticFlipClean = advisorPreparationClean;
export const advisorAutomaticFlipInventory = advisorPreparationInventory;
export const advisorAutomaticFlipPlayer = advisorPreparationPlayer;
/** Original native setup/phase controls; retain the source advisor stance by
* declining optional preparation and leave Guild's movement until last. */
export function nextAdvisorAutomaticFlip(game: Game): AdvisorAutomaticFlipStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'advisorBattle')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
if (game.decision?.kind === 'guildTiming')
return { actor: game.decision.player, action: { type: 'decision', take: false } };
}
return nextAdvisorPreparationStep(game);
}
export function advanceAdvisorAutomaticFlip(game: Game, until: (state: Game) => boolean,
steps: AdvisorAutomaticFlipStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The native automatic-flip chronology ended before its requested boundary');
const next = nextAdvisorAutomaticFlip(game);
assert.ok(next, `Choose the original private control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actAdvisorAutomaticFlip(game, next.actor, next.action, steps);
}
throw Error('The genuine automatic-flip source did not reach its finite boundary');
}
export function passAdvisorAutomaticFlip(game: Game, steps: AdvisorAutomaticFlipStep[] = []): Game {
for (let count = 0; game.response && count < 60; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actAdvisorAutomaticFlip(game, actor.id, { type: 'passResponse' }, steps);
}
assert.ok(!game.response); return game;
}
/** Public Advanced BG/Atreides/Guild with the native advisor actually placed at
* occupied Arrakeen. Stop BEFORE a real ground movement would leave advisors
* alone: BG empty-territory arrival, or the last Atreides outsiders' departure.
* No played force, stance, phase, wallet, hand or prevention receipt is set. */
export function createAdvisorAutomaticFlipFixture(options: { kind: 'arrival' | 'departure' } = { kind: 'arrival' }) {
const kind = options.kind, bg = 'beneGesserit', holder = 'atreides', observer = 'guild';
let game = createGame('ADVISORAUTOMATICFLIP', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
joinGame(game, newPlayer(observer, 'Guild', 'guild'));
const initial = structuredClone(game), steps: AdvisorAutomaticFlipStep[] = [];
const act = (actor: string, action: Action) => { game = actAdvisorAutomaticFlip(game, actor, action, steps); };
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorAutomaticFlip(game, until, steps); };
const transition = (run: () => void): AdvisorAutomaticFlipTransition => {
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.equal(game.expansions.length, 0);
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 === 0));
const originalInventory = advisorAutomaticFlipInventory(game);
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: AdvisorAutomaticFlipTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextAdvisorAutomaticFlip(game); assert.ok(next);
const before = next.action.type === 'advisorSetup' ? structuredClone(game) : null;
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.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
assert.equal(advisorAutomaticFlipPlayer(game, bg).forces['arrakeen:10'], 1);
assert.ok(advisorAutomaticFlipPlayer(game, bg).advisors?.arrakeen);
assert.equal(advisorAutomaticFlipPlayer(game, holder).forces['arrakeen:10'], 10);
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);
reach(state => state.turn === 1 && state.phase === 5 && !state.phaseOpening && !state.response && state.decision?.kind === 'advisorBattle');
const openingOffer = structuredClone(game);
const declinedPreparation = transition(() => {
act(bg, { type: 'decision', accept: false });
reach(state => state.phase === 5 && state.active === bg && advisorAutomaticFlipClean(state));
});
let skippedBg: AdvisorAutomaticFlipTransition | null = null;
if (kind === 'departure') skippedBg = transition(() => {
act(bg, { type: 'endMovement' });
reach(state => state.phase === 5 && state.active === holder && advisorAutomaticFlipClean(state));
});
const actor = kind === 'arrival' ? bg : holder, amount = kind === 'arrival' ? 1 : 10;
const territory = kind === 'arrival' ? 'imperial_basin' : 'arrakeen';
assert.equal(game.active, actor); assert.ok(advisorAutomaticFlipClean(game));
assert.equal(advisorAutomaticFlipPlayer(game, bg).forces['arrakeen:10'], 1);
assert.ok(advisorAutomaticFlipPlayer(game, bg).advisors?.arrakeen);
assert.equal(advisorAutomaticFlipPlayer(game, holder).forces['arrakeen:10'], 10);
assert.ok(game.players.every(player => (player.forces['imperial_basin:10'] ?? 0) === 0));
assert.ok(advisorAutomaticFlipPlayer(game, holder).hand.some(card => card.id === karama.id));
assert.deepEqual(advisorAutomaticFlipInventory(game), originalInventory);
const action: Action = { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 10, amount };
return { game, source: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup,
advisorSetup, openingOffer, declinedPreparation, skippedBg,
kind, bg, owner: bg, holder, atreides: holder, observer, guild: observer,
actor, card: karama.id, karama: karama.id, action,
movementStep: { actor, action } satisfies AdvisorAutomaticFlipStep,
from: 'arrakeen:10', to: 'imperial_basin:10', territory, automaticTerritory: territory, sector: 10, amount,
steps, actions: steps, sourceActionCount: steps.length, originalInventory, originalSpice, dealOrder, spiceOrder,
staging: [
'Actual public Advanced Bene Gesserit/Atreides/Guild ready/startPreview, prediction and private Traitor choices. The original advisorSetup action places one advisor at occupied Arrakeen10 beside the ten native Atreides counters.',
'Select only original scalar shuffle order, three original unopened Treachery positions and two original unopened ordinary Spice positions. Atreides actually receives the original starting Karama; no later hand or card acquisition is staged.',
'Actually complete safe first Storm, both Spice blows, Charity, normal Bidding and Revival. Decline the original optional advisorBattle preparation and defer Guild through its native timing choices; no faction ships or moves an origin counter.',
kind === 'departure'
? 'Actually end BG’s unused movement turn, then stop at Atreides’ native active turn with all ten original Arrakeen counters still present. Their whole-group ground move to empty Imperial Basin10 remains the caller action.'
: 'Stop at BG’s native active turn with its original advisor still beside Atreides. Moving that one advisor to empty Imperial Basin10 remains the caller action.',
'The source ends before the tested movement. No played army, stance, phase, wallet, Tanks, hold, response or cancellation receipt is assigned, and no automatic-flip outcome is asserted.',
],
};
}
export type AdvisorAutomaticFlipFixture = ReturnType<typeof createAdvisorAutomaticFlipFixture>;
/** After the caller resolves any actual automatic-flip response, continue the
* original turns into the next native Battle/Collection boundary. */
export function finishAdvisorAutomaticFlip(source: Game, steps: AdvisorAutomaticFlipStep[] = []): Game {
return advanceAdvisorAutomaticFlip(source, game => game.phase >= 6 && game.phase <= 7 && advisorAutomaticFlipClean(game), steps);
}