diff --git a/components/game-table.tsx b/components/game-table.tsx
index 357bed5a..049036b8 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -2503,6 +2503,14 @@ export function GameTable({
{' '}Any already-played movement or declaration card keeps its original use. Choose a legal replacement if matching is prevented.
)}
+ {g.response.kind==='advisorFlip'&&g.response.advisorGatherMatchingSource&&(
+
+ Gathered group {g.response.advisorGatherMatchingPosition?.current} of {g.response.advisorGatherMatchingPosition?.total}:
+ {' '}the incoming forces from {territory(g.response.advisorGatherMatchingSource.origin).name} must match your residents.
+ {' '}Neither origin has moved, changed type or collected spice. Every required group response must allow the same original gather before it moves once.
+ {' '}Preventing one group leaves both origins and the movement unused, while this prevented packet here remains unavailable. Choose a legal replacement.
+
+ )}
{g.response.kind==='advisorFlip'&&g.response.advisorTransportMatchingSource&&(
The incoming transport group must match your residents before payment or force transfer.
diff --git a/components/planetologist-movement.tsx b/components/planetologist-movement.tsx
index 904a89d4..735482f0 100644
--- a/components/planetologist-movement.tsx
+++ b/components/planetologist-movement.tsx
@@ -10,6 +10,8 @@ import {
type PlanetologistMoveMode,
} from '@/game/planetologist-movement';
import { botGroundMoveAllowed } from '@/game/bot-mobility';
+import { isAdvisor } from '@/game/advisors';
+import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from '@/game/advisor-gather-matching';
import {
gameTerritories,
location,
@@ -92,6 +94,12 @@ export function planetologistMoveDraft(
? 'The selected route is beyond the boosted range or is blocked.'
: 'Each selected source must reach the destination using its normal range.',
};
+ if(mode==='gather'){
+ try{
+ quoteAdvisorGatherMatching(game,{owner:me.id,move:me.moved??0,destination,sector,
+ advisors:isAdvisor(me,destination),group:Object.fromEntries(selected)});
+ }catch(error){if(!(error instanceof AdvisorGatherMatchingError))throw error;return {action:null,blocked:error.message};}
+ }
return {
action: {
type: 'move',
@@ -168,6 +176,9 @@ export function PlanetologistMovement({
gather forces from exactly two territories into the selected
destination.
+ {game.advanced&&me.faction==='beneGesserit'&&mode==='gather'&&(
+ When joining your residents, each incoming origin that changes type has its own matching response. Both groups wait for every required allowance. Prevention leaves the movement unused and keeps both groups in place.
+ )}
Movement mode
;
+};
+
+export class AdvisorGatherMatchingError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'AdvisorGatherMatchingError';
+ }
+}
+
+const identifier = (value: unknown): value is string =>
+ typeof value === 'string' && value.trim().length > 0;
+const nonnegative = (value: unknown): value is number =>
+ Number.isSafeInteger(value) && (value as number) >= 0;
+const positive = (value: unknown): value is number =>
+ Number.isSafeInteger(value) && (value as number) > 0;
+const record = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+function requireMatching(value: unknown, message: string): asserts value {
+ if (!value) throw new AdvisorGatherMatchingError(message);
+}
+
+/** Resident gather changes each incoming origin independently. The original
+ * Planetologist quote retains trainer, route and complete-order permission;
+ * enemy-only and empty destinations retain their existing native policy. */
+export function quoteAdvisorGatherMatching(
+ ctx: AdvisorMatchingContext,
+ intent: AdvisorGatherMatchingIntent,
+): AdvisorMatchingQuote[] {
+ if (ctx.advanced !== true || ctx.status !== 'playing' || ctx.phase !== 5 || !positive(ctx.turn)) return [];
+ requireMatching(record(intent) && identifier(intent.owner), 'Choose the current BG gather owner.');
+ const owners = ctx.players.filter((player) => player.id === intent.owner);
+ const owner = owners[0];
+ if (owners.length !== 1 || owner.faction !== 'beneGesserit' || ctx.active !== owner.id) return [];
+ requireMatching(nonnegative(intent.move) && identifier(intent.destination) && nonnegative(intent.sector) &&
+ typeof intent.advisors === 'boolean',
+ 'Choose the original gather movement and resident destination stance.');
+ const residents = Object.entries(owner.forces).some(([key, count]) =>
+ splitLocation(key).territory === intent.destination && count > 0);
+ if (!residents) return [];
+ const places = gameTerritories(ctx);
+ requireMatching(places.some((place) => place.id === intent.destination && place.sectors.includes(intent.sector)),
+ 'Gather matching needs a current board destination sector.');
+ requireMatching(record(intent.group) && Object.keys(intent.group).length > 0,
+ 'Select two nonempty physical board origins for gather matching.');
+ const origins = new Map>();
+ let total = 0;
+ // Sorting also gives every public origin packet a stable physical-key order.
+ for (const key of Object.keys(intent.group).sort()) {
+ const point = splitLocation(key);
+ requireMatching(key === `${point.territory}:${point.sector}` &&
+ places.some((place) => place.id === point.territory && place.sectors.includes(point.sector)),
+ 'Gather matching needs physical counters from current canonical board sectors.');
+ const count = intent.group[key];
+ const held = owner.forces[key];
+ requireMatching(positive(count) && nonnegative(held) && held >= count,
+ 'The selected gather packet is no longer held at its original board sources.');
+ total += count;
+ requireMatching(positive(total), 'The selected gather packet exceeds the supported physical count.');
+ const group = origins.get(point.territory) ?? {};
+ group[key] = count;
+ origins.set(point.territory, group);
+ }
+ requireMatching(origins.size === 2, 'Resident gather matching requires exactly two selected origin territories.');
+ const quotes: AdvisorMatchingQuote[] = [];
+ try {
+ for (const origin of [...origins.keys()].sort()) {
+ const quote = quoteAdvisorMatching(ctx, {
+ kind: 'movement', owner: owner.id, move: intent.move, origin,
+ destination: intent.destination, sector: intent.sector, advisors: intent.advisors,
+ group: origins.get(origin)!,
+ });
+ if (quote) quotes.push(quote);
+ }
+ } catch (error) {
+ if (error instanceof AdvisorMatchingError) throw new AdvisorGatherMatchingError(error.message);
+ throw error;
+ }
+ return quotes;
+}
diff --git a/game/bots.ts b/game/bots.ts
index 891d6d9d..8f73a575 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -119,6 +119,7 @@ import { fighterCount, isAdvisor } from './advisors';
import { quoteAdvisorMatching, AdvisorMatchingError } from './advisor-matching';
import { quoteAdvisorAmbassadorMatching, AdvisorAmbassadorMatchingError } from './advisor-ambassador-matching';
import { guildTransportQuote } from './transport-quote';
+import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from './advisor-gather-matching';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
import { reinforcementsBattleForces } from './reinforcements-homeworld';
import type { NativeReserveSelections } from './homeworld-native-reserves';
@@ -4087,7 +4088,7 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
/** Obligations apply across policy branches, including choosing to move first. */
function botAdvisorMatchingAllowed(g:GameView,action:Action) {
- if(!g.advisorMatchingDenied?.length || !['move','ship'].includes(action.type) || action.planetologist==='gather') return true;
+ if(!g.advisorMatchingDenied?.length || !['move','ship'].includes(action.type)) return true;
const me=g.players.find(p=>p.id===g.me)!;
if(!g.advanced || me.faction!=='beneGesserit' || g.phase!==5 || g.active!==me.id) return true;
const destination=String(action.territory),sector=Number(action.sector);
@@ -4095,6 +4096,12 @@ function botAdvisorMatchingAllowed(g:GameView,action:Action) {
action.forces && typeof action.forces==='object' ? action.forces as Record :
{[String(action.from)]:Number(action.amount)};
const origins=[...new Set(Object.keys(group).map(key=>splitLocation(key).territory))];
+ if(action.type==='move'&&action.planetologist==='gather'){
+ try{
+ quoteAdvisorGatherMatching(g,{owner:me.id,move:me.moved??0,destination,sector,
+ advisors:isAdvisor(me,destination),group});return true;
+ }catch(error){if(!(error instanceof AdvisorGatherMatchingError))throw error;return false;}
+ }
if(action.type==='move' && origins.length!==1) return true;
try {
quoteAdvisorMatching(g,{kind:action.type==='ship'?'shipment':'movement',owner:me.id,
diff --git a/game/engine.ts b/game/engine.ts
index deee2631..79fc3748 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -612,6 +612,7 @@ import { quoteAdvisorMatching, validateAdvisorMatching, advisorMatchingKey,
AdvisorMatchingError, type AdvisorMatchingSource } from './advisor-matching';
import { quoteAdvisorAmbassadorMatching, validateAdvisorAmbassadorMatching, advisorAmbassadorMatchingKey, AdvisorAmbassadorMatchingError, type AdvisorAmbassadorMatchingSource } from './advisor-ambassador-matching';
import { quoteAdvisorTransportMatching, validateAdvisorTransportMatching, advisorTransportMatchingKey, AdvisorTransportMatchingError, type AdvisorTransportMatchingSource } from './advisor-transport-matching';
+import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from './advisor-gather-matching';
import { spiritualAdvisorTargetBlock, spiritualAdvisorArrival, validateSpiritualAdvisorSource,
spiritualAdvisorMatchingKey, SpiritualAdvisorError, type SpiritualAdvisorSource } from './spiritual-advisor';
import {
@@ -1076,6 +1077,8 @@ export type ResponseWindow = {
advisorMatchingSource?: AdvisorMatchingSource;
advisorAmbassadorMatchingSource?: AdvisorAmbassadorMatchingSource;
advisorTransportMatchingSource?: AdvisorTransportMatchingSource;
+ advisorGatherMatchingSource?: AdvisorMatchingSource;
+ advisorGatherMatchingPosition?: {current:number;total:number};
spiritualAdvisorSource?: SpiritualAdvisorSource;
fremenGrant?: FremenRevivalGrantSource;
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
@@ -1299,6 +1302,13 @@ export type Game = {
intent: {kind:'movement'; order:MovementOrder} | {kind:'shipment'; order:PendingShipment};
};
advisorMatchingDenied?: string[];
+ pendingAdvisorGatherMatching?: {
+ order: MovementOrder;
+ receipts: AdvisorMatchingSource[];
+ signature: string;
+ index: number;
+ approved: string[];
+ };
pendingAdvisorAmbassadorMatching?: {
receipt: AdvisorAmbassadorMatchingSource;
order: MovementOrder;
@@ -14642,6 +14652,7 @@ function revivalCancellationQuote(g: Game, response: ResponseWindow) {
}
}
function movementCancellationQuote(g: Game, response: ResponseWindow) {
+ if (response.advisorGatherMatchingSource) { currentAdvisorGatherMatching(g,response); return null; }
if (response.advisorTransportMatchingSource) { currentAdvisorTransportMatching(g,response); return null; }
if (response.advisorAmbassadorMatchingSource) { currentAdvisorAmbassadorMatching(g,response); return null; }
if (response.spiritualAdvisorSource) { currentSpiritualAdvisorResponse(g,response); return null; }
@@ -16604,6 +16615,7 @@ function groundMovementProbe(source: Game, actor: string): Game {
game.pendingFremenMove = null; game.pendingIxMove = null; game.pendingChoamMove = null;
game.pendingAmbassador = null; game.pendingTerrorEntry = null; game.pendingArrivalOverlap = null;
delete game.pendingAdvisorTransportMatching;
+ delete game.pendingAdvisorGatherMatching;
game.pendingArrivalReaction = []; game.pendingExchange = null;
if (game.pendingShipment?.player === game.active && recoverableUnsupportedShipment(game.pendingShipment)) {
const shipment = game.pendingShipment; game.pendingShipment = null;
@@ -17011,9 +17023,12 @@ function declareGroundMovement(g: Game, p: Player, action: Action, quoteOnly = f
);
validateFlightSelection(g, p, move);
const matching = quoteMatchingMovement(g, move);
+ const gatherMatching=quoteGatherMatching(g,move);
if (quoteOnly) return move;
checkGroundMovementPromises(g, p, move);
- if (advisorGather) {
+ if(gatherMatching.length){
+ openAdvisorGatherMatching(g,move,gatherMatching);
+ } else if (advisorGather) {
g.response = {
kind: 'advisorFlip', owner: id, location: to, advisors: false, amount: n, passed: [],
advisorResume: 'planetologistGather', advisorGatherMove: { turn: g.turn, move: p.moved, order: move },
@@ -20773,6 +20788,76 @@ function finishAdvisorAmbassadorMatching(g:Game,response:ResponseWindow,canceled
{faction:'beneGesserit',name:'Ambassador group matching prevented'});
}else offerChoamMovement(g,parent.order);
}
+function gatherMatchingRule(quote:()=>T):T{
+ try{return quote();}catch(error){if(error instanceof AdvisorGatherMatchingError)throw new RuleError(error.message);throw error;}
+}
+function quoteGatherMatching(g:Game,move:MovementOrder){
+ if(move.planetologist?.mode!=='gather')return [];
+ return gatherMatchingRule(()=>quoteAdvisorGatherMatching(g,{owner:move.player,move:getPlayer(g,move.player).moved,
+ destination:move.to,sector:move.sector,advisors:move.advisors,group:Object.fromEntries(move.group)}));
+}
+function nextAdvisorGatherMatching(g:Game){
+ const parent=g.pendingAdvisorGatherMatching!,source=parent.receipts[parent.index];
+ g.response={kind:'advisorFlip',owner:source.owner,location:source.destination,advisors:source.advisors,
+ amount:Object.values(source.group).reduce((n,count)=>n+count,0),passed:[],advisorGatherMatchingSource:source,
+ advisorGatherMatchingPosition:{current:parent.index+1,total:parent.receipts.length}};
+}
+function openAdvisorGatherMatching(g:Game,order:MovementOrder,quotes:Omit[]){
+ requireRule(!g.pendingAdvisorGatherMatching&&!g.response&&!g.decision&"es.length>0,
+ 'Finish the original gathered matching before another movement.');
+ const receipts=quotes.map(quote=>({event:crypto.randomUUID(),...quote}));
+ g.pendingAdvisorGatherMatching={order,receipts,signature:JSON.stringify([order,receipts]),index:0,approved:[]};
+ nextAdvisorGatherMatching(g);
+}
+function currentAdvisorGatherMatching(g:Game,response:ResponseWindow){
+ const parent=g.pendingAdvisorGatherMatching,source=response.advisorGatherMatchingSource;
+ requireRule(parent&&source&&Number.isSafeInteger(parent.index)&&parent.index>=0&&parent.indexevent===parent.receipts[index].event)&&
+ parent.signature===JSON.stringify([parent.order,parent.receipts])&&parent.order.planetologist?.mode==='gather'&&
+ JSON.stringify(parent.receipts[parent.index])===JSON.stringify(source)&&response.kind==='advisorFlip'&&
+ response.owner===source.owner&&response.location===source.destination&&response.advisors===source.advisors&&
+ response.amount===Object.values(source.group).reduce((n,count)=>n+count,0)&&
+ response.advisorGatherMatchingPosition?.current===parent.index+1&&
+ response.advisorGatherMatchingPosition?.total===parent.receipts.length&&
+ !response.advisorResume&&!response.advisorMatchingSource&&!response.advisorAmbassadorMatchingSource&&
+ !response.advisorTransportMatchingSource&&!response.spiritualAdvisorSource&&!response.automaticAdvisorFlip&&
+ !response.advisorPreparationSource&&!response.advisorGatherMove,
+ 'The gathered matching response lost its original two-origin declaration or approvals.');
+ matchingAdvisorRule(()=>validateAdvisorMatching(g,source));
+ validateMovementOrder(g,getPlayer(g,source.owner),parent.order);
+ const quotes=quoteGatherMatching(g,parent.order);
+ requireRule(JSON.stringify(quotes)===JSON.stringify(parent.receipts.map(receipt=>Object.fromEntries(
+ Object.entries(receipt).filter(([key])=>key!=='event')))),
+ 'The gathered matching responses no longer describe their original selected groups.');
+ return parent;
+}
+function advisorGatherMatchingIntegrity(g:Game){
+ if(!g.pendingAdvisorGatherMatching)return;
+ const continuation=g.pendingTreacheryDiscard?.continuation;
+ const controls=[g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume,
+ g.pendingRichesePurchaseIncome?.resume,g.summonedWorm?.resume,g.pendingKull?.resume,
+ continuation&&'resume'in continuation?continuation.resume:null];
+ const responses=controls.flatMap(c=>c?.response?[c.response]:[]);
+ for(const {pending}of savedKaramaContexts(g))if(pending.use.kind==='cancel')responses.push(pending.use.response);
+ const matching=responses.filter(r=>r.advisorGatherMatchingSource);
+ requireRule(matching.length>0,'Restore the original gathered matching response.');
+ for(const response of matching)currentAdvisorGatherMatching(g,response);
+}
+function finishAdvisorGatherMatching(g:Game,response:ResponseWindow,canceled:boolean){
+ const parent=currentAdvisorGatherMatching(g,response),source=parent.receipts[parent.index];
+ if(canceled){
+ delete g.pendingAdvisorGatherMatching;
+ const key=advisorMatchingKey(source);if(!(g.advisorMatchingDenied??=[]).includes(key))g.advisorMatchingDenied.push(key);
+ log(g,`${getPlayer(g,source.owner).name}'s gathered matching from ${territory(source.origin).name} in ${territory(source.destination).name} was prevented. Neither group moved or changed type, and the movement remains unused. Choose a legal replacement without this prevented packet.`,
+ {faction:'beneGesserit',name:'Gathered group matching prevented'});
+ }else{
+ parent.approved.push(source.event);parent.index++;
+ if(parent.index(quote: () => T): T {
try { return quote(); }
catch(error) { if(error instanceof AdvisorMatchingError) throw new RuleError(error.message); throw error; }
@@ -23305,7 +23390,7 @@ function decideSharedSpice(g: Game, id: string, action: Action) {
function victory(g: Game, quote?: VictoryQuote) {
requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport &&
!g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorHomeworldTransport &&
- !g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching && !g.pendingAdvisorAmbassadorMatching && !g.pendingAdvisorTransportMatching && !g.pendingSpiritualAdvisor,
+ !g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching && !g.pendingAdvisorAmbassadorMatching && !g.pendingAdvisorTransportMatching && !g.pendingAdvisorGatherMatching && !g.pendingSpiritualAdvisor,
'Resolve mandatory advisor conversions before checking victory.');
quote ??= currentVictoryQuote(g);
applyAdvisorReleases(g, quote.released);
@@ -29097,7 +29182,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
);
} else if (response.kind === 'advisorFlip') {
const bg = getPlayer(g, response.owner);
- if (response.advisorTransportMatchingSource) finishAdvisorTransportMatching(g,response,canceled);
+ if (response.advisorGatherMatchingSource) finishAdvisorGatherMatching(g,response,canceled);
+ else if (response.advisorTransportMatchingSource) finishAdvisorTransportMatching(g,response,canceled);
else if (response.advisorAmbassadorMatchingSource) finishAdvisorAmbassadorMatching(g,response,canceled);
else if (response.spiritualAdvisorSource) finishSpiritualAdvisorResponse(g,response,canceled);
else if (response.advisorMatchingSource) finishAdvisorMatching(g,response,canceled);
@@ -32784,6 +32870,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
treacheryDiscardIntegrity(state);
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
+ advisorGatherMatchingIntegrity(state);
advisorTransportMatchingIntegrity(state);
advisorAmbassadorMatchingIntegrity(state);
spiritualAdvisorIntegrity(state);
@@ -33197,6 +33284,7 @@ export function normalizeAutomaticGame(state: Game): Game {
treacheryDiscardIntegrity(state);
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
+ advisorGatherMatchingIntegrity(state);
advisorTransportMatchingIntegrity(state);
advisorAmbassadorMatchingIntegrity(state);
spiritualAdvisorIntegrity(state);
@@ -37286,6 +37374,7 @@ export function viewGame(state: Game, id: string) {
treacheryDiscardIntegrity(state);
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
+ advisorGatherMatchingIntegrity(state);
advisorTransportMatchingIntegrity(state);
advisorAmbassadorMatchingIntegrity(state);
spiritualAdvisorIntegrity(state);
diff --git a/game/karama-context.ts b/game/karama-context.ts
index 0537c0ad..a461ec47 100644
--- a/game/karama-context.ts
+++ b/game/karama-context.ts
@@ -194,6 +194,9 @@ const cancellationSources = {
...(response.automaticAdvisorFlip.producer === 'spiritualArrival'
? { spiritual:g.pendingSpiritualAdvisor?.source??null } : {}),
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
+ : response.advisorGatherMatchingSource
+ ? {matching:response.advisorGatherMatchingSource,
+ pending:g.pendingAdvisorGatherMatching?.receipts[g.pendingAdvisorGatherMatching.index]??null}
: response.advisorTransportMatchingSource
? {matching:response.advisorTransportMatchingSource,pending:g.pendingAdvisorTransportMatching?.receipt??null}
: response.advisorAmbassadorMatchingSource
diff --git a/game/karama-movement-cancellation.ts b/game/karama-movement-cancellation.ts
index 3a415c87..e408d100 100644
--- a/game/karama-movement-cancellation.ts
+++ b/game/karama-movement-cancellation.ts
@@ -309,6 +309,7 @@ export function quoteMovementCancellation(
if (response.kind === 'advisorFlip' && response.advisorMatchingSource) return null;
if (response.kind === 'advisorFlip' && response.advisorAmbassadorMatchingSource) return null;
if (response.kind === 'advisorFlip' && response.advisorTransportMatchingSource) return null;
+ if (response.kind === 'advisorFlip' && response.advisorGatherMatchingSource) return null;
if (response.kind === 'advisorFlip' && response.spiritualAdvisorSource) return null;
if (
![
diff --git a/game/reference.ts b/game/reference.ts
index a9145dc4..62f1224a 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4317,6 +4317,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A normal shipment enters as fighters unless joining existing advisors. Opposite-type normal ground arrivals and normal reserves joining advisors now offer Karama matching prevention before movement or payment. Residents keep their type. Provisionally stop a prevented packet into this territory for that action opportunity, leaving a legal replacement and the unused action available. Same-type moves need no flip. Advisors entering another occupied territory may keep their existing optional fighter choice.',
'An allied Fremen Ambassador relocation also offers incoming matching before any selected force moves or collects spice. Residents keep their type. Provisionally, prevention restores the same relocation chooser while denying that same packet here for this Ambassador event. The already-triggered token and ordinary movement/shipment remain unchanged; another legal packet or destination stays available.',
'Fresh Guild cross-transport and Junction board arrivals also offer incoming matching before their original permission, price, payment or transfer. Junction Homeworld groups provisionally enter fighter-side. Prevention leaves the original shipment and grant unused while that same packet here stays unavailable for this producer opportunity; choose another legal packet or destination. Junction permission and tariff remain immune. Secret Ally keeps its actual Nexus until the shipment commits.',
+ 'Planetologist gathering into your residents offers one matching response per incoming origin that must change type. Two changing origins need two separate responses. Provisionally, neither group moves or changes type until every required allowance; preventing any group leaves both origins and the movement unused. That prevented origin packet here is also unavailable to a normal move during the same opportunity. Choose another legal packet or destination.',
'After Trip to Gamont completes during Mentat, the last outsider’s return or empty No-Field reveal can leave advisors alone. Karama can then prevent their conversion without undoing the completed effect or card use. The final CHOAM opportunity resumes afterward; victory waits for its original confirmation. Ordinary Mentat does not gain an extra final chooser.',
'A committed Guild cross-shipment or return to reserves can also leave advisors alone. Original transport permission, rate and any Guild income resolve first; payment, force transfer and shipment use stay committed if the later conversion is prevented. The ordinary ground move remains unused. Cross-shipment arrival follows the conversion once; a reserve return has no arrival.',
'After actual battle losses leave surviving bystanding advisors alone, Karama may prevent their conversion without undoing losses or costs. Mandatory card disposal, original skills and substitution finish first. Provisionally resolve this before optional winner-card cleanup and later aftermath, then resume the original cleanup once. An explosion that destroys the advisors creates no conversion.',
@@ -4335,8 +4336,13 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Implementation",
"status": "Partial",
- "detail": "Fresh Guild/native Secret Ally and Junction board/Homeworld arrival declarations now own precommit matching before their original permission/rate/settlement suffix. Original scope, funding and order stay private; prevention retains unused shipment and grant. Fremen Ambassador incoming matching now owns a separate precommit counter tied to the actual allied grant, with scoped denial and one original CHOAM/transfer/arrival suffix. Residents, consumed trigger and ordinary allowances are retained. Fresh Advanced Spiritual declarations share target legality and distinct shipping/matching/committed-alone stages. A public seven-field source binds a private original chooser/parent/order; late unavailability restores choice before placement, and committed callbacks never recheck withdrawn reserves. Ordinary/Guild continuation and prior alone holds stay intact. Basic and already-recorded legacy responses keep their old protocol.",
+ "detail": "Resident Planetologist gathers partition incoming physical origins, queue one normal-format matching counter per changing group and retain a private original order/approval batch until atomic commitment. Fresh Guild/native Secret Ally and Junction board/Homeworld arrival declarations now own precommit matching before their original permission/rate/settlement suffix. Original scope, funding and order stay private; prevention retains unused shipment and grant. Fremen Ambassador incoming matching now owns a separate precommit counter tied to the actual allied grant, with scoped denial and one original CHOAM/transfer/arrival suffix. Residents, consumed trigger and ordinary allowances are retained. Fresh Advanced Spiritual declarations share target legality and distinct shipping/matching/committed-alone stages. A public seven-field source binds a private original chooser/parent/order; late unavailability restores choice before placement, and committed callbacks never recheck withdrawn reserves. Ordinary/Guild continuation and prior alone holds stay intact. Basic and already-recorded legacy responses keep their old protocol.",
"evidence": [
+ "game/advisor-gather-matching.ts",
+ "components/planetologist-movement.tsx",
+ "tests/advisor-gather-matching.test.ts",
+ "tests/advisor-gather-matching-engine.test.ts",
+ "tests/fixture-advisor-gather-matching.ts",
"game/advisor-transport-matching.ts",
"game/transport-quote.ts",
"game/junction-transport-options.ts",
@@ -4367,8 +4373,13 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Player controls",
"status": "Partial",
- "detail": "Transport and Junction quotes share scoped packet denials and persistent before-payment matching guidance; twenty native GameTable SSR journeys and two controlled Truthtrance cases pass. Ambassador relocation drafts and persistent matching notices reuse the shared packet denial quote. Six native declaration/response/replacement GameTable SSR journeys pass; callback/hydration and browser acceptance remain separate. Shared projected target choices drive ordinary/Guild buttons and minimal bots. Persistent notices distinguish uncommitted shipping/matching from an already-arrived force awaiting conversion. Same prevented matching packet stays blocked when the original chooser returns. Fifteen native GameTable SSR journeys and two controlled late-stance cases pass; callback/hydration and browser acceptance remain separate.",
+ "detail": "Gather drafts share normal/gather denial quotes; persistent progress identifies each required changing origin. Partial allowance keeps both groups in place. Seven native gather GameTable SSR journeys pass; callback/hydration/browser acceptance remains separate. Transport and Junction quotes share scoped packet denials and persistent before-payment matching guidance; twenty native GameTable SSR journeys and two controlled Truthtrance cases pass. Ambassador relocation drafts and persistent matching notices reuse the shared packet denial quote. Six native declaration/response/replacement GameTable SSR journeys pass; callback/hydration and browser acceptance remain separate. Shared projected target choices drive ordinary/Guild buttons and minimal bots. Persistent notices distinguish uncommitted shipping/matching from an already-arrived force awaiting conversion. Same prevented matching packet stays blocked when the original chooser returns. Fifteen native GameTable SSR journeys and two controlled late-stance cases pass; callback/hydration and browser acceptance remain separate.",
"evidence": [
+ "game/advisor-gather-matching.ts",
+ "components/planetologist-movement.tsx",
+ "tests/advisor-gather-matching.test.ts",
+ "tests/advisor-gather-matching-engine.test.ts",
+ "tests/fixture-advisor-gather-matching.ts",
"game/advisor-transport-matching.ts",
"game/transport-quote.ts",
"game/junction-transport-options.ts",
@@ -4388,8 +4399,13 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "AI",
"status": "Partial",
- "detail": "Transport policies filter prevented packets through the same quotes; sixteen native transport paths across existing profiles accept32 response/replacement actions. No strategic refinement or difficulty calibration. Fremen Ambassador candidates reuse the public packet quote to omit prevented retries. Eight native existing-profile paths accept sixteen response/replacement actions after Ambassador matching; no strategy refinement. Minimal policies consume projected legal targets, including all-blocked matching fallback to decline. Twenty-four paths across four existing profiles, two native parents and three stances accept72 choice/response actions. This is legal participation; no strategy refinement or difficulty calibration.",
+ "detail": "Minimal gather candidates reuse the partition quote so normal/gather prevented packets cannot bypass one another. Eight native gather paths across existing profiles accept sixteen response/replacement actions; no strategy refinement or calibration. Transport policies filter prevented packets through the same quotes; sixteen native transport paths across existing profiles accept32 response/replacement actions. No strategic refinement or difficulty calibration. Fremen Ambassador candidates reuse the public packet quote to omit prevented retries. Eight native existing-profile paths accept sixteen response/replacement actions after Ambassador matching; no strategy refinement. Minimal policies consume projected legal targets, including all-blocked matching fallback to decline. Twenty-four paths across four existing profiles, two native parents and three stances accept72 choice/response actions. This is legal participation; no strategy refinement or difficulty calibration.",
"evidence": [
+ "game/advisor-gather-matching.ts",
+ "components/planetologist-movement.tsx",
+ "tests/advisor-gather-matching.test.ts",
+ "tests/advisor-gather-matching-engine.test.ts",
+ "tests/fixture-advisor-gather-matching.ts",
"game/advisor-transport-matching.ts",
"game/transport-quote.ts",
"game/junction-transport-options.ts",
@@ -4432,7 +4448,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Documentation",
"status": "Partial",
- "detail": "Guild/Junction/World/Secret matching records provisional response priority, immune Junction tariff, private scope and fresh-declaration cutover. Source and native/control evidence remain separate from broader mode acceptance. Supplied physical28 redirects retained non-Polar fighters and starts Spiritual Advisors advisor-side, with no alone-conversion Polar exemption. Distinct physical17 counters, provisional Polar matching/restored-choice remedy, continuously-alone duration and the legacy source cutover are explicit. Wider combinations remain open.",
+ "detail": "Resident gathering records one counter per changing origin, provisional atomic commitment, shared normal denials and preserved enemy-only/empty/legacy boundaries. Guild/Junction/World/Secret matching records provisional response priority, immune Junction tariff, private scope and fresh-declaration cutover. Source and native/control evidence remain separate from broader mode acceptance. Supplied physical28 redirects retained non-Polar fighters and starts Spiritual Advisors advisor-side, with no alone-conversion Polar exemption. Distinct physical17 counters, provisional Polar matching/restored-choice remedy, continuously-alone duration and the legacy source cutover are explicit. Wider combinations remain open.",
"evidence": [
"docs/SPIRITUAL_ADVISORS.md",
"docs/ADVISOR_MATCHING.md",
@@ -4445,8 +4461,13 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Verification",
"status": "Partial",
- "detail": "The fresh transport batch passes25 files437/437, types/lint/build, twenty native Guild/Junction/World/Secret GameTable SSR journeys, two controlled Truthtrance cases and sixteen existing-profile paths. Genuine85/82/36/74-action sources earn original grants; permission and actual-discount responses remain separate, and Secret spends Nexus once at commitment. Review repairs the owned Truthtrance shadow and price consistency; smoke/type feedback is repaired. Earlier results below retain their original scope. The new Fremen Ambassador batch passes17 files358/358, types/lint/build, six native declaration/response/replacement GameTable SSR journeys and eight existing-profile paths. Genuine81-action sources earn a Nexus alliance and actual allied grant; both directions and responses retain trigger/ordinary allowances. One zero/absence assertion is repaired. The following earlier frozen results retain their original scope. The frozen batch passes22 files390/390, types/lint/build, fifteen native ordinary/Guild declaration/response/GameTable SSR journeys, two controlled late-stance cases and twenty-four existing-profile paths. Public35/37-action ordinary and45/48-action Guild sources earn actual shipment/Intrusion; a second genuine grant preserves the held group. Four older matching/projection expectations are updated. Controlled legacy responses are format compatibility, not saved captures. Wider Homeworld/Tech/Nexus/No-Field combinations, other matching producers, full modes and final assurance remain open.",
+ "detail": "Resident gather proof passes15 files274/274, types/lint/build, seven native group-response/replacement GameTable SSR journeys and eight existing-profile paths. Genuine78/73-action sources earn Planetologist, original Karama, paid origins and real funding/Intrusion. Partial allowance is physically inert; second prevention retains the whole movement, and normal/gather denials cannot be bypassed. Fixture type feedback is repaired. Earlier results retain their original scope. The fresh transport batch passes25 files437/437, types/lint/build, twenty native Guild/Junction/World/Secret GameTable SSR journeys, two controlled Truthtrance cases and sixteen existing-profile paths. Genuine85/82/36/74-action sources earn original grants; permission and actual-discount responses remain separate, and Secret spends Nexus once at commitment. Review repairs the owned Truthtrance shadow and price consistency; smoke/type feedback is repaired. Earlier results below retain their original scope. The new Fremen Ambassador batch passes17 files358/358, types/lint/build, six native declaration/response/replacement GameTable SSR journeys and eight existing-profile paths. Genuine81-action sources earn a Nexus alliance and actual allied grant; both directions and responses retain trigger/ordinary allowances. One zero/absence assertion is repaired. The following earlier frozen results retain their original scope. The frozen batch passes22 files390/390, types/lint/build, fifteen native ordinary/Guild declaration/response/GameTable SSR journeys, two controlled late-stance cases and twenty-four existing-profile paths. Public35/37-action ordinary and45/48-action Guild sources earn actual shipment/Intrusion; a second genuine grant preserves the held group. Four older matching/projection expectations are updated. Controlled legacy responses are format compatibility, not saved captures. Wider Homeworld/Tech/Nexus/No-Field combinations, other matching producers, full modes and final assurance remain open.",
"evidence": [
+ "game/advisor-gather-matching.ts",
+ "components/planetologist-movement.tsx",
+ "tests/advisor-gather-matching.test.ts",
+ "tests/advisor-gather-matching-engine.test.ts",
+ "tests/fixture-advisor-gather-matching.ts",
"game/advisor-transport-matching.ts",
"game/transport-quote.ts",
"game/junction-transport-options.ts",
diff --git a/tests/advisor-gather-matching-engine.test.ts b/tests/advisor-gather-matching-engine.test.ts
new file mode 100644
index 00000000..b9a0c23e
--- /dev/null
+++ b/tests/advisor-gather-matching-engine.test.ts
@@ -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;indexc.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));}
+});
diff --git a/tests/advisor-gather-matching.test.ts b/tests/advisor-gather-matching.test.ts
new file mode 100644
index 00000000..85913fe3
--- /dev/null
+++ b/tests/advisor-gather-matching.test.ts
@@ -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 {
+ 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 {
+ 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[] = [
+ {}, { '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);
+});
diff --git a/tests/fixture-advisor-gather-matching.ts b/tests/fixture-advisor-gather-matching.ts
new file mode 100644
index 00000000..279e5d7b
--- /dev/null
+++ b/tests/fixture-advisor-gather-matching.ts
@@ -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 = {
+ 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;
+
+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);
+}