Counter incoming advisor matching before normal movement and shipment
Preserve residents and unused actions when one selected packet cannot match. Bind public matching receipts to private declarations and share packet-opportunity denials with legal bot filtering.
This commit is contained in:
1 parent
abf0305ddf
commit
438a5692a6
14 files changed
+992
-10
No files matched your search
@@ -0,0 +1,159 @@
|
||||
import { gameTerritories, splitLocation, type MobileBoard } from './board';
|
||||
import type { FactionId } from './catalog';
|
||||
import type { AdvisorStance } from './advisor-automatic-flip';
|
||||
|
||||
/** One incoming selected group changes to the resident BG group's stance. */
|
||||
export type AdvisorMatchingSource = {
|
||||
event: string;
|
||||
kind: 'movement' | 'shipment';
|
||||
owner: string;
|
||||
turn: number;
|
||||
move: number;
|
||||
origin: string;
|
||||
destination: string;
|
||||
sector: number;
|
||||
advisors: boolean;
|
||||
group: Record<string, number>;
|
||||
};
|
||||
|
||||
export type AdvisorMatchingIntent = Omit<AdvisorMatchingSource, 'event' | 'turn'>;
|
||||
export type AdvisorMatchingQuote = Omit<AdvisorMatchingSource, 'event'>;
|
||||
|
||||
export type AdvisorMatchingPlayer = {
|
||||
id: string;
|
||||
faction: FactionId;
|
||||
forces: Readonly<Record<string, number>>;
|
||||
advisors?: Readonly<Record<string, AdvisorStance>>;
|
||||
moved?: number;
|
||||
reserves?: number;
|
||||
};
|
||||
|
||||
export type AdvisorMatchingContext = MobileBoard & {
|
||||
advanced: boolean;
|
||||
status: string;
|
||||
phase: number;
|
||||
turn: number;
|
||||
active: string | null;
|
||||
players: readonly AdvisorMatchingPlayer[];
|
||||
advisorMatchingDenied?: readonly string[];
|
||||
};
|
||||
|
||||
export class AdvisorMatchingError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'AdvisorMatchingError';
|
||||
}
|
||||
}
|
||||
|
||||
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 AdvisorMatchingError(message);
|
||||
}
|
||||
|
||||
/** Destination-sector retries and fresh response UUIDs do not change the packet.
|
||||
* A new turn, native opportunity, selected group or target stance has its own key. */
|
||||
export function advisorMatchingKey(source: AdvisorMatchingSource | AdvisorMatchingQuote): string {
|
||||
const group = Object.entries(source.group).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
|
||||
return JSON.stringify([
|
||||
'advisorMatching', source.turn, source.owner, source.kind, source.move,
|
||||
source.origin, source.destination, source.advisors, group,
|
||||
]);
|
||||
}
|
||||
|
||||
function residentForces(player: AdvisorMatchingPlayer, destination: string) {
|
||||
return Object.entries(player.forces).some(([key, count]) =>
|
||||
splitLocation(key).territory === destination && count > 0);
|
||||
}
|
||||
|
||||
function selectedGroup(
|
||||
player: AdvisorMatchingPlayer,
|
||||
intent: AdvisorMatchingIntent,
|
||||
originSectors: readonly number[],
|
||||
): Record<string, number> {
|
||||
requireMatching(record(intent.group) && Object.keys(intent.group).length > 0,
|
||||
'Select one nonempty physical group for advisor matching.');
|
||||
const group: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const key of Object.keys(intent.group)) {
|
||||
if (intent.kind === 'shipment') {
|
||||
requireMatching(key === 'reserves', 'Normal BG shipment matching uses only the selected reserve forces.');
|
||||
} else {
|
||||
const point = splitLocation(key);
|
||||
requireMatching(key === `${point.territory}:${point.sector}` && point.territory === intent.origin &&
|
||||
originSectors.includes(point.sector),
|
||||
'Select physical movement counters from the original board territory.');
|
||||
}
|
||||
const count = intent.group[key];
|
||||
const held = intent.kind === 'shipment' ? player.reserves : player.forces[key];
|
||||
requireMatching(positive(count) && nonnegative(held) && held >= count,
|
||||
'The selected incoming physical group is no longer available.');
|
||||
total += count;
|
||||
requireMatching(positive(total), 'The selected incoming group exceeds the supported physical count.');
|
||||
group[key] = count;
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/** Matching is an incoming-group counter only. The caller keeps normal route,
|
||||
* arrival permissions, funding and the uncommitted original action authoritative. */
|
||||
export function quoteAdvisorMatching(
|
||||
ctx: AdvisorMatchingContext,
|
||||
intent: AdvisorMatchingIntent,
|
||||
): AdvisorMatchingQuote | null {
|
||||
if (ctx.advanced !== true || ctx.status !== 'playing' || ctx.phase !== 5 || !positive(ctx.turn)) return null;
|
||||
requireMatching(record(intent) && identifier(intent.owner), 'Choose the current advisor-matching 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 null;
|
||||
requireMatching((intent.kind === 'movement' || intent.kind === 'shipment') && nonnegative(intent.move) &&
|
||||
identifier(intent.origin) && identifier(intent.destination) && nonnegative(intent.sector) &&
|
||||
typeof intent.advisors === 'boolean',
|
||||
'Choose a valid native movement or reserve-shipment matching declaration.');
|
||||
const places = gameTerritories(ctx);
|
||||
const destination = places.find((place) => place.id === intent.destination && place.sectors.includes(intent.sector));
|
||||
const origin = places.find((place) => place.id === intent.origin);
|
||||
requireMatching(destination && (intent.kind === 'shipment' ? intent.origin === 'reserves' : origin),
|
||||
'Advisor matching needs its current board destination and original physical source.');
|
||||
if (!residentForces(owner, intent.destination)) return null;
|
||||
const targetAdvisors = !!owner.advisors?.[intent.destination];
|
||||
requireMatching(intent.advisors === targetAdvisors,
|
||||
'The resident Bene Gesserit group no longer has the declared target stance.');
|
||||
// Selected ordinary reserve forces provisionally arrive with the normal fighter default.
|
||||
const incomingAdvisors = intent.kind === 'shipment' ? false : !!owner.advisors?.[intent.origin];
|
||||
if (incomingAdvisors === targetAdvisors) return null;
|
||||
requireMatching(intent.kind === 'shipment' ? intent.move === 0 :
|
||||
nonnegative(owner.moved) && intent.move === owner.moved,
|
||||
'Advisor matching lost its original owned movement or shipment opportunity.');
|
||||
const group = selectedGroup(owner, intent, origin?.sectors ?? []);
|
||||
const quote: AdvisorMatchingQuote = {
|
||||
kind: intent.kind, owner: owner.id, turn: ctx.turn, move: intent.move,
|
||||
origin: intent.origin, destination: intent.destination, sector: intent.sector,
|
||||
advisors: targetAdvisors, group,
|
||||
};
|
||||
requireMatching(ctx.advisorMatchingDenied === undefined ||
|
||||
(Array.isArray(ctx.advisorMatchingDenied) && ctx.advisorMatchingDenied.every((key) => typeof key === 'string')),
|
||||
'The advisor-matching prevention history is invalid.');
|
||||
requireMatching(!ctx.advisorMatchingDenied?.includes(advisorMatchingKey(quote)),
|
||||
'Karama already prevented this selected group from matching in that territory during the current owned opportunity.');
|
||||
return quote;
|
||||
}
|
||||
|
||||
/** Validate before either response outcome; this never commits or refunds the action. */
|
||||
export function validateAdvisorMatching(ctx: AdvisorMatchingContext, source: AdvisorMatchingSource): void {
|
||||
requireMatching(record(source) &&
|
||||
Object.keys(source).sort().join(',') === 'advisors,destination,event,group,kind,move,origin,owner,sector,turn' &&
|
||||
identifier(source.event) && positive(source.turn) && source.turn === ctx.turn,
|
||||
'Advisor matching lost its original current-turn public source.');
|
||||
const quote = quoteAdvisorMatching(ctx, {
|
||||
kind: source.kind, owner: source.owner, move: source.move, 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 matching source.');
|
||||
}
|
||||
+20
-1
@@ -116,6 +116,7 @@ import { botNexusIxianBetrayalActions } from './bot-nexus-ixian-betrayal';
|
||||
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 { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
|
||||
import { reinforcementsBattleForces } from './reinforcements-homeworld';
|
||||
import type { NativeReserveSelections } from './homeworld-native-reserves';
|
||||
@@ -4074,6 +4075,24 @@ 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;
|
||||
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);
|
||||
const group:Record<string,number>=action.type==='ship' ? {reserves:Number(action.amount)} :
|
||||
action.forces && typeof action.forces==='object' ? action.forces as Record<string,number> :
|
||||
{[String(action.from)]:Number(action.amount)};
|
||||
const origins=[...new Set(Object.keys(group).map(key=>splitLocation(key).territory))];
|
||||
if(action.type==='move' && origins.length!==1) return true;
|
||||
try {
|
||||
quoteAdvisorMatching(g,{kind:action.type==='ship'?'shipment':'movement',owner:me.id,
|
||||
move:action.type==='ship'?0:me.moved??0,
|
||||
origin:action.type==='ship'?'reserves':origins[0],destination,sector,
|
||||
advisors:isAdvisor(me,destination),group});
|
||||
return true;
|
||||
} catch(error) { if(error instanceof AdvisorMatchingError) return false; throw error; }
|
||||
}
|
||||
export function botActions(g: GameView): Action[] {
|
||||
if (g.homeworldTupileBlocked) return [];
|
||||
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
|
||||
@@ -4249,7 +4268,7 @@ export function botActions(g: GameView): Action[] {
|
||||
if (action.type === 'battlePlan' && g.ecazSpecialKarama?.declared &&
|
||||
(action.weapon || action.defense)) return [];
|
||||
const sourced = withNativeShipmentSources(g, action);
|
||||
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) && !botArrivalBlock(g, sourced) ? [sourced] : [];
|
||||
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) && !botArrivalBlock(g, sourced) && botAdvisorMatchingAllowed(g,sourced) ? [sourced] : [];
|
||||
});
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const advancedDepartures = g.advancedAllySeparation?.territories;
|
||||
|
||||
+80
-3
@@ -608,6 +608,8 @@ import {
|
||||
type TechState,
|
||||
} from './tech-tokens';
|
||||
import { canUseAsKaramaRole } from './shrine';
|
||||
import { quoteAdvisorMatching, validateAdvisorMatching, advisorMatchingKey,
|
||||
AdvisorMatchingError, type AdvisorMatchingSource } from './advisor-matching';
|
||||
import {
|
||||
isAdvisor,
|
||||
fighterCount,
|
||||
@@ -1067,6 +1069,7 @@ type SpiceBankerIncomePayment = {
|
||||
export type ResponseWindow = {
|
||||
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
|
||||
advisorPreparationSource?: AdvisorPreparationSource;
|
||||
advisorMatchingSource?: AdvisorMatchingSource;
|
||||
fremenGrant?: FremenRevivalGrantSource;
|
||||
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
|
||||
bureaucratPayment?: BureaucratPaymentSource;
|
||||
@@ -1283,6 +1286,12 @@ export type Game = {
|
||||
signature: string;
|
||||
resume: { kind: 'ambassador'; event: string } | { kind: 'discovery'; child: DiscoveryEntryArrivalChild };
|
||||
};
|
||||
pendingAdvisorMatching?: {
|
||||
receipt: AdvisorMatchingSource;
|
||||
signature: string;
|
||||
intent: {kind:'movement'; order:MovementOrder} | {kind:'shipment'; order:PendingShipment};
|
||||
};
|
||||
advisorMatchingDenied?: string[];
|
||||
advisorPreparation?: AdvisorPreparationWindow;
|
||||
ecazOccupyPreview?: boolean;
|
||||
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
|
||||
@@ -14598,6 +14607,7 @@ function revivalCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
}
|
||||
}
|
||||
function movementCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
if (response.advisorMatchingSource) { currentAdvisorMatching(g, response); return null; }
|
||||
try {
|
||||
if (response.automaticAdvisorFlip) { currentAutomaticAdvisorFlip(g, response); return null; }
|
||||
if (response.advisorResume === 'preparation') { currentAdvisorPreparationResponse(g, response); return null; }
|
||||
@@ -16957,6 +16967,7 @@ function declareGroundMovement(g: Game, p: Player, action: Action, quoteOnly = f
|
||||
: `That destination is blocked or more than ${speed} territories away.`,
|
||||
);
|
||||
validateFlightSelection(g, p, move);
|
||||
const matching = quoteMatchingMovement(g, move);
|
||||
if (quoteOnly) return move;
|
||||
checkGroundMovementPromises(g, p, move);
|
||||
if (advisorGather) {
|
||||
@@ -16964,6 +16975,8 @@ function declareGroundMovement(g: Game, p: Player, action: Action, quoteOnly = f
|
||||
kind: 'advisorFlip', owner: id, location: to, advisors: false, amount: n, passed: [],
|
||||
advisorResume: 'planetologistGather', advisorGatherMove: { turn: g.turn, move: p.moved, order: move },
|
||||
};
|
||||
} else if (matching) {
|
||||
openAdvisorMatching(g, matching, {kind:'movement',order:move});
|
||||
} else if (
|
||||
!move.ornithopterRange && !move.discoveryFlight &&
|
||||
p.faction === 'ixians' &&
|
||||
@@ -20334,6 +20347,61 @@ function automaticAdvisorRule<T>(quote: () => T): T {
|
||||
try { return quote(); }
|
||||
catch (error) { if (error instanceof AutomaticAdvisorFlipError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function matchingAdvisorRule<T>(quote: () => T): T {
|
||||
try { return quote(); }
|
||||
catch(error) { if(error instanceof AdvisorMatchingError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function quoteMatchingMovement(g: Game, move: MovementOrder) {
|
||||
if (move.planetologist?.mode === 'gather') return null;
|
||||
return matchingAdvisorRule(() => quoteAdvisorMatching(g, {kind:'movement',owner:move.player,
|
||||
move:getPlayer(g,move.player).moved,origin:move.origin,destination:move.to,sector:move.sector,
|
||||
advisors:move.advisors,group:Object.fromEntries(move.group)}));
|
||||
}
|
||||
function quoteMatchingShipment(g: Game, shipment: PendingShipment) {
|
||||
if (shipment.source || shipment.noField || shipment.ambassadorMarker || shipment.alliedNoField) return null;
|
||||
return matchingAdvisorRule(() => quoteAdvisorMatching(g, {kind:'shipment',owner:shipment.player,
|
||||
move:0,origin:'reserves',destination:shipment.territory,sector:shipment.sector,
|
||||
advisors:shipment.advisors,group:{reserves:shipment.amount}}));
|
||||
}
|
||||
function openAdvisorMatching(g: Game, quote: Omit<AdvisorMatchingSource,'event'>,
|
||||
intent: NonNullable<Game['pendingAdvisorMatching']>['intent']) {
|
||||
requireRule(!g.pendingAdvisorMatching && !g.response && !g.decision,
|
||||
'Finish the original group matching before another arrival.');
|
||||
const receipt={event:crypto.randomUUID(),...quote};
|
||||
g.pendingAdvisorMatching={receipt,intent,signature:JSON.stringify(intent)};
|
||||
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:[],advisorMatchingSource:receipt};
|
||||
}
|
||||
function currentAdvisorMatching(g: Game,response:ResponseWindow) {
|
||||
const source=response.advisorMatchingSource,parent=g.pendingAdvisorMatching;
|
||||
requireRule(source && parent && response.kind==='advisorFlip' && response.owner===source.owner &&
|
||||
response.location===source.destination && response.advisors===source.advisors &&
|
||||
!response.automaticAdvisorFlip && !response.advisorPreparationSource && !response.advisorResume &&
|
||||
JSON.stringify(parent.receipt)===JSON.stringify(source) &&
|
||||
parent.signature===JSON.stringify(parent.intent) && parent.intent.kind===source.kind,
|
||||
'The matching counter has lost its original uncommitted arrival.');
|
||||
matchingAdvisorRule(()=>validateAdvisorMatching(g,source));
|
||||
const quote=parent.intent.kind==='movement'
|
||||
? quoteMatchingMovement(g,parent.intent.order) : quoteMatchingShipment(g,parent.intent.order);
|
||||
requireRule(quote && JSON.stringify(quote)===JSON.stringify(Object.fromEntries(
|
||||
Object.entries(source).filter(([key])=>key!=='event'))),
|
||||
'The matching counter no longer describes the selected incoming group.');
|
||||
return parent;
|
||||
}
|
||||
function finishAdvisorMatching(g:Game,response:ResponseWindow,canceled:boolean) {
|
||||
const parent=currentAdvisorMatching(g,response),source=parent.receipt;
|
||||
delete g.pendingAdvisorMatching;
|
||||
if(canceled){
|
||||
const key=advisorMatchingKey(source);
|
||||
if(!(g.advisorMatchingDenied??=[]).includes(key)) g.advisorMatchingDenied.push(key);
|
||||
log(g,`${getPlayer(g,source.owner).name}'s incoming group matching in ${territory(source.destination).name} was prevented before ${source.kind==='movement'?'movement':'shipment'}. Residents keep their type; forces, payment and the unused action remain. Choose a legal replacement.`,
|
||||
{faction:'beneGesserit',name:'Incoming group matching prevented'});
|
||||
} else if(parent.intent.kind==='movement') {
|
||||
validateMovementOrder(g,getPlayer(g,source.owner),parent.intent.order);
|
||||
offerChoamMovement(g,parent.intent.order);
|
||||
} else offerShipment(g,parent.intent.order,true);
|
||||
}
|
||||
function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) {
|
||||
const source = response.automaticAdvisorFlip;
|
||||
requireRule(response.kind === 'advisorFlip' && source && response.advisors === false &&
|
||||
@@ -22806,7 +22874,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.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching,
|
||||
'Resolve mandatory advisor conversions before checking victory.');
|
||||
quote ??= currentVictoryQuote(g);
|
||||
applyAdvisorReleases(g, quote.released);
|
||||
@@ -28598,7 +28666,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
);
|
||||
} else if (response.kind === 'advisorFlip') {
|
||||
const bg = getPlayer(g, response.owner);
|
||||
if (response.advisorResume === 'planetologistGather') {
|
||||
if (response.advisorMatchingSource) finishAdvisorMatching(g,response,canceled);
|
||||
else if (response.advisorResume === 'planetologistGather') {
|
||||
movementCancellationQuote(g, response);
|
||||
const pending = response.advisorGatherMove!;
|
||||
if (canceled) {
|
||||
@@ -30099,12 +30168,17 @@ function finishGuildTransportRateResponse(g: Game, response: ResponseWindow, can
|
||||
log(g, `Karama removed Guild's half-price transport rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged physical transport.`);
|
||||
}
|
||||
}
|
||||
function offerShipment(g: Game, shipment: PendingShipment) {
|
||||
function offerShipment(g: Game, shipment: PendingShipment, matchingApproved = false) {
|
||||
validatePhysicalShipment(g, shipment);
|
||||
validateShipmentArrival(g, shipment);
|
||||
if (!shipment.ambassadorMarker) checkShipmentIncomeFunding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
|
||||
const p = getPlayer(g, shipment.player),
|
||||
id = p.id;
|
||||
const matching = quoteMatchingShipment(g, shipment);
|
||||
if (matching && !matchingApproved) {
|
||||
openAdvisorMatching(g, matching, {kind:'shipment',order:shipment});
|
||||
return;
|
||||
}
|
||||
const { territory: to, sector: s } = shipment;
|
||||
const n = shipment.alliedNoField || shipment.ambassadorMarker ? 1 : shipment.amount;
|
||||
const guild = byFaction(g, 'guild');
|
||||
@@ -36039,6 +36113,7 @@ function applyActionInner(
|
||||
shipment.noFieldSkillProof = noFieldSkillProof(shipment);
|
||||
validatePhysicalShipment(g, shipment);
|
||||
}
|
||||
quoteMatchingShipment(g, shipment);
|
||||
if (nexusEvent) recordNexusRicheseShipment(g,shipment);
|
||||
bindGuildCunningShipment(g,p,'reserve',shipment);
|
||||
if (execution === 'movementPreparation') {
|
||||
@@ -37569,6 +37644,8 @@ export function viewGame(state: Game, id: string) {
|
||||
freeBlocked: g.revivalRules?.freeBlocked?.includes(id) ?? false,
|
||||
},
|
||||
ghola: gholaOptions(g, me),
|
||||
...(me.faction === 'beneGesserit' && g.advisorMatchingDenied?.length
|
||||
? {advisorMatchingDenied:g.advisorMatchingDenied} : {}),
|
||||
aid: {
|
||||
pledged: g.aid?.[id]?.amount ?? 0,
|
||||
available:
|
||||
|
||||
@@ -190,6 +190,8 @@ const cancellationSources = {
|
||||
...(response.automaticAdvisorFlip.producer === 'specialRelocation'
|
||||
? { relocation: g.pendingAutomaticAdvisorRelocation?.receipt ?? null } : {}),
|
||||
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
|
||||
: response.advisorMatchingSource
|
||||
? {matching:response.advisorMatchingSource,pending:g.pendingAdvisorMatching?.receipt??null}
|
||||
: response.advisorPreparationSource
|
||||
? { preparation: response.advisorPreparationSource, window: g.advisorPreparation ?? null }
|
||||
: response.advisorResume === 'ambassador'
|
||||
|
||||
@@ -306,6 +306,7 @@ export function quoteMovementCancellation(
|
||||
): MovementCancellationQuote | null {
|
||||
// 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 (
|
||||
![
|
||||
'guildTiming',
|
||||
|
||||
+19
-6
@@ -4314,7 +4314,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Advisors do not fight, contest stronghold victory, block another faction’s entry, collect spice, earn stronghold income, grant ornithopters or enable Family Atomics. Storms, worms, explosions and Atomics still destroy them.',
|
||||
'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. Moving or shipping into your existing group must match its type. Advisors moved into an occupied territory without another Bene Gesserit group may remain peaceful or request a flip to fighters.',
|
||||
'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.',
|
||||
'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 +4333,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Implementation",
|
||||
"status": "Partial",
|
||||
"detail": "Fremen Ambassador and Discovery free entry now capture resulting alone advisors before original arrivals. Preserve an empty Ambassador advisor arrival through one mandatory counter, without changing Discovery non-advisor admission. Private parent cursors retain original flip/entry stages; public six-field location evidence omits orders, No-Fields and skills. Matching-group and wider combinations remain unfinished.",
|
||||
"detail": "Normal opposite-type ground arrivals and reserve-to-advisor shipments now use a precommit matching receipt and private original order. Shared packet-scoped denial leaves residents and unused actions intact. Same-type ground movement remains direct. Special transport/gather and Spiritual source cutovers remain unfinished.",
|
||||
"evidence": [
|
||||
"docs/ADVISOR_MATCHING.md",
|
||||
"game/advisor-automatic-flip.ts",
|
||||
"game/advisors.ts",
|
||||
"game/board-resolution-quote.ts",
|
||||
@@ -4348,8 +4349,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Player controls",
|
||||
"status": "Partial",
|
||||
"detail": "Standard Karama/Allow controls distinguish completed special force transfer from pending conversion and original arrival. Existing producer and preparation guidance remains, without another confirmation or timer.",
|
||||
"detail": "Standard Karama/Allow explains that matching precedes physical arrival/payment and does not flip residents. Prevention retains unused actions and original card uses, with a legal replacement. Seven native GameTable SSR journeys render standard controls; no new confirmation or timer.",
|
||||
"evidence": [
|
||||
"docs/ADVISOR_MATCHING.md",
|
||||
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
|
||||
"components/advisor-preparation.tsx",
|
||||
"components/game-table.tsx",
|
||||
@@ -4359,8 +4361,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "AI",
|
||||
"status": "Partial",
|
||||
"detail": "Eight existing-profile native Ambassador/Discovery conversion paths resolve legally through original arrival completion. Transfers, allowances and original parent flow remain. Earlier producers retain separate evidence; no strategy refinement or difficulty calibration.",
|
||||
"detail": "Twelve existing-profile matching responses and twelve accepted legal replacements use shared packet/opportunity denials. Earlier producers retain separate evidence. No strategy refinement or difficulty calibration.",
|
||||
"evidence": [
|
||||
"tests/fixture-advisor-matching-move.ts",
|
||||
"tests/advisor-matching-engine.test.ts",
|
||||
"tests/advisor-matching.test.ts",
|
||||
"game/advisor-matching.ts",
|
||||
"docs/ADVISOR_MATCHING.md",
|
||||
"tests/fixture-advisor-relocation-flip.ts",
|
||||
"tests/advisor-relocation-flip-engine.test.ts",
|
||||
"tests/storm-worm-cancellation.test.ts",
|
||||
@@ -4382,8 +4389,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Documentation",
|
||||
"status": "Partial",
|
||||
"detail": "Physical17/28 E composes conversion with original independent relocation. Priority after physical transfer/collection and before original arrivals is explicitly provisional. Original Ambassador phase permission and Discovery non-advisor-only admission remain; cancellation duration and wider combinations stay explicit.",
|
||||
"detail": "Physical28 E explicitly marks matching as counterable and physical17 identifies the incoming group. Shipment defaults and precommit remedy/packet-opportunity restraint are provisional. Spiritual destination/Polar matching cutovers and broader transports remain explicit open scope.",
|
||||
"evidence": [
|
||||
"docs/ADVISOR_MATCHING.md",
|
||||
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
|
||||
"docs/ADVISOR_PREPARATION.md",
|
||||
"docs/RULE_DECISIONS.md",
|
||||
@@ -4393,8 +4401,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Verification",
|
||||
"status": "Partial",
|
||||
"detail": "Special relocation passes frozen19-file270/270, types/lint/build, five native transfer/response/GameTable SSR journeys, two labelled controlled empty-arrival cases and eight profile paths. Genuine44/49-action base33 sources earn native packet/placement/trigger or actual Cistern placement, Intrusion, Collection reveal and next-turn entry. Both outcomes retain transfer; original decline preserves its group. Controlled alliances are not native-alliance evidence. Focused compatibility regression retains older serialized counters. Wider No-Field/skills/Homeworld/Nexus combinations, native allied BG arrival, matching-group interpretation, full modes and final assurance remain open.",
|
||||
"detail": "Normal matching passes frozen19-file244/244, types/lint/build, seven native declaration/response/GameTable SSR journeys, twelve profile responses and twelve accepted replacements. Genuine34/33-action base33 sources earn original advisors/fighters/Karama. Both directions and reserve shipment wait without transfer; allowance commits once and shipment pays1. Prevention keeps payment/action unused, rejects same-packet retry and permits legal replacement. Same-type movement has no counter. Special relocation, Guild/Homeworld, gathering, Spiritual matching/source cutover, full modes and final assurance remain open.",
|
||||
"evidence": [
|
||||
"tests/fixture-advisor-matching-move.ts",
|
||||
"tests/advisor-matching-engine.test.ts",
|
||||
"tests/advisor-matching.test.ts",
|
||||
"game/advisor-matching.ts",
|
||||
"docs/ADVISOR_MATCHING.md",
|
||||
"tests/fixture-advisor-relocation-flip.ts",
|
||||
"tests/advisor-relocation-flip-engine.test.ts",
|
||||
"tests/storm-worm-cancellation.test.ts",
|
||||
|
||||
Reference in new issue
Block a user