Counter incoming BG matching before Fremen Ambassador relocation

Bind the selected packet to its original allied grant before transfer or collection. Preserve the committed trigger, restore legal replacement choices after prevention and resume the original movement once.
This commit is contained in:
Quintine committed 2026-10-24 22:38:55 +11:00
1 parent 767bace774
commit c999054ae0
15 files changed
+1040 -12

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import type { FactionId } from './catalog';
import type { AdvisorStance } from './advisor-automatic-flip';
export type AdvisorAmbassadorMatchingSource = {
event: string;
parent: string;
owner: string;
turn: number;
phase: number;
origin: string;
destination: string;
sector: number;
advisors: boolean;
group: Record<string, number>;
};
export type AdvisorAmbassadorMatchingIntent = Omit<AdvisorAmbassadorMatchingSource, 'event' | 'turn' | 'phase'>;
export type AdvisorAmbassadorMatchingQuote = Omit<AdvisorAmbassadorMatchingSource, 'event'>;
export type AdvisorAmbassadorMatchingPlayer = {
id: string;
faction: FactionId;
forces: Readonly<Record<string, number>>;
advisors?: Readonly<Record<string, AdvisorStance>>;
};
export type AdvisorAmbassadorMatchingContext = MobileBoard & {
advanced: boolean;
status: string;
turn: number;
phase: number;
players: readonly AdvisorAmbassadorMatchingPlayer[];
pendingAmbassador?: {
event: string;
turn?: number;
phase?: number;
effect?: string;
stage?: string;
beneficiary?: string | null;
} | null;
advisorAmbassadorMatchingDenied?: readonly string[];
};
export class AdvisorAmbassadorMatchingError extends Error {
constructor(message: string) {
super(message);
this.name = 'AdvisorAmbassadorMatchingError';
}
}
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<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function requireMatching(value: unknown, message: string): asserts value {
if (!value) throw new AdvisorAmbassadorMatchingError(message);
}
/** Only the original public Ambassador opportunity, packet and target stance
* identify prevention. A new response UUID or destination sector is no retry. */
export function advisorAmbassadorMatchingKey(
source: AdvisorAmbassadorMatchingSource | AdvisorAmbassadorMatchingQuote,
): string {
const group = Object.entries(source.group).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
return JSON.stringify([
'advisorAmbassadorMatching', source.turn, source.parent, source.owner,
source.origin, source.destination, source.advisors, group,
]);
}
function hasResidents(player: AdvisorAmbassadorMatchingPlayer, territory: string) {
return Object.entries(player.forces).some(([key, count]) =>
splitLocation(key).territory === territory && count > 0);
}
/** The original Fremen grant, not the active ordinary mover, owns this request.
* Native phase permission, route, locks and occupancy stay with that producer. */
export function quoteAdvisorAmbassadorMatching(
ctx: AdvisorAmbassadorMatchingContext,
intent: AdvisorAmbassadorMatchingIntent,
): AdvisorAmbassadorMatchingQuote | null {
if (ctx.advanced !== true || ctx.status !== 'playing') return null;
requireMatching(record(intent) && identifier(intent.owner), 'Choose the native BG Ambassador beneficiary.');
const owners = ctx.players.filter((player) => player.id === intent.owner);
const owner = owners[0];
if (owners.length !== 1 || owner.faction !== 'beneGesserit') return null;
const parent = ctx.pendingAmbassador;
if (!record(parent) || parent.effect !== 'fremen' || parent.stage !== 'move' || parent.beneficiary !== owner.id) return null;
requireMatching(identifier(intent.parent) && identifier(parent.event) && intent.parent === parent.event &&
positive(ctx.turn) && parent.turn === ctx.turn && nonnegative(ctx.phase) && ctx.phase <= 8 && parent.phase === ctx.phase,
'The original current-turn Fremen Ambassador relocation has changed.');
requireMatching(identifier(intent.origin) && identifier(intent.destination) && nonnegative(intent.sector) &&
typeof intent.advisors === 'boolean',
'Choose the original board packet and resident destination stance.');
const places = gameTerritories(ctx);
const origin = places.find((place) => place.id === intent.origin);
const destination = places.find((place) => place.id === intent.destination && place.sectors.includes(intent.sector));
requireMatching(origin && destination, 'Ambassador matching needs a current board origin and destination sector.');
// Sector-only relocation retains the territory's existing single BG type.
if (intent.origin === intent.destination || !hasResidents(owner, intent.destination)) return null;
const targetAdvisors = !!owner.advisors?.[intent.destination];
requireMatching(intent.advisors === targetAdvisors,
'The resident BG group no longer has the declared Ambassador matching stance.');
if (!!owner.advisors?.[intent.origin] === targetAdvisors) return null;
requireMatching(record(intent.group) && Object.keys(intent.group).length > 0,
'Select one nonempty physical BG packet for Ambassador matching.');
const group: Record<string, number> = {};
let total = 0;
for (const key of Object.keys(intent.group)) {
const point = splitLocation(key);
requireMatching(key === `${point.territory}:${point.sector}` && point.territory === intent.origin &&
origin.sectors.includes(point.sector),
'Select the incoming physical packet from one current board origin.');
const count = intent.group[key];
const held = owner.forces[key];
requireMatching(positive(count) && nonnegative(held) && held >= count,
'The incoming Ambassador packet is no longer held at its original location.');
total += count;
requireMatching(positive(total), 'The incoming Ambassador packet exceeds the supported physical count.');
group[key] = count;
}
const quote: AdvisorAmbassadorMatchingQuote = {
parent: parent.event, owner: owner.id, turn: ctx.turn, phase: ctx.phase,
origin: intent.origin, destination: intent.destination, sector: intent.sector,
advisors: targetAdvisors, group,
};
requireMatching(ctx.advisorAmbassadorMatchingDenied === undefined ||
(Array.isArray(ctx.advisorAmbassadorMatchingDenied) &&
ctx.advisorAmbassadorMatchingDenied.every((key) => typeof key === 'string')),
'The Ambassador matching prevention history is invalid.');
requireMatching(!ctx.advisorAmbassadorMatchingDenied?.includes(advisorAmbassadorMatchingKey(quote)),
'Karama already prevented this selected packet from matching in that territory during the original Ambassador opportunity.');
return quote;
}
/** Recheck the same uncommitted packet before either counter outcome. */
export function validateAdvisorAmbassadorMatching(
ctx: AdvisorAmbassadorMatchingContext,
source: AdvisorAmbassadorMatchingSource,
): void {
requireMatching(record(source) &&
Object.keys(source).sort().join(',') === 'advisors,destination,event,group,origin,owner,parent,phase,sector,turn' &&
identifier(source.event) && positive(source.turn) && source.turn === ctx.turn &&
nonnegative(source.phase) && source.phase === ctx.phase,
'Ambassador matching lost its original current-turn public source.');
const quote = quoteAdvisorAmbassadorMatching(ctx, {
parent: source.parent, owner: source.owner, origin: source.origin, destination: source.destination,
sector: source.sector, advisors: source.advisors, group: source.group,
});
requireMatching(quote, 'There is no current incoming BG stance change for this Fremen Ambassador source.');
}
+7
View File
@@ -117,6 +117,7 @@ import { botNexusHarkonnenBetrayalActions } from './bot-nexus-harkonnen-betrayal
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { fighterCount, isAdvisor } from './advisors';
import { quoteAdvisorMatching, AdvisorMatchingError } from './advisor-matching';
import { quoteAdvisorAmbassadorMatching, AdvisorAmbassadorMatchingError } from './advisor-ambassador-matching';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
import { reinforcementsBattleForces } from './reinforcements-homeworld';
import type { NativeReserveSelections } from './homeworld-native-reserves';
@@ -418,6 +419,12 @@ function fremenAmbassadorMoves(g: GameView): Action[] {
(destination.advisors ? 3 : 0);
const sandmaster = !marker
? sandmasterDestinationCollection(g, me.id, source.territory, destination.territory, destination.sector) : null;
try{
quoteAdvisorAmbassadorMatching({...g,pendingAmbassador:{...entry,turn:g.turn,phase:g.phase,
beneficiary:me.id},advisorAmbassadorMatchingDenied:entry.matchingDenied},
{parent:entry.event,owner:me.id,origin:source.territory,destination:destination.territory,
sector:destination.sector,advisors:destination.advisors,group:forces});
}catch(error){if(!(error instanceof AdvisorAmbassadorMatchingError))throw error;continue;}
candidates.push({
score,
action: {
+90 -8
View File
@@ -610,6 +610,7 @@ import {
import { canUseAsKaramaRole } from './shrine';
import { quoteAdvisorMatching, validateAdvisorMatching, advisorMatchingKey,
AdvisorMatchingError, type AdvisorMatchingSource } from './advisor-matching';
import { quoteAdvisorAmbassadorMatching, validateAdvisorAmbassadorMatching, advisorAmbassadorMatchingKey, AdvisorAmbassadorMatchingError, type AdvisorAmbassadorMatchingSource } from './advisor-ambassador-matching';
import { spiritualAdvisorTargetBlock, spiritualAdvisorArrival, validateSpiritualAdvisorSource,
spiritualAdvisorMatchingKey, SpiritualAdvisorError, type SpiritualAdvisorSource } from './spiritual-advisor';
import {
@@ -1072,6 +1073,7 @@ export type ResponseWindow = {
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
advisorPreparationSource?: AdvisorPreparationSource;
advisorMatchingSource?: AdvisorMatchingSource;
advisorAmbassadorMatchingSource?: AdvisorAmbassadorMatchingSource;
spiritualAdvisorSource?: SpiritualAdvisorSource;
fremenGrant?: FremenRevivalGrantSource;
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
@@ -1295,6 +1297,12 @@ export type Game = {
intent: {kind:'movement'; order:MovementOrder} | {kind:'shipment'; order:PendingShipment};
};
advisorMatchingDenied?: string[];
pendingAdvisorAmbassadorMatching?: {
receipt: AdvisorAmbassadorMatchingSource;
order: MovementOrder;
signature: string;
parentSignature: string;
};
pendingSpiritualAdvisor?: {
event: string;
turn: number;
@@ -1825,6 +1833,7 @@ export type Game = {
owner: string; territory: string; sector: number; tokenEvent: string;
offer?: GuildAmbassadorMarkerQuote;
};
advisorMatchingDenied?: string[];
relocation?: {
order: FremenAmbassadorMove;
next: 'flip' | 'intrusion' | 'terror' | 'finish';
@@ -14623,6 +14632,7 @@ function revivalCancellationQuote(g: Game, response: ResponseWindow) {
}
}
function movementCancellationQuote(g: Game, response: ResponseWindow) {
if (response.advisorAmbassadorMatchingSource) { currentAdvisorAmbassadorMatching(g,response); return null; }
if (response.spiritualAdvisorSource) { currentSpiritualAdvisorResponse(g,response); return null; }
if (response.advisorMatchingSource) { currentAdvisorMatching(g, response); return null; }
try {
@@ -18559,7 +18569,9 @@ function finishGuildAmbassadorAdvisorArrival(g:Game,response:ResponseWindow,
function quoteAmbassadorRelocation(g: Game, action: Action) {
const { beneficiary } = currentFremenAmbassador(g, stringField(action.event));
try {
return quoteFremenAmbassadorMove(g, beneficiary.id, action);
const move=quoteFremenAmbassadorMove(g, beneficiary.id, action);
quoteAmbassadorMatching(g,move);
return move;
} catch (error) {
if (error instanceof FremenAmbassadorMoveError)
throw new RuleError(error.message);
@@ -19764,11 +19776,10 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
} else if (entry.stage === 'move') {
const move = quoteAmbassadorRelocation(g, action);
validateAmbassadorRelocationArrival(g, move);
offerChoamMovement(g, {
...move,
source: 'ambassador',
ambassadorEvent: entry.event,
});
const order:MovementOrder={...move,source:'ambassador',ambassadorEvent:entry.event};
const matching=quoteAmbassadorMatching(g,move);
if(matching)openAdvisorAmbassadorMatching(g,matching,order);
else offerChoamMovement(g,order);
} else {
requireRule(
entry.effect === 'choam' || entry.effect === 'ixians',
@@ -20578,6 +20589,71 @@ function completeSpiritualAdvisorArrival(g:Game){
openTerritoryEntry(g,bg,order.territory,order.sector,order.amount,0,'advisor');
}
}
function ambassadorMatchingRule<T>(quote:()=>T):T{
try{return quote();}catch(error){if(error instanceof AdvisorAmbassadorMatchingError)throw new RuleError(error.message);throw error;}
}
function quoteAmbassadorMatching(g:Game,move:FremenAmbassadorMove){
const entry=g.pendingAmbassador;
if(!entry)return null;
return ambassadorMatchingRule(()=>quoteAdvisorAmbassadorMatching({...g,
advisorAmbassadorMatchingDenied:entry.advisorMatchingDenied},
{parent:entry.event,owner:move.player,origin:move.origin,destination:move.to,sector:move.sector,
advisors:move.advisors,group:Object.fromEntries(move.group)}));
}
function openAdvisorAmbassadorMatching(g:Game,quote:Omit<AdvisorAmbassadorMatchingSource,'event'>,order:MovementOrder){
requireRule(!g.pendingAdvisorAmbassadorMatching&&!g.response&&!g.decision,
'Finish the original Ambassador matching response first.');
const receipt={event:crypto.randomUUID(),...quote};
g.pendingAdvisorAmbassadorMatching={receipt,order,signature:JSON.stringify(order),
parentSignature:JSON.stringify(g.pendingAmbassador)};
g.response={kind:'advisorFlip',owner:receipt.owner,location:receipt.destination,advisors:receipt.advisors,
amount:Object.values(receipt.group).reduce((n,count)=>n+count,0),passed:[],advisorAmbassadorMatchingSource:receipt};
}
function currentAdvisorAmbassadorMatching(g:Game,response:ResponseWindow){
const parent=g.pendingAdvisorAmbassadorMatching,source=response.advisorAmbassadorMatchingSource;
requireRule(parent&&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.advisorResume&&!response.advisorMatchingSource&&!response.spiritualAdvisorSource&&
!response.automaticAdvisorFlip&&!response.advisorPreparationSource&&
JSON.stringify(parent.receipt)===JSON.stringify(source)&&parent.signature===JSON.stringify(parent.order)&&
parent.parentSignature===JSON.stringify(g.pendingAmbassador)&&parent.order.source==='ambassador'&&
parent.order.ambassadorEvent===source.parent&&parent.order.player===source.owner,
'The Ambassador matching counter lost its original uncommitted relocation.');
currentFremenAmbassador(g,source.parent);
ambassadorMatchingRule(()=>validateAdvisorAmbassadorMatching({...g,
advisorAmbassadorMatchingDenied:g.pendingAmbassador!.advisorMatchingDenied},source));
validateMovementOrder(g,getPlayer(g,source.owner),parent.order);
validateAmbassadorRelocationArrival(g,parent.order);
const quote=quoteAmbassadorMatching(g,parent.order);
requireRule(quote&&JSON.stringify(quote)===JSON.stringify(Object.fromEntries(
Object.entries(source).filter(([key])=>key!=='event'))),
'The Ambassador matching counter no longer describes its original selected packet.');
return parent;
}
function advisorAmbassadorMatchingIntegrity(g:Game){
if(!g.pendingAdvisorAmbassadorMatching)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(response=>response.advisorAmbassadorMatchingSource);
requireRule(matching.length>0,'Restore the original Ambassador matching response.');
for(const response of matching)currentAdvisorAmbassadorMatching(g,response);
}
function finishAdvisorAmbassadorMatching(g:Game,response:ResponseWindow,canceled:boolean){
const parent=currentAdvisorAmbassadorMatching(g,response),source=parent.receipt;
delete g.pendingAdvisorAmbassadorMatching;
if(canceled){
const key=advisorAmbassadorMatchingKey(source),entry=g.pendingAmbassador!;
if(!(entry.advisorMatchingDenied??=[]).includes(key))entry.advisorMatchingDenied.push(key);
g.decision={kind:'ecazAmbassador',player:source.owner};
log(g,`${getPlayer(g,source.owner).name}'s incoming Ambassador group matching in ${territory(source.destination).name} was prevented before relocation. Choose another legal packet or destination; the original trigger stays committed and ordinary allowances are unchanged.`,
{faction:'beneGesserit',name:'Ambassador group matching prevented'});
}else offerChoamMovement(g,parent.order);
}
function matchingAdvisorRule<T>(quote: () => T): T {
try { return quote(); }
catch(error) { if(error instanceof AdvisorMatchingError) throw new RuleError(error.message); throw error; }
@@ -23110,7 +23186,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.pendingSpiritualAdvisor,
!g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching && !g.pendingAdvisorAmbassadorMatching && !g.pendingSpiritualAdvisor,
'Resolve mandatory advisor conversions before checking victory.');
quote ??= currentVictoryQuote(g);
applyAdvisorReleases(g, quote.released);
@@ -28902,7 +28978,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
);
} else if (response.kind === 'advisorFlip') {
const bg = getPlayer(g, response.owner);
if (response.spiritualAdvisorSource) finishSpiritualAdvisorResponse(g,response,canceled);
if (response.advisorAmbassadorMatchingSource) finishAdvisorAmbassadorMatching(g,response,canceled);
else if (response.spiritualAdvisorSource) finishSpiritualAdvisorResponse(g,response,canceled);
else if (response.advisorMatchingSource) finishAdvisorMatching(g,response,canceled);
else if (response.advisorResume === 'planetologistGather') {
movementCancellationQuote(g, response);
@@ -32586,6 +32663,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
treacheryDiscardIntegrity(state);
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
advisorAmbassadorMatchingIntegrity(state);
spiritualAdvisorIntegrity(state);
ambassadorRelocationIntegrity(state);
ambassadorRevivalIncomeIntegrity(state);
@@ -32997,6 +33075,7 @@ export function normalizeAutomaticGame(state: Game): Game {
treacheryDiscardIntegrity(state);
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
advisorAmbassadorMatchingIntegrity(state);
spiritualAdvisorIntegrity(state);
ambassadorRelocationIntegrity(state);
ambassadorRevivalIncomeIntegrity(state);
@@ -37083,6 +37162,7 @@ export function viewGame(state: Game, id: string) {
treacheryDiscardIntegrity(state);
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
advisorAmbassadorMatchingIntegrity(state);
spiritualAdvisorIntegrity(state);
ambassadorRelocationIntegrity(state);
ambassadorRevivalIncomeIntegrity(state);
@@ -37366,6 +37446,8 @@ export function viewGame(state: Game, id: string) {
effect: entry.effect ?? token.effect,
beneficiary: entry.beneficiary ?? null,
noField: ambassadorMarkerView(g, id),
...(entry.beneficiary===id&&entry.advisorMatchingDenied?.length
? {matchingDenied:[...entry.advisorMatchingDenied]} : {}),
shipment:
entry.stage === 'ship' && id === entry.beneficiary
? (() => {
+2
View File
@@ -194,6 +194,8 @@ 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.advisorAmbassadorMatchingSource
? {matching:response.advisorAmbassadorMatchingSource,pending:g.pendingAdvisorAmbassadorMatching?.receipt??null}
: response.advisorMatchingSource
? {matching:response.advisorMatchingSource,pending:g.pendingAdvisorMatching?.receipt??null}
: response.spiritualAdvisorSource
+1
View File
@@ -307,6 +307,7 @@ export function quoteMovementCancellation(
// This native pre-shipment action has a phase-independent source validator.
if (response.kind === 'advisorFlip' && response.advisorResume === 'preparation') return null;
if (response.kind === 'advisorFlip' && response.advisorMatchingSource) return null;
if (response.kind === 'advisorFlip' && response.advisorAmbassadorMatchingSource) return null;
if (response.kind === 'advisorFlip' && response.spiritualAdvisorSource) return null;
if (
![
+18 -4
View File
@@ -4315,6 +4315,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In Advanced, advisors may coexist with their ally without triggering the end-turn alliance constraint in either direction. Neither the advisors nor the allied fighters are lost for that coexistence. Bene Gesserit fighters remain subject to ordinary separation; Basic receives no new advisor exception.',
'After Fremen setup, choose the starting advisor’s territory and sector. Setup conversions remain automatic. During play, advisors alone must become fighters. A ground move into an empty territory or the last outsider’s ground departure now offers Karama prevention after the physical move, before fighter benefits. Allow to convert once; cancellation leaves the completed movement intact.',
'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.',
'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.',
@@ -4333,8 +4334,12 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Implementation",
"status": "Partial",
"detail": "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": "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-ambassador-matching.ts",
"tests/advisor-ambassador-matching.test.ts",
"tests/advisor-ambassador-matching-engine.test.ts",
"tests/fixture-advisor-ambassador-matching.ts",
"tests/fixture-spiritual-advisor-flow.ts",
"tests/spiritual-advisor-engine.test.ts",
"tests/spiritual-advisor.test.ts",
@@ -4354,8 +4359,9 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Player controls",
"status": "Partial",
"detail": "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": "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": [
"components/ambassador-movement.tsx",
"docs/SPIRITUAL_ADVISORS.md",
"docs/ADVISOR_MATCHING.md",
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
@@ -4367,8 +4373,12 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "AI",
"status": "Partial",
"detail": "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": "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-ambassador-matching.ts",
"tests/advisor-ambassador-matching.test.ts",
"tests/advisor-ambassador-matching-engine.test.ts",
"tests/fixture-advisor-ambassador-matching.ts",
"tests/fixture-spiritual-advisor-flow.ts",
"tests/spiritual-advisor-engine.test.ts",
"tests/spiritual-advisor.test.ts",
@@ -4413,8 +4423,12 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Verification",
"status": "Partial",
"detail": "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": "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-ambassador-matching.ts",
"tests/advisor-ambassador-matching.test.ts",
"tests/advisor-ambassador-matching-engine.test.ts",
"tests/fixture-advisor-ambassador-matching.ts",
"tests/fixture-spiritual-advisor-flow.ts",
"tests/spiritual-advisor-engine.test.ts",
"tests/spiritual-advisor.test.ts",