Counter each changing origin in a resident BG gather

Hold both physical groups until all required matching responses allow the original movement. Share normal packet denials across gather and ordinary moves, then resume one original movement suffix.
This commit is contained in:
Quintine committed 2026-10-24 23:16:02 +11:00
1 parent b2a20226d7
commit 0d8481e821
16 files changed
+987 -10

No files matched your search

@@ -0,0 +1,102 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction,normalizeAutomaticGame,viewGame,type Action,type Game } from '../game/engine';
import { isAdvisor } from '../game/advisors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { planetologistMoveDraft } from '../components/planetologist-movement';
import { createAdvisorGatherMatchingFixture as fixture,actAdvisorGatherMatching as act,
finishAdvisorGatherMatching as finish,advisorGatherMatchingPlayer as player,
advisorGatherMatchingInventory as inventory,advisorGatherMatchingSkillInventory as skills,
} from './fixture-advisor-gather-matching';
const physical=(g:Game)=>g.players.map(p=>({id:p.id,forces:p.forces,advisors:p.advisors,
reserves:p.reserves,tanks:p.tanks,spice:p.spice,moved:p.moved,shipped:p.shipped}));
function allow(g:Game){
const source=g.response?.advisorGatherMatchingSource??g.response?.advisorMatchingSource;assert.ok(source);
for(let n=0;(g.response?.advisorGatherMatchingSource??g.response?.advisorMatchingSource)?.event===source.event&&n<30;n++){
const actor=g.players.find(p=>!g.response!.passed.includes(p.id));assert.ok(actor);g=act(g,actor.id,{type:'passResponse'});
}
assert.notEqual((g.response?.advisorGatherMatchingSource??g.response?.advisorMatchingSource)?.event,source.event);return g;
}
function complete(g:Game){
for(let n=0;g.response&&(g.response.advisorGatherMatchingSource||g.response.advisorMatchingSource)&&n<4;n++)g=allow(g);
return finish(g);
}
function reject(g:Game,actor:string,action:Action){const before=structuredClone(g);assert.throws(()=>applyAction(g,actor,action));assert.deepEqual(g,before);}
for(const kind of ['mixed','double'] as const){
void test(`native ${kind} resident gather offers one response per changing origin and moves once only after every allowance`,()=>{
const f=fixture({kind}),draft=planetologistMoveDraft(viewGame(f.game,f.bg),'gather',f.resident,10,f.forces,{});
assert.equal(draft.blocked,null);assert.ok(draft.action);
let g=act(f.game,f.bg,draft.action);const seen:string[]=[];
for(let index=0;index<f.matchingCount;index++){
const source=g.response?.advisorGatherMatchingSource;assert.ok(source);seen.push(source.origin);
assert.equal(g.response?.advisorGatherMatchingPosition?.current,index+1);assert.equal(g.response?.advisorGatherMatchingPosition?.total,f.matchingCount);
assert.deepEqual(physical(g),physical(f.game));assert.deepEqual(physical(normalizeAutomaticGame(g)),physical(f.game));
assert.equal(g.pendingAdvisorGatherMatching?.approved.length,index);
assert.ok(player(g,f.holder).hand.some(c=>c.id===f.karama));g=allow(g);
}
assert.deepEqual(seen,[...seen].sort());g=finish(g);
assert.equal(g.pendingAdvisorGatherMatching,undefined);assert.equal(player(g,f.bg).forces[f.location],3);
assert.equal(player(g,f.bg).forces['imperial_basin:9'],1);assert.equal(player(g,f.bg).forces['oh_gap:9']??0,0);
assert.equal(isAdvisor(player(g,f.bg),f.resident),true);assert.equal(player(g,f.bg).moved,player(f.game,f.bg).moved+1);
assert.equal(player(g,f.bg).shipped,player(f.game,f.bg).shipped);assert.equal(player(g,f.bg).spice,player(f.game,f.bg).spice);
assert.deepEqual(inventory(g),f.originalInventory);assert.deepEqual(skills(g),f.originalSkillInventory);
});
void test(`native ${kind} gather prevention preserves both groups and shares the normal packet denial`,()=>{
const f=fixture({kind}),pending=act(f.game,f.bg,f.action),source=pending.response?.advisorGatherMatchingSource;assert.ok(source);
const stopped=act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'});
assert.deepEqual(physical(stopped),physical(f.game));assert.equal(stopped.pendingAdvisorGatherMatching,undefined);
reject(stopped,f.bg,f.action);
reject(stopped,f.bg,{...f.action,forces:Object.fromEntries(Object.entries(f.forces).reverse())});
reject(stopped,f.bg,{type:'move',forces:source.group,territory:f.resident,sector:10});
const draft=planetologistMoveDraft(viewGame(stopped,f.bg),'gather',f.resident,10,f.forces,{});
assert.equal(draft.action,null);assert.match(draft.blocked??'',/prevented/);
const done=complete(act(stopped,f.bg,f.sameTypeReplacement));
assert.equal(player(done,f.bg).moved,1);assert.equal(done.discard.filter(c=>c.id===f.karama).length,1);
assert.deepEqual(inventory(done),f.originalInventory);assert.deepEqual(skills(done),f.originalSkillInventory);
});
void test(`native ${kind} public group receipt retains normal ten-field format without the private batch`,()=>{
const f=fixture({kind}),pending=act(f.game,f.bg,f.action),view=viewGame(pending,f.holder),source=view.response?.advisorGatherMatchingSource;assert.ok(source);
assert.deepEqual(Object.keys(source).sort(),['advisors','destination','event','group','kind','move','origin','owner','sector','turn']);
assert.equal(source.kind,'movement');assert.equal('pendingAdvisorGatherMatching'in view,false);
assert.equal(JSON.stringify(source).includes('trainer'),false);
});
for(const profile of DIFFICULTIES)void test(`${profile} responds to native ${kind} gather and chooses a legal replacement after denial`,()=>{
const f=fixture({kind});let pending=act(f.game,f.bg,f.action);if(kind==='double')pending=allow(pending);
const view=viewGame(pending,f.holder);view.players.find(p=>p.id===f.holder)!.bot=profile;
const before=structuredClone(view),answer=botActions(view)[0];assert.ok(answer);assert.deepEqual(view,before);
assert.doesNotThrow(()=>applyAction(pending,f.holder,answer));
const stopped=act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}),replacementView=viewGame(stopped,f.bg);
replacementView.players.find(p=>p.id===f.bg)!.bot=profile;const replacement=botActions(replacementView)[0];assert.ok(replacement);
assert.doesNotThrow(()=>applyAction(stopped,f.bg,replacement));
});
}
void test('native second-group prevention after partial allowance leaves both original groups and the whole movement unused',()=>{
const f=fixture({kind:'double'}),first=act(f.game,f.bg,f.action),firstSource=first.response?.advisorGatherMatchingSource;assert.ok(firstSource);
const second=allow(first),source=second.response?.advisorGatherMatchingSource;assert.ok(source);assert.notEqual(source.event,firstSource.event);
assert.equal(source.origin,'oh_gap');assert.equal(second.pendingAdvisorGatherMatching?.approved.length,1);
assert.deepEqual(physical(second),physical(f.game));
const stopped=act(JSON.parse(JSON.stringify(second)),f.holder,{type:'card',card:f.karama,mode:'cancel'});
assert.deepEqual(physical(stopped),physical(f.game));assert.equal(stopped.pendingAdvisorGatherMatching,undefined);reject(stopped,f.bg,f.action);
const done=complete(act(stopped,f.bg,f.ordinaryReplacement));assert.equal(player(done,f.bg).forces[f.location],2);
assert.equal(player(done,f.bg).forces['oh_gap:9'],1);assert.equal(player(done,f.bg).moved,1);
});
void test('native first-group prevention admits a different actual Basin packet for one atomic replacement gather',()=>{
const f=fixture({kind:'double'}),stopped=act(act(f.game,f.bg,f.action),f.holder,{type:'card',card:f.karama,mode:'cancel'});
const done=complete(act(stopped,f.bg,f.largerPacket));assert.equal(player(done,f.bg).forces[f.location],4);
assert.equal(player(done,f.bg).forces['imperial_basin:9']??0,0);assert.equal(player(done,f.bg).forces['oh_gap:9']??0,0);
assert.equal(player(done,f.bg).moved,1);assert.equal(done.discard.filter(c=>c.id===f.karama).length,1);
});
void test('native ordinary matching prevention cannot be bypassed by adding Planetologist second origin',()=>{
const f=fixture({kind:'double'}),normal=act(f.game,f.bg,f.ordinaryReplacement);assert.ok(normal.response?.advisorMatchingSource);
const stopped=act(normal,f.holder,{type:'card',card:f.karama,mode:'cancel'});reject(stopped,f.bg,f.action);
const done=complete(act(stopped,f.bg,f.largerPacket));assert.equal(player(done,f.bg).forces[f.location],4);assert.equal(player(done,f.bg).moved,1);
});
void test('an invalid original gather signature or unearned approval index fails before cancellation cost',()=>{
const f=fixture({kind:'double'}),pending=act(f.game,f.bg,f.action);
const signature=structuredClone(pending);signature.pendingAdvisorGatherMatching!.signature+='changed';
const index=structuredClone(pending);index.pendingAdvisorGatherMatching!.index=1;
const approved=structuredClone(pending);approved.pendingAdvisorGatherMatching!.approved.push('unearned');
for(const changed of [signature,index,approved]){reject(changed,f.holder,{type:'card',card:f.karama,mode:'cancel'});assert.ok(player(changed,f.holder).hand.some(c=>c.id===f.karama));}
});
+245
View File
@@ -0,0 +1,245 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
AdvisorGatherMatchingError,
quoteAdvisorGatherMatching,
type AdvisorGatherMatchingIntent,
} from '../game/advisor-gather-matching';
import {
advisorMatchingKey,
AdvisorMatchingError,
quoteAdvisorMatching,
validateAdvisorMatching,
type AdvisorMatchingContext,
type AdvisorMatchingIntent,
type AdvisorMatchingSource,
} from '../game/advisor-matching';
function context(): AdvisorMatchingContext {
return {
advanced: true, status: 'playing', turn: 4, phase: 5, active: 'b',
players: [
{
id: 'b', faction: 'beneGesserit', moved: 0,
forces: {
'hagga_basin:12': 3, 'hagga_basin:13': 2, 'arrakeen:10': 2,
'carthag:11': 2, 'imperial_basin:9': 2,
},
advisors: { imperial_basin: { lockedTurn: 4 }, carthag: {} },
},
{ id: 'a', faction: 'atreides', forces: { 'polar_sink:0': 1, 'imperial_basin:9': 1 } },
],
};
}
function intent(fields: Partial<AdvisorGatherMatchingIntent> = {}): AdvisorGatherMatchingIntent {
return {
owner: 'b', move: 0, destination: 'imperial_basin', sector: 10, advisors: true,
group: { 'hagga_basin:12': 1, 'arrakeen:10': 1 }, ...fields,
};
}
function normal(fields: Partial<AdvisorMatchingIntent> = {}): AdvisorMatchingIntent {
return {
kind: 'movement', owner: 'b', move: 0, origin: 'hagga_basin',
destination: 'imperial_basin', sector: 10, advisors: true,
group: { 'hagga_basin:12': 1 }, ...fields,
};
}
void test('resident gather returns one normal quote per changing origin in deterministic order without changing either packet', () => {
const ctx = context();
const incoming = intent({ group: { 'hagga_basin:13': 1, 'arrakeen:10': 1, 'hagga_basin:12': 2 } });
const before = structuredClone({ ctx, incoming });
const quotes = quoteAdvisorGatherMatching(ctx, incoming);
assert.deepEqual(quotes, [
{
kind: 'movement', owner: 'b', turn: 4, move: 0, origin: 'arrakeen',
destination: 'imperial_basin', sector: 10, advisors: true, group: { 'arrakeen:10': 1 },
},
{
kind: 'movement', owner: 'b', turn: 4, move: 0, origin: 'hagga_basin',
destination: 'imperial_basin', sector: 10, advisors: true,
group: { 'hagga_basin:12': 2, 'hagga_basin:13': 1 },
},
]);
assert.deepEqual({ ctx, incoming }, before);
assert.deepEqual(quoteAdvisorGatherMatching(ctx, intent({
group: { 'hagga_basin:12': 2, 'hagga_basin:13': 1, 'arrakeen:10': 1 },
})), quotes);
quotes[1].group['hagga_basin:12'] = 99;
assert.equal(incoming.group['hagga_basin:12'], 2);
assert.equal(ctx.players[0].forces['hagga_basin:12'], 3);
});
void test('same-type halves need no counter and mixed gather quotes only its changing incoming half in either stance direction', () => {
const ctx = context();
const one = quoteAdvisorGatherMatching(ctx, intent({
group: { 'carthag:11': 1, 'hagga_basin:12': 1 },
}));
assert.equal(one.length, 1);
assert.equal(one[0].origin, 'hagga_basin');
assert.equal(one[0].advisors, true);
const fighters = quoteAdvisorGatherMatching(ctx, intent({
destination: 'arrakeen', sector: 10, advisors: false,
group: { 'imperial_basin:9': 1, 'carthag:11': 1 },
}));
assert.deepEqual(fighters.map((quote) => [quote.origin, quote.advisors]), [
['carthag', false], ['imperial_basin', false],
]);
ctx.players[0].advisors = { imperial_basin: {} };
assert.deepEqual(quoteAdvisorGatherMatching(ctx, intent({
destination: 'arrakeen', sector: 10, advisors: false,
group: { 'carthag:11': 1, 'hagga_basin:12': 1 },
})), []);
});
void test('Basic, other windows, foreign or inactive owners, empty and enemy-only destinations keep their existing nonmatching branch', () => {
for (const patch of [
{ advanced: false }, { status: 'setup' }, { phase: 4 }, { phase: 6 }, { active: 'a' },
]) {
assert.deepEqual(quoteAdvisorGatherMatching({ ...context(), ...patch }, intent()), []);
}
const foreign = context();
foreign.players[0].faction = 'atreides';
assert.deepEqual(quoteAdvisorGatherMatching(foreign, intent()), []);
assert.deepEqual(quoteAdvisorGatherMatching(context(), intent({ owner: 'missing' })), []);
assert.deepEqual(quoteAdvisorGatherMatching(context(), intent({
destination: 'polar_sink', sector: 0, advisors: false,
})), []);
const empty = context();
empty.players[0].forces = { ...empty.players[0].forces, 'imperial_basin:9': 0 };
assert.deepEqual(quoteAdvisorGatherMatching(empty, intent()), []);
// The original native gather branch remains responsible for its original
// physical order when there is no resident BG group to match.
assert.deepEqual(quoteAdvisorGatherMatching(empty, intent({ group: {} })), []);
});
void test('resident gather validates every selected physical packet including a same-type half before any quote is usable', () => {
const cases: Record<string, number>[] = [
{}, { 'hagga_basin:12': 1, 'hagga_basin:13': 1 },
{ 'hagga_basin:12': 1, 'arrakeen:10': 1, 'carthag:11': 1 },
{ 'hagga_basin:12': 4, 'arrakeen:10': 1 },
{ 'hagga_basin:12': 1, 'carthag:11': 3 },
{ 'hagga_basin:12': 0, 'arrakeen:10': 1 },
{ 'hagga_basin:12': -1, 'arrakeen:10': 1 },
{ 'hagga_basin:12': 1.5, 'arrakeen:10': 1 },
{ 'hagga_basin:12.0': 1, 'arrakeen:10': 1 },
{ 'hagga_basin:11': 1, 'arrakeen:10': 1 },
{ reserves: 1, 'arrakeen:10': 1 },
{ 'homeworld:beneGesserit': 1, 'arrakeen:10': 1 },
{ 'missing:0': 1, 'arrakeen:10': 1 },
];
for (const group of cases) {
const ctx = context();
const incoming = intent({ group });
const before = structuredClone({ ctx, incoming });
assert.throws(() => quoteAdvisorGatherMatching(ctx, incoming), AdvisorGatherMatchingError);
assert.deepEqual({ ctx, incoming }, before);
}
});
void test('normal movement prevention also denies its identical gather half despite selection reordering or a different destination sector', () => {
const ctx = context();
const original = quoteAdvisorMatching(ctx, normal());
assert.ok(original);
ctx.advisorMatchingDenied = [advisorMatchingKey(original)];
const before = structuredClone(ctx);
assert.throws(() => quoteAdvisorGatherMatching(ctx, intent()), (error: unknown) =>
error instanceof AdvisorGatherMatchingError && /already prevented/.test(error.message));
assert.throws(() => quoteAdvisorGatherMatching(ctx, intent({
sector: 11, group: { 'arrakeen:10': 1, 'hagga_basin:12': 1 },
})), AdvisorGatherMatchingError);
assert.deepEqual(ctx, before);
assert.equal(quoteAdvisorGatherMatching(ctx, intent({
group: { 'arrakeen:10': 1, 'hagga_basin:12': 2 },
})).length, 2);
assert.equal(quoteAdvisorGatherMatching(ctx, intent({ destination: 'carthag', sector: 11 })).length, 2);
ctx.players[0].moved = 1;
assert.equal(quoteAdvisorGatherMatching(ctx, intent({ move: 1 })).length, 2);
ctx.players[0].moved = 0;
ctx.turn++;
assert.equal(quoteAdvisorGatherMatching(ctx, intent()).length, 2);
});
void test('a prevented gather origin uses the same normal denial key and cannot be retried as ordinary movement', () => {
const ctx = context();
const original = quoteAdvisorGatherMatching(ctx, intent())[0];
assert.equal(original.origin, 'arrakeen');
ctx.advisorMatchingDenied = [advisorMatchingKey(original)];
const ordinary = normal({ origin: 'arrakeen', group: { 'arrakeen:10': 1 } });
assert.throws(() => quoteAdvisorMatching(ctx, ordinary), AdvisorMatchingError);
assert.throws(() => validateAdvisorMatching(ctx, {
event: 'new-counter', ...original, sector: 11,
}), AdvisorMatchingError);
assert.ok(quoteAdvisorMatching(ctx, normal()));
});
void test('stale selected groups, current movement and resident stance produce the named gather error without changing the original action', () => {
const changes: ((ctx: AdvisorMatchingContext, incoming: AdvisorGatherMatchingIntent) => void)[] = [
(ctx) => { ctx.players[0].moved = 1; },
(ctx) => { delete ctx.players[0].moved; },
(ctx) => { ctx.players[0].forces = { ...ctx.players[0].forces, 'arrakeen:10': 0 }; },
(ctx) => { ctx.players[0].advisors = { carthag: {} }; },
(_ctx, incoming) => { incoming.advisors = false; },
(_ctx, incoming) => { incoming.destination = 'imperial_basin'; incoming.sector = 8; },
];
for (const change of changes) {
const ctx = context();
const incoming = intent();
change(ctx, incoming);
const before = structuredClone({ ctx, incoming });
assert.throws(() => quoteAdvisorGatherMatching(ctx, incoming), (error: unknown) =>
error instanceof AdvisorGatherMatchingError && error.name === 'AdvisorGatherMatchingError');
assert.deepEqual({ ctx, incoming }, before);
}
});
void test('gather emits the unchanged ordinary movement receipt shape and delegates its actual source validation', () => {
const ctx = context();
const quotes = quoteAdvisorGatherMatching(ctx, intent());
for (const quote of quotes) {
const original: AdvisorMatchingSource = { event: `counter-${quote.origin}`, ...quote };
assert.deepEqual(Object.keys(original).sort(), [
'advisors', 'destination', 'event', 'group', 'kind', 'move', 'origin', 'owner', 'sector', 'turn',
]);
assert.equal(original.kind, 'movement');
assert.doesNotThrow(() => validateAdvisorMatching(ctx, original));
assert.throws(() => validateAdvisorMatching(ctx, {
...original, gatherOrder: 'private-parent',
} as AdvisorMatchingSource), AdvisorMatchingError);
}
});
void test('gather reads public physical counters and stance only, leaving private trainer, routes, concealed forces and funding to the native quote', () => {
const ctx = context();
const incoming = intent();
for (const player of ctx.players) {
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'skills', 'noField', 'eliteForces', 'reserves', 'shipped']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Gather matching read ${player.id} private or unrelated ${field}`); },
});
}
}
for (const stance of Object.values(ctx.players[0].advisors!)) {
for (const field of ['lockedTurn', 'aloneFlipHold']) {
Object.defineProperty(stance, field, {
get() { throw new Error(`Gather matching read unrelated stance ${field}`); },
});
}
}
for (const object of [ctx, incoming]) {
for (const field of ['leaderSkills', 'planetologist', 'order', 'signature', 'forecast', 'stormCard', 'pendingMove', 'payment']) {
Object.defineProperty(object, field, {
get() { throw new Error(`Gather matching read private native ${field}`); },
});
}
}
Object.defineProperty(ctx.players[1], 'forces', {
get() { throw new Error('Gather matching reran native foreign occupancy'); },
});
assert.equal(quoteAdvisorGatherMatching(ctx, incoming).length, 2);
const unexpected = new Error('native physical getter failure');
Object.defineProperty(ctx.players[0], 'moved', { get() { throw unexpected; } });
assert.throws(() => quoteAdvisorGatherMatching(ctx, incoming), (error: unknown) => error === unexpected);
});
+231
View File
@@ -0,0 +1,231 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { distance } from '../game/board';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { planetologistLeader } from '../game/planetologist-movement';
import {
actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory,
advisorPreparationPlayer, nextAdvisorPreparationStep,
} from './fixture-advisor-preparation';
export type AdvisorGatherMatchingStep = { actor: string; action: Action };
export type AdvisorGatherMatchingTransition = { before: Game; steps: AdvisorGatherMatchingStep[]; after: Game };
export const actAdvisorGatherMatching = actAdvisorPreparation;
export const advisorGatherMatchingPlayer = advisorPreparationPlayer;
export const advisorGatherMatchingInventory = advisorPreparationInventory;
export const advisorGatherMatchingClean = advisorPreparationClean;
export const advisorGatherMatchingSkillInventory = (game: Game) => {
const state = game.leaderSkills!;
const ids = [...state.deck, ...Object.values(state.offers).flatMap(offer => offer.cards),
...state.assignments.map(assignment => assignment.skill)].sort();
assert.equal(new Set(ids).size, ids.length, 'Every original Leader Skill has one physical slot'); return ids;
};
const training: Record<string, LeaderSkillId> = {
beneGesserit: 'planetologist', atreides: 'killer-medic', emperor: 'swordmaster-of-ginaz',
};
/** All technique confirmation is through original owned offers. Intrusion and
* later matching flips stay caller-owned; ordinary preparation is declined. */
export function nextAdvisorGatherMatching(game: Game): AdvisorGatherMatchingStep | null {
if (game.response?.kind === 'advisorFlip') return null;
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
const skill = training[actor], offer=own.offer; assert.ok(offer && offer.cards.includes(skill) && !own.unavailableSkills?.[skill]);
const leader = own.eligibleLeaders.sort((a, b) => {
const player = advisorGatherMatchingPlayer(game, actor);
return player.leaders.find(disc => disc.id === b.id)!.strength - player.leaders.find(disc => disc.id === a.id)!.strength || a.id.localeCompare(b.id);
})[0]; assert.ok(leader);
return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
}
if (game.decision?.kind === 'intrusion') return null;
if (game.decision?.kind === 'advisorBattle')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
}
return nextAdvisorPreparationStep(game);
}
export function advanceAdvisorGatherMatching(game: Game, until: (state: Game) => boolean,
steps: AdvisorGatherMatchingStep[] = []): Game {
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original gather chronology ended before its requested boundary');
const next = nextAdvisorGatherMatching(game);
assert.ok(next, `Choose the original Intrusion/matching control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actAdvisorGatherMatching(game, next.actor, next.action, steps);
}
throw Error('The native advisor gather matching source did not reach its finite boundary');
}
export function passAdvisorGatherMatching(game: Game, steps: AdvisorGatherMatchingStep[] = []): Game {
for (let count = 0; game.response && game.response.kind !== 'advisorFlip' && count < 60; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actAdvisorGatherMatching(game, actor.id, { type: 'passResponse' }, steps);
}
assert.ok(!game.response || game.response.kind === 'advisorFlip'); return game;
}
/** Actual public Advanced classic/all14 Skills source. The initial untouched
* Skill Deck is ordered before real offers, then Planetologist is really
* assigned. Two paid shipments and optional real Intrusion earn both origins. */
export function createAdvisorGatherMatchingFixture(options: { kind?: 'mixed' | 'double' } = {}) {
const kind = options.kind ?? 'mixed', bg = 'beneGesserit', holder = 'atreides', emperor = 'emperor';
let game = createGame('ADVISORGATHERMATCHING', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
joinGame(game, newPlayer(holder, 'Atreides', 'atreides')); joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
const initial = structuredClone(game), steps: AdvisorGatherMatchingStep[] = [];
const act = (actor: string, action: Action) => { game = actAdvisorGatherMatching(game, actor, action, steps); };
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorGatherMatching(game, until, steps); };
const transition = (run: () => void): AdvisorGatherMatchingTransition => {
const before = structuredClone(game), start = steps.length; run();
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
};
const enableSkills = transition(() => { act(game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true }); });
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.leaderSkills);
assert.ok(!game.homeworlds && !game.nexusCards && !game.techTokens && !game.strongholdCards &&
!game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => !player.hand.length));
assert.ok(!Object.keys(game.leaderSkills.offers).length && !game.leaderSkills.assignments.length);
const originalInventory = advisorGatherMatchingInventory(game); assert.equal(originalInventory.length, 33);
const originalSkillInventory = advisorGatherMatchingSkillInventory(game);
assert.deepEqual(originalSkillInventory, LEADER_SKILL_CARDS.map(card => card.id).sort());
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
assert.equal(ordinary.length, 2);
const selected = [ordinary[0], karama, ordinary[1]];
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeSkillOrder = structuredClone(game), originalSkillOrder = [...game.leaderSkills.deck];
const wanted = game.players.map(player => training[player.id]);
const rest = game.leaderSkills.deck.filter(skill => !wanted.includes(skill));
const skillOrder = wanted.flatMap(skill => [skill, rest.shift()!]).concat(rest);
game.leaderSkills.deck.splice(0, game.leaderSkills.deck.length, ...skillOrder);
assert.deepEqual(advisorGatherMatchingSkillInventory(game), originalSkillInventory);
const beforeStartingDeal = structuredClone(game);
let advisorSetup: AdvisorGatherMatchingTransition | null = null;
let skillOffers: Game | null = null;
const skillChoices: AdvisorGatherMatchingTransition[] = [];
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextAdvisorGatherMatching(game); assert.ok(next);
const before = structuredClone(game), start = steps.length;
if (before.setupStage === 'leaderSkills' && !skillOffers) skillOffers = structuredClone(game);
act(next.actor, next.action);
const record = { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
if (next.action.type === 'leaderSkill') skillChoices.push(record);
if (next.action.type === 'advisorSetup') advisorSetup = record;
}
assert.equal(game.status, 'playing'); assert.ok(advisorSetup && skillOffers); assert.equal(skillChoices.length, 3);
const afterSetup = structuredClone(game), trainer = planetologistLeader(game, bg); assert.ok(trainer);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
assert.equal(advisorGatherMatchingPlayer(game, bg).forces['arrakeen:10'], 1);
assert.ok(advisorGatherMatchingPlayer(game, bg).advisors?.arrakeen);
assert.equal(advisorGatherMatchingPlayer(game, holder).forces['arrakeen:10'], 10);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1, 2, 3].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
!['imperial_basin', 'oh_gap', 'arrakeen'].includes(card.territory));
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.active === bg && advisorGatherMatchingClean(state));
const fundingAction: Action = { type: 'bribe', target: bg, amount: 1 };
const funding = transition(() => { act(emperor, fundingAction); });
const firstShipment = transition(() => {
act(bg, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0 });
reach(advisorGatherMatchingClean);
});
assert.equal(advisorGatherMatchingPlayer(firstShipment.before, bg).spice - advisorGatherMatchingPlayer(game, bg).spice, 4);
let outsiderShipment: AdvisorGatherMatchingTransition | null = null;
let intrusion: AdvisorGatherMatchingTransition | null = null;
if (kind === 'mixed') {
reach(state => state.turn === 1 && state.phase === 5 && state.active === emperor && advisorGatherMatchingClean(state));
outsiderShipment = transition(() => {
act(emperor, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0 });
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'intrusion');
});
intrusion = transition(() => {
act(bg, { type: 'decision', accept: true });
for (let count = 0; game.response && count < 60; count++) {
assert.equal(game.response.kind, 'advisorFlip'); assert.equal(game.response.advisors, true);
assert.ok(!game.response.automaticAdvisorFlip, 'Only the original Intrusion response is allowed in the source');
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
act(actor.id, { type: 'passResponse' });
}
reach(advisorGatherMatchingClean);
});
assert.ok(advisorGatherMatchingPlayer(game, bg).advisors?.imperial_basin);
}
reach(state => state.turn === 2 && state.phase === 5 && state.active === bg && advisorGatherMatchingClean(state));
const beforeSecondShipment = structuredClone(game);
const secondShipment = transition(() => {
act(bg, { type: 'ship', territory: 'oh_gap', sector: 9, amount: 1, elite: 0, allyPayment: 0 });
reach(advisorGatherMatchingClean);
});
assert.equal(advisorGatherMatchingPlayer(secondShipment.before, bg).spice - advisorGatherMatchingPlayer(game, bg).spice, 2);
const resident = 'arrakeen', location = 'arrakeen:10', sourceLocations = ['imperial_basin:9', 'oh_gap:9'];
const player = advisorGatherMatchingPlayer(game, bg);
assert.equal(player.forces[sourceLocations[0]], 2); assert.equal(player.forces[sourceLocations[1]], 1);
assert.equal(!!player.advisors?.imperial_basin, kind === 'mixed'); assert.ok(!player.advisors?.oh_gap);
assert.equal(player.forces[location], 1); assert.ok(player.advisors?.[resident]);
assert.equal(player.shipped, true); assert.equal(player.moved, 0);
assert.equal(planetologistLeader(game, bg), trainer);
assert.ok(advisorGatherMatchingPlayer(game, holder).hand.some(card => card.id === karama.id));
assert.deepEqual(sourceLocations.map(source => distance(source, location)), [1, 1]);
assert.deepEqual(advisorGatherMatchingInventory(game), originalInventory);
assert.deepEqual(advisorGatherMatchingSkillInventory(game), originalSkillInventory);
const forces = { 'imperial_basin:9': 1, 'oh_gap:9': 1 };
const action: Action = { type: 'move', planetologist: 'gather', forces, territory: resident, sector: 10 };
const largerPacket: Action = { type: 'move', planetologist: 'gather',
forces: { 'imperial_basin:9': 2, 'oh_gap:9': 1 }, territory: resident, sector: 10 };
const emptyReplacement: Action = { type: 'move', planetologist: 'gather', forces,
territory: 'rim_wall_west', sector: 9 };
const ordinaryReplacement: Action = { type: 'move', from: 'imperial_basin:9', territory: resident, sector: 10, amount: 1 };
const sameTypeReplacement: Action = kind === 'mixed'
? ordinaryReplacement
: { type: 'move', from: 'imperial_basin:9', territory: 'oh_gap', sector: 9, amount: 1 };
const sourceGroups = sourceLocations.map(source => ({ source, available: player.forces[source],
selected: forces[source as keyof typeof forces], advisors: source.startsWith('imperial_basin:') && kind === 'mixed' }));
return { game, source: structuredClone(game), beforeAction: structuredClone(game), initial, enableSkills, lobby, setup,
beforeStartingDeal, beforeSkillOrder, originalSkillOrder, skillOrder, skillOffers, skillChoices, afterSetup, advisorSetup,
funding, fundingAction, fundingStep: { actor: emperor, action: fundingAction } satisfies AdvisorGatherMatchingStep,
firstShipment, outsiderShipment, intrusion, beforeSecondShipment, secondShipment,
kind, bg, owner: bg, holder, atreides: holder, emperor, karama: karama.id, card: karama.id,
skill: 'planetologist' as const, trainer, resident, territory: resident, location, destination: resident, destinationLocation: location,
sourceLocations, sourceGroups, sourceCounts: { 'imperial_basin:9': 2, 'oh_gap:9': 1 }, residentCount: player.forces[location],
actor: bg, forces, group: forces, cost: 0, action,
largerPacket, largerBasinPacket: largerPacket, replacement: largerPacket,
replacements: [largerPacket, emptyReplacement, ordinaryReplacement, sameTypeReplacement],
emptyReplacement, ordinaryReplacement, ordinaryBasinToArrakeen: ordinaryReplacement, sameTypeReplacement,
incomingStances: { imperial_basin: kind === 'mixed' ? 'advisors' : 'fighters', oh_gap: 'fighters' },
matchingCount: kind === 'mixed' ? 1 : 2,
gatherStep: { actor: bg, action } satisfies AdvisorGatherMatchingStep,
steps, actions: steps, sourceActionCount: steps.length, originalInventory, originalSkillInventory,
originalSpice, dealOrder, spiceOrder,
staging: [
'Actual public Advanced BG/Atreides/Emperor lobby selects only all14 Leader Skills through moduleOption, then ready/startPreview with original base33. No Homeworld, Nexus, Tech, Stronghold, Discovery or independent-card module is selected.',
'Before BG’s actual prediction and the original Skill/Treachery deals, reorder only the untouched all14 Skill Deck and three original unopened Treachery positions. The native two-card offer really contains Planetologist; BG assigns it through its real leaderSkill action to a living eligible native trainer. The other two original skill offers are actually confirmed too, and Atreides retains its originally dealt Karama.',
'Actual advisorSetup places BG’s original advisor beside Atreides’ native ten Arrakeen10 counters. Select four safe original ordinary Spice draws, then run native Storm/dual Spice/Charity/Bidding/Revival and decline original preparation. No advisor ornithopter range is claimed.',
'Emperor actually bribes one spice to BG. BG really pays four on turn1 to ship two reserves to empty Basin9, then pays two on turn2 to ship one reserve to empty Oh Gap9. The genuine one-spice transfer funds the six-spice programme without installing a wallet; both origins are physically one graph step from Arrakeen10.',
kind === 'mixed'
? 'During its original turn1 movement Emperor really pays to ship one reserve into BG’s Basin group. BG actually accepts Intrusion and all original advisorFlip responses allow the change; separate Spiritual accompaniment is declined. On turn2 this earned incoming advisor group is no longer fresh, while Oh Gap remains a fighter origin.'
: 'Emperor skips its normal turn without entering Basin. Both earned incoming groups remain fighters; the single originally held Atreides Karama can prevent a later matching check after an earlier check is allowed.',
'Stop at BG’s real turn2 movement with the original resident Arrakeen advisor, live Planetologist, paid second shipment and unused ordinary move. Return the two-origin gather, a larger original Basin packet, an empty-target alternative and a same-type ordinary replacement; no matching response is installed or answered.',
'No played phase, force, wallet, hand, Skill assignment, alliance, stance, prevention or movement receipt is installed. Every Skill assignment, source group and advisor lock follows actual original setup or gameplay actions.',
] };
}
export type AdvisorGatherMatchingFixture = ReturnType<typeof createAdvisorGatherMatchingFixture>;
export function commitAdvisorGatherMatching(source: Game, fixture: AdvisorGatherMatchingFixture,
action: Action = fixture.action, steps: AdvisorGatherMatchingStep[] = []) {
const before = structuredClone(source), start = steps.length;
const game = actAdvisorGatherMatching(source, fixture.bg, action, steps);
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)) };
}
export function finishAdvisorGatherMatching(source: Game, steps: AdvisorGatherMatchingStep[] = []): Game {
return advanceAdvisorGatherMatching(source, game => advisorGatherMatchingClean(game), steps);
}