Separate Spiritual Advisor shipment matching and alone conversion
Apply the Advanced retained-fighter target cutover through shared quotes and controls. Preserve committed foreign shipments while handling free-packet shipping, Polar matching, actual placement and original arrival once.
This commit is contained in:
1 parent
438a5692a6
commit
767bace774
24 files changed
+1536
-69
No files matched your search
@@ -2,6 +2,7 @@ import type { FactionId } from './catalog';
|
||||
import { gameTerritories, splitLocation, type MobileBoard } from './board';
|
||||
import { presenceAt, type ForcePresence } from './force-presence';
|
||||
import { stormExposesTerritory, stormSectorAfter } from './disaster-rules';
|
||||
import { validateSpiritualAdvisorSource, type SpiritualAdvisorSource } from './spiritual-advisor';
|
||||
|
||||
export type AdvisorStance = {
|
||||
lockedTurn?: number;
|
||||
@@ -93,6 +94,7 @@ export type AutomaticAdvisorFlipSource = {
|
||||
| { producer: 'faceDance'; faceDance: AutomaticAdvisorFaceDance }
|
||||
| { producer: 'storm'; storm: AutomaticAdvisorStorm }
|
||||
| { producer: 'specialRelocation'; relocation: AutomaticAdvisorRelocation }
|
||||
| { producer: 'spiritualArrival'; spiritual: SpiritualAdvisorSource }
|
||||
);
|
||||
|
||||
export type AutomaticAdvisorFlipPlayer = ForcePresence & {
|
||||
@@ -439,6 +441,23 @@ function validateRelocationSource(
|
||||
'Automatic advisor conversion lost the original relocated actor at its destination.');
|
||||
}
|
||||
|
||||
function validateSpiritualArrivalSource(
|
||||
ctx: AutomaticAdvisorFlipContext,
|
||||
source: Extract<AutomaticAdvisorFlipSource, { producer: 'spiritualArrival' }>,
|
||||
) {
|
||||
try { validateSpiritualAdvisorSource(ctx, source.spiritual); }
|
||||
catch (cause) {
|
||||
throw new AutomaticAdvisorFlipError(cause instanceof Error ? cause.message : 'The public Spiritual Advisor source changed.');
|
||||
}
|
||||
const receipt = source.spiritual;
|
||||
requireFlip(receipt.owner === source.owner && receipt.territory === source.territory,
|
||||
'Automatic advisor conversion must use the original Spiritual Advisor owner and target.');
|
||||
const owner = ctx.players.find((player) => player.id === source.owner)!;
|
||||
const deposited = owner.forces[`${receipt.territory}:${receipt.sector}`];
|
||||
requireFlip(nonnegative(deposited) && deposited >= receipt.amount,
|
||||
'Automatic advisor conversion lost its completed Spiritual Advisor placement.');
|
||||
}
|
||||
|
||||
/** Validate the same committed producer and exact held group before either response outcome. */
|
||||
export function validateAutomaticAdvisorFlip(
|
||||
ctx: AutomaticAdvisorFlipContext,
|
||||
@@ -460,12 +479,15 @@ export function validateAutomaticAdvisorFlip(
|
||||
(source.producer === 'storm' &&
|
||||
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,storm,territory,turn') ||
|
||||
(source.producer === 'specialRelocation' &&
|
||||
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,relocation,territory,turn')) &&
|
||||
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,relocation,territory,turn') ||
|
||||
(source.producer === 'spiritualArrival' &&
|
||||
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,spiritual,territory,turn')) &&
|
||||
identifier(source.event) && identifier(source.owner) && identifier(source.territory) &&
|
||||
positive(source.turn) && source.turn === ctx.turn &&
|
||||
(source.producer === 'specialRelocation'
|
||||
? record(source.relocation) && (source.relocation.kind === 'discovery'
|
||||
? source.phase === 0 : source.relocation.kind === 'ambassador' && [1, 3, 5, 8].includes(source.phase))
|
||||
: source.producer === 'spiritualArrival' ? [1, 3, 5, 8].includes(source.phase)
|
||||
: source.phase === (source.producer === 'storm' ? 0 : source.producer === 'gamont' ? 8 :
|
||||
source.producer === 'battleLosses' || source.producer === 'faceDance' ? 6 : 5)) &&
|
||||
currentWindow(ctx) && source.phase === ctx.phase,
|
||||
@@ -487,7 +509,8 @@ export function validateAutomaticAdvisorFlip(
|
||||
else if (source.producer === 'battleLosses') validateBattleSource(ctx, source);
|
||||
else if (source.producer === 'faceDance') validateFaceDanceSource(ctx, source);
|
||||
else if (source.producer === 'storm') validateStormSource(ctx, source);
|
||||
else validateRelocationSource(ctx, source);
|
||||
else if (source.producer === 'specialRelocation') validateRelocationSource(ctx, source);
|
||||
else validateSpiritualArrivalSource(ctx, source);
|
||||
const forces = physicalGroup(owner, source.territory);
|
||||
requireFlip(record(source.forces) && Object.keys(forces).length > 0 &&
|
||||
Object.values(forces).every(positive) && Object.values(source.forces).every(positive) &&
|
||||
|
||||
+5
-2
@@ -2695,8 +2695,11 @@ function policyActions(g: GameView): Action[] {
|
||||
{ type: 'decision', accept: false },
|
||||
];
|
||||
}
|
||||
if (d.kind === 'advisor' && g.guildAmbassadorAdvisorChoices) {
|
||||
const choices = g.guildAmbassadorAdvisorChoices.choices.filter(
|
||||
if(d.kind==='advisor'&&!d.ambassadorEvent&&(liveShipmentPromises(g.shipmentPromises??[],me.id,g.turn).length ||
|
||||
liveMovementPromises(g.movementPromises??[],me.id,g.turn).some(promise=>promise.answer)))
|
||||
return [{type:'decision',accept:false}];
|
||||
if (d.kind === 'advisor' && (g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices)) {
|
||||
const choices = (g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices)!.choices.filter(
|
||||
(choice) => !choice.blocked,
|
||||
);
|
||||
return [
|
||||
|
||||
+279
-10
@@ -610,6 +610,8 @@ import {
|
||||
import { canUseAsKaramaRole } from './shrine';
|
||||
import { quoteAdvisorMatching, validateAdvisorMatching, advisorMatchingKey,
|
||||
AdvisorMatchingError, type AdvisorMatchingSource } from './advisor-matching';
|
||||
import { spiritualAdvisorTargetBlock, spiritualAdvisorArrival, validateSpiritualAdvisorSource,
|
||||
spiritualAdvisorMatchingKey, SpiritualAdvisorError, type SpiritualAdvisorSource } from './spiritual-advisor';
|
||||
import {
|
||||
isAdvisor,
|
||||
fighterCount,
|
||||
@@ -1070,6 +1072,7 @@ export type ResponseWindow = {
|
||||
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
|
||||
advisorPreparationSource?: AdvisorPreparationSource;
|
||||
advisorMatchingSource?: AdvisorMatchingSource;
|
||||
spiritualAdvisorSource?: SpiritualAdvisorSource;
|
||||
fremenGrant?: FremenRevivalGrantSource;
|
||||
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
|
||||
bureaucratPayment?: BureaucratPaymentSource;
|
||||
@@ -1292,6 +1295,19 @@ export type Game = {
|
||||
intent: {kind:'movement'; order:MovementOrder} | {kind:'shipment'; order:PendingShipment};
|
||||
};
|
||||
advisorMatchingDenied?: string[];
|
||||
pendingSpiritualAdvisor?: {
|
||||
event: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
decision: Extract<Decision,{kind:'advisor'}>;
|
||||
signature: string;
|
||||
stage: 'offer' | 'shipping' | 'matching' | 'arrival';
|
||||
denied: string[];
|
||||
source?: SpiritualAdvisorSource;
|
||||
selection?: string;
|
||||
order?: {player:string;territory:string;sector:number;amount:number;elite:0;advisors:boolean};
|
||||
deposit?: {key:string;before:number};
|
||||
};
|
||||
advisorPreparation?: AdvisorPreparationWindow;
|
||||
ecazOccupyPreview?: boolean;
|
||||
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
|
||||
@@ -14607,6 +14623,7 @@ function revivalCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
}
|
||||
}
|
||||
function movementCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
if (response.spiritualAdvisorSource) { currentSpiritualAdvisorResponse(g,response); return null; }
|
||||
if (response.advisorMatchingSource) { currentAdvisorMatching(g, response); return null; }
|
||||
try {
|
||||
if (response.automaticAdvisorFlip) { currentAutomaticAdvisorFlip(g, response); return null; }
|
||||
@@ -14737,6 +14754,10 @@ function noFieldCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
}
|
||||
}
|
||||
function terminalCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
if(response.spiritualAdvisorSource){
|
||||
currentSpiritualAdvisorResponse(g,response);
|
||||
return response.kind==='advisor'?{kind:'advisor' as const,owner:response.owner}:null;
|
||||
}
|
||||
try {
|
||||
return validateTerminalCancellation(g, response);
|
||||
} catch (error) {
|
||||
@@ -17677,6 +17698,12 @@ function ambassadorRelocationIntegrity(g: Game) {
|
||||
'The saved Guild Ambassador arrival has an invalid continuation stage.',
|
||||
);
|
||||
const { order } = guildAmbassadorArrival(g, entry.event, next);
|
||||
if(next==='advisor' && g.pendingSpiritualAdvisor?.decision.ambassadorEvent===entry.event){
|
||||
requireRule(!relatedResponses.length && !relatedDecisions.length && !relatedTerror,
|
||||
'The original Guild shipment must wait for its owned Spiritual Advisor flow.');
|
||||
spiritualAdvisorIntegrity(g);
|
||||
return;
|
||||
}
|
||||
for (const response of relatedResponses) {
|
||||
requireRule(
|
||||
response.advisorResume === 'ambassador' &&
|
||||
@@ -18447,7 +18474,7 @@ function finishGuildAmbassadorAdvisor(
|
||||
sector: at.sector,
|
||||
territory: at.territory,
|
||||
amount: response.amount ?? 1,
|
||||
}),
|
||||
},true),
|
||||
);
|
||||
validateGuildAdvisorEntry(g, quote);
|
||||
const bg = getPlayer(g, quote.player);
|
||||
@@ -18456,6 +18483,14 @@ function finishGuildAmbassadorAdvisor(
|
||||
techIncome(g, 'heighliners', bg);
|
||||
if (quote.advisors)
|
||||
(bg.advisors ??= {})[quote.territory] = { ...bg.advisors?.[quote.territory], lockedTurn: g.turn };
|
||||
logSpiritualAdvisorPlacement(g,quote,true);
|
||||
finishGuildAmbassadorAdvisorArrival(g,response,quote);
|
||||
}
|
||||
function finishGuildAmbassadorAdvisorArrival(g:Game,response:ResponseWindow,
|
||||
quote:ReturnType<typeof quoteGuildAmbassadorAdvisor>){
|
||||
const event=response.advisorAmbassadorEvent!;
|
||||
const {order}=guildAmbassadorArrival(g,event,'advisor');
|
||||
const bg=getPlayer(g,quote.player);
|
||||
const receipt = g.pendingAmbassador!.shipmentReceipt!;
|
||||
receipt.next = 'finish';
|
||||
receipt.advisorArrival = {
|
||||
@@ -18465,11 +18500,6 @@ function finishGuildAmbassadorAdvisor(
|
||||
amount: quote.amount,
|
||||
elite: 0,
|
||||
};
|
||||
log(
|
||||
g,
|
||||
`${bg.name} sent ${quote.amount} free reserve ${quote.amount === 1 ? 'force' : 'forces'} to ${territory(quote.territory).name}, sector ${quote.sector}, after the Guild Ambassador shipment${quote.amount === 2 ? ' using high-population Wallach IX' : ''}.`,
|
||||
{ faction: bg.faction, name: 'Spiritual Advisors' },
|
||||
);
|
||||
if (guildAdvisorAmbassador(g, quote)) {
|
||||
const parent = g.pendingAmbassador!;
|
||||
const arrivalTag = parent.revivalEvent !== undefined ? 'revivalEvent' :
|
||||
@@ -20347,6 +20377,207 @@ function automaticAdvisorRule<T>(quote: () => T): T {
|
||||
try { return quote(); }
|
||||
catch (error) { if (error instanceof AutomaticAdvisorFlipError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function spiritualRule<T>(quote:()=>T):T{
|
||||
try{return quote();}catch(error){if(error instanceof SpiritualAdvisorError)throw new RuleError(error.message);throw error;}
|
||||
}
|
||||
function logSpiritualAdvisorPlacement(g:Game,order:{player:string;territory:string;sector:number;amount:number},ambassador:boolean){
|
||||
const bg=getPlayer(g,order.player);
|
||||
log(g,ambassador
|
||||
? `${bg.name} sent ${order.amount} free reserve ${order.amount===1?'force':'forces'} to ${territory(order.territory).name}, sector ${order.sector}, after the Guild Ambassador shipment${order.amount===2?' using high-population Wallach IX':''}.`
|
||||
: `${bg.name} sent ${order.amount} free ${order.amount===1?'force':'forces'} to ${territory(order.territory).name}${order.amount===2?' using high-population Wallach IX':''}.`,
|
||||
{faction:bg.faction,name:'Spiritual Advisors'});
|
||||
}
|
||||
function spiritualParentSignature(g:Game,decision:Extract<Decision,{kind:'advisor'}>){
|
||||
return JSON.stringify([g.turn,g.phase,g.active,decision,decision.ambassadorEvent
|
||||
? g.pendingAmbassador : g.pendingArrivalReaction??null]);
|
||||
}
|
||||
function quoteSpiritualOrder(g:Game,decision:Extract<Decision,{kind:'advisor'}>,action:Action){
|
||||
const bg=getPlayer(g,decision.player);
|
||||
let order:{player:string;territory:string;sector:number;amount:number;elite:0;advisors:boolean};
|
||||
if(decision.ambassadorEvent){
|
||||
const {order:shipment}=guildAmbassadorArrival(g,decision.ambassadorEvent,'advisor');
|
||||
order=guildAmbassadorRule(()=>quoteGuildAmbassadorAdvisor(g,bg.id,shipment,action));
|
||||
}else{
|
||||
requireRule(action.accompany===undefined || typeof action.accompany==='boolean','Choose an accompaniment option.');
|
||||
const target=action.accompany===true&&g.advanced?decision.destination:'polar_sink:0';
|
||||
requireRule(target,'This shipment has no accompanying destination.');
|
||||
const point=splitLocation(target);
|
||||
if(action.accompany===true&&g.advanced&&action.sector!==undefined)
|
||||
point.sector=integer(action.sector,0,18,'Sector');
|
||||
requireRule(action.territory===undefined||action.territory===point.territory,'Use the original shipment territory or Polar Sink.');
|
||||
const amount=integer(action.amount===undefined?1:action.amount,1,spiritualAdvisorMaximum(g,bg.id,point.territory),'Spiritual Advisor forces');
|
||||
order={player:bg.id,territory:point.territory,sector:point.sector,amount,elite:0,advisors:false};
|
||||
}
|
||||
const block=spiritualRule(()=>spiritualAdvisorTargetBlock(g,bg.id,order.territory));
|
||||
requireRule(!block,block??'This Spiritual Advisor destination is unavailable.');
|
||||
const arrival=spiritualRule(()=>spiritualAdvisorArrival(g,bg.id,order.territory));
|
||||
order.advisors=arrival.advisors;
|
||||
allowedEntry(g,bg,order.territory,order.sector,false,order.advisors);
|
||||
if(decision.ambassadorEvent){
|
||||
const quote={...order,amount:order.amount as 1|2};
|
||||
validateGuildAdvisorEntry(g,quote);
|
||||
if(arrival.alone&&!bg.advisors?.[order.territory]?.aloneFlipHold)
|
||||
validateGuildAdvisorEntry(g,{...quote,advisors:false});
|
||||
}
|
||||
return order;
|
||||
}
|
||||
function spiritualAdvisorChoices(g:Game,id:string){
|
||||
const decision=g.decision;
|
||||
if(!g.advanced||decision?.kind!=='advisor'||decision.player!==id)return null;
|
||||
const destination=decision.destination?splitLocation(decision.destination).territory:null;
|
||||
const choices=[...(destination?gameTerritories(g).find(t=>t.id===destination)?.sectors.map(sector=>
|
||||
({accompany:true,territory:destination,sector,amount:1}))??[]:[]),
|
||||
...Array.from({length:Math.max(1,spiritualAdvisorMaximum(g,id))},(_,index)=>
|
||||
({accompany:false,territory:'polar_sink',sector:0,amount:index+1}))];
|
||||
return {choices:choices.map(choice=>{
|
||||
try{
|
||||
const order=quoteSpiritualOrder(g,decision,{type:'decision',...choice});
|
||||
const frame=g.pendingSpiritualAdvisor;
|
||||
if(frame&&spiritualAdvisorArrival(g,id,choice.territory).matching){
|
||||
const source={event:frame.event,owner:id,turn:g.turn,phase:g.phase,
|
||||
territory:order.territory,sector:order.sector,amount:order.amount};
|
||||
requireRule(!frame.denied.includes(spiritualAdvisorMatchingKey(source)),
|
||||
'Matching this free packet here was prevented. Choose another legal packet or decline.');
|
||||
}
|
||||
return {...choice,blocked:null as string|null};
|
||||
}catch(error){if(!(error instanceof RuleError))throw error;return {...choice,blocked:error.message};}
|
||||
})};
|
||||
}
|
||||
function spiritualFrameBody(g:Game){
|
||||
const frame=g.pendingSpiritualAdvisor;
|
||||
requireRule(frame&&g.advanced&&g.status==='playing'&&frame.turn===g.turn&&frame.phase===g.phase&&
|
||||
getPlayer(g,frame.decision.player).faction==='beneGesserit'&&
|
||||
frame.signature===JSON.stringify([frame.event,spiritualParentSignature(g,frame.decision)]),
|
||||
'Spiritual Advisors lost the original committed foreign shipment.');
|
||||
if(frame.stage!=='offer'){
|
||||
requireRule(frame.source&&frame.order&&frame.selection===JSON.stringify([frame.source,frame.order])&&
|
||||
frame.source.event===frame.event&&frame.source.owner===frame.decision.player&&
|
||||
frame.order.player===frame.source.owner&&frame.order.territory===frame.source.territory&&
|
||||
frame.order.sector===frame.source.sector&&frame.order.amount===frame.source.amount,
|
||||
'The Spiritual packet lost its exact original declaration.');
|
||||
spiritualRule(()=>validateSpiritualAdvisorSource(g,frame.source!));
|
||||
}
|
||||
if(frame.stage==='arrival'){
|
||||
const deposit=frame.deposit,source=frame.source!,owner=getPlayer(g,source.owner);
|
||||
requireRule(deposit&&deposit.key===location(source.territory,source.sector)&&
|
||||
owner.forces[deposit.key]===deposit.before+source.amount,
|
||||
'The committed Spiritual packet lost its actual physical deposit.');
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
function spiritualAdvisorIntegrity(g:Game){
|
||||
if(!g.pendingSpiritualAdvisor)return;
|
||||
const frame=spiritualFrameBody(g),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);
|
||||
if(frame.stage==='offer'){
|
||||
requireRule(!frame.source&&!frame.order&&controls.some(c=>JSON.stringify(c?.decision)===JSON.stringify(frame.decision)),
|
||||
'Restore the original owned Spiritual Advisor chooser.');
|
||||
}else if(frame.stage==='arrival'){
|
||||
requireRule(responses.some(r=>r.automaticAdvisorFlip?.producer==='spiritualArrival'&&
|
||||
JSON.stringify(r.automaticAdvisorFlip.spiritual)===JSON.stringify(frame.source)),
|
||||
'The committed Spiritual arrival lost its pending alone conversion.');
|
||||
}else requireRule(responses.some(r=>JSON.stringify(r.spiritualAdvisorSource)===JSON.stringify(frame.source)&&
|
||||
r.kind===(frame.stage==='shipping'?'advisor':'advisorFlip')),
|
||||
'The original Spiritual packet lost its owned shipping or matching response.');
|
||||
}
|
||||
function beginSpiritualAdvisor(g:Game,decision:Extract<Decision,{kind:'advisor'}>,action:Action){
|
||||
const order=quoteSpiritualOrder(g,decision,action);
|
||||
let frame=g.pendingSpiritualAdvisor;
|
||||
if(frame){
|
||||
spiritualFrameBody(g);
|
||||
requireRule(frame.stage==='offer'&&JSON.stringify(frame.decision)===JSON.stringify(decision),
|
||||
'Finish the original Spiritual Advisor opportunity first.');
|
||||
}else{
|
||||
const event=crypto.randomUUID();
|
||||
frame={event,turn:g.turn,phase:g.phase,decision:structuredClone(decision),
|
||||
signature:JSON.stringify([event,spiritualParentSignature(g,decision)]),stage:'offer',denied:[]};
|
||||
}
|
||||
const source:SpiritualAdvisorSource={event:frame.event,owner:decision.player,turn:g.turn,phase:g.phase,
|
||||
territory:order.territory,sector:order.sector,amount:order.amount};
|
||||
if(spiritualAdvisorArrival(g,source.owner,source.territory).matching)
|
||||
requireRule(!frame.denied.includes(spiritualAdvisorMatchingKey(source)),
|
||||
'Matching this free packet here was prevented. Choose another legal packet or decline.');
|
||||
frame.source=source;frame.order=order;frame.selection=JSON.stringify([source,order]);frame.stage='shipping';
|
||||
g.pendingSpiritualAdvisor=frame;
|
||||
g.response={kind:'advisor',owner:source.owner,location:location(source.territory,source.sector),
|
||||
amount:source.amount,passed:[],spiritualAdvisorSource:source};
|
||||
}
|
||||
function currentSpiritualAdvisorResponse(g:Game,response:ResponseWindow){
|
||||
const frame=spiritualFrameBody(g),source=response.spiritualAdvisorSource;
|
||||
requireRule(source&&JSON.stringify(source)===JSON.stringify(frame.source)&&response.owner===source.owner&&
|
||||
response.location===location(source.territory,source.sector)&&response.amount===source.amount&&
|
||||
!response.advisorResume&&!response.advisorAmbassadorEvent&&!response.automaticAdvisorFlip&&
|
||||
!response.advisorMatchingSource&&!response.advisorPreparationSource&&
|
||||
(frame.stage==='shipping'&&response.kind==='advisor'||
|
||||
frame.stage==='matching'&&response.kind==='advisorFlip'&&response.advisors===false),
|
||||
'The Spiritual response lost its original shipping or matching packet.');
|
||||
return frame;
|
||||
}
|
||||
function restoreSpiritualAdvisorOffer(g:Game,matchingPrevented:boolean){
|
||||
const frame=spiritualFrameBody(g);
|
||||
if(matchingPrevented)frame.denied.push(spiritualAdvisorMatchingKey(frame.source!));
|
||||
delete frame.source;delete frame.order;delete frame.selection;delete frame.deposit;frame.stage='offer';
|
||||
g.decision=structuredClone(frame.decision);
|
||||
log(g,matchingPrevented?'Spiritual Advisor matching was prevented before the free packet arrived. Choose another legal packet or decline; the original foreign shipment stays committed.':
|
||||
'The free Spiritual packet is no longer available as declared. Choose a legal replacement or decline; the original foreign shipment stays committed.');
|
||||
}
|
||||
function finishSpiritualAdvisorResponse(g:Game,response:ResponseWindow,canceled:boolean){
|
||||
const frame=currentSpiritualAdvisorResponse(g,response);
|
||||
if(canceled){
|
||||
if(frame.stage==='matching'){restoreSpiritualAdvisorOffer(g,true);return;}
|
||||
delete g.pendingSpiritualAdvisor;
|
||||
log(g,'Karama prevented the free Spiritual Advisor shipment. The original foreign shipment remains committed.');
|
||||
if(frame.decision.ambassadorEvent)finishAmbassador(g);
|
||||
return;
|
||||
}
|
||||
let order:NonNullable<typeof frame.order>;
|
||||
try{order=quoteSpiritualOrder(g,frame.decision,{type:'decision',accompany:frame.source!.territory!=='polar_sink',
|
||||
territory:frame.source!.territory,sector:frame.source!.sector,amount:frame.source!.amount});}
|
||||
catch(error){if(!(error instanceof RuleError))throw error;restoreSpiritualAdvisorOffer(g,false);return;}
|
||||
if(frame.stage==='shipping'&&spiritualAdvisorArrival(g,frame.source!.owner,frame.source!.territory).matching){
|
||||
frame.order=order;frame.selection=JSON.stringify([frame.source,order]);
|
||||
frame.stage='matching';g.response={kind:'advisorFlip',owner:frame.source!.owner,
|
||||
location:response.location,amount:response.amount,advisors:false,passed:[],spiritualAdvisorSource:frame.source};
|
||||
return;
|
||||
}
|
||||
const bg=getPlayer(g,order.player),key=location(order.territory,order.sector);
|
||||
frame.order=order;frame.selection=JSON.stringify([frame.source,order]);
|
||||
frame.deposit={key,before:bg.forces[key]??0};frame.stage='arrival';
|
||||
withdrawNativeReserves(g,bg,order.amount,0);place(bg,order.territory,order.sector,order.amount);
|
||||
techIncome(g,'heighliners',bg);
|
||||
if(order.advisors)(bg.advisors??={})[order.territory]={...bg.advisors?.[order.territory],lockedTurn:g.turn};
|
||||
else if(bg.advisors)delete bg.advisors[order.territory];
|
||||
logSpiritualAdvisorPlacement(g,order,!!frame.decision.ambassadorEvent);
|
||||
const groups=automaticAdvisorRule(()=>quoteAutomaticAdvisorFlips(g)).filter(group=>group.territory===order.territory);
|
||||
for(const group of groups){
|
||||
requireRule(!g.pendingAutomaticAdvisorFlips?.length,'Finish the original advisor conversion before this Spiritual arrival.');
|
||||
const source:AutomaticAdvisorFlipSource={event:crypto.randomUUID(),owner:group.player,
|
||||
territory:group.territory,turn:g.turn,phase:g.phase,forces:group.forces,
|
||||
producer:'spiritualArrival',spiritual:frame.source!};
|
||||
getPlayer(g,group.player).advisors![group.territory].aloneFlipHold=source.event;
|
||||
(g.pendingAutomaticAdvisorFlips??=[]).push(source);
|
||||
}
|
||||
if(groups.length){openAutomaticAdvisorFlip(g);return;}
|
||||
completeSpiritualAdvisorArrival(g);
|
||||
}
|
||||
function completeSpiritualAdvisorArrival(g:Game){
|
||||
const frame=spiritualFrameBody(g),order=frame.order!;
|
||||
requireRule(frame.stage==='arrival'&&!g.pendingAutomaticAdvisorFlips?.length,
|
||||
'Finish the committed Spiritual conversion before arrival.');
|
||||
const bg=getPlayer(g,order.player);
|
||||
delete g.pendingSpiritualAdvisor;
|
||||
if(frame.decision.ambassadorEvent){
|
||||
finishGuildAmbassadorAdvisorArrival(g,{kind:'advisor',owner:bg.id,passed:[],
|
||||
advisorAmbassadorEvent:frame.decision.ambassadorEvent},
|
||||
{...order,amount:order.amount as 1|2,advisors:isAdvisor(bg,order.territory)});
|
||||
}else{
|
||||
openTerritoryEntry(g,bg,order.territory,order.sector,order.amount,0,'advisor');
|
||||
}
|
||||
}
|
||||
function matchingAdvisorRule<T>(quote: () => T): T {
|
||||
try { return quote(); }
|
||||
catch(error) { if(error instanceof AdvisorMatchingError) throw new RuleError(error.message); throw error; }
|
||||
@@ -20445,6 +20676,11 @@ function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) {
|
||||
'The mandatory conversion has lost its original completed relocation.');
|
||||
validateRelocationAdvisorParent(g, parent);
|
||||
}
|
||||
if(source.producer==='spiritualArrival'){
|
||||
const frame=spiritualFrameBody(g);
|
||||
requireRule(frame.stage==='arrival'&&JSON.stringify(frame.source)===JSON.stringify(source.spiritual),
|
||||
'The mandatory conversion lost its committed Spiritual Advisor packet.');
|
||||
}
|
||||
automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source));
|
||||
return source;
|
||||
}
|
||||
@@ -22874,7 +23110,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.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching && !g.pendingSpiritualAdvisor,
|
||||
'Resolve mandatory advisor conversions before checking victory.');
|
||||
quote ??= currentVictoryQuote(g);
|
||||
applyAdvisorReleases(g, quote.released);
|
||||
@@ -28666,7 +28902,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
);
|
||||
} else if (response.kind === 'advisorFlip') {
|
||||
const bg = getPlayer(g, response.owner);
|
||||
if (response.advisorMatchingSource) finishAdvisorMatching(g,response,canceled);
|
||||
if (response.spiritualAdvisorSource) finishSpiritualAdvisorResponse(g,response,canceled);
|
||||
else if (response.advisorMatchingSource) finishAdvisorMatching(g,response,canceled);
|
||||
else if (response.advisorResume === 'planetologistGather') {
|
||||
movementCancellationQuote(g, response);
|
||||
const pending = response.advisorGatherMove!;
|
||||
@@ -28697,7 +28934,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
}
|
||||
if (automaticAdvisorFlip) {
|
||||
if (canceled)
|
||||
log(g, `${bg.name}'s mandatory conversion in ${territory(to).name} was prevented. The completed ${automaticAdvisorFlip.producer === 'guildTransport' || automaticAdvisorFlip.producer === 'homeworldTransport' ? 'transport and payment stay' : automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont effect and card use stay' : automaticAdvisorFlip.producer === 'battleLosses' ? 'battle losses and costs stay' : automaticAdvisorFlip.producer === 'faceDance' ? 'Face Dance replacement and reveal stay' : automaticAdvisorFlip.producer === 'storm' ? 'current-sector storm casualties stay' : automaticAdvisorFlip.producer === 'specialRelocation' ? 'relocation and any collection stay' : 'movement stays'} committed; this group remains advisors while continuously alone.`,
|
||||
log(g, `${bg.name}'s mandatory conversion in ${territory(to).name} was prevented. The completed ${automaticAdvisorFlip.producer === 'guildTransport' || automaticAdvisorFlip.producer === 'homeworldTransport' ? 'transport and payment stay' : automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont effect and card use stay' : automaticAdvisorFlip.producer === 'battleLosses' ? 'battle losses and costs stay' : automaticAdvisorFlip.producer === 'faceDance' ? 'Face Dance replacement and reveal stay' : automaticAdvisorFlip.producer === 'storm' ? 'current-sector storm casualties stay' : automaticAdvisorFlip.producer === 'specialRelocation' ? 'relocation and any collection stay' : automaticAdvisorFlip.producer === 'spiritualArrival' ? 'free Spiritual force placement and any income stay' : 'movement stays'} committed; this group remains advisors while continuously alone.`,
|
||||
{ faction: bg.faction, name: 'Mandatory advisor conversion prevented' });
|
||||
g.pendingAutomaticAdvisorFlips!.shift();
|
||||
if (!g.pendingAutomaticAdvisorFlips!.length) delete g.pendingAutomaticAdvisorFlips;
|
||||
@@ -28709,6 +28946,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
finishAutomaticAdvisorFaceDance(g);
|
||||
finishAutomaticAdvisorStorm(g);
|
||||
finishAutomaticAdvisorRelocation(g);
|
||||
if(g.pendingSpiritualAdvisor?.stage==='arrival')completeSpiritualAdvisorArrival(g);
|
||||
} else openAutomaticAdvisorFlip(g);
|
||||
} else finishAdvisorReaction(g, response);
|
||||
}
|
||||
@@ -29006,6 +29244,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
} else log(g, 'Karama prevented the Harkonnen bonus treachery card.');
|
||||
nextAuction(g);
|
||||
} else if (response.kind === 'advisor') {
|
||||
if(response.spiritualAdvisorSource){finishSpiritualAdvisorResponse(g,response,canceled);return;}
|
||||
if (response.advisorResume === 'ambassador') {
|
||||
finishGuildAmbassadorAdvisor(g, response, canceled);
|
||||
return;
|
||||
@@ -32347,6 +32586,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
treacheryDiscardIntegrity(state);
|
||||
shipmentPromiseIntegrity(state);
|
||||
saphoMovementIntegrity(state);
|
||||
spiritualAdvisorIntegrity(state);
|
||||
ambassadorRelocationIntegrity(state);
|
||||
ambassadorRevivalIncomeIntegrity(state);
|
||||
ecazCollectionIntegrity(state);
|
||||
@@ -32646,7 +32886,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
|
||||
if (!g.response && !g.decision && g.pendingAutomaticAdvisorFlips?.length) openAutomaticAdvisorFlip(g);
|
||||
if (g.pendingArrivalReaction?.length && !g.response && !g.decision &&
|
||||
!g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap) {
|
||||
!g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap && !g.pendingSpiritualAdvisor) {
|
||||
const deferred = g.pendingArrivalReaction.shift()!;
|
||||
if (!g.pendingArrivalReaction.length) g.pendingArrivalReaction = null;
|
||||
const entrant = g.players.find(p => p.id === deferred.entrant);
|
||||
@@ -32757,6 +32997,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
treacheryDiscardIntegrity(state);
|
||||
shipmentPromiseIntegrity(state);
|
||||
saphoMovementIntegrity(state);
|
||||
spiritualAdvisorIntegrity(state);
|
||||
ambassadorRelocationIntegrity(state);
|
||||
ambassadorRevivalIncomeIntegrity(state);
|
||||
ecazCollectionIntegrity(state);
|
||||
@@ -34563,6 +34804,31 @@ function applyActionInner(
|
||||
};
|
||||
} else movementTurn(g);
|
||||
} else if (decision.kind === 'advisor') {
|
||||
if(g.advanced){
|
||||
requireRule(typeof action.accept==='boolean','Choose the free Spiritual shipment or decline.');
|
||||
if(action.accept){
|
||||
beginSpiritualAdvisor(g,decision,action);
|
||||
if(liveShipmentPromises(g.shipmentPromises??[],p.id,g.turn).length ||
|
||||
liveMovementPromises(g.movementPromises??[],p.id,g.turn).some(promise=>promise.answer)){
|
||||
const afterAdvisor=structuredClone(g);
|
||||
for(let step=0;step<4&&afterAdvisor.response&&(afterAdvisor.response.spiritualAdvisorSource ||
|
||||
afterAdvisor.response.automaticAdvisorFlip?.producer==='spiritualArrival');step++)
|
||||
finishResponse(afterAdvisor,false);
|
||||
reconcileShipmentPromises(afterAdvisor,{actor:p.id,action});
|
||||
reconcileGroundMovementPromises(afterAdvisor,p.id,action,g.phase);
|
||||
}
|
||||
}else{
|
||||
if(g.pendingSpiritualAdvisor){
|
||||
const frame=spiritualFrameBody(g);
|
||||
requireRule(frame.stage==='offer'&&JSON.stringify(frame.decision)===JSON.stringify(decision),
|
||||
'Decline only the original Spiritual Advisor chooser.');
|
||||
delete g.pendingSpiritualAdvisor;
|
||||
}
|
||||
log(g,`${p.name} declined the free Spiritual Advisor shipment.`);
|
||||
if(decision.ambassadorEvent)finishAmbassador(g);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
requireRule(
|
||||
typeof action.accept === 'boolean',
|
||||
'Choose whether to ship an advisor.',
|
||||
@@ -36817,6 +37083,7 @@ export function viewGame(state: Game, id: string) {
|
||||
treacheryDiscardIntegrity(state);
|
||||
shipmentPromiseIntegrity(state);
|
||||
saphoMovementIntegrity(state);
|
||||
spiritualAdvisorIntegrity(state);
|
||||
ambassadorRelocationIntegrity(state);
|
||||
ambassadorRevivalIncomeIntegrity(state);
|
||||
ecazCollectionIntegrity(state);
|
||||
@@ -36887,6 +37154,8 @@ export function viewGame(state: Game, id: string) {
|
||||
battleChoices: g.status === 'playing' && g.phase === 6 ? battleOrderQuote(g).choices : [],
|
||||
battleOrder: projectedBattleOrder(g),
|
||||
guildAmbassadorAdvisorChoices: guildAdvisorChoices(g, id),
|
||||
...(g.advanced&&g.decision?.kind==='advisor'&&g.decision.player===id
|
||||
? {spiritualAdvisorChoices:spiritualAdvisorChoices(g,id)} : {}),
|
||||
truthMovementTargets: g.players.filter(p => movementPromiseTargetAvailable(g, p.id)).map(p => p.id),
|
||||
truthMovementAnswers: g.truthtrance?.stage === 'answer' &&
|
||||
g.truthtrance.question?.kind === 'movement' && g.truthtrance.question.target === id
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Action, Game, Player } from './engine';
|
||||
import { arrivalAsAdvisor } from './advisors';
|
||||
import { spiritualAdvisorTargetBlock, spiritualAdvisorArrival } from './spiritual-advisor';
|
||||
import {
|
||||
gameTerritories,
|
||||
location,
|
||||
@@ -274,6 +275,7 @@ export function quoteGuildAmbassadorAdvisor(
|
||||
bgId: string,
|
||||
shipment: GuildAmbassadorShipment,
|
||||
action: Action,
|
||||
recordedLegacy = false,
|
||||
): GuildAmbassadorAdvisor {
|
||||
const bg = beneficiary(g, bgId);
|
||||
const shipper = g.players.find((p) => p.id === shipment?.player);
|
||||
@@ -343,7 +345,10 @@ export function quoteGuildAmbassadorAdvisor(
|
||||
to !== MOBILE_STRONGHOLD || (accompanying && shipper.faction === 'ixians'),
|
||||
'Accompany only an Ixian shipment into the mobile stronghold.',
|
||||
);
|
||||
const advisors = arrivalAsAdvisor(g, bg, to, undefined, accompanying);
|
||||
const targetBlock=recordedLegacy?null:spiritualAdvisorTargetBlock(g,bg.id,to);
|
||||
requireShipment(!targetBlock,targetBlock??'This Spiritual Advisor target is unavailable.');
|
||||
const advisors = recordedLegacy ? arrivalAsAdvisor(g, bg, to, undefined, accompanying)
|
||||
: spiritualAdvisorArrival(g,bg.id,to).advisors;
|
||||
validateDestinationOccupancy(g, bg, to, advisors);
|
||||
requireShipment(
|
||||
count((bg.forces[location(to, sector)] ?? 0) + amount) &&
|
||||
|
||||
@@ -102,7 +102,9 @@ const cancellationSources = {
|
||||
declaration: g.nexusSardaukarHistory?.find(record => record.stage === 'pending') ?? null,
|
||||
}),
|
||||
advisor: (g, response) =>
|
||||
response.advisorResume === 'ambassador'
|
||||
response.spiritualAdvisorSource
|
||||
? {source:response.spiritualAdvisorSource,pending:g.pendingSpiritualAdvisor?.source??null}
|
||||
: response.advisorResume === 'ambassador'
|
||||
? {
|
||||
entry: g.pendingAmbassador ?? null,
|
||||
ambassadors: g.ecazAmbassadors ?? null,
|
||||
@@ -189,9 +191,13 @@ const cancellationSources = {
|
||||
storm: response.automaticAdvisorFlip.producer === 'storm' ? g.pendingAutomaticAdvisorStorm?.receipt ?? null : null,
|
||||
...(response.automaticAdvisorFlip.producer === 'specialRelocation'
|
||||
? { relocation: g.pendingAutomaticAdvisorRelocation?.receipt ?? null } : {}),
|
||||
...(response.automaticAdvisorFlip.producer === 'spiritualArrival'
|
||||
? { spiritual:g.pendingSpiritualAdvisor?.source??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.spiritualAdvisorSource
|
||||
? {spiritual:response.spiritualAdvisorSource,pending:g.pendingSpiritualAdvisor?.source??null}
|
||||
: response.advisorPreparationSource
|
||||
? { preparation: response.advisorPreparationSource, window: g.advisorPreparation ?? null }
|
||||
: response.advisorResume === 'ambassador'
|
||||
|
||||
@@ -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.spiritualAdvisorSource) return null;
|
||||
if (
|
||||
![
|
||||
'guildTiming',
|
||||
|
||||
+23
-6
@@ -4324,7 +4324,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A completed Fremen Ambassador relocation or next-turn Discovery force transfer can leave advisors alone at its source. Ambassador movement can also bring advisors to an empty destination. Provisionally resolve their mandatory conversion after movement and any original collection, before the original arrival reactions continue. Karama keeps the transfer committed. Discovery free entry still admits non-advisor forces only.',
|
||||
'Provisional cancellation duration: a prevented advisor group stays advisors while continuously alone, including across phase and turn changes. Another faction returning or the group becoming empty ends that held occurrence. Later arrivals joining that same group match its stance. Matching-group flip counters and wider combinations remain unfinished.',
|
||||
'When another faction ships, moves or worm rides into your fighters, immediately choose whether to become advisors. The choice opens a Karama response. Repeated entry can offer a new choice.',
|
||||
'After another faction’s off-planet shipment, choose a free force in the Polar Sink or an accompanying force at the shipment destination. An accompanying advisor group cannot flip to fighters in that turn while other factions remain.',
|
||||
'After another faction’s eligible off-planet shipment, choose a free advisor at the shipment destination or Polar Sink. Retained fighters outside Polar Sink require Polar Sink or decline unless Intrusion already changed them to advisors. The free shipping counter comes first. Provisionally, Polar fighters require a separate incoming matching counter before placement; preventing matching restores the owned choice while that same packet here stays unavailable. Empty arrivals then have a separate committed alone-conversion counter. Earlier locked advisor rules still apply while others remain.',
|
||||
'After Spice Blow and Nexus, use Prepare advisors for battle during Charity, Bidding, Revival or before the first actual shipment. Quiet boundaries retain current mandatory priority. Each whole-territory flip has its original Karama response; its direct continuation preserves the current phase, auction, readiness and active movement. Declining the initial phase-five chooser leaves this window open. Ordinary allied presence, storm and fresh-advisor locks still restrict eligibility; the recorded supported Ecaz co-side composition remains separate.',
|
||||
'A current-turn window closes on a committed shipment, including a concealed zero No-Field, independent Ambassador shipment or positive Sneak Attack. Proposals, stopped shipments, ground movement and unshipped turns do not close it. Under the explicit provisional retry policy, Karama prevents that territory’s preparation for the rest of this window; Intrusion, Nexus and automatic flips remain independent. Focused evidence does not certify complete modes, broader priority/optional or remaining automatic-flip cancellation behavior.',
|
||||
],
|
||||
@@ -4333,8 +4333,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Implementation",
|
||||
"status": "Partial",
|
||||
"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.",
|
||||
"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.",
|
||||
"evidence": [
|
||||
"tests/fixture-spiritual-advisor-flow.ts",
|
||||
"tests/spiritual-advisor-engine.test.ts",
|
||||
"tests/spiritual-advisor.test.ts",
|
||||
"game/spiritual-advisor.ts",
|
||||
"docs/SPIRITUAL_ADVISORS.md",
|
||||
"docs/ADVISOR_MATCHING.md",
|
||||
"game/advisor-automatic-flip.ts",
|
||||
"game/advisors.ts",
|
||||
@@ -4349,8 +4354,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Player controls",
|
||||
"status": "Partial",
|
||||
"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.",
|
||||
"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.",
|
||||
"evidence": [
|
||||
"docs/SPIRITUAL_ADVISORS.md",
|
||||
"docs/ADVISOR_MATCHING.md",
|
||||
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
|
||||
"components/advisor-preparation.tsx",
|
||||
@@ -4361,8 +4367,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "AI",
|
||||
"status": "Partial",
|
||||
"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.",
|
||||
"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.",
|
||||
"evidence": [
|
||||
"tests/fixture-spiritual-advisor-flow.ts",
|
||||
"tests/spiritual-advisor-engine.test.ts",
|
||||
"tests/spiritual-advisor.test.ts",
|
||||
"game/spiritual-advisor.ts",
|
||||
"docs/SPIRITUAL_ADVISORS.md",
|
||||
"tests/fixture-advisor-matching-move.ts",
|
||||
"tests/advisor-matching-engine.test.ts",
|
||||
"tests/advisor-matching.test.ts",
|
||||
@@ -4389,8 +4400,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Documentation",
|
||||
"status": "Partial",
|
||||
"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.",
|
||||
"detail": "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",
|
||||
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
|
||||
"docs/ADVISOR_PREPARATION.md",
|
||||
@@ -4401,8 +4413,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Verification",
|
||||
"status": "Partial",
|
||||
"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.",
|
||||
"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.",
|
||||
"evidence": [
|
||||
"tests/fixture-spiritual-advisor-flow.ts",
|
||||
"tests/spiritual-advisor-engine.test.ts",
|
||||
"tests/spiritual-advisor.test.ts",
|
||||
"game/spiritual-advisor.ts",
|
||||
"docs/SPIRITUAL_ADVISORS.md",
|
||||
"tests/fixture-advisor-matching-move.ts",
|
||||
"tests/advisor-matching-engine.test.ts",
|
||||
"tests/advisor-matching.test.ts",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { gameTerritories, splitLocation, type MobileBoard } from './board';
|
||||
import type { FactionId } from './catalog';
|
||||
import { presenceAt, type ForcePresence } from './force-presence';
|
||||
|
||||
export type SpiritualAdvisorSource = {
|
||||
event: string;
|
||||
owner: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
territory: string;
|
||||
sector: number;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
export type SpiritualAdvisorPlayer = ForcePresence & {
|
||||
id: string;
|
||||
faction: FactionId;
|
||||
advisors?: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type SpiritualAdvisorBoardContext = {
|
||||
advanced: boolean;
|
||||
players: readonly SpiritualAdvisorPlayer[];
|
||||
};
|
||||
|
||||
export type SpiritualAdvisorContext = SpiritualAdvisorBoardContext & MobileBoard & {
|
||||
status: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
};
|
||||
|
||||
export type SpiritualAdvisorTarget = string | { territory: string; sector?: number };
|
||||
|
||||
export class SpiritualAdvisorError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SpiritualAdvisorError';
|
||||
}
|
||||
}
|
||||
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 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 requireSpiritual(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new SpiritualAdvisorError(message);
|
||||
}
|
||||
|
||||
function nativeOwner(ctx: SpiritualAdvisorBoardContext, owner: string) {
|
||||
const players = ctx.players.filter((player) => player.id === owner);
|
||||
return players.length === 1 && players[0].faction === 'beneGesserit' ? players[0] : undefined;
|
||||
}
|
||||
|
||||
function territoryId(target: SpiritualAdvisorTarget) {
|
||||
return typeof target === 'string' ? target : target.territory;
|
||||
}
|
||||
|
||||
function hasResidents(player: SpiritualAdvisorPlayer, territory: string) {
|
||||
return Object.entries(player.forces).some(([key, count]) =>
|
||||
splitLocation(key).territory === territory && count > 0);
|
||||
}
|
||||
|
||||
/** Physical28's Advanced Spiritual exception does not change ordinary arrival.
|
||||
* Polar Sink retains the selected provisional fallback, even with BG fighters. */
|
||||
export function spiritualAdvisorTargetBlock(
|
||||
ctx: SpiritualAdvisorBoardContext,
|
||||
owner: string,
|
||||
target: SpiritualAdvisorTarget,
|
||||
): string | null {
|
||||
if (!ctx.advanced) return null;
|
||||
const player = nativeOwner(ctx, owner);
|
||||
if (!player) return 'Spiritual Advisors need the current native Bene Gesserit owner.';
|
||||
const territory = territoryId(target);
|
||||
if (territory === 'polar_sink') return null;
|
||||
return hasResidents(player, territory) && !player.advisors?.[territory]
|
||||
? 'Advanced Spiritual Advisors cannot accompany into a non-Polar territory containing your fighters; choose the Polar Sink or decline. Intrusion that already made them advisors removes this block.'
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Describe the incoming advisor group; target and resource permission remain
|
||||
* separate. No resident group or prior continuously-alone hold is changed. */
|
||||
export function spiritualAdvisorArrival(
|
||||
ctx: SpiritualAdvisorBoardContext,
|
||||
owner: string,
|
||||
target: SpiritualAdvisorTarget,
|
||||
): { advisors: boolean; matching: boolean; alone: boolean } {
|
||||
if (!ctx.advanced) return { advisors: false, matching: false, alone: false };
|
||||
const player = nativeOwner(ctx, owner);
|
||||
requireSpiritual(player, 'Spiritual Advisors need the current native Bene Gesserit owner.');
|
||||
const territory = territoryId(target);
|
||||
const matching = hasResidents(player, territory) && !player.advisors?.[territory];
|
||||
return {
|
||||
advisors: !matching,
|
||||
matching,
|
||||
alone: !ctx.players.some((other) => other.id !== owner && presenceAt(other, territory) > 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** Original foreign-shipment event and packet bind a prevented matching retry.
|
||||
* Destination sector does not create another use of the same opportunity. */
|
||||
export function spiritualAdvisorMatchingKey(source: SpiritualAdvisorSource): string {
|
||||
return JSON.stringify([
|
||||
'spiritualAdvisorMatching', source.event, source.owner, source.turn, source.territory, source.amount,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Public receipt only. Native resource entitlement and target permissions are
|
||||
* checked by the original shipment parent, before any physical placement. */
|
||||
export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, source: SpiritualAdvisorSource): void {
|
||||
requireSpiritual(record(source) &&
|
||||
Object.keys(source).sort().join(',') === 'amount,event,owner,phase,sector,territory,turn' &&
|
||||
identifier(source.event) && identifier(source.owner) && positive(source.turn) && source.turn === ctx.turn &&
|
||||
source.phase === ctx.phase && [1, 3, 5, 8].includes(source.phase) && ctx.status === 'playing' &&
|
||||
identifier(source.territory) && Number.isSafeInteger(source.sector) && source.sector >= 0 &&
|
||||
(source.amount === 1 || source.amount === 2),
|
||||
'Spiritual Advisors lost their exact current-turn public shipment source.');
|
||||
requireSpiritual(nativeOwner(ctx, source.owner), 'Spiritual Advisors lost their current native Bene Gesserit owner.');
|
||||
requireSpiritual(gameTerritories(ctx).some((place) =>
|
||||
place.id === source.territory && place.sectors.includes(source.sector)),
|
||||
'Spiritual Advisors need their current board destination and sector.');
|
||||
}
|
||||
Reference in new issue
Block a user