Resolve advisor conversion after Ambassador and Discovery transfers
Preserve original transfer and arrival cursors, admit no new Discovery advisor sources, and offer one mandatory counter for empty Ambassador advisor arrivals.
This commit is contained in:
1 parent
d66b594770
commit
abf0305ddf
12 files changed
+996
-18
No files matched your search
@@ -14,6 +14,7 @@ import {
|
||||
type AutomaticAdvisorHomeworldTransport,
|
||||
type AutomaticAdvisorFaceDance,
|
||||
type AutomaticAdvisorStorm,
|
||||
type AutomaticAdvisorRelocation,
|
||||
} from '../game/advisor-automatic-flip';
|
||||
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
|
||||
|
||||
@@ -24,6 +25,7 @@ type BattleSource = Extract<AutomaticAdvisorFlipSource, { producer: 'battleLosse
|
||||
type HomeworldSource = Extract<AutomaticAdvisorFlipSource, { producer: 'homeworldTransport' }>;
|
||||
type FaceDanceSource = Extract<AutomaticAdvisorFlipSource, { producer: 'faceDance' }>;
|
||||
type StormSource = Extract<AutomaticAdvisorFlipSource, { producer: 'storm' }>;
|
||||
type RelocationSource = Extract<AutomaticAdvisorFlipSource, { producer: 'specialRelocation' }>;
|
||||
|
||||
function context(): AutomaticAdvisorFlipContext {
|
||||
return {
|
||||
@@ -74,7 +76,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' }, { status: 'setup', phase: 0 }, { phase: 1 }, { phase: 4 }, { phase: 7 },
|
||||
{ advanced: false }, { status: 'lobby' }, { status: 'setup', phase: 0 }, { phase: 2 }, { phase: 4 }, { phase: 7 },
|
||||
]) {
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips({ ...context(), ...patch }), []);
|
||||
}
|
||||
@@ -1172,3 +1174,188 @@ void test('storm stays phase-zero-only while every existing producer keeps its o
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
|
||||
}
|
||||
});
|
||||
|
||||
function relocation(kind: AutomaticAdvisorRelocation['kind'] = 'ambassador', phase = kind === 'discovery' ? 0 : 5) {
|
||||
const { ctx, source: ground } = held();
|
||||
ctx.phase = phase;
|
||||
ctx.players[0].forces = { 'imperial_basin:9': 2, 'imperial_basin:10': 1, 'arrakeen:10': 1 };
|
||||
ctx.players[1].faction = kind === 'discovery' ? 'fremen' : 'atreides';
|
||||
ctx.players[1].forces = { 'carthag:11': 1 };
|
||||
const receipt: AutomaticAdvisorRelocation = {
|
||||
event: 'completed-public-special-relocation', kind, player: 'a',
|
||||
origin: 'imperial_basin', destination: 'carthag', sector: 11,
|
||||
};
|
||||
const source: RelocationSource = {
|
||||
event: 'automatic-after-original-special-relocation', owner: ground.owner,
|
||||
territory: ground.territory, turn: ground.turn, phase,
|
||||
forces: ground.forces, producer: 'specialRelocation', relocation: receipt,
|
||||
};
|
||||
ctx.players[0].advisors = {
|
||||
...ctx.players[0].advisors,
|
||||
imperial_basin: { lockedTurn: 4, aloneFlipHold: source.event },
|
||||
};
|
||||
return { ctx, source };
|
||||
}
|
||||
|
||||
void test('Ambassador relocations retain exact six-field public receipts in all supported source phases and allow a BG arrival group', () => {
|
||||
for (const phase of [1, 3, 5, 8]) {
|
||||
const original = relocation('ambassador', phase);
|
||||
const before = structuredClone(original);
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
|
||||
assert.deepEqual(original, before);
|
||||
assert.deepEqual(Object.keys(original.source).sort(), ['event', 'forces', 'owner', 'phase', 'producer', 'relocation', 'territory', 'turn']);
|
||||
assert.deepEqual(Object.keys(original.source.relocation).sort(), ['destination', 'event', 'kind', 'origin', 'player', 'sector']);
|
||||
}
|
||||
const arrival = relocation();
|
||||
arrival.source.relocation.player = 'b';
|
||||
arrival.source.relocation.destination = 'hagga_basin';
|
||||
arrival.source.relocation.sector = 12;
|
||||
arrival.source.territory = 'hagga_basin';
|
||||
arrival.source.forces = { 'hagga_basin:12': 2 };
|
||||
arrival.ctx.players[0].forces = { 'hagga_basin:12': 2 };
|
||||
arrival.ctx.players[0].advisors = { hagga_basin: { aloneFlipHold: arrival.source.event } };
|
||||
arrival.ctx.players[1].forces = {};
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(arrival.ctx, arrival.source));
|
||||
});
|
||||
|
||||
void test('Discovery relocation uses actual revealed interiors and the room board admits a placed HMS sector zero', () => {
|
||||
const discovery = relocation('discovery');
|
||||
discovery.ctx.discoveries = {
|
||||
tokens: [{ face: 'cistern', status: 'placed', territory: 'hagga_basin', sector: 12, revealedTurn: 1 }],
|
||||
};
|
||||
discovery.source.relocation.destination = 'cistern';
|
||||
discovery.source.relocation.sector = 0;
|
||||
discovery.ctx.players[1].forces = { 'cistern:0': 1 };
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(discovery.ctx, discovery.source));
|
||||
discovery.ctx.discoveries.tokens[0].revealedTurn = null;
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(discovery.ctx, discovery.source), AutomaticAdvisorFlipError);
|
||||
discovery.ctx.discoveries.tokens[0].revealedTurn = 1;
|
||||
discovery.source.relocation.origin = 'cistern';
|
||||
discovery.source.relocation.destination = 'carthag';
|
||||
discovery.source.relocation.sector = 11;
|
||||
discovery.source.territory = 'cistern';
|
||||
discovery.source.forces = { 'cistern:0': 2 };
|
||||
discovery.ctx.players[0].forces = { 'cistern:0': 2 };
|
||||
discovery.ctx.players[0].advisors = { cistern: { aloneFlipHold: discovery.source.event } };
|
||||
discovery.ctx.players[1].forces = { 'carthag:11': 1 };
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(discovery.ctx, discovery.source));
|
||||
const mobile = relocation();
|
||||
mobile.ctx.players[1].faction = 'ixians';
|
||||
mobile.ctx.mobileStronghold = { location: 'imperial_basin:9' };
|
||||
mobile.source.relocation.destination = MOBILE_STRONGHOLD;
|
||||
mobile.source.relocation.sector = 0;
|
||||
mobile.ctx.players[1].forces = { [MOBILE_LOCATION]: 1 };
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(mobile.ctx, mobile.source));
|
||||
mobile.ctx.mobileStronghold.location = null;
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(mobile.ctx, mobile.source), AutomaticAdvisorFlipError);
|
||||
});
|
||||
|
||||
void test('a relocated concealed No-Field supplies effective destination presence without exposing its denomination or identity', () => {
|
||||
const { ctx, source } = relocation();
|
||||
source.relocation.player = 'r';
|
||||
ctx.players[2].forces = {};
|
||||
ctx.players[2].noField = { deployed: { location: { territory: 'carthag', sector: 11 } } };
|
||||
for (const field of ['tokens', 'lastUsed', 'lastShipped', 'value']) {
|
||||
Object.defineProperty(ctx.players[2].noField, field, {
|
||||
get() { throw new Error(`Special relocation read hidden No-Field ${field}`); },
|
||||
});
|
||||
}
|
||||
for (const field of ['tokenId', 'value']) {
|
||||
Object.defineProperty(ctx.players[2].noField.deployed, field, {
|
||||
get() { throw new Error(`Special relocation read hidden deployed No-Field ${field}`); },
|
||||
});
|
||||
}
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
|
||||
ctx.players[2].noField.deployed = null;
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
|
||||
});
|
||||
|
||||
void test('special relocation rejects changed actor, current board locations, source binding, exact held group and private payloads', () => {
|
||||
const changes: ((ctx: AutomaticAdvisorFlipContext, source: RelocationSource) => void)[] = [
|
||||
(_ctx, source) => { source.relocation.event = ''; },
|
||||
(_ctx, source) => { source.relocation.player = 'missing'; },
|
||||
(ctx) => { ctx.players = [...ctx.players, { ...ctx.players[1] }]; },
|
||||
(_ctx, source) => { source.relocation.origin = 'missing'; },
|
||||
(_ctx, source) => { source.relocation.origin = 'homeworld:guild'; },
|
||||
(_ctx, source) => { source.relocation.destination = 'homeworld:atreides'; },
|
||||
(_ctx, source) => { source.relocation.destination = 'missing'; },
|
||||
(_ctx, source) => { source.relocation.destination = 'carthag:11'; },
|
||||
(_ctx, source) => { source.relocation.sector = -1; },
|
||||
(_ctx, source) => { source.relocation.sector = 11.5; },
|
||||
(_ctx, source) => { source.relocation.sector = 10; },
|
||||
(_ctx, source) => { source.relocation.origin = 'hagga_basin'; },
|
||||
(ctx) => { ctx.players[1].forces = {}; },
|
||||
(ctx) => { ctx.players[1].forces = { 'arrakeen:10': 1 }; },
|
||||
(ctx) => { ctx.players[0].forces = {}; },
|
||||
(_ctx, source) => { source.forces = { 'imperial_basin:9': 3 }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const original = relocation();
|
||||
change(original.ctx, original.source);
|
||||
const before = structuredClone(original);
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
|
||||
assert.deepEqual(original, before);
|
||||
}
|
||||
const { ctx, source } = relocation();
|
||||
for (const field of ['group', 'noField', 'tokenId', 'order', 'skills', 'signature', 'amount']) {
|
||||
for (const malformed of [
|
||||
{ ...source, [field]: 'private-original-state' },
|
||||
{ ...source, relocation: { ...source.relocation, [field]: 'private-original-state' } },
|
||||
]) {
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
|
||||
}
|
||||
}
|
||||
for (const malformed of [
|
||||
{ ...source, relocation: null },
|
||||
{ ...source, relocation: { ...source.relocation, kind: 'worm' } },
|
||||
{ ...source, relocation: { ...source.relocation, sector: '11' } },
|
||||
]) {
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as unknown as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('relocation kind controls its own phases without widening any existing producer receipt or phase', () => {
|
||||
for (const original of [held(), transported(), gamont(false), battleResult(), homeworldTransport(), faceDance(), storm()]) {
|
||||
const before = JSON.stringify(original.source);
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
|
||||
assert.equal(JSON.stringify(original.source), before);
|
||||
for (const phase of [1, 3]) {
|
||||
original.ctx.phase = phase;
|
||||
original.source.phase = phase;
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
|
||||
}
|
||||
}
|
||||
for (const phase of [0, 2, 4, 6, 7]) {
|
||||
const original = relocation('ambassador', phase);
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
|
||||
}
|
||||
for (const phase of [1, 3, 5, 6, 8]) {
|
||||
const original = relocation('discovery', phase);
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
|
||||
}
|
||||
for (const phase of [1, 3]) {
|
||||
const quiet = context();
|
||||
quiet.phase = phase;
|
||||
assert.equal(quoteAutomaticAdvisorFlips(quiet).some((entry) => entry.territory === 'imperial_basin'), true);
|
||||
quiet.status = 'setup';
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips(quiet), []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('completed relocation validation never reads private parent signatures, hands, skills or original allowances', () => {
|
||||
for (const original of [relocation(), relocation('discovery')]) {
|
||||
for (const player of original.ctx.players) {
|
||||
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'reserves', 'moved', 'shipped', 'skills']) {
|
||||
Object.defineProperty(player, field, {
|
||||
get() { throw new Error(`Special relocation read ${player.id} private or original ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const field of ['pendingAmbassador', 'pendingDiscoveryRelocation', 'pendingAutomaticAdvisorRelocation', 'movementOrder', 'leaderSkills']) {
|
||||
Object.defineProperty(original.ctx, field, {
|
||||
get() { throw new Error(`Special relocation read private parent ${field}`); },
|
||||
});
|
||||
}
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { fighterCount, isAdvisor } from '../game/advisors';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { canceledResponseSource } from '../game/karama-context';
|
||||
import { createAdvisorStormFlipFixture, commitAdvisorStormFlip } from './fixture-advisor-storm-flip';
|
||||
import {
|
||||
createAdvisorAmbassadorFlipFixture, createAdvisorDiscoveryFlipFixture,
|
||||
actAdvisorRelocationFlip as act, advisorRelocationFlipPlayer as player,
|
||||
advisorRelocationFlipInventory as inventory, finishAdvisorRelocationFlip as finish,
|
||||
commitAdvisorRelocationFlip as commit,
|
||||
} from './fixture-advisor-relocation-flip';
|
||||
|
||||
const fixtures = [
|
||||
['ambassador', createAdvisorAmbassadorFlipFixture],
|
||||
['discovery', createAdvisorDiscoveryFlipFixture],
|
||||
] as const;
|
||||
function allow(game: Game) {
|
||||
for (let n = 0; game.response?.automaticAdvisorFlip && n < 30; n++) {
|
||||
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;
|
||||
}
|
||||
const physical = (game: Game) => game.players.map(p => ({ id:p.id, forces:p.forces,
|
||||
reserves:p.reserves, tanks:p.tanks, spice:p.spice, shipped:p.shipped, moved:p.moved }));
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('special relocation does not add a field to the saved source shape of an existing storm counter',()=>{
|
||||
const f=createAdvisorStormFlipFixture(),pending=commitAdvisorStormFlip(f.game,f).game;
|
||||
assert.ok(pending.response);
|
||||
const selected=canceledResponseSource(pending,pending.response) as {source:Record<string,unknown>};
|
||||
assert.deepEqual(Object.keys(selected.source).sort(),['battle','faceDance','gamont','group','homeworld','pending','source','storm','transport']);
|
||||
assert.equal(JSON.stringify(selected).includes('"relocation":'),false);
|
||||
});
|
||||
|
||||
for (const [kind, fixture] of fixtures) for (const prevented of [false,true]) {
|
||||
void test(`native ${kind} ${prevented ? 'prevention' : 'allowance'} keeps the completed transfer and resumes original arrival once`, () => {
|
||||
const f = fixture(), before = structuredClone(f.game), pending = commit(f.game,f).game;
|
||||
assert.deepEqual(f.game,before);
|
||||
const source = pending.response?.automaticAdvisorFlip; assert.ok(source?.producer === 'specialRelocation');
|
||||
assert.equal(source.relocation.kind,kind); assert.equal(source.territory,f.origin);
|
||||
assert.equal(source.relocation.player,f.mover); assert.equal(source.relocation.destination,f.destination);
|
||||
assert.deepEqual(Object.keys(source.relocation).sort(),['destination','event','kind','origin','player','sector']);
|
||||
assert.equal(fighterCount(player(pending,f.bg),f.origin),0);
|
||||
assert.equal(player(pending,f.bg).forces[f.location],1);
|
||||
assert.equal(player(pending,f.mover).forces[f.from],undefined);
|
||||
const amount = Object.entries(player(before,f.mover).forces).filter(([key]) => splitLocation(key).territory === f.origin)
|
||||
.reduce((sum,[,count]) => sum+count,0);
|
||||
assert.ok(amount > 0);
|
||||
assert.equal(player(pending,f.mover).forces[f.destinationLocation],(player(before,f.mover).forces[f.destinationLocation]??0)+amount);
|
||||
assert.deepEqual(pending.players.map(p => [p.spice,p.reserves,p.tanks,p.shipped,p.moved]),
|
||||
before.players.map(p => [p.spice,p.reserves,p.tanks,p.shipped,p.moved]));
|
||||
if (kind === 'ambassador') assert.equal(pending.pendingAmbassador?.relocation?.next,'flip');
|
||||
else assert.equal(pending.discoveryEntry?.stage,'arrival');
|
||||
assert.ok(viewGame(pending,f.holder).responseControls?.cancelCards.includes(f.karama));
|
||||
assert.ok(!JSON.stringify(viewGame(pending,f.holder).response).includes('signature'));
|
||||
const committed = physical(pending), stable = normalizeAutomaticGame(pending);
|
||||
assert.deepEqual(physical(stable),committed);
|
||||
assert.deepEqual(stable.pendingAutomaticAdvisorRelocation,pending.pendingAutomaticAdvisorRelocation);
|
||||
let done = prevented ? act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}) : allow(pending);
|
||||
done = finish(done);
|
||||
assert.equal(done.pendingAutomaticAdvisorRelocation,undefined);
|
||||
assert.equal(done.pendingAutomaticAdvisorFlips,undefined);
|
||||
assert.ok(!done.pendingAmbassador); assert.equal(done.discoveryEntry,undefined);
|
||||
assert.equal(isAdvisor(player(done,f.bg),f.origin),prevented);
|
||||
assert.equal(fighterCount(player(done,f.bg),f.origin),prevented?0:1);
|
||||
assert.deepEqual(physical(done),committed);
|
||||
assert.equal(done.discard.filter(c => c.id === f.karama).length,Number(prevented));
|
||||
assert.deepEqual(inventory(done),f.originalInventory);
|
||||
assert.deepEqual(physical(normalizeAutomaticGame(done)),committed);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [kind, fixture] of fixtures) void test(`${kind} rejects a changed private continuation before spending the conversion card`, () => {
|
||||
const f=fixture(),pending=commit(f.game,f).game;
|
||||
pending.pendingAutomaticAdvisorRelocation!.signature='different-original-relocation';
|
||||
reject(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
assert.ok(player(pending,f.holder).hand.some(c=>c.id===f.karama));
|
||||
});
|
||||
|
||||
void test('declining original native Discovery entry keeps the mixed parent group without a conversion',()=>{
|
||||
const f=createAdvisorDiscoveryFlipFixture(),before=physical(f.game);
|
||||
const done=finish(act(f.game,f.actor,{type:'decision',event:f.event,accept:false}));
|
||||
assert.equal(done.pendingAutomaticAdvisorRelocation,undefined);assert.equal(done.pendingAutomaticAdvisorFlips,undefined);
|
||||
assert.equal(done.discoveryEntry,undefined);assert.deepEqual(physical(done),before);
|
||||
assert.equal(isAdvisor(player(done,f.bg),f.origin),true);
|
||||
assert.ok(player(done,f.holder).hand.some(c=>c.id===f.karama));assert.deepEqual(inventory(done),f.originalInventory);
|
||||
});
|
||||
|
||||
for (const prevented of [false,true]) void test(`controlled allied BG empty Ambassador arrival ${prevented?'prevention':'allowance'} has one mandatory flip and no optional retry`, () => {
|
||||
const f=createAdvisorAmbassadorFlipFixture(),source=structuredClone(f.game);
|
||||
// Borrow original cards, pieces, consumed token and entrant from the native
|
||||
// source, but stage alliance/beneficiary selection explicitly. This is not
|
||||
// evidence of a native earned alliance journey.
|
||||
const owner=source.pendingAmbassador!.owner;
|
||||
player(source,owner).ally=f.bg; player(source,f.bg).ally=owner;
|
||||
source.pendingAmbassador!.beneficiary=f.bg; source.decision!.player=f.bg;
|
||||
const action:Action={...f.action,forces:{[f.location]:1},eliteForces:{},fighters:true};
|
||||
const pending=act(source,f.bg,action),automatic=pending.response?.automaticAdvisorFlip;
|
||||
assert.ok(automatic?.producer==='specialRelocation');
|
||||
assert.equal(automatic.territory,f.destination); assert.equal(automatic.owner,f.bg);
|
||||
assert.equal(pending.pendingAmbassador?.relocation?.order.wantsFighters,false);
|
||||
assert.equal(pending.pendingAmbassador?.relocation?.order.advisors,false);
|
||||
assert.equal(isAdvisor(player(pending,f.bg),f.destination),true);
|
||||
assert.equal(fighterCount(player(pending,f.bg),f.destination),0);
|
||||
const committed=physical(pending);
|
||||
const done=finish(prevented?act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}):allow(pending));
|
||||
assert.ok(!done.pendingAmbassador); assert.equal(done.pendingAutomaticAdvisorRelocation,undefined);
|
||||
assert.equal(done.response?.kind==='advisorFlip',false,'An empty arrival cannot immediately retry the prevented occurrence.');
|
||||
assert.equal(isAdvisor(player(done,f.bg),f.destination),prevented);
|
||||
assert.deepEqual(physical(done),committed); assert.deepEqual(inventory(done),f.originalInventory);
|
||||
});
|
||||
|
||||
void test('all four existing profiles resolve native Ambassador and Discovery conversion responses legally',()=>{
|
||||
for(const [,fixture] of fixtures) for(const profile of DIFFICULTIES){
|
||||
const f=fixture(),pending=commit(f.game,f).game,own=viewGame(pending,f.holder);
|
||||
own.players.find(p=>p.id===f.holder)!.bot=profile;
|
||||
const actions=botActions(own);assert.ok(actions.length);
|
||||
const done=finish(allow(act(pending,f.holder,actions[0])));
|
||||
assert.equal(done.pendingAutomaticAdvisorRelocation,undefined);
|
||||
assert.deepEqual(physical(done),physical(pending));assert.deepEqual(inventory(done),f.originalInventory);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import {
|
||||
advisorPreparationClean, advisorPreparationInventory, advisorPreparationPlayer,
|
||||
nextAdvisorPreparationStep,
|
||||
} from './fixture-advisor-preparation';
|
||||
|
||||
export type AdvisorRelocationStep = { actor: string; action: Action };
|
||||
export type AdvisorRelocationTransition = { before: Game; steps: AdvisorRelocationStep[]; after: Game };
|
||||
export const advisorRelocationPlayer = advisorPreparationPlayer;
|
||||
export const advisorRelocationInventory = advisorPreparationInventory;
|
||||
export const advisorRelocationClean = advisorPreparationClean;
|
||||
const cisternPlacement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
|
||||
/** Original native scalar draws only. Optional selected draws are consumed by
|
||||
* one real packet/placement producer; UUIDs and byte entropy remain native. */
|
||||
function originalRelocationAction(game: Game, actor: string, action: Action,
|
||||
steps: AdvisorRelocationStep[], scalars: readonly number[] = []): Game {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1)
|
||||
array[0] = cursor < scalars.length ? scalars[cursor++] : 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
const next = applyAction(game, actor, action);
|
||||
assert.equal(cursor, scalars.length, 'The actual original producer consumes its selected scalar draws');
|
||||
steps.push({ actor, action: structuredClone(action) }); return next;
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actAdvisorRelocation(game: Game, actor: string, action: Action,
|
||||
steps: AdvisorRelocationStep[] = []): Game {
|
||||
return originalRelocationAction(game, actor, action, steps);
|
||||
}
|
||||
/** Original Ambassador choices stay owned; Discovery entries may be declined
|
||||
* while seeking another seat's original offer. New conversion is never passed. */
|
||||
export function nextAdvisorRelocation(game: Game): AdvisorRelocationStep | null {
|
||||
if (game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
const ecaz = game.players.find(player => player.faction === 'ecaz');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces') {
|
||||
if (ecaz?.reserves === 20)
|
||||
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
|
||||
if (!advisorRelocationPlayer(game, 'beneGesserit').advisorSetup)
|
||||
return { actor: 'beneGesserit', action: { type: 'advisorSetup',
|
||||
territory: ecaz ? 'imperial_basin' : cisternPlacement.territory,
|
||||
sector: ecaz ? 10 : cisternPlacement.sector } };
|
||||
}
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'ecazPlacement' || decision?.kind === 'ecazAmbassador') return null;
|
||||
if (decision?.kind === 'advisorBattle')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (decision?.kind === 'intrusion')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: true } };
|
||||
if (decision?.kind === 'discoveryEntry')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
|
||||
}
|
||||
return nextAdvisorPreparationStep(game);
|
||||
}
|
||||
export function advanceAdvisorRelocation(game: Game, until: (state: Game) => boolean,
|
||||
steps: AdvisorRelocationStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The native relocation chronology ended before its requested boundary');
|
||||
const next = nextAdvisorRelocation(game);
|
||||
assert.ok(next, `Choose the original Ambassador/conversion at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actAdvisorRelocation(game, next.actor, next.action, steps);
|
||||
}
|
||||
throw Error('The native advisor relocation source did not reach its finite boundary');
|
||||
}
|
||||
export function passAdvisorRelocation(game: Game, steps: AdvisorRelocationStep[] = []): 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 = actAdvisorRelocation(game, actor.id, { type: 'passResponse' }, steps);
|
||||
}
|
||||
assert.ok(!game.response || game.response.automaticAdvisorFlip); return game;
|
||||
}
|
||||
function fremenPacket() {
|
||||
const original = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['fremen', ...original.filter(effect => effect !== 'fremen').slice(0, 4)];
|
||||
const order = [...supply, ...original.filter(effect => !supply.includes(effect))];
|
||||
const current = [...original], scalars: number[] = [];
|
||||
for (let index = current.length - 1; index > 0; index--) {
|
||||
const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index);
|
||||
scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1)));
|
||||
[current[index], current[from]] = [current[from], current[index]];
|
||||
}
|
||||
assert.deepEqual(current, order); assert.equal(scalars.length, 9); return { supply, order, scalars };
|
||||
}
|
||||
function createRelocationSetup(kind: 'ambassador' | 'discovery') {
|
||||
const bg = 'beneGesserit', holder = 'atreides', other = kind === 'ambassador' ? 'ecaz' : 'emperor';
|
||||
let game = createGame('ADVISORRELOCATIONFLIP', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true,
|
||||
kind === 'ambassador' ? ['ecaz'] : []);
|
||||
if (kind === 'ambassador') {
|
||||
joinGame(game, newPlayer(other, 'Ecaz', 'ecaz')); joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
|
||||
} else {
|
||||
joinGame(game, newPlayer(holder, 'Atreides', 'atreides')); joinGame(game, newPlayer(other, 'Emperor', 'emperor'));
|
||||
}
|
||||
const initial = structuredClone(game), steps: AdvisorRelocationStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actAdvisorRelocation(game, actor, action, steps); };
|
||||
if (kind === 'discovery') act(game.host, { type: 'moduleOption', module: 'discoveries', enabled: true });
|
||||
const configured = 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.discoveryEnabled, kind === 'discovery');
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => !player.hand.length));
|
||||
const originalInventory = advisorRelocationInventory(game); assert.equal(originalInventory.length, 33);
|
||||
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
|
||||
assert.equal(ordinary.length, 2);
|
||||
const selected = kind === 'ambassador' ? [ordinary[0], ordinary[1], karama] : [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), packet = kind === 'ambassador' ? fremenPacket() : null;
|
||||
let packetCreation: AdvisorRelocationTransition | null = null;
|
||||
let advisorSetup: AdvisorRelocationTransition | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const next = nextAdvisorRelocation(game); assert.ok(next);
|
||||
const before = structuredClone(game), start = steps.length;
|
||||
const initializesForces = packet && game.setupStage === 'traitors' && next.action.type === 'traitor' &&
|
||||
game.players.filter(player => player.traitorChoices.length).length === 1;
|
||||
game = initializesForces
|
||||
? originalRelocationAction(game, next.actor, next.action, steps, packet!.scalars)
|
||||
: actAdvisorRelocation(game, next.actor, next.action, steps);
|
||||
const record = { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
|
||||
if (initializesForces) packetCreation = record;
|
||||
if (next.action.type === 'advisorSetup') advisorSetup = record;
|
||||
}
|
||||
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.ok(advisorRelocationPlayer(game, holder).hand.some(card => card.id === karama.id));
|
||||
if (packet) {
|
||||
assert.ok(packetCreation);
|
||||
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id => game.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), packet.supply);
|
||||
}
|
||||
return { game, initial, configured, lobby, setup, beforeStartingDeal, afterSetup, advisorSetup,
|
||||
packet, packetCreation, kind, bg, holder, atreides: holder, other, karama: karama.id,
|
||||
steps, actions: steps, originalInventory, dealOrder };
|
||||
}
|
||||
function orderOriginalSpice(game: Game, discovery: boolean) {
|
||||
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 &&
|
||||
(discovery && position === 0 ? card.discovery === 'discovery-hagga-basin' : !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);
|
||||
return { originalSpice, spiceOrder };
|
||||
}
|
||||
|
||||
/** Real Ecaz6/BG setup, paid original Fremen token and actual A entrant.
|
||||
* Stop before Ecaz relocates its entire native Basin group away from BG. */
|
||||
export function createAdvisorAmbassadorRelocationFixture() {
|
||||
const base = createRelocationSetup('ambassador'), { bg, holder, karama, steps } = base, ecaz = base.other;
|
||||
let game = base.game;
|
||||
const act = (actor: string, action: Action) => { game = actAdvisorRelocation(game, actor, action, steps); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorRelocation(game, until, steps); };
|
||||
const transition = (run: () => void): AdvisorRelocationTransition => {
|
||||
const before = structuredClone(game), start = steps.length; run();
|
||||
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
const spice = orderOriginalSpice(game, false), territory = 'imperial_basin', location = 'imperial_basin:10';
|
||||
assert.equal(advisorRelocationPlayer(game, ecaz).forces[location], 6);
|
||||
assert.equal(advisorRelocationPlayer(game, bg).forces[location], 1);
|
||||
assert.ok(advisorRelocationPlayer(game, bg).advisors?.[territory]);
|
||||
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
const token = game.ecazAmbassadors!.tokens.find(candidate => candidate.effect === 'fremen')!;
|
||||
assert.equal(token.zone, 'supply');
|
||||
const placement = transition(() => {
|
||||
act(ecaz, { type: 'decision', token: token.id, territory: 'carthag' });
|
||||
game = passAdvisorRelocation(game, steps);
|
||||
});
|
||||
assert.equal(advisorRelocationPlayer(placement.before, ecaz).spice - advisorRelocationPlayer(game, ecaz).spice, 1);
|
||||
act(ecaz, { type: 'decision', decline: true });
|
||||
reach(state => state.phase === 5 && state.active === holder && advisorRelocationClean(state));
|
||||
const shipment = transition(() => {
|
||||
act(holder, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'offer');
|
||||
});
|
||||
const offer = structuredClone(game), event = game.pendingAmbassador!.event;
|
||||
const trigger = transition(() => {
|
||||
act(ecaz, { type: 'decision', event, trigger: true, beneficiary: ecaz });
|
||||
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'move');
|
||||
});
|
||||
assert.equal(game.pendingAmbassador!.effect, 'fremen'); assert.equal(game.pendingAmbassador!.beneficiary, ecaz);
|
||||
const destination = 'tueks_sietch', sector = 5;
|
||||
const action: Action = { type: 'decision', event, forces: { [location]: 6 }, territory: destination, sector };
|
||||
assert.ok(advisorRelocationPlayer(game, holder).hand.some(card => card.id === karama));
|
||||
assert.deepEqual(advisorRelocationInventory(game), base.originalInventory);
|
||||
return { ...base, ...spice, game, source: structuredClone(game), beforeAction: structuredClone(game),
|
||||
ecaz, actor: ecaz, mover: ecaz, shipper: ecaz, kind: 'ambassador' as const, event, token: token.id,
|
||||
placement, shipment, offer, trigger, territory, location, from: location, origin: territory,
|
||||
destination, destinationLocation: `${destination}:${sector}`, to: `${destination}:${sector}`, sector, amount: 6, action,
|
||||
relocationStep: { actor: ecaz, action } satisfies AdvisorRelocationStep,
|
||||
sourceActionCount: steps.length, staging: [
|
||||
'Actual public Advanced BG/Ecaz/Atreides ready/startPreview, native prediction, private Traitors and original base33. Exactly nine original packet draws on the last actual Traitor choice earn Fremen in Ecaz’s original supply; no token state is installed.',
|
||||
'Original ecazSetup places six counters in Basin10; actual BG advisorSetup places one advisor beside them. Only three unopened Treachery positions and two original ordinary Spice positions are selected; Atreides retains its originally dealt Karama.',
|
||||
'Native first Storm, both Spice blows, Charity, Bidding and Revival execute. Ecaz actually pays one to place the original Fremen token in Carthag, then ends placement. Earlier native movement turns skip without moving Basin forces.',
|
||||
'Atreides actually ships one reserve to Carthag. The original entry offer really triggers the Fremen token for Ecaz and reaches its real relocation choice. Stop before moving all six original Basin counters to empty Tuek’s5, leaving the original BG advisor behind.',
|
||||
'No played force, wallet, hand, alliance, phase, stance, relocation receipt or prevention is installed. The optional BG-beneficiary arrival route is excluded because it would require a separate genuine alliance chronology.',
|
||||
] };
|
||||
}
|
||||
|
||||
/** Actual Cistern placement/reveal and next-turn A entry. Only the original
|
||||
* token placement lottery is selected; the parent is printed Gara Kulon8. */
|
||||
export function createAdvisorDiscoveryRelocationFixture() {
|
||||
const base = createRelocationSetup('discovery'), { bg, holder, karama, steps } = base;
|
||||
let game = base.game;
|
||||
const act = (actor: string, action: Action) => { game = actAdvisorRelocation(game, actor, action, steps); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorRelocation(game, until, steps); };
|
||||
const transition = (run: () => void): AdvisorRelocationTransition => {
|
||||
const before = structuredClone(game), start = steps.length; run();
|
||||
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
const spice = orderOriginalSpice(game, true), territory = cisternPlacement.territory;
|
||||
const location = `${territory}:${cisternPlacement.sector}`;
|
||||
assert.equal(advisorRelocationPlayer(game, bg).forces[location], 1);
|
||||
assert.ok(!advisorRelocationPlayer(game, bg).advisors?.[territory]);
|
||||
reach(state => state.phase === 1 && advisorRelocationClean(state));
|
||||
const beforeToken = structuredClone(game), tokenStart = steps.length;
|
||||
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID.cistern.type);
|
||||
const selected = eligible.findIndex(token => token.face === 'cistern'); assert.ok(selected >= 0);
|
||||
const tokenScalar = Math.floor((selected + 0.5) * 4294967296 / eligible.length);
|
||||
for (let count = 0; !game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed') && count < 100; count++) {
|
||||
const next = nextAdvisorRelocation(game); assert.ok(next);
|
||||
const places = !game.response && !game.phaseOpening && !game.decision && next.action.type === 'ready' &&
|
||||
game.ready.length === game.players.length - 1;
|
||||
game = originalRelocationAction(game, next.actor, next.action, steps, places ? [tokenScalar] : []);
|
||||
}
|
||||
const token = game.discoveries!.tokens.find(candidate => candidate.face === 'cistern')!;
|
||||
assert.equal(token.status, 'placed'); assert.equal(token.territory, territory);
|
||||
const tokenPlacement: AdvisorRelocationTransition = {
|
||||
before: beforeToken, steps: structuredClone(steps.slice(tokenStart)), after: structuredClone(game),
|
||||
};
|
||||
reach(state => state.phase === 5 && state.active === bg && advisorRelocationClean(state));
|
||||
act(bg, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === holder && advisorRelocationClean(state));
|
||||
const shipment = transition(() => {
|
||||
act(holder, { type: 'ship', territory, sector: cisternPlacement.sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'intrusion');
|
||||
});
|
||||
const intrusion = transition(() => {
|
||||
act(bg, { type: 'decision', accept: true });
|
||||
reach(advisorRelocationClean);
|
||||
});
|
||||
assert.equal(advisorRelocationPlayer(shipment.before, holder).spice - advisorRelocationPlayer(game, holder).spice, 2);
|
||||
assert.equal(advisorRelocationPlayer(game, holder).forces[location], 1);
|
||||
assert.ok(advisorRelocationPlayer(game, bg).advisors?.[territory]);
|
||||
reach(state => state.turn === 1 && state.phase === 7 && advisorRelocationClean(state));
|
||||
assert.ok(viewGame(game, holder).discoveries!.canInspect.includes(token.id));
|
||||
const revelation = transition(() => {
|
||||
act(holder, { type: 'discovery', token: token.id, reveal: false });
|
||||
act(holder, { type: 'discovery', token: token.id, reveal: true });
|
||||
});
|
||||
reach(state => state.turn === 2 && state.phase === 0 && !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'discoveryEntry' && state.decision.player === holder);
|
||||
const action = discoveryEntryMoveAction(viewGame(game, holder), [{ source: location, normal: 1, elite: 0 }]);
|
||||
assert.ok(action); assert.ok(game.decision?.kind === 'discoveryEntry');
|
||||
const event = game.decision.event;
|
||||
assert.equal(advisorRelocationPlayer(game, holder).forces[location], 1);
|
||||
assert.equal(advisorRelocationPlayer(game, bg).forces[location], 1);
|
||||
assert.ok(advisorRelocationPlayer(game, bg).advisors?.[territory]);
|
||||
assert.ok(advisorRelocationPlayer(game, holder).hand.some(card => card.id === karama));
|
||||
assert.deepEqual(advisorRelocationInventory(game), base.originalInventory);
|
||||
return { ...base, ...spice, game, source: structuredClone(game), beforeAction: structuredClone(game),
|
||||
emperor: base.other, actor: holder, mover: holder, shipper: holder, kind: 'discovery' as const,
|
||||
event, token: token.id, tokenPlacement, tokenScalar, shipment, intrusion, revelation,
|
||||
territory, location, from: location, origin: territory, destination: 'cistern', destinationLocation: 'cistern:0', to: 'cistern:0', sector: 0, amount: 1,
|
||||
action, relocationStep: { actor: holder, action } satisfies AdvisorRelocationStep,
|
||||
sourceActionCount: steps.length, staging: [
|
||||
'Actual public Advanced BG/Atreides/Emperor lobby enables only Discoveries through moduleOption, then ready/startPreview and original base33 setup. Original prediction, private Traitors and actual advisorSetup place BG’s counter in empty printed Gara Kulon8, making it a native fighter.',
|
||||
'Only three original unopened Treachery positions, the original Hagga Basin Discovery Spice Card plus one ordinary Spice position, and the actual supply-token placement lottery are selected. That printed card places a real Cistern Hiereg in Gara Kulon8; no token identity, location or revealed turn is assigned.',
|
||||
'Native Storm/dual Spice/Charity/Bidding/Revival run. BG actually ends movement. Atreides pays two to ship one reserve beside BG at the parent; actual accepted Intrusion and allowed ordinary advisorFlip make BG peaceful, and separate Spiritual Advisors accompaniment is declined. No battle is manufactured.',
|
||||
'At real Collection, Atreides actually inspects and reveals Cistern. Remaining Collection/Mentat controls reach the original next-turn free Discovery entry. Stop before Atreides moves its sole parent counter into empty Cistern0, leaving BG’s original advisor behind.',
|
||||
'Discovery entry remains restricted to non-advisor sources; BG never uses an advisor-only free entry. No played force, wallet, hand, phase, stance, revealed identity, entry frame, relocation receipt or prevention is installed.',
|
||||
] };
|
||||
}
|
||||
export type AdvisorRelocationFixture = ReturnType<typeof createAdvisorAmbassadorRelocationFixture> |
|
||||
ReturnType<typeof createAdvisorDiscoveryRelocationFixture>;
|
||||
|
||||
/** Commit only the real source action; the new conversion remains caller-owned. */
|
||||
export function commitAdvisorRelocation(source: Game, fixture: AdvisorRelocationFixture,
|
||||
steps: AdvisorRelocationStep[] = []) {
|
||||
const before = structuredClone(source), start = steps.length;
|
||||
const game = actAdvisorRelocation(source, fixture.actor, fixture.action, steps);
|
||||
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)) };
|
||||
}
|
||||
/** After the explicit counter, resume only the original parent. Discovery
|
||||
* stops when its entry round clears, before unrelated Storm readiness. */
|
||||
export function finishAdvisorRelocation(source: Game, steps: AdvisorRelocationStep[] = []): Game {
|
||||
const discovery = source.phase === 0;
|
||||
return advanceAdvisorRelocation(source, game => discovery ? !game.discoveryEntry && !game.response?.automaticAdvisorFlip
|
||||
: !game.pendingAmbassador && advisorRelocationClean(game), steps);
|
||||
}
|
||||
|
||||
export const createAdvisorAmbassadorFlipFixture = createAdvisorAmbassadorRelocationFixture;
|
||||
export const createAdvisorDiscoveryFlipFixture = createAdvisorDiscoveryRelocationFixture;
|
||||
export const actAdvisorRelocationFlip = actAdvisorRelocation;
|
||||
export const advisorRelocationFlipPlayer = advisorRelocationPlayer;
|
||||
export const advisorRelocationFlipInventory = advisorRelocationInventory;
|
||||
export const passAdvisorRelocationFlip = passAdvisorRelocation;
|
||||
export const finishAdvisorRelocationFlip = finishAdvisorRelocation;
|
||||
export const commitAdvisorRelocationFlip = commitAdvisorRelocation;
|
||||
Reference in new issue
Block a user