Resolve advisor conversion after Gamont before final victory

Commit original Gamont use/return or empty revelation before its separate mandatory-conversion counter. Preserve private fuel provenance beside a public phase8 receipt, retain legacy phase5 sources and defer the genuine final chooser through both direct and Semuta cost completion. Consume the parent once; ordinary Mentat gains no chooser and victory waits for original confirmation. Existing provisional continuously-alone duration remains.

Validation: 18 files277/277, types/lint/build, five native Gamont/response/GameTable SSR journeys and eight existing-profile paths. Genuine52/56-action public Advanced sources earn a real third-stronghold win versus prevented next-turn outcome. Empty revelation remains pure-source proof; full combinations/remaining producers, browser/deployed and final assurance stay open. No saved-room/D1/restart/deployment operation or AI refinement.
This commit is contained in:
Quintine committed 2026-10-24 20:16:05 +11:00
1 parent d0f52c7bae
commit 035be51915
12 files changed
+748 -26

No files matched your search

+149
View File
@@ -9,11 +9,13 @@ import {
type AutomaticAdvisorFlipContext,
type AutomaticAdvisorFlipSource,
type AutomaticAdvisorTransport,
type AutomaticAdvisorGamont,
} 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' }>;
type GamontSource = Extract<AutomaticAdvisorFlipSource, { producer: 'gamont' }>;
function context(): AutomaticAdvisorFlipContext {
return {
@@ -333,3 +335,150 @@ void test('transport validation reads only public slot and physical deposit meta
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
}
});
function gamont(returned = true, mentat = false) {
const { ctx, source: ground } = held();
ctx.phase = 8;
ctx.players[1].faction = 'choam';
ctx.players[2].reserves = returned ? 1 : 0;
ctx.players[2].noField = { deployed: null };
const receipt: AutomaticAdvisorGamont = {
event: 'completed-trip-to-gamont', player: 'a', target: 'r',
location: 'imperial_basin:9', elite: 0, returned, mentat,
};
const source: GamontSource = {
event: 'automatic-after-completed-gamont', owner: ground.owner,
territory: ground.territory, turn: ground.turn, phase: 8,
forces: ground.forces, producer: 'gamont', gamont: receipt,
};
ctx.players[0].advisors = {
...ctx.players[0].advisors,
imperial_basin: { lockedTurn: 4, aloneFlipHold: source.event },
};
if (mentat) ctx.choamMentatPending = true;
return { ctx, source };
}
void test('phase-eight public groups can be quoted and Gamont validates with a real return or an empty No-Field reveal', () => {
const quiet = context();
quiet.phase = 8;
const before = structuredClone(quiet);
assert.equal(quoteAutomaticAdvisorFlips(quiet).some((entry) => entry.territory === 'imperial_basin'), true);
assert.deepEqual(quiet, before);
for (const returned of [false, true]) {
const { ctx, source } = gamont(returned);
const original = structuredClone({ ctx, source });
assert.deepEqual(ctx.players[2].forces, {});
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
assert.deepEqual({ ctx, source }, original);
assert.deepEqual(Object.keys(source).sort(), ['event', 'forces', 'gamont', 'owner', 'phase', 'producer', 'territory', 'turn']);
assert.deepEqual(Object.keys(source.gamont).sort(), ['elite', 'event', 'location', 'mentat', 'player', 'returned', 'target']);
}
});
void test('only a final Gamont receipt requires the still-open native CHOAM Mentat flag', () => {
const { ctx, source } = gamont(true, true);
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
ctx.choamMentatPending = false;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
delete ctx.choamMentatPending;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
source.gamont.mentat = false;
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
});
void test('Gamont metadata binds native CHOAM, the distinct target, current location, outcome and exact held group', () => {
const changes: ((ctx: AutomaticAdvisorFlipContext, source: GamontSource) => void)[] = [
(ctx) => { ctx.advanced = false; },
(ctx) => { ctx.status = 'lobby'; },
(ctx) => { ctx.turn++; },
(_ctx, source) => { source.gamont.event = ''; },
(_ctx, source) => { source.gamont.player = 'missing'; },
(ctx) => { ctx.players[1].faction = 'atreides'; },
(ctx) => { ctx.players = [...ctx.players, { ...ctx.players[1] }]; },
(_ctx, source) => { source.gamont.target = source.gamont.player; },
(_ctx, source) => { source.gamont.target = 'missing'; },
(ctx) => { ctx.players = [...ctx.players, { ...ctx.players[2] }]; },
(_ctx, source) => { source.gamont.location = 'missing:0'; },
(_ctx, source) => { source.gamont.location = 'imperial_basin:8'; },
(_ctx, source) => { source.gamont.location = 'imperial_basin:9.0'; },
(_ctx, source) => { source.gamont.location = 'arrakeen:10'; },
(_ctx, source) => { source.gamont.elite = -1; },
(_ctx, source) => { source.gamont.elite = 2; },
(_ctx, source) => { source.gamont.elite = 0.5; },
(ctx) => { ctx.players[2].reserves = 0; },
(ctx) => { delete ctx.players[2].reserves; },
(_ctx, source) => { source.forces = { 'imperial_basin:9': 3 }; },
(ctx) => { ctx.players[2].forces = { 'imperial_basin:10': 1 }; },
(ctx) => { ctx.players[0].advisors = { imperial_basin: { aloneFlipHold: 'another-event' } }; },
];
for (const change of changes) {
const { ctx, source } = gamont();
change(ctx, source);
const before = structuredClone({ ctx, source });
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
assert.deepEqual({ ctx, source }, before);
}
const { ctx, source } = gamont();
source.gamont.elite = 1;
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
for (const malformed of [
{ ...source, gamont: null },
{ ...source, gamont: { ...source.gamont, returned: 'yes' } },
{ ...source, gamont: { ...source.gamont, mentat: 1 } },
{ ...source, gamont: { ...source.gamont, elite: '1' } },
]) {
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as unknown as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
}
});
void test('private Gamont fuel and Nexus identifiers are rejected in either public source layer', () => {
const { ctx, source } = gamont();
for (const malformed of [
{ ...source, card: 'private-fuel-card' },
{ ...source, fuel: 'private-fuel-card' },
{ ...source, nexusEvent: 'private-nexus-event' },
{ ...source, gamont: { ...source.gamont, card: 'private-fuel-card' } },
{ ...source, gamont: { ...source.gamont, fuel: 'private-fuel-card' } },
{ ...source, gamont: { ...source.gamont, nexusEvent: 'private-nexus-event' } },
]) {
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
}
});
void test('producer phases stay distinct while the original ground and transport receipts remain accepted in phase five', () => {
for (const original of [held(), transported()]) {
const before = JSON.stringify(original.source);
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
assert.equal(JSON.stringify(original.source), before);
original.ctx.phase = 8;
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
original.source.phase = 8;
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
}
const { ctx, source } = gamont();
ctx.phase = 5;
source.phase = 5;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
});
void test('post-Gamont validation does not ask for prior availability, a remaining target counter or private fuel', () => {
const { ctx, source } = gamont(false);
for (const player of ctx.players) {
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'moved', 'shipped', 'gamontAvailable']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Gamont advisor rule read ${player.id} ${field}`); },
});
}
}
for (const field of ['gamontAvailable', 'pendingAutomaticAdvisorGamont', 'choamMentatPending']) {
Object.defineProperty(ctx, field, {
get() { throw new Error(`Gamont advisor rule read private or prior ${field}`); },
});
}
Object.defineProperty(ctx.players[2], 'reserves', {
get() { throw new Error('An empty No-Field reveal has no returned counter to validate'); },
});
assert.deepEqual(ctx.players[2].forces, {});
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
});
+124
View File
@@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Action } from '../game/engine';
import { fighterCount, isAdvisor } from '../game/advisors';
import { strongholdProgress } from '../game/victory-progress';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createAdvisorGamontFlipFixture as fixture,
actAdvisorGamontFlip as act,
commitAdvisorGamontFlip as commit,
advisorGamontFlipPlayer as player,
advisorGamontFlipInventory as inventory,
passAdvisorGamontFlip as pass,
finishAdvisorGamontFlip as finish,
} from './fixture-advisor-gamont-flip';
function rejected(g: Game, actor: string, action: Action) {
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
const holds = (g: Game, id: string) => strongholdProgress(g).progress.find(p => p.player === id)!.strongholds.length;
for (const final of [false, true]) for (const canceled of [false, true]) {
void test(`native ${final ? 'final' : 'ordinary'} Gamont ${canceled ? 'prevention' : 'allowance'} keeps the return and changes the genuine victory outcome`, () => {
const f = fixture({ final }), before = structuredClone(f.game);
assert.equal(holds(before, f.bg), 2);
const pending = commit(f.game, f).game;
assert.deepEqual(f.game, before);
assert.equal(pending.response?.kind, 'advisorFlip');
const source = pending.response?.automaticAdvisorFlip;
assert.ok(source?.producer === 'gamont');
assert.equal(source.gamont.mentat, final);
assert.equal(source.gamont.returned, true);
assert.equal(pending.decision, null);
assert.equal(pending.status, 'playing');
assert.deepEqual(pending.winner, []);
assert.equal(player(pending, f.holder).forces[f.from], undefined);
assert.equal(player(pending, f.holder).reserves, player(before, f.holder).reserves + 1);
assert.equal(player(pending, f.holder).tanks, player(before, f.holder).tanks);
assert.equal(pending.discard.filter(c => c.id === f.card).length, 1);
assert.equal(fighterCount(player(pending, f.bg), f.territory), 0);
assert.equal(holds(pending, f.bg), 2);
rejected(pending, f.choam, { type: 'decision', done: true });
assert.ok(viewGame(pending, f.holder).responseControls?.cancelCards.includes(f.karama));
const publicResponse = JSON.stringify(viewGame(pending, f.holder).response);
assert.ok(!publicResponse.includes(f.card), 'The public conversion never copies the spent fuel card');
assert.equal(pending.pendingAutomaticAdvisorGamont?.card, f.card);
const returned = structuredClone(player(pending, f.holder));
let done = canceled
? act(pending, f.holder, { type: 'card', card: f.karama, mode: 'cancel' })
: pass(pending);
assert.equal(player(done, f.holder).reserves, returned.reserves);
assert.equal(player(done, f.holder).tanks, returned.tanks);
assert.equal(player(done, f.holder).forces[f.from], undefined);
assert.equal(done.discard.filter(c => c.id === f.card).length, 1);
assert.equal(done.pendingAutomaticAdvisorGamont, undefined);
assert.equal(done.pendingAutomaticAdvisorFlips, undefined);
assert.equal(isAdvisor(player(done, f.bg), f.territory), canceled);
assert.equal(holds(done, f.bg), canceled ? 2 : 3);
assert.equal(done.status, 'playing');
assert.deepEqual(done.winner, []);
if (final) assert.deepEqual(done.decision, { kind: 'choamMentat', player: f.choam });
else assert.equal(done.decision, null);
for (let read = 0; read < 3; read++) {
done = normalizeAutomaticGame(done);
assert.equal(done.status, 'playing');
assert.equal(player(done, f.holder).reserves, returned.reserves);
}
assert.deepEqual(inventory(done), f.originalInventory);
const finished = finish(done);
if (canceled) {
assert.equal(finished.status, 'playing');
assert.equal(finished.turn, before.turn + 1);
assert.deepEqual(finished.winner, []);
} else {
assert.equal(finished.status, 'finished');
assert.deepEqual(finished.winner, [f.bg]);
assert.equal(finished.turn, before.turn);
}
assert.equal(finished.discard.filter(c => c.id === f.card).length, 1);
});
}
void test('preventing the original Gamont effect leaves the last outsider and final chooser intact without a conversion parent', () => {
const f = fixture(), declared = act(f.game, f.choam, f.action);
assert.equal(declared.response?.kind, 'choamWorthless');
const done = act(declared, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
assert.equal(player(done, f.holder).forces[f.from], 1);
assert.equal(player(done, f.holder).reserves, player(f.game, f.holder).reserves);
assert.ok(player(done, f.choam).hand.some(c => c.id === f.card));
assert.equal(done.pendingAutomaticAdvisorGamont, undefined);
assert.equal(done.pendingAutomaticAdvisorFlips, undefined);
assert.deepEqual(done.decision, { kind: 'choamMentat', player: f.choam });
assert.equal(holds(done, f.bg), 2);
const next = finish(done);
assert.equal(next.status, 'playing'); assert.equal(next.turn, f.game.turn + 1);
});
void test('a changed Gamont parent rejects before spending a second card or returning a second force', () => {
const f = fixture(), pending = commit(f.game, f).game;
pending.pendingAutomaticAdvisorGamont = { ...pending.pendingAutomaticAdvisorGamont!,
receipt: { ...pending.pendingAutomaticAdvisorGamont!.receipt, target: f.bg } };
rejected(pending, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
assert.equal(pending.discard.filter(c => c.id === f.card).length, 1);
assert.ok(player(pending, f.holder).hand.some(c => c.id === f.karama));
});
void test('the existing four profiles resolve ordinary and final conversion responses legally before victory', () => {
for (const final of [false, true]) for (const profile of DIFFICULTIES) {
const f = fixture({ final }), pending = commit(f.game, f).game;
const 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 = pass(act(pending, f.holder, actions[0]));
assert.equal(done.pendingAutomaticAdvisorGamont, undefined);
assert.equal(done.status, 'playing');
assert.equal(player(done, f.holder).reserves, player(f.game, f.holder).reserves + 1);
const next = finish(done);
assert.ok(next.status === 'finished' || next.turn === f.game.turn + 1);
assert.equal(next.discard.filter(c => c.id === f.card).length, 1);
assert.deepEqual(inventory(next), f.originalInventory);
}
});
+202
View File
@@ -0,0 +1,202 @@
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 AdvisorGamontFlipStep = { actor: string; action: Action };
export type AdvisorGamontFlipTransition = { before: Game; steps: AdvisorGamontFlipStep[]; after: Game };
export const actAdvisorGamontFlip = actAdvisorPreparation;
export const advisorGamontFlipClean = advisorPreparationClean;
export const advisorGamontFlipInventory = advisorPreparationInventory;
export const advisorGamontFlipPlayer = advisorPreparationPlayer;
/** Preserve native cards, decline preparation, and stop at the final original
* CHOAM chooser. Closing markets are finished without selling the held Gamont. */
export function nextAdvisorGamontFlip(game: Game): AdvisorGamontFlipStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'choamMentat') return null;
if (game.decision?.kind === 'advisorBattle')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
}
return nextAdvisorPreparationStep(game);
}
export function advanceAdvisorGamontFlip(game: Game, until: (state: Game) => boolean,
steps: AdvisorGamontFlipStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine Gamont chronology ended before its requested boundary');
const next = nextAdvisorGamontFlip(game);
assert.ok(next, `Choose the original final CHOAM/private control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actAdvisorGamontFlip(game, next.actor, next.action, steps);
}
throw Error('The native advisor/Gamont source did not reach its finite boundary');
}
export function passAdvisorGamontFlip(game: Game, steps: AdvisorGamontFlipStep[] = []): 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 = actAdvisorGamontFlip(game, actor.id, { type: 'passResponse' }, steps);
}
assert.ok(!game.response); return game;
}
/** Actual public Advanced E2 source. BG earns fighters in Carthag and Tabr,
* while its original advisor remains beside the last Atreides Arrakeen counter.
* Stop BEFORE the original Gamont declaration, in ordinary or final Mentat.
* No played force, phase, hand, wallet, stance, hold or receipt is assigned. */
export function createAdvisorGamontFlipFixture(options: { final?: boolean } = {}) {
const final = options.final ?? true, bg = 'beneGesserit', holder = 'atreides', choam = 'choam';
let game = createGame('ADVISORGAMONTFLIP', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true, ['choam']);
joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
joinGame(game, newPlayer(choam, 'CHOAM', 'choam'));
const initial = structuredClone(game), steps: AdvisorGamontFlipStep[] = [];
const act = (actor: string, action: Action) => { game = actAdvisorGamontFlip(game, actor, action, steps); };
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorGamontFlip(game, until, steps); };
const transition = (run: () => void): AdvisorGamontFlipTransition => {
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, ['choam']);
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 = advisorGamontFlipInventory(game);
assert.equal(originalInventory.length, 35);
const karama = game.deck.find(card => card.effect === 'karama');
const gamont = game.deck.find(card => card.name === 'Trip to Gamont');
const ordinary = game.deck.find(card => card.kind === 'projectile');
assert.ok(karama && gamont && ordinary);
const selected = [ordinary, karama, gamont];
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: AdvisorGamontFlipTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextAdvisorGamontFlip(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(advisorGamontFlipPlayer(game, bg).forces['arrakeen:10'], 1);
assert.ok(advisorGamontFlipPlayer(game, bg).advisors?.arrakeen);
assert.equal(advisorGamontFlipPlayer(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 && advisorGamontFlipClean(state));
});
const bgShipment = transition(() => {
act(bg, { type: 'ship', territory: 'carthag', sector: 11, amount: 2, elite: 0, allyPayment: 0 });
reach(advisorGamontFlipClean);
});
assert.equal(advisorGamontFlipPlayer(bgShipment.before, bg).spice - advisorGamontFlipPlayer(game, bg).spice, 2);
assert.equal(advisorGamontFlipPlayer(game, bg).forces['carthag:11'], 2);
assert.ok(!advisorGamontFlipPlayer(game, bg).advisors?.carthag);
const bgMovement = transition(() => {
act(bg, { type: 'move', from: 'carthag:11', territory: 'sietch_tabr', sector: 14, amount: 1 });
reach(advisorGamontFlipClean);
});
assert.equal(advisorGamontFlipPlayer(game, bg).forces['carthag:11'], 1);
assert.equal(advisorGamontFlipPlayer(game, bg).forces['sietch_tabr:14'], 1);
assert.ok(!advisorGamontFlipPlayer(game, bg).advisors?.sietch_tabr);
act(bg, { type: 'endMovement' });
reach(state => state.phase === 5 && state.active === holder && advisorGamontFlipClean(state));
const holderDeparture = transition(() => {
act(holder, { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 10, amount: 9 });
reach(advisorGamontFlipClean);
});
assert.equal(advisorGamontFlipPlayer(game, holder).forces['arrakeen:10'], 1);
assert.equal(advisorGamontFlipPlayer(game, holder).forces['imperial_basin:10'], 9);
assert.ok(advisorGamontFlipPlayer(game, bg).advisors?.arrakeen);
reach(state => state.turn === 1 && state.phase === 8 && advisorGamontFlipClean(state));
const ordinaryMentat = structuredClone(game), ordinaryMentatActionCount = steps.length;
if (final) reach(state => state.phase === 8 && !state.phaseOpening && !state.response && state.decision?.kind === 'choamMentat');
assert.equal(game.status, 'playing'); assert.equal(game.turn, 1); assert.equal(game.phase, 8);
assert.equal(advisorGamontFlipPlayer(game, bg).forces['arrakeen:10'], 1);
assert.ok(advisorGamontFlipPlayer(game, bg).advisors?.arrakeen);
assert.equal(advisorGamontFlipPlayer(game, bg).forces['carthag:11'], 1);
assert.equal(advisorGamontFlipPlayer(game, bg).forces['sietch_tabr:14'], 1);
assert.equal(advisorGamontFlipPlayer(game, holder).forces['arrakeen:10'], 1);
assert.ok(advisorGamontFlipPlayer(game, holder).hand.some(card => card.id === karama.id));
assert.ok(advisorGamontFlipPlayer(game, choam).hand.some(card => card.id === gamont.id));
assert.deepEqual(advisorGamontFlipInventory(game), originalInventory);
const action: Action = { type: 'card', mode: 'choam', card: gamont.id, target: holder, from: 'arrakeen:10', elite: 0 };
return { game, source: structuredClone(game), beforeAction: structuredClone(game), initial, lobby, setup,
beforeStartingDeal, afterSetup, advisorSetup, openingOffer, declinedPreparation,
bgShipment, bgMovement, holderDeparture, ordinaryMentat, ordinaryMentatActionCount,
final, bg, owner: bg, choam, actor: choam, holder, atreides: holder,
card: gamont.id, gamont: gamont.id, karama: karama.id, action,
declarationStep: { actor: choam, action } satisfies AdvisorGamontFlipStep,
territory: 'arrakeen', from: 'arrakeen:10', to: 'reserves', sector: 10,
nearWin: { fighters: ['carthag', 'sietch_tabr'], advisors: ['arrakeen'], lastOutsider: holder },
steps, actions: steps, sourceActionCount: steps.length, originalInventory, originalSpice, dealOrder, spiceOrder,
staging: [
'Actual public Advanced Bene Gesserit/Atreides/CHOAM ready/startPreview, prediction and private Traitor choices, with the original CHOAM35 deck and no optional module. Actual advisorSetup places BG’s starting advisor beside Atreides’ native ten Arrakeen counters.',
'Select only original scalar shuffle order, three original unopened Treachery positions and two original unopened ordinary Spice positions. BG receives an ordinary weapon, Atreides its original Karama and CHOAM its original Trip to Gamont; no played hand or later card acquisition is staged.',
'Actually complete safe first Storm, both Spice blows, Charity, normal Bidding and Revival, and decline original advisor preparation. BG pays two to ship two reserves as fighters into empty Carthag11, then uses its real city movement range to move one original counter from Carthag11 to empty Sietch Tabr14.',
'BG actually ends movement. Atreides actually moves nine original counters from Arrakeen10 to Imperial Basin10, leaving one beside the original advisor. Other native turns finish normally; there is no battle or assigned casualty. Actual Collection gives BG two fighter-held strongholds while the Arrakeen advisor remains peaceful.',
final
? 'Actually complete ordinary Mentat readiness and its closing CHOAM market without selling Gamont. Stop at the original public final choamMentat decision before declaring the held card.'
: 'Stop at ordinary clean first Mentat before readiness and before declaring the held Gamont.',
'No played phase, force, wallet, hand, stance, hold, prevention or receipt is installed. Gamont’s normal response, physical return, automatic advisor conversion and final victory continuation remain caller-owned outcomes.',
],
};
}
export type AdvisorGamontFlipFixture = ReturnType<typeof createAdvisorGamontFlipFixture>;
/** Declare and allow only the original Worthless response, stopping before a
* new mandatory conversion response or at the completed native return boundary.
* The original final chooser is never answered by this helper. */
export function commitAdvisorGamontFlip(source: Game, fixture: AdvisorGamontFlipFixture,
steps: AdvisorGamontFlipStep[] = []) {
const before = structuredClone(source), start = steps.length;
let game = actAdvisorGamontFlip(source, fixture.choam, fixture.action, steps);
for (let count = 0; count < 80; count++) {
const returned = (advisorGamontFlipPlayer(game, fixture.holder).forces[fixture.from] ?? 0) === 0 &&
advisorGamontFlipPlayer(game, fixture.holder).reserves === advisorGamontFlipPlayer(before, fixture.holder).reserves + 1;
if (game.response?.automaticAdvisorFlip || (returned && !game.response && !game.pendingTreacheryDiscard)) {
assert.ok(returned, 'The original Gamont force returns before its advisor-conversion response');
assert.ok(advisorGamontFlipPlayer(game, fixture.holder).hand.some(card => card.id === fixture.karama));
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)) };
}
assert.equal(game.response?.kind, 'choamWorthless', 'Allow only the original Gamont declaration before its committed return');
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actAdvisorGamontFlip(game, actor.id, { type: 'passResponse' }, steps);
}
throw Error('The original Gamont did not reach its committed physical-return boundary');
}
/** Explicitly finish the original Mentat after resolving the caller's owned
* conversion choice. Native final confirmation may win or advance the turn. */
export function finishAdvisorGamontFlip(source: Game, steps: AdvisorGamontFlipStep[] = []): Game {
const turn = source.turn;
let game = source;
for (let count = 0; count < 200; count++) {
if (game.status === 'finished' || game.turn > turn) return game;
const next = game.decision?.kind === 'choamMentat' && !game.response && !game.phaseOpening
? { actor: game.decision.player, action: { type: 'decision', done: true } satisfies Action }
: nextAdvisorGamontFlip(game);
assert.ok(next, 'Complete the original owned Gamont continuation before final Mentat');
game = actAdvisorGamontFlip(game, next.actor, next.action, steps);
}
throw Error('The original Gamont Mentat continuation did not finish');
}