Connect revived force arrivals to Bene Gesserit responses
This commit is contained in:
1 parent
33523d1926
commit
ab69f97cfa
22 files changed
+1269
-121
No files matched your search
@@ -2,7 +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';
|
||||
import { validateSpiritualAdvisorSource, type SpiritualAdvisorSource, type SpiritualAdvisorContext } from './spiritual-advisor';
|
||||
|
||||
export type AdvisorStance = {
|
||||
lockedTurn?: number;
|
||||
@@ -115,6 +115,9 @@ export type AutomaticAdvisorFlipContext = AdvisorSettlementContext & MobileBoard
|
||||
status: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
homeworldRevivalReturn?: SpiritualAdvisorContext['homeworldRevivalReturn'];
|
||||
pendingSpiritualAdvisor?: SpiritualAdvisorContext['pendingSpiritualAdvisor'];
|
||||
pendingAmbassador?: SpiritualAdvisorContext['pendingAmbassador'];
|
||||
choamMentatPending?: boolean;
|
||||
lastBattleContext?: {
|
||||
event: string;
|
||||
@@ -167,7 +170,7 @@ function otherPresence(ctx: AdvisorSettlementContext, player: AutomaticAdvisorFl
|
||||
|
||||
function currentWindow(ctx: AutomaticAdvisorFlipContext) {
|
||||
return ctx.advanced === true && ctx.status === 'playing' &&
|
||||
[0, 1, 3, 5, 6, 8].includes(ctx.phase) && positive(ctx.turn);
|
||||
[0, 1, 3, 4, 5, 6, 8].includes(ctx.phase) && positive(ctx.turn);
|
||||
}
|
||||
|
||||
/** A supported committed producer opens a response for these current groups.
|
||||
@@ -487,7 +490,7 @@ export function validateAutomaticAdvisorFlip(
|
||||
(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.producer === 'spiritualArrival' ? [1, 3, 4, 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,
|
||||
|
||||
@@ -22,7 +22,7 @@ export type AmbassadorPhaseContext = Pick<
|
||||
| 'pendingAmbassador'
|
||||
>;
|
||||
|
||||
/** Extra phases belong only to the recorded arrival and its actual BG suffixes. */
|
||||
/** Extra phases belong only to the recorded arrival and its actual BG arrivals. */
|
||||
export function ambassadorPhaseAllowed(g: AmbassadorPhaseContext): boolean {
|
||||
const entry = g.pendingAmbassador;
|
||||
const candidates: {
|
||||
|
||||
+47
-19
@@ -144,7 +144,6 @@ import {
|
||||
type HomeworldRevivalProgress,
|
||||
validateHomeworldRevivalReturn,
|
||||
homeworldRevivalResumeSignature,
|
||||
appendHomeworldRevivalAmbassador,
|
||||
homeworldRevivalArrivalSignature,
|
||||
type HomeworldRevivalReturn,
|
||||
} from './homeworld-revival-return';
|
||||
@@ -1026,6 +1025,7 @@ export type Decision =
|
||||
shipment: string;
|
||||
destination?: string;
|
||||
ambassadorEvent?: string;
|
||||
revivalEvent?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'intrusion';
|
||||
@@ -1034,7 +1034,8 @@ export type Decision =
|
||||
wormRide?: boolean;
|
||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||
ambassadorEvent?: string;
|
||||
followup?: { shipment: string; destination: string };
|
||||
revivalEvent?: string;
|
||||
followup?: { shipment: string; destination: string; revivalEvent?: string };
|
||||
}
|
||||
| { kind: 'advisorBattle'; player: string; territories: string[] }
|
||||
| {
|
||||
@@ -1110,8 +1111,9 @@ export type ResponseWindow = {
|
||||
advisorGatherMove?: { turn: number; move: number; order: MovementOrder };
|
||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||
advisorAmbassadorEvent?: string;
|
||||
advisorRevivalEvent?: string;
|
||||
advisorRemaining?: string[];
|
||||
advisorFollowup?: { shipment: string; destination: string };
|
||||
advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string };
|
||||
amount?: number;
|
||||
elite?: number;
|
||||
recipient?: string;
|
||||
@@ -1208,6 +1210,7 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
|
||||
allyPayment: number;
|
||||
pools: ReturnType<typeof quoteHomeworldShipment>['sources'] | ReturnType<typeof quoteGuildHomeworldShipment>['boardSources'];
|
||||
};
|
||||
type RevivalEntryParent = { event: string; spiritual?: SpiritualAdvisorSource };
|
||||
type PendingShipment = {
|
||||
/** Only new declarations use the bonus; legacy quoted shipments retain their original price. */
|
||||
smuggler?: SmugglerShipment;
|
||||
@@ -1977,6 +1980,7 @@ export type Game = {
|
||||
resume: 'none' | 'wormRide';
|
||||
turn: number;
|
||||
phase: number;
|
||||
revivalArrival?: RevivalEntryParent;
|
||||
}[] | null;
|
||||
/** One committed entry with two independently optional expansion reactions. */
|
||||
pendingArrivalOverlap?: {
|
||||
@@ -17547,6 +17551,7 @@ function openAmbassadorEntry(
|
||||
g: Game, owner: Player, token: AmbassadorToken, entrant: Player, to: string,
|
||||
sector: number, resume: 'none' | 'wormRide',
|
||||
arrivalCause?: NonNullable<Game['pendingTerrorEntry']>['cause'],
|
||||
revivalArrival?: RevivalEntryParent,
|
||||
): boolean {
|
||||
g.pendingAmbassador = {
|
||||
...(arrivalCause ? { arrivalCause } : {}),
|
||||
@@ -17567,6 +17572,14 @@ function openAmbassadorEntry(
|
||||
: [],
|
||||
};
|
||||
g.decision = { kind: 'ecazAmbassador', player: owner.id };
|
||||
if (revivalArrival) {
|
||||
const frame = g.homeworldRevivalReturn;
|
||||
requireRule(frame?.stage === 'arrival' && frame.event === revivalArrival.event,
|
||||
'This entry has lost its original revived group.');
|
||||
g.pendingAmbassador.revivalEvent = frame.event;
|
||||
homeworldRule(() => appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador!, undefined,
|
||||
revivalArrival.spiritual));
|
||||
}
|
||||
log(g, `${owner.name} may trigger the ${faction(token.effect).name} Ambassador because ${entrant.name} entered ${territory(to).name}. The entrant’s remaining actions wait for this opportunity.`);
|
||||
return true;
|
||||
}
|
||||
@@ -17587,6 +17600,7 @@ function openTerritoryEntry(
|
||||
elite: number,
|
||||
cause: NonNullable<Game['pendingTerrorEntry']>['cause'],
|
||||
resume: 'none' | 'wormRide' = 'none',
|
||||
revivalArrival?: RevivalEntryParent,
|
||||
) {
|
||||
// The arrival's own interaction (guild payment, advisor choice, storm
|
||||
// protection) settles first; the entry then opens from the automatic pass.
|
||||
@@ -17595,6 +17609,7 @@ function openTerritoryEntry(
|
||||
(g.pendingArrivalReaction ??= []).push({
|
||||
entrant: entrant.id, territory: to, sector, amount, elite, cause, resume,
|
||||
turn: g.turn, phase: g.phase,
|
||||
...(revivalArrival ? { revivalArrival } : {}),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -17634,7 +17649,8 @@ function openTerritoryEntry(
|
||||
requireRule(!g.response && !g.decision && !g.pendingTerrorEntry && !g.pendingAmbassador,
|
||||
'Finish the other arrival reaction before opening this entry.');
|
||||
if (!terrorTokens.length)
|
||||
return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume, cause);
|
||||
return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume, cause, revivalArrival);
|
||||
requireRule(!revivalArrival, 'Revival deployment with overlapping Terror and Ambassador reactions is still being implemented.');
|
||||
requireRule(arrivalOverlapModeSupported(g) && !g.pendingArrivalOverlap &&
|
||||
moritani && g.order.includes(owner.id) && g.order.includes(moritani.id),
|
||||
'Ambassador and Terror overlap still needs a supported classic Ecaz/Moritani table.');
|
||||
@@ -18447,6 +18463,7 @@ function continueGuildAmbassadorShipment(g: Game, event: string) {
|
||||
shipment: p.id,
|
||||
destination: location(order.territory, order.sector),
|
||||
ambassadorEvent: event,
|
||||
...(g.phase === 4 && entry.revivalEvent ? {revivalEvent:entry.revivalEvent} : {}),
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -20524,9 +20541,9 @@ function quoteSpiritualOrder(g:Game,decision:Extract<Decision,{kind:'advisor'}>,
|
||||
}
|
||||
function spiritualAdvisorChoices(g:Game,id:string){
|
||||
const decision=g.decision;
|
||||
if(!g.advanced||decision?.kind!=='advisor'||decision.player!==id)return null;
|
||||
if(decision?.kind!=='advisor'||decision.player!==id||(!g.advanced&&!decision.revivalEvent))return null;
|
||||
const destination=decision.destination?splitLocation(decision.destination).territory:null;
|
||||
const choices=[...(destination?gameTerritories(g).find(t=>t.id===destination)?.sectors.map(sector=>
|
||||
const choices=[...(g.advanced&&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}))];
|
||||
@@ -20546,7 +20563,7 @@ function spiritualAdvisorChoices(g:Game,id:string){
|
||||
}
|
||||
function spiritualFrameBody(g:Game){
|
||||
const frame=g.pendingSpiritualAdvisor;
|
||||
requireRule(frame&&g.advanced&&g.status==='playing'&&frame.turn===g.turn&&frame.phase===g.phase&&
|
||||
requireRule(frame&&(g.advanced||frame.phase===4&&!!frame.decision.revivalEvent)&&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.');
|
||||
@@ -20598,7 +20615,9 @@ function beginSpiritualAdvisor(g:Game,decision:Extract<Decision,{kind:'advisor'}
|
||||
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};
|
||||
territory:order.territory,sector:order.sector,amount:order.amount,
|
||||
...(g.phase===4&&(decision.revivalEvent||g.pendingAmbassador?.revivalEvent)
|
||||
?{revivalEvent:decision.revivalEvent??g.pendingAmbassador!.revivalEvent}: {})};
|
||||
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.');
|
||||
@@ -20676,7 +20695,8 @@ function completeSpiritualAdvisorArrival(g:Game){
|
||||
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');
|
||||
openTerritoryEntry(g,bg,order.territory,order.sector,order.amount,0,'advisor','none',
|
||||
frame.source!.revivalEvent ? {event:frame.source!.revivalEvent,spiritual:frame.source} : undefined);
|
||||
}
|
||||
}
|
||||
type TransportMatchingIntent=NonNullable<Game['pendingAdvisorTransportMatching']>['intent'];
|
||||
@@ -22099,8 +22119,9 @@ function finishAdvisorReaction(
|
||||
advisorResume?: ResponseWindow['advisorResume'];
|
||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||
advisorAmbassadorEvent?: string;
|
||||
advisorRevivalEvent?: string;
|
||||
advisorRemaining?: string[];
|
||||
advisorFollowup?: { shipment: string; destination: string };
|
||||
advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string };
|
||||
},
|
||||
) {
|
||||
const bg = byFaction(g, 'beneGesserit')!;
|
||||
@@ -22125,7 +22146,8 @@ function intrusion(
|
||||
wormRide?: boolean;
|
||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||
ambassadorEvent?: string;
|
||||
followup?: { shipment: string; destination: string };
|
||||
revivalEvent?: string;
|
||||
followup?: { shipment: string; destination: string; revivalEvent?: string };
|
||||
} = {},
|
||||
) {
|
||||
const bg = byFaction(g, 'beneGesserit');
|
||||
@@ -27959,7 +27981,8 @@ function resumeHomeworldRevivalReturn(g: Game) {
|
||||
if (!frame || frame.stage === 'complete' || frame.stage === 'choice' ||
|
||||
g.pendingTreacheryDiscard || g.pendingNullentropy || g.truthtrance ||
|
||||
g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador ||
|
||||
g.pendingTerrorEntry || g.pendingKarama || g.decision) return;
|
||||
g.pendingTerrorEntry || g.pendingKarama || g.decision || g.pendingSpiritualAdvisor ||
|
||||
g.pendingArrivalReaction?.length || g.pendingArrivalOverlap) return;
|
||||
if (frame.stage === 'arrival') {
|
||||
if (!g.response) completeHomeworldRevivalReturn(g);
|
||||
return;
|
||||
@@ -28014,11 +28037,14 @@ function decideHomeworldRevivalReturn(g: Game, p: Player, action: Action) {
|
||||
frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
|
||||
if (quote.selected.territory) {
|
||||
place(p, quote.selected.territory, quote.selected.sector!, amount, frame.quote.elite);
|
||||
openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, amount, frame.quote.elite, 'homeworldRevival');
|
||||
if (g.pendingAmbassador) {
|
||||
g.pendingAmbassador.revivalEvent = frame.event;
|
||||
appendHomeworldRevivalAmbassador(frame, g.pendingAmbassador);
|
||||
}
|
||||
const bg = byFaction(g, 'beneGesserit');
|
||||
const followup = frame.quote.kind === 'tleilax' && bg && bg.id !== p.id &&
|
||||
spiritualAdvisorMaximum(g, bg.id) > 0
|
||||
? {shipment:p.id,destination:quote.selected.id,revivalEvent:frame.event} : undefined;
|
||||
if (!intrusion(g, p, quote.selected.territory, {revivalEvent:frame.event,...(followup ? {followup} : {})}) && followup)
|
||||
g.decision = {kind:'advisor',player:bg!.id,...followup};
|
||||
openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, amount,
|
||||
frame.quote.elite, 'homeworldRevival', 'none', {event:frame.event});
|
||||
}
|
||||
log(g, `${p.name} placed ${amount} newly revived ${frame.quote.kind === 'fedaykin' ? 'Fedaykin' : 'free forces'} in ${quote.selected.name}. No additional revival payment or shipment allowance was used.`,
|
||||
{ faction: p.faction, name: frame.quote.kind === 'fedaykin' ? 'Southern Hemisphere revival' : 'Tleilax free revival' });
|
||||
@@ -33378,7 +33404,8 @@ function settleAutomaticContinuations(g: Game) {
|
||||
const entrant = g.players.find(p => p.id === deferred.entrant);
|
||||
requireRule(!!entrant, 'The deferred arrival entry has lost its entrant.');
|
||||
openTerritoryEntry(g, entrant!, deferred.territory, deferred.sector,
|
||||
deferred.amount, deferred.elite, deferred.cause, deferred.resume);
|
||||
deferred.amount, deferred.elite, deferred.cause, deferred.resume, deferred.revivalArrival);
|
||||
if (deferred.revivalArrival) resumeHomeworldRevivalReturn(g);
|
||||
continue;
|
||||
}
|
||||
const response = g.response;
|
||||
@@ -35259,6 +35286,7 @@ function applyActionInner(
|
||||
? ('wormRide' as const)
|
||||
: undefined,
|
||||
advisorAmbassadorEvent: decision.ambassadorEvent,
|
||||
advisorRevivalEvent: decision.revivalEvent,
|
||||
discoveryEntry: decision.discoveryEntry,
|
||||
};
|
||||
if (action.accept)
|
||||
@@ -35297,7 +35325,7 @@ function applyActionInner(
|
||||
};
|
||||
} else movementTurn(g);
|
||||
} else if (decision.kind === 'advisor') {
|
||||
if(g.advanced){
|
||||
if(g.advanced||decision.revivalEvent!==undefined){
|
||||
requireRule(typeof action.accept==='boolean','Choose the free Spiritual shipment or decline.');
|
||||
if(action.accept){
|
||||
beginSpiritualAdvisor(g,decision,action);
|
||||
|
||||
+79
-22
@@ -1,12 +1,14 @@
|
||||
import type { Game } from './engine';
|
||||
import { location, validLocation } from './board';
|
||||
import { location, splitLocation, validLocation } from './board';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import type { SpiritualAdvisorSource } from './spiritual-advisor';
|
||||
|
||||
export type HomeworldAmbassadorArrival = {
|
||||
event: string;
|
||||
entrant: string;
|
||||
destination: string;
|
||||
parent?: string;
|
||||
spiritual?: SpiritualAdvisorSource;
|
||||
completed: boolean;
|
||||
};
|
||||
export type HomeworldArrivalFrame = {
|
||||
@@ -42,12 +44,45 @@ function keys(value: unknown, allowed: string[]): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** This is the public source of an already committed Spiritual Advisor arrival,
|
||||
* not another permission or a reconstruction from current BG reserves. */
|
||||
function spiritualSource(
|
||||
frame: HomeworldArrivalFrame,
|
||||
entry: HomeworldAmbassadorArrival,
|
||||
): SpiritualAdvisorSource & { revivalEvent: string } {
|
||||
const source = entry.spiritual as (SpiritualAdvisorSource & { revivalEvent?: string }) | undefined;
|
||||
if (!source || !keys(source, ['event', 'owner', 'turn', 'phase', 'territory', 'sector', 'amount', 'revivalEvent'])) fail();
|
||||
const destination = splitLocation(frame.destination!);
|
||||
if (
|
||||
Object.keys(source).sort().join(',') !== 'amount,event,owner,phase,revivalEvent,sector,territory,turn' ||
|
||||
!text(source.event) ||
|
||||
!text(source.owner) ||
|
||||
source.owner !== entry.entrant ||
|
||||
source.owner === frame.player ||
|
||||
source.revivalEvent !== frame.event ||
|
||||
frame.phase !== 4 || source.phase !== 4 ||
|
||||
source.turn !== frame.turn ||
|
||||
!Number.isSafeInteger(source.turn) || source.turn < 1 ||
|
||||
!text(source.territory) ||
|
||||
!Number.isSafeInteger(source.sector) ||
|
||||
!validLocation(source.territory, source.sector) ||
|
||||
!validLocation(destination.territory, destination.sector) ||
|
||||
(source.territory !== destination.territory && source.territory !== 'polar_sink') ||
|
||||
(source.amount !== 1 && source.amount !== 2) ||
|
||||
entry.parent !== undefined ||
|
||||
entry.destination !== location(source.territory, source.sector)
|
||||
) fail();
|
||||
return source as SpiritualAdvisorSource & { revivalEvent: string };
|
||||
}
|
||||
|
||||
/** Arrival provenance is fixed separately from the original revival grant. */
|
||||
export function homeworldArrivalSignature(
|
||||
frame: HomeworldArrivalFrame,
|
||||
): string {
|
||||
if (!text(frame.destination) || !Array.isArray(frame.ambassadors)) fail();
|
||||
const seen = new Set<string>();
|
||||
const spiritualEvents = new Set<string>();
|
||||
let primary = false;
|
||||
for (const [index, entry] of frame.ambassadors.entries()) {
|
||||
if (
|
||||
!keys(entry, [
|
||||
@@ -55,6 +90,7 @@ export function homeworldArrivalSignature(
|
||||
'entrant',
|
||||
'destination',
|
||||
'parent',
|
||||
'spiritual',
|
||||
'completed',
|
||||
]) ||
|
||||
!text(entry.event) ||
|
||||
@@ -62,14 +98,17 @@ export function homeworldArrivalSignature(
|
||||
!text(entry.destination) ||
|
||||
typeof entry.completed !== 'boolean' ||
|
||||
(index < frame.ambassadors.length - 1 && !entry.completed) ||
|
||||
seen.has(entry.event) ||
|
||||
(index === 0
|
||||
? entry.parent !== undefined ||
|
||||
entry.entrant !== frame.player ||
|
||||
entry.destination !== frame.destination
|
||||
: !text(entry.parent) || !seen.has(entry.parent))
|
||||
seen.has(entry.event)
|
||||
)
|
||||
fail();
|
||||
if (entry.spiritual !== undefined) {
|
||||
const source = spiritualSource(frame, entry);
|
||||
if (spiritualEvents.has(source.event)) fail();
|
||||
spiritualEvents.add(source.event);
|
||||
} else if (entry.parent === undefined) {
|
||||
if (primary || entry.entrant !== frame.player || entry.destination !== frame.destination) fail();
|
||||
primary = true;
|
||||
} else if (index === 0 || !text(entry.parent) || !seen.has(entry.parent)) fail();
|
||||
seen.add(entry.event);
|
||||
}
|
||||
if (frame.destination === 'decline' && frame.ambassadors.length !== 0) fail();
|
||||
@@ -84,6 +123,18 @@ export function homeworldArrivalSignature(
|
||||
entry.destination,
|
||||
entry.parent ?? null,
|
||||
entry.completed,
|
||||
// Legacy rows remain exactly five values. Only a new independent
|
||||
// Spiritual root adds its canonical, public placement source.
|
||||
...(entry.spiritual === undefined ? [] : [[
|
||||
entry.spiritual.event,
|
||||
entry.spiritual.owner,
|
||||
entry.spiritual.turn,
|
||||
entry.spiritual.phase,
|
||||
entry.spiritual.territory,
|
||||
entry.spiritual.sector,
|
||||
entry.spiritual.amount,
|
||||
spiritualSource(frame, entry).revivalEvent,
|
||||
]]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -100,6 +151,7 @@ export function appendHomeworldArrivalAmbassador(
|
||||
phase: number;
|
||||
},
|
||||
parent?: string,
|
||||
spiritual?: SpiritualAdvisorSource,
|
||||
): void {
|
||||
if (
|
||||
frame.stage !== 'arrival' ||
|
||||
@@ -110,20 +162,23 @@ export function appendHomeworldArrivalAmbassador(
|
||||
(parent !== undefined && parent !== frame.ambassadors!.at(-1)?.event)
|
||||
)
|
||||
fail();
|
||||
const appended: HomeworldAmbassadorArrival = {
|
||||
event: entry.event,
|
||||
entrant: entry.entrant,
|
||||
destination: location(entry.territory, entry.sector),
|
||||
completed: false,
|
||||
...(parent === undefined ? {} : { parent }),
|
||||
...(spiritual === undefined ? {} : { spiritual }),
|
||||
};
|
||||
const ambassadors = [
|
||||
...frame.ambassadors!,
|
||||
{
|
||||
event: entry.event,
|
||||
entrant: entry.entrant,
|
||||
destination: location(entry.territory, entry.sector),
|
||||
completed: false,
|
||||
...(parent === undefined ? {} : { parent }),
|
||||
},
|
||||
appended,
|
||||
];
|
||||
const arrivalSignature = homeworldArrivalSignature({
|
||||
...frame,
|
||||
ambassadors,
|
||||
});
|
||||
if (spiritual !== undefined) appended.spiritual = structuredClone(spiritual);
|
||||
frame.ambassadors = ambassadors;
|
||||
frame.arrivalSignature = arrivalSignature;
|
||||
}
|
||||
@@ -175,14 +230,16 @@ export function validateHomeworldArrival(
|
||||
(frame.stage === 'arrival' &&
|
||||
(frame.turn !== g.turn || frame.phase !== g.phase)) ||
|
||||
frame.arrivalSignature !== homeworldArrivalSignature(frame) ||
|
||||
frame.ambassadors!.some(
|
||||
(entry, index) =>
|
||||
!g.players.some(
|
||||
(seat) =>
|
||||
seat.id === entry.entrant &&
|
||||
(index === 0 || seat.faction === 'beneGesserit'),
|
||||
),
|
||||
)
|
||||
frame.ambassadors!.some((entry) => {
|
||||
if (entry.spiritual !== undefined) {
|
||||
const entrants = g.players.filter((seat) => seat.id === entry.entrant);
|
||||
const owners = g.players.filter((seat) => seat.id === frame.player);
|
||||
return tag !== 'revivalEvent' || entrants.length !== 1 ||
|
||||
entrants[0].faction !== 'beneGesserit' || owners.length !== 1 || owners[0].faction !== 'tleilaxu';
|
||||
}
|
||||
return !g.players.some((seat) => seat.id === entry.entrant &&
|
||||
(entry.parent === undefined || seat.faction === 'beneGesserit'));
|
||||
})
|
||||
)
|
||||
fail();
|
||||
const unfinished = frame.ambassadors!.find((entry) => !entry.completed);
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { Game } from './engine';
|
||||
import { gameTerritories, location, MOBILE_STRONGHOLD, splitLocation } from './board';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { fighterCount } from './advisors';
|
||||
import { territoryEntryBlock } from './occupancy';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
||||
import { homeworldForceGroups, quoteHomeworldCustody, HomeworldCustodyError } from './homeworld-custody';
|
||||
import { quoteNativeReserveWithdrawal } from './homeworld-native-reserves';
|
||||
import { lowGrummanRevealBlock } from './homeworld-collection';
|
||||
import { homeworldSpiritualAdvisorLimit } from './homeworld-mobility';
|
||||
import type { HomeworldRevivalDeploymentQuote } from './homeworld-revival-deployment';
|
||||
|
||||
type Context = Pick<Game, 'advanced' | 'players' | 'homeworlds' | 'storm' | 'mobileStronghold' | 'moritaniTerror'>;
|
||||
@@ -22,7 +20,7 @@ export type HomeworldRevivalDestination = {
|
||||
};
|
||||
|
||||
/** Only the complete earned group to one destination is admitted here.
|
||||
* Shipment-specific reactions remain explicit source boundaries. No token face,
|
||||
* The engine owns provisional SEND-entry reactions and their continuation. No token face,
|
||||
* opposing hand, spice balance or concealed denomination influences these lists. */
|
||||
export function homeworldRevivalDestinations(
|
||||
g: Context,
|
||||
@@ -33,17 +31,13 @@ export function homeworldRevivalDestinations(
|
||||
if (!p || !g.homeworlds?.custody)
|
||||
throw new HomeworldCustodyError('Revival placement needs its seated native Homeworld owner.');
|
||||
const amount = grant.normal + grant.elite;
|
||||
const bg = g.players.find((seat) => seat.faction === 'beneGesserit');
|
||||
const moritani = g.players.find((seat) => seat.faction === 'moritani');
|
||||
const advisors = grant.kind === 'tleilax' && bg && bg.reserves > 0 &&
|
||||
homeworldSpiritualAdvisorLimit(g, bg.id, 'polar_sink') > 0
|
||||
? 'Spiritual Advisors after Tleilax revival deployment await a shipment-classification ruling.' : null;
|
||||
const moritani = g.players.find(seat => seat.faction === 'moritani');
|
||||
const destinations: HomeworldRevivalDestination[] = gameTerritories(g).flatMap((t) => {
|
||||
if (grant.kind === 'fedaykin' && presenceAt(p, t.id) === 0) return [];
|
||||
return t.sectors.map((sector) => {
|
||||
const effectiveSector = t.id === MOBILE_STRONGHOLD
|
||||
? splitLocation(g.mobileStronghold!.location!).sector : sector;
|
||||
let blocked = grant.blocked ?? advisors;
|
||||
let blocked = grant.blocked;
|
||||
if (!blocked && effectiveSector !== 0 && effectiveSector === g.storm)
|
||||
blocked = grant.kind === 'fedaykin'
|
||||
? 'Fedaykin revival placement into storm awaits its own permission ruling.'
|
||||
@@ -51,12 +45,10 @@ export function homeworldRevivalDestinations(
|
||||
if (!blocked && t.id === MOBILE_STRONGHOLD)
|
||||
blocked = 'Revival placement in the Hidden Mobile Stronghold awaits its shipment-classification ruling.';
|
||||
blocked ??= territoryEntryBlock(g.players, p.id, t.id);
|
||||
if (!blocked && g.advanced && bg && bg.id !== p.id && fighterCount(bg, t.id) > 0)
|
||||
blocked = 'Intrusion after Homeworld revival placement awaits an entry-classification ruling.';
|
||||
if (!blocked && moritani && p.id !== moritani.id && p.id !== moritani.ally &&
|
||||
!lowGrummanRevealBlock(g, moritani.id, amount) &&
|
||||
g.moritaniTerror?.tokens.some((token) => token.status === 'placed' && token.location === t.id))
|
||||
blocked = 'Terror after Homeworld revival placement awaits an entry-classification ruling.';
|
||||
!lowGrummanRevealBlock(g, moritani.id, amount) &&
|
||||
g.moritaniTerror?.tokens.some(token => token.status === 'placed' && token.location === t.id))
|
||||
blocked = 'Revival SEND into Terror awaits its original entry continuation.';
|
||||
return { id: location(t.id, sector), name: `${t.name}${sector ? ` · sector ${sector}` : ''}`,
|
||||
territory: t.id, sector, blocked };
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { validAmbassadorResume } from './ambassador-resume';
|
||||
import { validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound } from './discovery-entry';
|
||||
import { ambassadorPhaseAllowed } from './ambassador-phase';
|
||||
import { validateHomeworldRevivalReturn } from './homeworld-revival-return';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import {
|
||||
validateGuildAmbassadorArrivalContext,
|
||||
GuildAmbassadorContinuationError,
|
||||
@@ -512,6 +514,28 @@ export function quoteMovementCancellation(
|
||||
'Only an Ambassador arrival may carry its event.',
|
||||
);
|
||||
if (response.advisorResume === 'wormRide') return null;
|
||||
if (response.advisorRevivalEvent !== undefined) {
|
||||
const parent = g.homeworldRevivalReturn;
|
||||
try { validateHomeworldRevivalReturn(g, parent); }
|
||||
catch (error) {
|
||||
if (error instanceof HomeworldCustodyError) throw new MovementCancellationError(error.message);
|
||||
throw error;
|
||||
}
|
||||
requireContext(g.advanced && response.advisors === true &&
|
||||
parent?.stage === 'arrival' && parent.event === response.advisorRevivalEvent &&
|
||||
parent.turn === g.turn && parent.phase === g.phase && parent.player !== owner.id &&
|
||||
typeof parent.destination === 'string' && sectorKey(g, parent.destination) &&
|
||||
response.location === splitLocation(parent.destination).territory &&
|
||||
response.advisorResume === undefined && response.advisorRemaining === undefined,
|
||||
'The canceled Intrusion lost its original committed revived-force arrival.');
|
||||
const followup = response.advisorFollowup;
|
||||
requireContext(followup === undefined || (parent.quote.kind === 'tleilax' &&
|
||||
followup.shipment === parent.player && followup.destination === parent.destination &&
|
||||
followup.revivalEvent === parent.event),
|
||||
'The revived Intrusion lost its original Spiritual Advisor followup.');
|
||||
return { kind: 'advisorFlip', successor: followup &&
|
||||
homeworldSpiritualAdvisorLimit(g, owner.id, 'polar_sink') > 0 ? 'advisor' : 'none' };
|
||||
}
|
||||
requireContext(
|
||||
g.phase === 5,
|
||||
'This advisor cancellation is outside its shipment and movement phase.',
|
||||
|
||||
@@ -4587,6 +4587,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Battle cleanup checks population when each card is actually discarded. Losing-card disposal comes before winner casualties. Mandatory winning cards, including a used Poison Tooth, are discarded after winner casualties and any Ixian substitution, before optional winning-card cleanup. If Ecaz falls below seven during its winning losses, its later card discards earn no poison income.',
|
||||
'If Tleilax starts Revival at low population, it receives no bank income for other factions’ ordinary free revivals throughout that phase, even if its population rises. Paid revival receipts and the separately named Ghola reward remain available.',
|
||||
'Low Caladan prevents the phase-opening Spice Blow peek. Low Wallach IX prevents free Spiritual Advisors, while high Wallach IX allows one or two to Polar Sink after another faction’s qualifying shipment. Accompanying elsewhere still uses one. Low Ix prevents moving the mobile stronghold; low Richese prevents moving a No-Field token. Physical-force movement and ordinary paid shipments retain their separate rules.',
|
||||
'Newly revived eligible placement permits Advanced BG Intrusion when entering fighters. Under the provisional SEND composition, Tleilax free forces sent from off-planet to Arrakis also earn ordinary Spiritual accompaniment; Southern on-planet placement does not. The original group stays placed through independent BG counters, and original revival income waits until arrival finishes. No fee, ordinary allowance, Guild stop or Revival-phase industry is added. Phase-four Spiritual sources retain the original return or actual Guild child. Terror-entry continuations remain unfinished.',
|
||||
'After a supported Atreides victory, Caladan with at least six native reserves may add one reserve force to the battle location if an Atreides force survives there. The owned choice follows ordinary battle cleanup and offers an eligible sector or the original foreign Homeworld. This spends no spice or shipment allowance. Leaving the force in reserves is optional; a battle on Caladan itself cannot create an extra counter from reserves already there.',
|
||||
'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Placement involving Face Dancer ordering, storm, the mobile stronghold or unresolved entry reactions remains guarded. These guards do not certify those combined rules.',
|
||||
'At low population, Tupile lets CHOAM learn an opposing faction’s current spice and either its weapon count or defense count, once per faction for the entire game. CHOAM must have forces on that faction’s Homeworld, or that faction must have forces on Tupile. Either Emperor Homeworld supplies the same single entitlement. The answer is recorded privately as a historical observation and does not update when that faction’s hand or spice changes.',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { gameTerritories, splitLocation, type MobileBoard } from './board';
|
||||
import type { FactionId } from './catalog';
|
||||
import type { Game } from './engine';
|
||||
import { presenceAt, type ForcePresence } from './force-presence';
|
||||
|
||||
export type SpiritualAdvisorSource = {
|
||||
@@ -10,6 +11,7 @@ export type SpiritualAdvisorSource = {
|
||||
territory: string;
|
||||
sector: number;
|
||||
amount: number;
|
||||
revivalEvent?: string;
|
||||
};
|
||||
|
||||
export type SpiritualAdvisorPlayer = ForcePresence & {
|
||||
@@ -27,6 +29,11 @@ export type SpiritualAdvisorContext = SpiritualAdvisorBoardContext & MobileBoard
|
||||
status: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
homeworldRevivalReturn?: Pick<NonNullable<Game['homeworldRevivalReturn']>, 'event' | 'turn' | 'phase' | 'stage' | 'destination'>;
|
||||
pendingSpiritualAdvisor?: Pick<NonNullable<Game['pendingSpiritualAdvisor']>, 'event' | 'turn' | 'phase'> & {
|
||||
decision: Pick<NonNullable<Game['pendingSpiritualAdvisor']>['decision'], 'player' | 'destination' | 'ambassadorEvent' | 'revivalEvent'>;
|
||||
};
|
||||
pendingAmbassador?: Pick<NonNullable<Game['pendingAmbassador']>, 'event' | 'revivalEvent' | 'turn' | 'phase' | 'stage'> | null;
|
||||
};
|
||||
|
||||
export type SpiritualAdvisorTarget = string | { territory: string; sector?: number };
|
||||
@@ -110,9 +117,10 @@ export function spiritualAdvisorMatchingKey(source: SpiritualAdvisorSource): str
|
||||
* 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' &&
|
||||
Object.keys(source).sort().join(',') === (source.phase === 4
|
||||
? 'amount,event,owner,phase,revivalEvent,sector,territory,turn' : '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' &&
|
||||
source.phase === ctx.phase && [1, 3, 4, 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.');
|
||||
@@ -120,4 +128,29 @@ export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, sou
|
||||
requireSpiritual(gameTerritories(ctx).some((place) =>
|
||||
place.id === source.territory && place.sectors.includes(source.sector)),
|
||||
'Spiritual Advisors need their current board destination and sector.');
|
||||
if (source.phase === 4) {
|
||||
const parent = ctx.homeworldRevivalReturn;
|
||||
requireSpiritual(identifier(source.revivalEvent) && record(parent) &&
|
||||
parent.event === source.revivalEvent && parent.turn === ctx.turn && parent.phase === 4 &&
|
||||
parent.stage === 'arrival' && identifier(parent.destination),
|
||||
'Revival Spiritual Advisors need their original current-turn arrival parent.');
|
||||
const destination = splitLocation(parent.destination);
|
||||
requireSpiritual(parent.destination === `${destination.territory}:${destination.sector}` &&
|
||||
gameTerritories(ctx).some(place => place.id === destination.territory && place.sectors.includes(destination.sector)),
|
||||
'Revival Spiritual Advisors need their original board arrival parent.');
|
||||
const frame = ctx.pendingSpiritualAdvisor, ambassador = ctx.pendingAmbassador;
|
||||
const decision = frame?.decision;
|
||||
const childDestination = identifier(decision?.destination) ? splitLocation(decision.destination) : null;
|
||||
const guildChild = record(frame) && frame.event === source.event && frame.turn === ctx.turn && frame.phase === 4 &&
|
||||
record(decision) && decision.player === source.owner && identifier(decision.ambassadorEvent) &&
|
||||
(decision.revivalEvent === undefined || decision.revivalEvent === source.revivalEvent) &&
|
||||
record(ambassador) && ambassador.event === decision.ambassadorEvent && ambassador.revivalEvent === source.revivalEvent &&
|
||||
ambassador.turn === ctx.turn && ambassador.phase === 4 && ambassador.stage === 'arrival' &&
|
||||
childDestination && decision.destination === `${childDestination.territory}:${childDestination.sector}` &&
|
||||
gameTerritories(ctx).some(place => place.id === childDestination.territory && place.sectors.includes(childDestination.sector)) &&
|
||||
source.territory === childDestination.territory;
|
||||
requireSpiritual(source.territory === 'polar_sink' ||
|
||||
(decision?.ambassadorEvent !== undefined ? guildChild : source.territory === destination.territory),
|
||||
'Revival Spiritual Advisors need the original board arrival, its owned Guild shipment or Polar Sink.');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user