Resolve advisor conversion after battle losses before optional cleanup

Preserve committed casualties, payments and mandatory disposal when battle
losses leave BG bystanders alone. Bind the public phase-six receipt to the
original battle and resume its private winner/aftermath cursor once. Record
provisional priority; explosions with no surviving advisors create no response.

Repair stale Semuta fixtures to acknowledge the existing final battle window;
a representative failure reproduces against unchanged base035be51. Production
Semuta guards stay intact.

Validation: 16 affected files264/264, types/lint/build, five native battle and
GameTable SSR journeys, eight existing-profile response paths. Genuine44-action
sources earn original cards/forces and actual Collection outcomes. Broader
combinations and complete modes remain unfinished. Not deployed; no room/D1,
reset or restart operation. Runtime fingerprint
665defe51a1f46a6b5992b6e1e4b034203f91e7101acf860eb549f5bf9865151;
checkpoint prose/reference overlays are reviewed separately.
This commit is contained in:
Quintine committed 2026-10-24 20:37:09 +11:00
1 parent 035be51915
commit d83db397c0
17 files changed
+908 -26

No files matched your search

+151 -1
View File
@@ -10,12 +10,14 @@ import {
type AutomaticAdvisorFlipSource,
type AutomaticAdvisorTransport,
type AutomaticAdvisorGamont,
type AutomaticAdvisorBattle,
} 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' }>;
type BattleSource = Extract<AutomaticAdvisorFlipSource, { producer: 'battleLosses' }>;
function context(): AutomaticAdvisorFlipContext {
return {
@@ -66,7 +68,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: 6 },
{ advanced: false }, { status: 'lobby' }, { phase: 0 }, { phase: 4 }, { phase: 7 },
]) {
assert.deepEqual(quoteAutomaticAdvisorFlips({ ...context(), ...patch }), []);
}
@@ -482,3 +484,151 @@ void test('post-Gamont validation does not ask for prior availability, a remaini
assert.deepEqual(ctx.players[2].forces, {});
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
});
function battleResult(result: AutomaticAdvisorBattle['result'] = 'normal') {
const { ctx, source: ground } = held();
ctx.phase = 6;
ctx.battle = null;
ctx.players[1].forces = {};
ctx.players[2].forces = {};
const receipt: AutomaticAdvisorBattle = {
event: 'original-resolved-battle', turn: ctx.turn, territory: 'imperial_basin',
combatants: ['a', 'r'], winner: result === 'normal' || result === 'traitor' ? 'a' : null, result,
};
ctx.lastBattleContext = { ...receipt, combatants: [...receipt.combatants] };
const source: BattleSource = {
event: 'automatic-after-original-battle-losses', owner: ground.owner,
territory: ground.territory, turn: ground.turn, phase: 6,
forces: ground.forces, producer: 'battleLosses', battle: receipt,
};
ctx.players[0].advisors = {
...ctx.players[0].advisors,
imperial_basin: { lockedTurn: 4, aloneFlipHold: source.event },
};
return { ctx, source };
}
void test('phase-six quotes and exact public resolved battle receipts retain a physically nonempty BG bystander group', () => {
const quiet = context();
quiet.phase = 6;
const original = structuredClone(quiet);
assert.equal(quoteAutomaticAdvisorFlips(quiet).some((entry) => entry.territory === 'imperial_basin'), true);
assert.deepEqual(quiet, original);
for (const result of ['normal', 'traitor', 'mutualTraitors'] as const) {
const { ctx, source } = battleResult(result);
const before = structuredClone({ ctx, source });
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
assert.deepEqual({ ctx, source }, before);
assert.deepEqual(Object.keys(source).sort(), ['battle', 'event', 'forces', 'owner', 'phase', 'producer', 'territory', 'turn']);
assert.deepEqual(Object.keys(source.battle).sort(), ['combatants', 'event', 'result', 'territory', 'turn', 'winner']);
}
});
void test('a stale battle event, turn, winner, participant, location or active battle cannot settle the held source', () => {
const changes: ((ctx: AutomaticAdvisorFlipContext, source: BattleSource) => void)[] = [
(ctx) => { ctx.advanced = false; },
(ctx) => { ctx.status = 'lobby'; },
(ctx) => { ctx.turn++; },
(ctx) => { ctx.battle = {}; },
(ctx) => { ctx.lastBattleContext = null; },
(ctx) => { delete ctx.lastBattleContext; },
(ctx) => { ctx.lastBattleContext!.event = 'changed-battle'; },
(ctx) => { ctx.lastBattleContext!.turn++; },
(ctx) => { ctx.lastBattleContext!.territory = 'arrakeen'; },
(ctx) => { ctx.lastBattleContext!.winner = 'r'; },
(ctx) => { ctx.lastBattleContext!.result = 'traitor'; },
(_ctx, source) => { source.battle.event = 'stale-battle'; },
(_ctx, source) => { source.battle.turn++; },
(_ctx, source) => { source.battle.territory = 'arrakeen'; },
(_ctx, source) => { source.battle.territory = 'missing'; },
(_ctx, source) => { source.battle.combatants = ['a', 'a']; },
(_ctx, source) => { source.battle.combatants = ['a', 'missing']; },
(_ctx, source) => { source.battle.combatants.reverse(); },
(_ctx, source) => { source.battle.winner = null; },
(_ctx, source) => { source.battle.winner = 'b'; },
(_ctx, source) => { source.battle.result = 'mutualTraitors'; },
(_ctx, source) => { source.battle.result = 'explosion'; },
(ctx) => { ctx.players = [...ctx.players, { ...ctx.players[1] }]; },
(ctx) => { ctx.players[1].forces = { 'imperial_basin:9': 1 }; },
(_ctx, source) => { source.forces = { 'imperial_basin:9': 3 }; },
];
for (const change of changes) {
const { ctx, source } = battleResult();
change(ctx, source);
const before = structuredClone({ ctx, source });
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
assert.deepEqual({ ctx, source }, before);
}
const { ctx, source } = battleResult();
for (const malformed of [
{ ...source, battle: null },
{ ...source, battle: { ...source.battle, result: 'legacy' } },
{ ...source, battle: { ...source.battle, winner: 1 } },
{ ...source, battle: { ...source.battle, combatants: ['a'] } },
{ ...source, battle: { ...source.battle, combatants: ['a', 'r', 'b'] } },
]) {
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as unknown as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
}
});
void test('a BG combatant or an explosion with no remaining physical advisor group has no valid bystander conversion', () => {
const combatant = battleResult();
combatant.source.battle.combatants = ['b', 'r'];
combatant.source.battle.winner = 'r';
combatant.ctx.lastBattleContext = { ...combatant.source.battle, combatants: ['b', 'r'] };
assert.throws(() => validateAutomaticAdvisorFlip(combatant.ctx, combatant.source), AutomaticAdvisorFlipError);
const exploded = battleResult('explosion');
assert.equal(exploded.source.battle.winner, null);
for (const player of exploded.ctx.players) player.forces = {};
assert.deepEqual(quoteAutomaticAdvisorFlips(exploded.ctx), []);
assert.throws(() => validateAutomaticAdvisorFlip(exploded.ctx, exploded.source), AutomaticAdvisorFlipError);
});
void test('battle source and receipt layers reject private cards, skills, casualty choices and continuation payloads', () => {
const { ctx, source } = battleResult();
for (const field of ['cards', 'skills', 'casualtyChoices', 'continuation']) {
for (const malformed of [
{ ...source, [field]: 'private-payload' },
{ ...source, battle: { ...source.battle, [field]: 'private-payload' } },
]) {
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
}
}
});
void test('phase-six battle conversion does not widen the old movement, transport or Gamont producer windows', () => {
for (const original of [held(), transported(), gamont(false)]) {
const before = JSON.stringify(original.source);
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
assert.equal(JSON.stringify(original.source), before);
original.ctx.phase = 6;
original.source.phase = 6;
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
}
for (const phase of [5, 8]) {
const { ctx, source } = battleResult();
ctx.phase = phase;
source.phase = phase;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
}
});
void test('battle validation reads only its six public context fields and the current physical group', () => {
const { ctx, source } = battleResult();
for (const player of ctx.players) {
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'moved', 'shipped', 'reserves']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Battle advisor rule read ${player.id} private or unrelated ${field}`); },
});
}
}
for (const field of ['cardRoles', 'mandatoryDiscard', 'winnerDiscards', 'skills', 'casualtyChoices', 'continuation']) {
Object.defineProperty(ctx.lastBattleContext, field, {
enumerable: true,
get() { throw new Error(`Battle advisor rule read private context ${field}`); },
});
}
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
ctx.battle = false;
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
});
+128
View File
@@ -0,0 +1,128 @@
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 { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createAdvisorBattleFlipFixture as fixture, actAdvisorBattleFlip as act,
commitAdvisorBattleFlip as commit, finishAdvisorBattleFlip as finish,
advisorBattleFlipPlayer as player, advisorBattleFlipInventory as inventory,
advisorBattleFlipTraitors as traitors,
} from './fixture-advisor-battle-flip';
function allow(source: Game) {
let game = source;
for (let count = 0; game.response?.automaticAdvisorFlip && count < 30; count++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = act(game, actor.id, { type: 'passResponse' });
}
assert.ok(!game.response?.automaticAdvisorFlip); return game;
}
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, battleLosses: p.battleLosses, leaders: p.leaders }));
for (const mutualTraitors of [false, true]) for (const canceled of [false, true]) {
void test(`native ${mutualTraitors ? 'mutual traitors' : 'winner dial-all'} ${canceled ? 'prevention' : 'allowance'} preserves committed losses and resumes original cleanup once`, () => {
const f = fixture({ mutualTraitors }), original = structuredClone(f.game);
const pending = commit(f.game, f).game;
assert.deepEqual(f.game, original);
assert.equal(pending.phase, 6); assert.equal(pending.battle, null);
assert.equal(pending.response?.kind, 'advisorFlip');
const source = pending.response?.automaticAdvisorFlip; assert.ok(source?.producer === 'battleLosses');
assert.equal(source.battle.event, f.event);
assert.equal(source.battle.result, mutualTraitors ? 'mutualTraitors' : 'normal');
assert.equal(source.battle.winner, mutualTraitors ? null : f.atreides);
assert.deepEqual(Object.keys(source.battle).sort(), ['combatants', 'event', 'result', 'territory', 'turn', 'winner']);
assert.equal(pending.pendingTreacheryDiscard == null, true);
assert.equal(pending.pendingWinnerDiscards == null, true);
assert.equal(pending.decision, null);
assert.equal(player(pending, f.atreides).forces[f.location], undefined);
assert.equal(player(pending, f.harkonnen).forces[f.location], undefined);
assert.equal(player(pending, f.atreides).tanks, player(original, f.atreides).tanks + 1);
assert.equal(player(pending, f.harkonnen).tanks, player(original, f.harkonnen).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.discard.filter(c => c.id === f.shield).length, mutualTraitors ? 1 : 0);
assert.ok(viewGame(pending, f.holder).responseControls?.cancelCards.includes(f.karama));
assert.ok(!JSON.stringify(viewGame(pending, f.holder).response).includes('resume'));
const parent = pending.pendingAutomaticAdvisorBattle!;
assert.equal(parent.resume?.kind, mutualTraitors ? 'aftermath' : 'winner');
if (parent.resume?.kind === 'winner') assert.deepEqual(parent.resume.cards, [f.shield]);
rejected(pending, f.atreides, { type: 'decision', discard: [] });
for (let read = 0; read < 3; read++) {
const stable = normalizeAutomaticGame(pending);
assert.deepEqual(stable.pendingAutomaticAdvisorBattle, parent);
assert.deepEqual(physical(stable), physical(pending));
assert.equal(isAdvisor(player(stable, f.bg), f.territory), true);
}
const committed = physical(pending), bgSpice = player(pending, f.bg).spice;
const done = canceled
? act(pending, f.holder, { type: 'card', card: f.karama, mode: 'cancel' })
: allow(pending);
assert.equal(done.pendingAutomaticAdvisorBattle, undefined);
assert.equal(done.pendingAutomaticAdvisorFlips, undefined);
assert.deepEqual(physical(done), committed);
assert.equal(isAdvisor(player(done, f.bg), f.territory), canceled);
if (!mutualTraitors) {
assert.deepEqual(done.decision, { kind: 'battleCards', player: f.atreides,
territory: f.territory, cards: [f.shield] });
assert.ok(player(done, f.atreides).hand.some(c => c.id === f.shield));
}
const collected = finish(done);
assert.equal(collected.phase, 7);
assert.equal(player(collected, f.bg).spice, bgSpice + (canceled ? 0 : 2));
assert.deepEqual(physical(collected), committed);
assert.equal(collected.discard.filter(c => c.id === f.shield).length, mutualTraitors ? 1 : 0);
assert.equal(collected.discard.filter(c => c.id === f.karama).length, canceled ? 1 : 0);
assert.deepEqual(inventory(collected), f.originalInventory);
assert.deepEqual(traitors(collected), f.originalTraitors);
assert.equal(isAdvisor(player(normalizeAutomaticGame(collected), f.bg), f.territory), canceled);
});
}
void test('changing the original public battle parent or private cleanup cursor rejects before spending Karama', () => {
const f = fixture(), pending = commit(f.game, f).game;
for (const kind of ['receipt', 'cursor'] as const) {
const invalid = structuredClone(pending);
if (kind === 'receipt') invalid.pendingAutomaticAdvisorBattle!.receipt.event = 'different-battle';
else invalid.pendingAutomaticAdvisorBattle!.resume = { kind: 'aftermath' };
rejected(invalid, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
assert.ok(player(invalid, f.holder).hand.some(c => c.id === f.karama));
assert.equal(player(invalid, f.atreides).tanks, player(pending, f.atreides).tanks);
}
});
void test('a native Lasgun Shield explosion destroys the bystanding advisors without inventing a conversion', () => {
const f = fixture({ explosion: true }), done = commit(f.game, f).game;
assert.equal(done.lastBattleContext?.result, 'explosion');
assert.equal(done.lastBattleContext?.winner, null);
assert.equal(done.pendingAutomaticAdvisorBattle, 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);
const collected = finish(done);
assert.equal(player(collected, f.bg).spice, player(f.game, f.bg).spice);
assert.ok(player(collected, f.holder).hand.some(c => c.id === f.karama));
assert.equal(collected.discard.filter(c => c.id === f.shield).length, 1);
assert.equal(collected.discard.filter(c => c.id === f.extra).length, 1);
assert.deepEqual(inventory(collected), f.originalInventory);
});
void test('the four existing profiles resolve both committed battle conversion responses legally without replay', () => {
for (const mutualTraitors of [false, true]) for (const profile of DIFFICULTIES) {
const f = fixture({ mutualTraitors }), 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 = finish(allow(act(pending, f.holder, actions[0])));
assert.equal(done.pendingAutomaticAdvisorBattle, undefined);
assert.equal(done.phase, 7);
assert.deepEqual(physical(done), physical(pending));
assert.deepEqual(inventory(done), f.originalInventory);
}
});
+284
View File
@@ -0,0 +1,284 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import {
actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory,
advisorPreparationPlayer, nextAdvisorPreparationStep,
} from './fixture-advisor-preparation';
export type AdvisorBattleFlipStep = { actor: string; action: Action };
export type AdvisorBattleFlipTransition = { before: Game; steps: AdvisorBattleFlipStep[]; after: Game };
export const actAdvisorBattleFlip = actAdvisorPreparation;
export const advisorBattleFlipClean = advisorPreparationClean;
export const advisorBattleFlipInventory = advisorPreparationInventory;
export const advisorBattleFlipPlayer = advisorPreparationPlayer;
export const advisorBattleFlipTraitors = (game: Game) => {
const identities = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
[...player.traitors, ...player.traitorChoices])];
assert.equal(new Set(identities).size, identities.length, 'Each original physical Traitor has one current slot');
return identities.sort();
};
/** Native continuation preserves both caller-owned conversion and winner-card
* choices. No new advisor counter or optional winner discard is passed here. */
export function nextAdvisorBattleFlip(game: Game): AdvisorBattleFlipStep | null {
if (game.response?.automaticAdvisorFlip) return null;
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'battleCards') return null;
if (game.decision?.kind === 'advisorBattle')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
if (game.status === 'setup' && game.setupStage === 'traitors') {
const player = game.players.find(candidate => candidate.traitorChoices.length); assert.ok(player);
const leader = player.faction === 'atreides' && player.traitorChoices.includes('harkonnen-3')
? 'harkonnen-3' : player.traitorChoices[0];
return { actor: player.id, action: { type: 'traitor', leader } };
}
}
return nextAdvisorPreparationStep(game);
}
export function advanceAdvisorBattleFlip(game: Game, until: (state: Game) => boolean,
steps: AdvisorBattleFlipStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine battle chronology ended before its requested boundary');
const next = nextAdvisorBattleFlip(game);
assert.ok(next, `Choose the original private plan, conversion or winner cards at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actAdvisorBattleFlip(game, next.actor, next.action, steps);
}
throw Error('The native advisor battle source did not reach its finite boundary');
}
/** Pass ordinary original responses only; the mandatory conversion stays owned. */
export function passAdvisorBattleFlip(game: Game, steps: AdvisorBattleFlipStep[] = []): 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 = actAdvisorBattleFlip(game, actor.id, { type: 'passResponse' }, steps);
}
assert.ok(!game.response || game.response.automaticAdvisorFlip); return game;
}
/** Compute only original Fisher-Yates scalar draws, before any Traitor is
* dealt. The actual native prediction action performs the resulting shuffle. */
function traitorPermutation(original: string[], desired: string[]) {
assert.deepEqual([...original].sort(), [...desired].sort());
const current = [...original], scalars: number[] = [];
for (let index = current.length - 1; index > 0; index--) {
const from = current.indexOf(desired[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, desired); return scalars;
}
function predictWithOriginalTraitors(game: Game, actor: string, action: Action,
scalars: number[], steps: AdvisorBattleFlipStep[]): Game {
assert.equal(game.status, 'setup'); assert.equal(game.setupStage, 'prediction');
assert.ok(game.players.every(player => !player.traitors.length && !player.traitorChoices.length));
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = 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 Traitor shuffle consumes selected prediction entropy');
array[0] = scalars[cursor++];
} else Reflect.apply(original, crypto, [array]);
return array;
};
try {
const next = applyAction(game, actor, action);
assert.equal(cursor, scalars.length, 'The actual original Traitor shuffle uses its complete selected permutation');
steps.push({ actor, action: structuredClone(action) }); return next;
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Actual public Advanced BG/Atreides/Harkonnen base33. Atreides leaves one
* counter beside the original advisor; Harkonnen moves one native counter in.
* Stop immediately after choosing that real battle, before preparation/plans. */
export function createAdvisorBattleFlipFixture(options: { mutualTraitors?: boolean; explosion?: boolean } = {}) {
const bg = 'beneGesserit', atreides = 'atreides', harkonnen = 'harkonnen';
const mutualTraitors = options.mutualTraitors ?? false;
const explosion = options.explosion ?? false;
assert.ok(!mutualTraitors || !explosion, 'Select one original battle outcome');
let game = createGame('ADVISORBATTLEFLIP', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
joinGame(game, newPlayer(atreides, 'Atreides', 'atreides'));
joinGame(game, newPlayer(harkonnen, 'Harkonnen', 'harkonnen'));
const initial = structuredClone(game), steps: AdvisorBattleFlipStep[] = [];
const act = (actor: string, action: Action) => { game = actAdvisorBattleFlip(game, actor, action, steps); };
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorBattleFlip(game, until, steps); };
const transition = (run: () => void): AdvisorBattleFlipTransition => {
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.setupStage, 'prediction');
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 && !player.traitorChoices.length));
const originalInventory = advisorBattleFlipInventory(game); assert.equal(originalInventory.length, 33);
const karama = game.deck.find(card => card.effect === 'karama');
const shield = game.deck.find(card => card.kind === 'shield');
const ordinary = game.deck.find(card => card.kind === 'projectile');
const extra = game.deck.find(card => card.kind === (explosion ? 'lasgun' : 'poison')); assert.ok(karama && shield && ordinary && extra);
const selected = [ordinary, shield, karama, extra];
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 beforeTraitorDeal = structuredClone(game);
const originalTraitors = game.players.flatMap(player => player.leaders.map(leader => leader.id)).sort();
const traitorOrder = game.players.flatMap(player => player.leaders.map(leader => leader.id));
if (mutualTraitors) {
const iakin = traitorOrder.indexOf('harkonnen-3'), thufir = traitorOrder.indexOf('atreides-0');
assert.ok(iakin >= 0 && thufir >= 0);
[traitorOrder[4], traitorOrder[iakin]] = [traitorOrder[iakin], traitorOrder[4]];
[traitorOrder[8], traitorOrder[thufir]] = [traitorOrder[thufir], traitorOrder[8]];
}
const traitorScalars = traitorPermutation(game.players.flatMap(player => player.leaders.map(leader => leader.id)), traitorOrder);
const traitorDeal = transition(() => {
game = predictWithOriginalTraitors(game, bg, { type: 'predict', faction: 'atreides', turn: 10 }, traitorScalars, steps);
});
const beforeStartingDeal = structuredClone(game);
let advisorSetup: AdvisorBattleFlipTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextAdvisorBattleFlip(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(advisorBattleFlipPlayer(game, bg).forces['arrakeen:10'], 1);
assert.ok(advisorBattleFlipPlayer(game, bg).advisors?.arrakeen);
assert.equal(advisorBattleFlipPlayer(game, atreides).forces['arrakeen:10'], 10);
assert.equal(advisorBattleFlipPlayer(game, harkonnen).forces['carthag:11'], 10);
if (mutualTraitors) {
assert.ok(advisorBattleFlipPlayer(game, atreides).traitors.includes('harkonnen-3'));
assert.ok(advisorBattleFlipPlayer(game, harkonnen).traitors.includes('atreides-0'));
}
assert.deepEqual(advisorBattleFlipTraitors(game), originalTraitors);
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 && advisorBattleFlipClean(state));
});
act(bg, { type: 'endMovement' });
reach(state => state.phase === 5 && state.active === atreides && advisorBattleFlipClean(state));
const atreidesDeparture = transition(() => {
act(atreides, { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 10, amount: 9 });
reach(advisorBattleFlipClean);
});
act(atreides, { type: 'endMovement' });
reach(state => state.phase === 5 && state.active === harkonnen && advisorBattleFlipClean(state));
const harkonnenArrival = transition(() => {
act(harkonnen, { type: 'move', from: 'carthag:11', territory: 'arrakeen', sector: 10, amount: 1 });
reach(advisorBattleFlipClean);
});
reach(state => state.phase === 6 && advisorBattleFlipClean(state) && !!state.active);
const beforeBattle = structuredClone(game);
const choice = game.players.flatMap(player => viewGame(game, player.id).battleChoices)
.find(candidate => candidate.territory === 'arrakeen'); assert.ok(choice);
const battleStep: AdvisorBattleFlipStep = { actor: choice.chooser, action: {
type: 'chooseBattle', territory: 'arrakeen',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker,
} };
act(battleStep.actor, battleStep.action);
const beforePreparation = structuredClone(game);
assert.ok(game.battle && !game.battle.revealed && !Object.keys(game.battle.plans).length);
assert.equal(advisorBattleFlipPlayer(game, atreides).forces['arrakeen:10'], 1);
assert.equal(advisorBattleFlipPlayer(game, atreides).forces['imperial_basin:10'], 9);
assert.equal(advisorBattleFlipPlayer(game, harkonnen).forces['arrakeen:10'], 1);
assert.equal(advisorBattleFlipPlayer(game, harkonnen).forces['carthag:11'], 9);
assert.ok(advisorBattleFlipPlayer(game, bg).advisors?.arrakeen);
assert.ok(advisorBattleFlipPlayer(game, harkonnen).hand.some(card => card.id === karama.id));
assert.deepEqual(advisorBattleFlipInventory(game), originalInventory);
assert.deepEqual(advisorBattleFlipTraitors(game), originalTraitors);
const atreidesPlan: Action = { type: 'battlePlan', leader: 'atreides-0', dial: 1, support: 1, weapon: null, defense: shield.id };
const harkonnenPlan: Action = { type: 'battlePlan', leader: 'harkonnen-3', dial: 0, support: 0, weapon: explosion ? extra.id : null, defense: null };
const plans: AdvisorBattleFlipStep[] = [
{ actor: atreides, action: atreidesPlan }, { actor: harkonnen, action: harkonnenPlan },
];
return { game, source: structuredClone(game), initial, lobby, setup, beforeTraitorDeal, traitorDeal,
beforeStartingDeal, afterSetup, advisorSetup, openingOffer, declinedPreparation,
atreidesDeparture, holderDeparture: atreidesDeparture, harkonnenArrival,
beforeBattle, battleStep, beforePreparation,
bg, owner: bg, atreides, winner: atreides, harkonnen, holder: harkonnen, observer: harkonnen,
karama: karama.id, card: karama.id, shield: shield.id, ordinary: ordinary.id, extra: extra.id,
territory: 'arrakeen', sector: 10, location: 'arrakeen:10', event: game.battle.event,
mutualTraitors, explosion, atreidesPlan, harkonnenPlan, ownPlan: atreidesPlan, opponentPlan: harkonnenPlan, plans, planSteps: plans,
steps, actions: steps, sourceActionCount: steps.length,
originalInventory, originalTraitors, traitorOrder, traitorScalars, originalSpice, dealOrder, spiceOrder,
staging: [
'Actual public Advanced Bene Gesserit/Atreides/Harkonnen ready/startPreview with original base33 and no optional modules. Native prediction, private Traitor choices and advisorSetup place the original BG advisor beside Atreides’ ten Arrakeen10 counters.',
`Select only original scalar shuffle draws, four unopened Treachery positions and two unopened ordinary Spice positions. BG receives an ordinary weapon, Atreides Shield, and Harkonnen its actual two starting cards: Karama and ${explosion ? 'Lasgun' : 'an ordinary poison weapon'}. No hand is installed.`,
mutualTraitors
? 'During the actual prediction action, before any original Traitor is dealt, select the original 15-card Fisher-Yates permutation so Atreides is offered and actually chooses Iakin, while Harkonnen naturally keeps Thufir in its original four-card packet.'
: 'The original Traitor shuffle preserves identity order; BG and Atreides make actual private choices and Harkonnen keeps its actual four-card packet. Battle continuation declines all original Traitor calls.',
'Actually complete safe first Storm, both Spice blows, Charity, normal Bidding and Revival. Decline the original preparation offer and end BG movement. Atreides actually moves nine Arrakeen counters to Basin10, leaving one with the peaceful advisor; Harkonnen uses actual city range to move one original Carthag11 counter to Arrakeen10, leaving nine at home.',
`Choose the actual one-versus-one Arrakeen battle, then stop before preparation and either sealed plan. Returned plans use original living Thufir5 plus dial1/support1 and the real Shield against original living Iakin2/dial0/support0 with ${explosion ? 'the original Lasgun' : 'no cards'}. The held original Harkonnen Karama remains unspent.`,
'No played phase, army, wallet, Tanks, hand, leader status, stance, prevention, plan, casualty or receipt is installed. Battle resolution, mandatory advisor conversion and optional winner card cleanup remain actual caller-owned continuations.',
],
};
}
export type AdvisorBattleFlipFixture = ReturnType<typeof createAdvisorBattleFlipFixture>;
/** Perform actual preparation, original plans and the original Traitor votes.
* Stop before the new conversion response, the real Shield winner choice, or
* the completed native no-winner boundary on a pre-feature runtime. */
export function commitAdvisorBattleFlip(source: Game, fixture: AdvisorBattleFlipFixture,
steps: AdvisorBattleFlipStep[] = []) {
const before = structuredClone(source), start = steps.length;
let game = source;
for (let count = 0; count < 240; count++) {
if (game.response?.automaticAdvisorFlip || game.decision?.kind === 'battleCards' || !game.battle) {
assert.ok(advisorBattleFlipPlayer(game, fixture.holder).hand.some(card => card.id === fixture.karama));
assert.deepEqual(advisorBattleFlipInventory(game), fixture.originalInventory);
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)),
context: structuredClone(game.lastBattleContext), pendingDiscard: structuredClone(game.pendingTreacheryDiscard) };
}
if (advisorBattleFlipClean(game) && !game.battle.revealed && !game.battle.preparation &&
game.battle.preLeader?.closed !== false) {
const plan = fixture.plans.find(step => !game.battle!.plans[step.actor]); assert.ok(plan);
game = actAdvisorBattleFlip(game, plan.actor, plan.action, steps); continue;
}
if (advisorBattleFlipClean(game) && game.battle.revealed) {
const actor = viewGame(game, game.host).battle!.traitorVoters
.find(id => game.battle!.traitorCalls[id] === undefined);
if (actor) {
game = actAdvisorBattleFlip(game, actor, { type: 'traitorCall', call: fixture.mutualTraitors }, steps);
continue;
}
}
const next = nextAdvisorBattleFlip(game); assert.ok(next, 'Choose the original native battle control');
game = actAdvisorBattleFlip(game, next.actor, next.action, steps);
}
throw Error('The actual advisor battle did not reach its committed loss boundary');
}
/** Call after explicitly resolving the conversion. Keep the real optional
* winner Shield, allow mandatory discard/aftermath, and reach Collection or
* Mentat through native controls. This never answers a conversion response. */
export function finishAdvisorBattleFlip(source: Game, steps: AdvisorBattleFlipStep[] = []): Game {
let game = source;
for (let count = 0; count < 300; count++) {
if (!game.battle && game.phase >= 7 && advisorBattleFlipClean(game)) return game;
assert.ok(!game.response?.automaticAdvisorFlip, 'Resolve the original advisor-conversion choice before finishing');
if (game.decision?.kind === 'battleCards' && !game.response && !game.phaseOpening) {
game = actAdvisorBattleFlip(game, game.decision.player, { type: 'decision', discard: [] }, steps); continue;
}
const next = nextAdvisorBattleFlip(game); assert.ok(next, 'Complete the original owned battle before Collection');
game = actAdvisorBattleFlip(game, next.actor, next.action, steps);
}
throw Error('The original advisor battle continuation did not reach Collection');
}
+2
View File
@@ -3,6 +3,7 @@ import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPla
import { botActions } from '../game/bots';
import { isAuditorLeader } from '../game/cards';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
export type SemutaBattleKaramaKind = 'capture' | 'choamAudit' | 'choamBattleIncome';
export const reloadBattleKarama = (game: Game): Game => JSON.parse(JSON.stringify(game));
@@ -114,6 +115,7 @@ export function semutaBattleKaramaFixture(kind: SemutaBattleKaramaKind) {
game = applyAction(game, loser, { type: 'battlePlan', dial: 0, support: 0,
leader: loserLeader.id, defense: defense.id });
for (const id of [winner, loser]) game = applyAction(game, id, { type: 'traitorCall', call: false });
game = finishSemutaBattleOutcome(game);
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const earlierEvents = [game.pendingTreacheryDiscard!.batch.event];
game = passBattleKaramaOffer(game);
@@ -5,7 +5,7 @@ import { splitLocation } from '../game/board';
import { nativeShipmentSources } from '../game/homeworld-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
createSemutaHomeworldChoamSetup } from './fixture-semuta-homeworld-choam';
createSemutaHomeworldChoamSetup, finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
import { moveSemutaEmperorCard, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
@@ -127,7 +127,8 @@ export function createSemutaHomeworldLoserFixture(advanced = false, fullHolder =
export function disposeSemutaHomeworldLoser(f: SemutaHomeworldLoserFixture,
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
return applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
const game = applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
return finishSemutaBattleOutcome(game, actions);
}
/** Keep the winner's held played cards, then use original income/market controls
@@ -4,7 +4,8 @@ import { nativeShipmentSources } from '../game/homeworld-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaEmperorPlayer as player, moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
createSemutaHomeworldChoamSetup, finishSemutaBattleOutcome,
holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
export type SemutaHomeworldWinnerKind = 'mandatory' | 'selected';
@@ -117,6 +118,7 @@ export function createSemutaHomeworldWinnerFixture(
let action = finalResponse;
if (kind === 'selected') {
game = applySemutaHomeworldChoamStep(game, voters[1], finalResponse, actions);
game = finishSemutaBattleOutcome(game, actions);
if (game.pendingTreacheryDiscard) {
earlierEvents.push(game.pendingTreacheryDiscard.batch.event);
game = passOffer(game, actions);
@@ -134,6 +136,7 @@ export function createSemutaHomeworldWinnerFixture(
export function disposeSemutaHomeworldWinner(f: SemutaHomeworldWinnerFixture,
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
let game = applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
game = finishSemutaBattleOutcome(game, actions);
if (f.kind === 'mandatory' && game.pendingTreacheryDiscard?.continuation.kind === 'battleResolved')
game = passOffer(game, actions);
return game;
+14
View File
@@ -29,6 +29,20 @@ export function holdSemutaHomeworldChoamCard(game: Game, owner: string, name: st
return moveSemutaEmperorCard(game, card.id, owner);
}
/** Acknowledge only the actual final battle window; leave fresh discards and
* every subsequent card, casualty or aftermath choice to the caller. */
export function finishSemutaBattleOutcome(state: Game, actions?: RicheseSkillsTechStep[]): Game {
let game = state;
while (game.battle?.outcomeWindow && !game.battle.outcomeWindow.closed) {
const window = game.battle.outcomeWindow;
const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id));
assert.ok(actor);
game = applySemutaHomeworldChoamStep(game, actor,
{ type: 'battleOutcomeReady', event: window.event }, actions);
}
return game;
}
/** Caller tests the requested boundary before each real native action. */
export function advanceSemutaHomeworldChoam(
state: Game, until: (game: Game) => boolean, actions?: RicheseSkillsTechStep[],
+2 -2
View File
@@ -6,7 +6,7 @@ import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { finishSemutaBattleOutcome, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
import {
createSemutaHomeworldWinnerFixture, disposeSemutaHomeworldWinner,
@@ -234,7 +234,7 @@ for (const advanced of [false, true]) for (const kind of ['mandatory', 'selected
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} original losing, mandatory winning and selected cleanup are distinct fresh events`, () => {
const f = createSemutaHomeworldWinnerFixture('mandatory', advanced);
let game = applyAction(f.game, f.actor, f.action);
let game = finishSemutaBattleOutcome(applyAction(f.game, f.actor, f.action));
const losingEvent = game.pendingTreacheryDiscard!.batch.event;
assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.worthless]);
assert.equal(player(game, f.owner).hand.some(c => c.id === f.card), true,