Preserve committed Guild transport through advisor conversion responses

Offer mandatory advisor conversion prevention after Guild cross-shipment and reserve returns. Keep original permission/rate/income, payment, physical pieces and shipment use; bind the exact group to one transport parent and resume original arrival/Intrusion once. Ordinary movement stays unused and the existing provisional continuously-alone duration remains. Preserve old ground receipts, private projection behavior and all transport/module guards.

Validation: 17 files243/243, types/lint/build, eight native transport/response/GameTable SSR journeys and eight existing-profile paths. Genuine36/42-action sources earn original counters/cards and actual paid-shipment Intrusion. Preserve the unaffordable full-price cross outcome and prove the affordable return. No saved-room/D1/restart/deployment operation; broader producers, full modes and browser/deployed acceptance remain open.
This commit is contained in:
Quintine committed 2026-10-24 19:58:56 +11:00
1 parent 3aca64412f
commit d0f52c7bae
13 files changed
+805 -43

No files matched your search

+3 -1
View File
@@ -30,7 +30,9 @@ for (const kind of ['arrival', 'departure'] as const) {
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);
const source = pending.response?.automaticAdvisorFlip;
assert.ok(source?.producer === 'groundMovement');
assert.equal(source.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);
+149 -2
View File
@@ -8,7 +8,12 @@ import {
type AdvisorSettlementContext,
type AutomaticAdvisorFlipContext,
type AutomaticAdvisorFlipSource,
type AutomaticAdvisorTransport,
} from '../game/advisor-automatic-flip';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
type GroundSource = Extract<AutomaticAdvisorFlipSource, { producer: 'groundMovement' }>;
type TransportSource = Extract<AutomaticAdvisorFlipSource, { producer: 'guildTransport' }>;
function context(): AutomaticAdvisorFlipContext {
return {
@@ -28,7 +33,7 @@ function context(): AutomaticAdvisorFlipContext {
function held() {
const ctx = context();
const choice = quoteAutomaticAdvisorFlips(ctx).find((entry) => entry.territory === 'imperial_basin')!;
const source: AutomaticAdvisorFlipSource = {
const source: GroundSource = {
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,
@@ -127,7 +132,7 @@ void test('the response validates its original held event and exact physical gro
});
void test('changed window, owner, occurrence, group, occupation and movement sources throw the named rule error', () => {
const changes: ((ctx: AutomaticAdvisorFlipContext, source: AutomaticAdvisorFlipSource) => void)[] = [
const changes: ((ctx: AutomaticAdvisorFlipContext, source: GroundSource) => void)[] = [
(ctx) => { ctx.advanced = false; },
(ctx) => { ctx.status = 'lobby'; },
(ctx) => { ctx.phase = 4; },
@@ -186,3 +191,145 @@ void test('quotes, settlement and response matching do not inspect private cards
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
assert.deepEqual(ctx.players.map((player) => ({ forces: player.forces, advisors: player.advisors })), before);
});
function transported(to: 'carthag' | 'reserves' = 'carthag') {
const { ctx, source: ground } = held();
const transport: AutomaticAdvisorTransport = {
event: 'committed-native-guild-transport', player: 'a', origin: 'imperial_basin',
to, sector: to === 'reserves' ? 0 : 11, amount: 3, elite: 0,
group: { 'imperial_basin:9': 2, 'imperial_basin:10': 1 },
};
ctx.players[1].shipped = true;
ctx.players[1].forces = to === 'reserves' ? {} : { 'carthag:11': 3 };
ctx.players[1].reserves = to === 'reserves' ? 3 : 0;
const source: TransportSource = {
event: 'automatic-after-original-guild-transport', owner: ground.owner,
territory: ground.territory, turn: ground.turn, phase: ground.phase,
forces: ground.forces, producer: 'guildTransport', transport,
};
ctx.players[0].advisors = {
...ctx.players[0].advisors,
imperial_basin: { lockedTurn: 4, aloneFlipHold: source.event },
};
return { ctx, source };
}
void test('committed cross-transport and reserve return bind the same held advisor origin and physical deposit', () => {
for (const destination of ['carthag', 'reserves'] as const) {
const { ctx, source } = transported(destination);
const before = structuredClone({ ctx, source });
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
assert.deepEqual({ ctx, source }, before);
assert.deepEqual(Object.keys(source).sort(), ['event', 'forces', 'owner', 'phase', 'producer', 'territory', 'transport', 'turn']);
assert.deepEqual(advisorStanceAfterSettlement({ advanced: true, players: ctx.players }, 'b', 'imperial_basin'), {
lockedTurn: 4, aloneFlipHold: source.event,
});
}
});
void test('a native BG transported advisor arrival can bind its destination group without imposing a Guild-faction actor', () => {
const { ctx, source } = transported();
source.transport = {
event: 'committed-bg-cross-transport', player: 'b', origin: 'hagga_basin',
to: 'imperial_basin', sector: 9, amount: 2, elite: 0, group: { 'hagga_basin:12': 2 },
};
ctx.players[0].shipped = true;
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
source.transport.sector = 10;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
});
void test('transport metadata uses current board identities and rejects unplaced dynamic destinations', () => {
const { ctx, source } = transported();
source.transport.to = MOBILE_STRONGHOLD;
source.transport.sector = 0;
ctx.players[1].forces = { [MOBILE_LOCATION]: 3 };
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
ctx.mobileStronghold = { location: 'imperial_basin:9' };
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
ctx.mobileStronghold.location = null;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
});
void test('changed transport metadata, selected group, native slot, deposit or response location rejects before settlement', () => {
const changes: ((ctx: AutomaticAdvisorFlipContext, source: TransportSource) => void)[] = [
(_ctx, source) => { source.transport.event = ''; },
(_ctx, source) => { source.transport.player = ''; },
(_ctx, source) => { source.transport.player = 'missing'; },
(_ctx, source) => { source.transport.origin = 'missing'; },
(_ctx, source) => { source.transport.to = 'missing'; },
(_ctx, source) => { source.transport.sector = -1; },
(_ctx, source) => { source.transport.sector = 11.5; },
(_ctx, source) => { source.transport.sector = 12; },
(_ctx, source) => { source.transport.amount = 0; },
(_ctx, source) => { source.transport.amount = 4; },
(_ctx, source) => { source.transport.elite = -1; },
(_ctx, source) => { source.transport.elite = 4; },
(_ctx, source) => { source.transport.elite = 1.5; },
(_ctx, source) => { source.transport.group = {}; },
(_ctx, source) => { source.transport.group = { 'imperial_basin:9': 3.5 }; },
(_ctx, source) => { source.transport.group = { 'imperial_basin:9': 3, 'imperial_basin:10': 0 }; },
(_ctx, source) => { source.transport.group = { 'carthag:11': 3 }; },
(_ctx, source) => { source.transport.group = { 'imperial_basin:8': 3 }; },
(_ctx, source) => { source.transport.group = { 'imperial_basin:9.0': 3 }; },
(_ctx, source) => {
source.transport.origin = 'arrakeen';
source.transport.group = { 'arrakeen:10': 3 };
},
(ctx) => { ctx.players[1].shipped = false; },
(ctx) => { delete ctx.players[1].shipped; },
(ctx) => { ctx.players[1].forces = { 'carthag:11': 2 }; },
(ctx) => { ctx.players[1].forces = { 'carthag:10': 3 }; },
(ctx) => { ctx.players = [...ctx.players, { ...ctx.players[1] }]; },
(_ctx, source) => { source.forces = { 'imperial_basin:9': 3 }; },
];
for (const change of changes) {
const { ctx, source } = transported();
change(ctx, source);
const before = structuredClone({ ctx, source });
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
assert.deepEqual({ ctx, source }, before);
}
const { ctx, source } = transported('reserves');
ctx.players[1].reserves = 2;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
delete ctx.players[1].reserves;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
for (const malformed of [
{ ...source, mover: 'a' },
{ ...source, transport: null },
{ ...source, transport: { ...source.transport, paid: 1 } },
{ ...source, transport: { ...source.transport, group: null } },
]) {
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
}
});
void test('old ground receipts keep their exact JSON shape and never require transport slot or reserve metadata', () => {
const { ctx, source } = held();
for (const player of ctx.players) {
for (const field of ['shipped', 'reserves']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Ground source read transport-only ${field}`); },
});
}
}
const saved = JSON.parse(JSON.stringify(source)) as GroundSource;
assert.deepEqual(Object.keys(saved).sort(), ['event', 'forces', 'move', 'mover', 'owner', 'phase', 'producer', 'territory', 'turn']);
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, saved));
assert.throws(() => validateAutomaticAdvisorFlip(ctx, { ...saved, transport: {} } as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
});
void test('transport validation reads only public slot and physical deposit metadata, not wallets, cards or leaders', () => {
for (const destination of ['carthag', 'reserves'] as const) {
const { ctx, source } = transported(destination);
for (const player of ctx.players) {
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Transport advisor rule read ${player.id} private ${field}`); },
});
}
}
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
}
});
+165
View File
@@ -0,0 +1,165 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { fighterCount, isAdvisor } from '../game/advisors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createAdvisorTransportFlipFixture as fixture,
actAdvisorTransportFlip as act,
commitAdvisorTransportFlip as commit,
advisorTransportFlipPlayer as player,
advisorTransportFlipInventory as inventory,
passAdvisorTransportFlip as pass,
finishAdvisorTransportFlip as finish,
} from './fixture-advisor-transport-flip';
function passKind(source: Game, kind: string) {
let g = source;
for (let i = 0; g.response?.kind === kind && i < 20; i++) {
const actor = g.players.find(p => !g.response!.passed.includes(p.id)); assert.ok(actor);
g = act(g, actor.id, { type: 'passResponse' });
}
assert.notEqual(g.response?.kind, kind); return g;
}
for (const kind of ['cross', 'return'] as const) for (const canceled of [false, true]) {
void test(`native ${kind} transport stays paid and physically committed after ${canceled ? 'preventing' : 'allowing'} advisor conversion`, () => {
const f = fixture({ kind }), original = structuredClone(f.game);
const committed = commit(f.game, f), pending = committed.game;
assert.deepEqual(f.game, original);
assert.equal(pending.response?.kind, 'advisorFlip');
assert.equal(pending.response?.automaticAdvisorFlip?.producer, 'guildTransport');
assert.ok(committed.price > 0);
assert.equal(player(pending, f.guild).spice, player(original, f.guild).spice - committed.price);
assert.equal(player(pending, f.guild).forces[f.from], undefined);
assert.equal(player(pending, f.guild).moved, 0);
assert.equal(player(pending, f.guild).shipped, true);
assert.equal(fighterCount(player(pending, f.bg), f.territory), 0);
assert.ok(viewGame(pending, f.holder).responseControls?.cancelCards.includes(f.card));
if (kind === 'return') assert.equal(player(pending, f.guild).reserves, player(original, f.guild).reserves + 5);
else assert.equal(player(pending, f.guild).forces[f.to], 5);
const actor = structuredClone(player(pending, f.guild));
const done = canceled
? act(pending, f.holder, { type: 'card', card: f.card, mode: 'cancel' })
: pass(pending);
assert.deepEqual(player(done, f.guild), actor);
assert.equal(done.pendingAutomaticAdvisorFlips, undefined);
assert.equal(done.pendingAutomaticAdvisorTransport, undefined);
assert.equal(isAdvisor(player(done, f.bg), f.territory), canceled);
assert.equal(done.discard.filter(c => c.id === f.card).length, canceled ? 1 : 0);
assert.deepEqual(inventory(done), f.originalInventory);
const continued = finish(normalizeAutomaticGame(done));
assert.ok(continued.phase >= 6);
assert.equal(isAdvisor(player(continued, f.bg), f.territory), canceled);
assert.deepEqual(inventory(continued), f.originalInventory);
});
}
void test('a completed cross transport retains an actual legal ordinary ground move after prevention', () => {
const f = fixture(), pending = commit(f.game, f).game;
const done = act(pending, f.holder, { type: 'card', card: f.card, mode: 'cancel' });
const price = player(done, f.guild).spice;
const moved = act(done, f.guild, { type: 'move', from: f.to,
territory: 'arrakeen', sector: 10, amount: 1 });
assert.equal(player(moved, f.guild).moved, 1);
assert.equal(player(moved, f.guild).spice, price);
assert.equal(player(moved, f.guild).forces['arrakeen:10'], 1);
assert.equal(moved.discard.filter(c => c.id === f.card).length, 1);
assert.equal(isAdvisor(player(moved, f.bg), f.territory), true);
});
void test('preventing original transport permission never creates a physical conversion occurrence', () => {
const f = fixture(), permission = act(f.game, f.guild, f.action);
assert.equal(permission.response?.kind, 'guildTransportPermission');
const done = act(permission, f.holder, { type: 'card', card: f.card, mode: 'cancel' });
assert.equal(player(done, f.guild).forces[f.from], 5);
assert.equal(player(done, f.guild).spice, player(f.game, f.guild).spice);
assert.equal(done.pendingAutomaticAdvisorFlips, undefined);
assert.equal(done.pendingAutomaticAdvisorTransport, undefined);
assert.equal(player(done, f.bg).advisors?.[f.territory]?.aloneFlipHold, undefined);
assert.equal(isAdvisor(player(done, f.bg), f.territory), true);
});
void test('a prevented rate still settles the original physical transport before an unopposed automatic conversion', () => {
const f = fixture({ kind: 'return' }), permission = act(f.game, f.guild, f.action);
const rate = passKind(permission, 'guildTransportPermission');
assert.equal(rate.response?.kind, 'guildRate');
const done = act(rate, f.holder, { type: 'card', card: f.card, mode: 'cancel' });
assert.equal(player(done, f.guild).forces[f.from], undefined);
assert.equal(player(done, f.guild).reserves, player(f.game, f.guild).reserves + 5);
assert.ok(player(done, f.guild).spice < player(f.game, f.guild).spice);
assert.equal(player(done, f.guild).moved, 0);
assert.equal(isAdvisor(player(done, f.bg), f.territory), false);
assert.equal(done.pendingAutomaticAdvisorTransport, undefined);
assert.equal(done.response, null);
assert.deepEqual(inventory(done), f.originalInventory);
});
void test('an unaffordable canceled cross tariff leaves the original army and advisor occurrence untouched', () => {
const f = fixture(), permission = act(f.game, f.guild, f.action);
const rate = passKind(permission, 'guildTransportPermission');
assert.equal(rate.response?.kind, 'guildRate');
const done = act(rate, f.holder, { type: 'card', card: f.card, mode: 'cancel' });
assert.equal(player(done, f.guild).forces[f.from], 5);
assert.equal(player(done, f.guild).forces[f.to], undefined);
assert.equal(player(done, f.guild).spice, player(f.game, f.guild).spice);
assert.equal(isAdvisor(player(done, f.bg), f.territory), true);
assert.equal(player(done, f.bg).advisors?.[f.territory]?.aloneFlipHold, undefined);
assert.equal(done.pendingAutomaticAdvisorTransport, undefined);
assert.equal(done.pendingAutomaticAdvisorFlips, undefined);
assert.deepEqual(inventory(done), f.originalInventory);
});
void test('a changed committed transport parent rejects before discarding the prevention card', () => {
const f = fixture(), pending = commit(f.game, f).game;
pending.pendingAutomaticAdvisorTransport = { ...pending.pendingAutomaticAdvisorTransport!, amount: 4 };
const before = structuredClone(pending);
assert.throws(() => applyAction(pending, f.holder, { type: 'card', card: f.card, mode: 'cancel' }));
assert.deepEqual(pending, before);
assert.ok(player(pending, f.holder).hand.some(c => c.id === f.card));
});
for (const canceled of [false, true]) {
void test(`transport arrival resumes its actual Intrusion once after ${canceled ? 'prevented' : 'allowed'} origin conversion`, () => {
const f = fixture({ intrusion: true }), pending = commit(f.game, f).game;
const paid = player(pending, f.guild).spice;
assert.equal(pending.decision, null);
assert.equal(pending.response?.automaticAdvisorFlip?.producer, 'guildTransport');
const arrival = canceled
? act(pending, f.holder, { type: 'card', card: f.card, mode: 'cancel' })
: pass(pending);
assert.equal(arrival.pendingAutomaticAdvisorTransport, undefined);
assert.equal(arrival.decision?.kind, 'intrusion');
assert.equal(arrival.decision?.kind === 'intrusion' && arrival.decision.territory, 'imperial_basin');
assert.equal(player(arrival, f.guild).forces[f.to], 5);
assert.equal(player(arrival, f.guild).spice, paid);
assert.equal(player(arrival, f.guild).moved, 0);
let done = pass(act(arrival, f.bg, { type: 'decision', accept: true }));
assert.equal(isAdvisor(player(done, f.bg), 'imperial_basin'), true);
assert.equal(isAdvisor(player(done, f.bg), f.territory), canceled);
for (let read = 0; read < 3; read++) {
done = normalizeAutomaticGame(done);
assert.equal(done.decision, null);
assert.equal(done.response, null);
assert.equal(done.pendingAutomaticAdvisorTransport, undefined);
}
assert.deepEqual(inventory(done), f.originalInventory);
assert.ok(finish(done).phase >= 6);
});
}
void test('four existing profiles resolve both committed transport conversions legally', () => {
for (const kind of ['cross', 'return'] as const) for (const profile of DIFFICULTIES) {
const f = fixture({ kind }), pending = commit(f.game, f).game;
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 done = pass(act(pending, f.holder, action));
assert.equal(done.pendingAutomaticAdvisorTransport, undefined);
assert.equal(player(done, f.guild).moved, 0);
assert.equal(player(done, f.guild).shipped, true);
assert.deepEqual(inventory(done), f.originalInventory);
assert.ok(finish(done).phase >= 6);
}
});
+197
View File
@@ -0,0 +1,197 @@
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 AdvisorTransportFlipStep = { actor: string; action: Action };
export type AdvisorTransportFlipTransition = {
before: Game; steps: AdvisorTransportFlipStep[]; after: Game;
};
export const actAdvisorTransportFlip = actAdvisorPreparation;
export const advisorTransportFlipClean = advisorPreparationClean;
export const advisorTransportFlipInventory = advisorPreparationInventory;
export const advisorTransportFlipPlayer = advisorPreparationPlayer;
/** The original public setup places the advisor beside Guild's native army.
* Guild may actually take its native early turn; no origin group is moved. */
export function nextAdvisorTransportFlip(game: Game): AdvisorTransportFlipStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.status === 'setup' && game.setupStage === 'forces' &&
!advisorTransportFlipPlayer(game, 'beneGesserit').advisorSetup)
return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'tueks_sietch', sector: 5 } };
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: true } };
}
return nextAdvisorPreparationStep(game);
}
export function advanceAdvisorTransportFlip(game: Game, until: (state: Game) => boolean,
steps: AdvisorTransportFlipStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine transport-flip chronology ended before its requested boundary');
const next = nextAdvisorTransportFlip(game);
assert.ok(next, `Choose the original native control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actAdvisorTransportFlip(game, next.actor, next.action, steps);
}
throw Error('The native transport-flip source did not reach its finite boundary');
}
export function passAdvisorTransportFlip(game: Game, steps: AdvisorTransportFlipStep[] = []): 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 = actAdvisorTransportFlip(game, actor.id, { type: 'passResponse' }, steps);
}
assert.ok(!game.response); return game;
}
/** Public Advanced BG/Atreides/Guild. The native advisor and all five original
* Guild counters share Tuek's Sietch, whose printed sector is 5. Stop BEFORE
* cross/return transport. All payment, transfer and flip responses are native
* caller continuations; no played force, phase, wallet, hand or hold is set. */
export function createAdvisorTransportFlipFixture(options: { kind?: 'cross' | 'return'; intrusion?: boolean } = {}) {
const kind = options.kind ?? 'cross', intrusion = options.intrusion ?? false;
assert.ok(!intrusion || kind === 'cross', 'The native Intrusion source uses the cross route');
const bg = 'beneGesserit', holder = 'atreides', guild = 'guild';
let game = createGame('ADVISORTRANSPORTFLIP', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
joinGame(game, newPlayer(guild, 'Guild', 'guild'));
const initial = structuredClone(game), steps: AdvisorTransportFlipStep[] = [];
const act = (actor: string, action: Action) => { game = actAdvisorTransportFlip(game, actor, action, steps); };
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorTransportFlip(game, until, steps); };
const transition = (run: () => void): AdvisorTransportFlipTransition => {
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 = advisorTransportFlipInventory(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: AdvisorTransportFlipTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextAdvisorTransportFlip(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(advisorTransportFlipPlayer(game, bg).forces['tueks_sietch:5'], 1);
assert.ok(advisorTransportFlipPlayer(game, bg).advisors?.tueks_sietch);
assert.equal(advisorTransportFlipPlayer(game, guild).forces['tueks_sietch:5'], 5);
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 });
if (intrusion) {
for (let count = 0; count < 100 && !(game.phase === 5 && game.active === bg && advisorTransportFlipClean(game)); count++) {
const next = nextAdvisorTransportFlip(game); assert.ok(next);
if (!game.phaseOpening && !game.response && game.decision?.kind === 'guildTiming')
next.action = { type: 'decision', take: false };
act(next.actor, next.action);
}
assert.ok(game.phase === 5 && game.active === bg && advisorTransportFlipClean(game));
} else reach(state => state.phase === 5 && state.active === guild && advisorTransportFlipClean(state));
});
let intrusionShipment: AdvisorTransportFlipTransition | null = null;
let bgEnd: AdvisorTransportFlipTransition | null = null;
if (intrusion) {
intrusionShipment = transition(() => {
act(bg, { type: 'ship', territory: 'imperial_basin', sector: 10, amount: 1, elite: 0, allyPayment: 0 });
reach(advisorTransportFlipClean);
});
assert.equal(advisorTransportFlipPlayer(intrusionShipment.before, bg).spice - advisorTransportFlipPlayer(game, bg).spice, 2);
assert.equal(advisorTransportFlipPlayer(game, bg).forces['imperial_basin:10'], 1);
assert.ok(!advisorTransportFlipPlayer(game, bg).advisors?.imperial_basin);
bgEnd = transition(() => {
act(bg, { type: 'endMovement' });
reach(state => state.phase === 5 && state.active === guild && advisorTransportFlipClean(state));
});
}
assert.equal(game.active, guild); assert.equal(game.phase, 5); assert.ok(advisorTransportFlipClean(game));
assert.equal(advisorTransportFlipPlayer(game, bg).forces['tueks_sietch:5'], 1);
assert.ok(advisorTransportFlipPlayer(game, bg).advisors?.tueks_sietch);
assert.equal(advisorTransportFlipPlayer(game, guild).forces['tueks_sietch:5'], 5);
assert.equal(advisorTransportFlipPlayer(game, guild).shipped, false);
assert.equal(advisorTransportFlipPlayer(game, guild).moved, 0);
assert.equal(advisorTransportFlipPlayer(game, bg).forces['imperial_basin:10'] ?? 0, intrusion ? 1 : 0);
assert.ok(game.players.filter(player => player.id !== bg).every(player => (player.forces['imperial_basin:10'] ?? 0) === 0));
assert.ok(advisorTransportFlipPlayer(game, holder).hand.some(card => card.id === karama.id));
assert.deepEqual(advisorTransportFlipInventory(game), originalInventory);
const from = 'tueks_sietch:5', to = kind === 'return' ? 'reserves' : 'imperial_basin:10';
const action: Action = { type: 'guildShip', from, amount: 5, elite: 0, allyPayment: 0,
territory: kind === 'return' ? 'reserves' : 'imperial_basin', ...(kind === 'cross' ? { sector: 10 } : {}) };
return { game, source: structuredClone(game), beforeAction: structuredClone(game), initial, lobby, setup,
beforeStartingDeal, afterSetup, advisorSetup, openingOffer, declinedPreparation, intrusionShipment, bgEnd,
kind, intrusion, bg, owner: bg, holder, atreides: holder, guild, actor: guild, observer: guild,
card: karama.id, karama: karama.id, action, transportStep: { actor: guild, action } satisfies AdvisorTransportFlipStep,
territory: 'tueks_sietch', sector: 5, from, to, amount: 5,
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 native advisorSetup action places one advisor at Tuek’s Sietch5 beside Guild’s original five counters. The printed board and native setup both use sector5; no sector9 position is manufactured.',
'Select only original scalar shuffle order, three original unopened Treachery positions and two original unopened ordinary Spice positions. Atreides really receives its original starting Karama; no later card or hand acquisition is staged.',
intrusion
? 'Actually complete safe first Storm, both Spice blows, Charity, normal Bidding and Revival, then decline optional advisor preparation and Guild’s first native early timing opportunity. BG pays two to ship one reserve into empty Imperial Basin10 as a fighter; original Guild interception/income controls resolve with the original Karama retained. BG really ends its turn, then Guild takes its next native early opportunity. The original BG advisor and five Guild counters remain at Tuek’s Sietch5.'
: 'Actually complete safe first Storm, both Spice blows, Charity, normal Bidding and Revival. Decline original optional advisor preparation, then actually take Guild’s native early movement turn and allow its original response. All five Guild source counters remain unmoved.',
kind === 'return'
? 'Stop before the actual guildShip return of all five original Tuek’s counters to abstract reserves. Permission, rate, payment, physical return and automatic advisor conversion remain caller actions.'
: intrusion
? 'Stop before the actual guildShip cross-shipment of all five original Tuek’s counters to Imperial Basin10, where BG’s real paid fighter now stands. Original permission/rate, physical transfer, origin automatic conversion and destination Intrusion remain caller continuations.'
: 'Stop before the actual guildShip cross-shipment of all five original Tuek’s counters to empty Imperial Basin10. Permission, rate, payment, physical transfer and automatic advisor conversion remain caller actions.',
'No played army, advisor stance, phase, wallet, hand, reserve, hold, response or receipt is assigned. This source does not assert an automatic-flip outcome.',
],
};
}
export type AdvisorTransportFlipFixture = ReturnType<typeof createAdvisorTransportFlipFixture>;
/** Allow only original transport continuations and stop BEFORE passing the
* automatic advisor response. Older engines can reach a clean committed state;
* this source helper does not substitute an expected flip outcome. */
export function commitAdvisorTransportFlip(source: Game, fixture: AdvisorTransportFlipFixture,
steps: AdvisorTransportFlipStep[] = []) {
const before = structuredClone(source), start = steps.length;
let game = source;
if (!game.pendingGuildTransport && !game.response)
game = actAdvisorTransportFlip(game, fixture.guild, fixture.action, steps);
for (let count = 0; count < 100; count++) {
const guild = advisorTransportFlipPlayer(game, fixture.guild);
const committed = guild.shipped && (guild.forces[fixture.from] ?? 0) === 0;
if (game.response?.automaticAdvisorFlip || (committed && advisorTransportFlipClean(game))) {
assert.ok(committed, 'The actual physical transport commits before its advisor response');
assert.ok(advisorTransportFlipPlayer(game, fixture.holder).hand.some(card => card.id === fixture.card));
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)),
price: advisorTransportFlipPlayer(before, fixture.guild).spice - guild.spice };
}
const next = nextAdvisorTransportFlip(game); assert.ok(next, 'The original transport continuation needs its live native control');
game = actAdvisorTransportFlip(game, next.actor, next.action, steps);
}
throw Error('The native Guild transport did not reach its physical commitment boundary');
}
export function finishAdvisorTransportFlip(source: Game, steps: AdvisorTransportFlipStep[] = []): Game {
return advanceAdvisorTransportFlip(source, game => game.phase >= 6 && game.phase <= 7 && advisorTransportFlipClean(game), steps);
}