Integrate Caladan victory placement and entry reactions
This commit is contained in:
1 parent
97edc3eac8
commit
2e4aba9630
18 files changed
+789
-76
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import type { Game } from './engine';
|
||||
import { caladanOwnsTerrorEntry } from './caladan-arrival';
|
||||
import { currentHomeworldRevivalArrival } from './homeworld-revival-return';
|
||||
import { validateRevivalArrivalSource, revivalOwnsTerrorEntry, validRevivalEntryParent,
|
||||
revivalEntryParentTuple, type RevivalEntryParent } from './revival-terror-entry';
|
||||
@@ -6,7 +7,8 @@ import { validateRevivalArrivalSource, revivalOwnsTerrorEntry, validRevivalEntry
|
||||
type Overlap = NonNullable<Game['pendingArrivalOverlap']> & { revivalArrival?: RevivalEntryParent };
|
||||
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']> & { revivalArrival?: RevivalEntryParent };
|
||||
type AmbassadorEntry = NonNullable<Game['pendingAmbassador']>;
|
||||
type DiscardContext = Parameters<typeof validateRevivalArrivalSource>[0] & Pick<Game, 'pendingArrivalOverlap' | 'order'>;
|
||||
type DiscardContext = Parameters<typeof validateRevivalArrivalSource>[0] & Pick<Game, 'pendingArrivalOverlap' | 'order' |
|
||||
'lastBattleContext' | 'homeworldVictoryReinforcement'>;
|
||||
|
||||
function sameRevivalSource(left: RevivalEntryParent | undefined, right: RevivalEntryParent | undefined) {
|
||||
return validRevivalEntryParent(left) && validRevivalEntryParent(right) &&
|
||||
@@ -19,7 +21,8 @@ export function arrivalOverlapSignature(record: Overlap): string {
|
||||
record.territory, record.sector, record.amount, record.elite, record.cause,
|
||||
record.resume, record.ecaz, record.moritani, record.first,
|
||||
record.ambassadorToken, record.terrorTokens,
|
||||
...(record.revivalArrival === undefined ? [] : [revivalEntryParentTuple(record.revivalArrival)])]);
|
||||
...(record.revivalArrival === undefined ? [] : [revivalEntryParentTuple(record.revivalArrival)]),
|
||||
...(record.victoryEvent === undefined ? [] : [['caladanVictory', record.victoryEvent]])]);
|
||||
}
|
||||
|
||||
/** A fresh discard belongs to the active owner of an ordinary or bound revived entry.
|
||||
@@ -34,6 +37,8 @@ function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Ove
|
||||
try {
|
||||
validateRevivalArrivalSource(g, overlap.revivalArrival, overlap);
|
||||
} catch { return null; }
|
||||
} else if (overlap.cause === 'caladanReinforcement' || overlap.victoryEvent !== undefined) {
|
||||
if (!caladanOwnsTerrorEntry(g, overlap)) return null;
|
||||
} else if (g.phase !== 5 ||
|
||||
!['shipment', 'movement'].includes(overlap.cause)) return null;
|
||||
const ecaz = g.order.indexOf(overlap.ecaz), moritani = g.order.indexOf(overlap.moritani);
|
||||
@@ -54,6 +59,7 @@ export function overlapOwnsAmbassadorDiscard(g: DiscardContext, entry: Ambassado
|
||||
entry.token === overlap.ambassadorToken && entry.entrant === overlap.entrant &&
|
||||
entry.territory === overlap.territory && entry.sector === overlap.sector &&
|
||||
entry.arrivalCause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase &&
|
||||
(overlap.victoryEvent === undefined || entry.victoryEvent === overlap.victoryEvent) &&
|
||||
(overlap.revivalArrival === undefined || entry.revivalEvent === overlap.revivalArrival.event &&
|
||||
!!arrival && sameRevivalSource(overlap.revivalArrival, { event: overlap.revivalArrival.event,
|
||||
...(arrival.spiritual === undefined ? {} : { spiritual: arrival.spiritual }),
|
||||
@@ -67,6 +73,7 @@ export function overlapOwnsTerrorDiscard(g: DiscardContext, entry: TerrorEntry,
|
||||
entry.territory === overlap.territory && entry.sector === overlap.sector &&
|
||||
entry.amount === overlap.amount && entry.elite === overlap.elite &&
|
||||
entry.cause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase &&
|
||||
(overlap.victoryEvent === undefined || entry.victoryEvent === overlap.victoryEvent) &&
|
||||
(overlap.revivalArrival === undefined ||
|
||||
sameRevivalSource(overlap.revivalArrival, entry.revivalArrival) && revivalOwnsTerrorEntry(g, entry));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Game } from './engine';
|
||||
import { location, validLocation } from './board';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import { validateHomeworldVictoryReturn } from './homeworld-victory-return';
|
||||
|
||||
export type CaladanArrivalContext = Pick<Game, 'status' | 'turn' | 'phase' | 'players' |
|
||||
'homeworlds' | 'lastBattleContext' | 'homeworldVictoryReinforcement'>;
|
||||
export type CaladanArrivalDetails = {
|
||||
victoryEvent?: string;
|
||||
entrant: string;
|
||||
territory: string;
|
||||
sector: number;
|
||||
amount: number;
|
||||
elite: number;
|
||||
cause: string;
|
||||
};
|
||||
|
||||
/** Validate the committed original addition, not the army after a reaction.
|
||||
* Atomics, alliances and other accepted effects may already have changed it. */
|
||||
export function validateCaladanArrivalSource(g: CaladanArrivalContext, entry: CaladanArrivalDetails): void {
|
||||
validateHomeworldVictoryReturn(g);
|
||||
const frame = g.homeworldVictoryReinforcement;
|
||||
if (g.status !== 'playing' || g.phase !== 6 || !frame || frame.stage !== 'arrival' ||
|
||||
frame.turn !== g.turn || frame.phase !== g.phase || entry.victoryEvent !== frame.event ||
|
||||
entry.entrant !== frame.player || entry.cause !== 'caladanReinforcement' ||
|
||||
entry.amount !== 1 || entry.elite !== 0 || !validLocation(entry.territory, entry.sector) ||
|
||||
frame.destination !== location(entry.territory, entry.sector))
|
||||
throw new HomeworldCustodyError('The Caladan arrival has lost its original committed victory reinforcement.');
|
||||
}
|
||||
|
||||
export function caladanOwnsTerrorEntry(g: CaladanArrivalContext, entry: CaladanArrivalDetails): boolean {
|
||||
try { validateCaladanArrivalSource(g, entry); return true; }
|
||||
catch (error) {
|
||||
if (error instanceof HomeworldCustodyError) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+52
-6
@@ -191,6 +191,7 @@ import { ambassadorPhaseAllowed } from './ambassador-phase';
|
||||
import { makeHomeworldVictoryReturn, validateHomeworldVictoryReturn, homeworldVictoryOfferSignature,
|
||||
homeworldVictoryObligationSignature, type HomeworldVictoryReturn, type HomeworldVictoryObligation } from './homeworld-victory-return';
|
||||
import { quoteHomeworldVictoryReinforcement, quoteHomeworldVictoryReinforcementDestination } from './homeworld-victory-reinforcement';
|
||||
import { validateCaladanArrivalSource } from './caladan-arrival';
|
||||
import { homeworldArrivalSignature, appendHomeworldArrivalAmbassador, completeHomeworldArrivalAmbassador, validateHomeworldArrival } from './homeworld-arrival';
|
||||
import {
|
||||
quoteHomeworldRevivalDeployment,
|
||||
@@ -1109,6 +1110,7 @@ export type Decision =
|
||||
}
|
||||
| {
|
||||
kind: 'intrusion';
|
||||
victoryEvent?: string;
|
||||
player: string;
|
||||
territory: string;
|
||||
wormRide?: boolean;
|
||||
@@ -1193,6 +1195,7 @@ export type ResponseWindow = {
|
||||
advisorGatherMove?: { turn: number; move: number; order: MovementOrder };
|
||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||
advisorAmbassadorEvent?: string;
|
||||
advisorVictoryEvent?: string;
|
||||
advisorRevivalEvent?: string;
|
||||
advisorRevivalTerror?: RevivalEntryParent['terror'];
|
||||
advisorRemaining?: string[];
|
||||
@@ -2046,6 +2049,7 @@ export type Game = {
|
||||
dukeVidal?: DukeState;
|
||||
dukeAcquisitionTurn?: number;
|
||||
pendingTerrorEntry?: {
|
||||
victoryEvent?: string;
|
||||
revivalArrival?: RevivalEntryParent;
|
||||
/** Original public arrival, absent only in legacy saves. */
|
||||
entrySignature?: string;
|
||||
@@ -2093,6 +2097,7 @@ export type Game = {
|
||||
/** Committed entries whose optional reactions must wait for the arrival's own
|
||||
* pending interaction (payment, advisor choice, storm protection). */
|
||||
pendingArrivalReaction?: {
|
||||
victoryEvent?: string;
|
||||
entrant: string;
|
||||
territory: string;
|
||||
sector: number;
|
||||
@@ -2108,6 +2113,7 @@ export type Game = {
|
||||
}[] | null;
|
||||
/** One committed entry with two independently optional expansion reactions. */
|
||||
pendingArrivalOverlap?: {
|
||||
victoryEvent?: string;
|
||||
revivalArrival?: RevivalEntryParent;
|
||||
event: string;
|
||||
signature: string;
|
||||
@@ -8265,6 +8271,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
'advisor',
|
||||
'wormRide',
|
||||
'homeworldRevival',
|
||||
'caladanReinforcement',
|
||||
'ambassador',
|
||||
].includes(entry.cause) &&
|
||||
['none', 'wormRide', 'ambassador'].includes(entry.resume) &&
|
||||
@@ -8311,7 +8318,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
(entry.resume === 'wormRide' || overlapOwnsWormTerror(g,entry)) && g.phase === 1 &&
|
||||
g.players.some(player => player.id === entry.entrant && player.faction === 'fremen')
|
||||
: entry.resume === 'none' && (entry.cause !== 'homeworldRevival' || !!entry.revivalArrival)) &&
|
||||
['shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'homeworldRevival', 'ambassador'].includes(entry.cause) &&
|
||||
['shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'homeworldRevival', 'caladanReinforcement', 'ambassador'].includes(entry.cause) &&
|
||||
entry.entrant !== owner?.id && entry.territory === aftermath.territory &&
|
||||
terrorEntryLocationAllowed(g,entry) && validGameLocation(g, entry.territory, entry.sector) &&
|
||||
token?.kind === 'atomics' && token.status === 'removed' &&
|
||||
@@ -21762,6 +21769,8 @@ function arrivalOverlapIntegrity(g: Game) {
|
||||
(continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics' ? continuation.entry : null);
|
||||
if (record.revivalArrival)
|
||||
homeworldRule(() => validateRevivalArrivalSource(g, record.revivalArrival!, record));
|
||||
if (record.victoryEvent !== undefined || record.cause === 'caladanReinforcement')
|
||||
homeworldRule(() => validateCaladanArrivalSource(g, record));
|
||||
const active = record.stage === 'first' ? record.first :
|
||||
record.first === 'ambassador' ? 'terror' : 'ambassador';
|
||||
requireRule(g.status === 'playing' && arrivalOverlapModeSupported(g, record.revivalArrival) &&
|
||||
@@ -21838,6 +21847,12 @@ function openAmbassadorEntry(
|
||||
g.pendingAmbassador.revivalEvent = frame.event;
|
||||
homeworldRule(() => appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador!, undefined,
|
||||
revivalArrival.spiritual, revivalArrival.terror));
|
||||
} else if (arrivalCause === 'caladanReinforcement') {
|
||||
const frame = g.homeworldVictoryReinforcement!;
|
||||
homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent:frame?.event, entrant:entrant.id,
|
||||
territory:to, sector, amount:1, elite:0, cause:arrivalCause}));
|
||||
g.pendingAmbassador.victoryEvent = frame.event;
|
||||
appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador);
|
||||
}
|
||||
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;
|
||||
@@ -21864,6 +21879,9 @@ function openTerritoryEntry(
|
||||
if (revivalArrival)
|
||||
homeworldRule(() => validateRevivalArrivalSource(g, revivalArrival,
|
||||
{entrant:entrant.id,territory:to,sector,amount,elite,cause}));
|
||||
const victoryEvent = cause === 'caladanReinforcement' ? g.homeworldVictoryReinforcement?.event : undefined;
|
||||
if (cause === 'caladanReinforcement')
|
||||
homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent, entrant:entrant.id, territory:to, sector, amount, elite, cause}));
|
||||
// The arrival's own interaction (guild payment, advisor choice, storm
|
||||
// protection) settles first; the entry then opens from the automatic pass.
|
||||
if (g.response || g.decision || g.pendingTerrorEntry || g.pendingAmbassador ||
|
||||
@@ -21871,6 +21889,7 @@ function openTerritoryEntry(
|
||||
(g.pendingArrivalReaction ??= []).push({
|
||||
entrant: entrant.id, territory: to, sector, amount, elite, cause, resume,
|
||||
turn: g.turn, phase: g.phase,
|
||||
...(victoryEvent === undefined ? {} : {victoryEvent}),
|
||||
...(revivalArrival ? { revivalArrival } : {}),
|
||||
});
|
||||
return true;
|
||||
@@ -21924,6 +21943,7 @@ function openTerritoryEntry(
|
||||
entrant: entrant.id, territory: to, sector, amount, elite, cause, resume,
|
||||
ecaz: owner.id, moritani: moritani.id, first, stage: 'first',
|
||||
ambassadorToken: token.id, terrorTokens: terrorTokens.map(t => t.id),
|
||||
...(victoryEvent === undefined ? {} : {victoryEvent}),
|
||||
...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}),
|
||||
};
|
||||
overlap.signature = arrivalOverlapSignature(overlap);
|
||||
@@ -24210,6 +24230,8 @@ function terrorEntryIntegrity(g: Game) {
|
||||
if (!entry) continue;
|
||||
homeworldRule(() => validateTerrorEntrySignature(entry));
|
||||
if (entry.revivalArrival) homeworldRule(() => validateRevivalTerrorEntry(g, entry));
|
||||
if (entry.victoryEvent !== undefined || entry.cause === 'caladanReinforcement')
|
||||
homeworldRule(() => validateCaladanArrivalSource(g, entry));
|
||||
if (entry.entrySignature === undefined) continue;
|
||||
requireRule(entry.turn === g.turn && entry.phase === g.phase &&
|
||||
g.players.some((p) => p.id === entry.entrant && p.faction !== 'moritani') &&
|
||||
@@ -24288,6 +24310,7 @@ function openTerrorEntry(
|
||||
stage: tokens.length > 1 ? 'select' : 'offer',
|
||||
resume,
|
||||
...(ambassadorEvent ? { ambassadorEvent } : {}),
|
||||
...(cause === 'caladanReinforcement' ? {victoryEvent:g.homeworldVictoryReinforcement?.event} : {}),
|
||||
...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}),
|
||||
};
|
||||
if (!TERROR_STRONGHOLDS.includes(to)) {
|
||||
@@ -26472,6 +26495,7 @@ function finishAdvisorReaction(
|
||||
advisorResume?: ResponseWindow['advisorResume'];
|
||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||
advisorAmbassadorEvent?: string;
|
||||
advisorVictoryEvent?: string;
|
||||
advisorRevivalEvent?: string;
|
||||
advisorRevivalTerror?: RevivalEntryParent['terror'];
|
||||
advisorRemaining?: string[];
|
||||
@@ -26500,6 +26524,7 @@ function intrusion(
|
||||
wormRide?: boolean;
|
||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||
ambassadorEvent?: string;
|
||||
victoryEvent?: string;
|
||||
revivalEvent?: string;
|
||||
revivalTerror?: RevivalEntryParent['terror'];
|
||||
followup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
|
||||
@@ -32170,6 +32195,26 @@ function homeworldVictoryReturnIntegrity(g: Game) {
|
||||
g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
|
||||
continuation && 'resume' in continuation ? continuation.resume : null];
|
||||
const decisions = contexts.flatMap((context) => context?.decision?.kind === 'caladanReinforcement' ? [context.decision] : []);
|
||||
const arrivals = g.pendingArrivalReaction?.filter(entry => entry.cause === 'caladanReinforcement' || entry.victoryEvent !== undefined) ?? [];
|
||||
for (const entry of arrivals) homeworldRule(() => validateCaladanArrivalSource(g, entry));
|
||||
const intrusionChoices = contexts.flatMap(context => context?.decision?.kind === 'intrusion' &&
|
||||
context.decision.victoryEvent !== undefined ? [context.decision] : []);
|
||||
const intrusionResponses = [...contexts.map(context => context?.response),
|
||||
g.pendingKarama?.use.kind === 'cancel' ? g.pendingKarama.use.response : undefined]
|
||||
.filter((response): response is ResponseWindow => !!response && response.advisorVictoryEvent !== undefined);
|
||||
for (const choice of intrusionChoices) {
|
||||
requireRule(choice.player === byFaction(g, 'beneGesserit')?.id, 'The Caladan Intrusion belongs to Bene Gesserit.');
|
||||
homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent:choice.victoryEvent,
|
||||
entrant:frame?.player ?? '', territory:choice.territory, sector:splitLocation(frame?.destination ?? '').sector,
|
||||
amount:1, elite:0, cause:'caladanReinforcement'}));
|
||||
}
|
||||
for (const response of intrusionResponses) {
|
||||
requireRule(response.kind === 'advisorFlip' && response.advisors === true &&
|
||||
response.owner === byFaction(g, 'beneGesserit')?.id, 'The Caladan Intrusion has lost its advisor response.');
|
||||
homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent:response.advisorVictoryEvent,
|
||||
entrant:frame?.player ?? '', territory:response.location ?? '', sector:splitLocation(frame?.destination ?? '').sector,
|
||||
amount:1, elite:0, cause:'caladanReinforcement'}));
|
||||
}
|
||||
requireRule(decisions.every((decision) => frame?.stage === 'choice' && decision.player === frame.player && decision.event === frame.event),
|
||||
'The Caladan choice has lost its original victory.');
|
||||
if (frame?.stage === 'choice')
|
||||
@@ -32255,11 +32300,8 @@ function decideHomeworldVictoryReturn(g: Game, p: Player, action: Action) {
|
||||
stampHomeworldVictoryStage(g);
|
||||
if (quote.selected.territory) {
|
||||
place(p, quote.selected.territory, quote.selected.sector!, 1);
|
||||
intrusion(g, p, quote.selected.territory, {victoryEvent:frame.event});
|
||||
openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, 1, 0, 'caladanReinforcement');
|
||||
if (g.pendingAmbassador) {
|
||||
g.pendingAmbassador.victoryEvent = frame.event;
|
||||
appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador);
|
||||
}
|
||||
}
|
||||
log(g, `${p.name} added one force from Caladan reserves to ${quote.selected.name} after winning the battle. The force joins an existing surviving army; no spice or shipment allowance was spent.`,
|
||||
{ faction: p.faction, name: 'Caladan victory reinforcement' });
|
||||
@@ -32268,7 +32310,8 @@ function resumeHomeworldVictoryReturn(g: Game) {
|
||||
const frame = g.homeworldVictoryReinforcement;
|
||||
if (frame?.stage !== 'arrival' || g.pendingTreacheryDiscard || g.pendingNullentropy ||
|
||||
g.truthtrance || g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador ||
|
||||
g.pendingTerrorEntry || g.pendingKarama || g.decision || g.response) return;
|
||||
g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingArrivalReaction?.length ||
|
||||
g.pendingKarama || g.decision || g.response) return;
|
||||
completeHomeworldVictoryReturn(g);
|
||||
finishBattle(g);
|
||||
}
|
||||
@@ -38903,6 +38946,8 @@ function settleAutomaticContinuations(g: Game) {
|
||||
!g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap && !g.pendingSpiritualAdvisor) {
|
||||
const deferred = g.pendingArrivalReaction.shift()!;
|
||||
if (!g.pendingArrivalReaction.length) g.pendingArrivalReaction = null;
|
||||
if (deferred.victoryEvent !== undefined || deferred.cause === 'caladanReinforcement')
|
||||
homeworldRule(() => validateCaladanArrivalSource(g, deferred));
|
||||
const entrant = g.players.find(p => p.id === deferred.entrant);
|
||||
requireRule(!!entrant, 'The deferred arrival entry has lost its entrant.');
|
||||
if (deferred.revivalSneakAdvisor) {
|
||||
@@ -40872,6 +40917,7 @@ function applyActionInner(
|
||||
? ('wormRide' as const)
|
||||
: undefined,
|
||||
advisorAmbassadorEvent: decision.ambassadorEvent,
|
||||
advisorVictoryEvent: decision.victoryEvent,
|
||||
advisorRevivalEvent: decision.revivalEvent,
|
||||
advisorRevivalTerror: decision.revivalTerror,
|
||||
discoveryEntry: decision.discoveryEntry,
|
||||
|
||||
@@ -2,11 +2,9 @@ import type { Game } from './engine';
|
||||
import {
|
||||
gameTerritories,
|
||||
location,
|
||||
MOBILE_STRONGHOLD,
|
||||
splitLocation,
|
||||
validLocation,
|
||||
} from './board';
|
||||
import { fighterCount } from './advisors';
|
||||
import { territoryEntryBlock } from './occupancy';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import {
|
||||
@@ -17,7 +15,6 @@ import {
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
||||
import { quoteNativeReserveWithdrawal } from './homeworld-native-reserves';
|
||||
import { lowGrummanRevealBlock } from './homeworld-collection';
|
||||
|
||||
export type HomeworldVictoryReinforcementContext = Pick<
|
||||
Game,
|
||||
@@ -168,31 +165,12 @@ export function quoteHomeworldVictoryReinforcement(
|
||||
}
|
||||
if (!Number.isSafeInteger(g.storm) || g.storm < 1 || g.storm > 18)
|
||||
invalid('Caladan reinforcement requires the current storm sector.');
|
||||
const bg = g.players.find((seat) => seat.faction === 'beneGesserit');
|
||||
const moritani = g.players.find((seat) => seat.faction === 'moritani');
|
||||
quote.destinations = board!.sectors.map((sector) => {
|
||||
let blocked: string | null = null;
|
||||
if (board!.id === MOBILE_STRONGHOLD)
|
||||
if (sector === g.storm)
|
||||
blocked =
|
||||
'Caladan reinforcement in the Hidden Mobile Stronghold awaits its entry-classification ruling.';
|
||||
if (!blocked && sector === g.storm)
|
||||
blocked =
|
||||
'Caladan reinforcement into a sector in storm awaits its placement-permission ruling.';
|
||||
'You cannot add the Caladan reinforcement to a sector in storm.';
|
||||
blocked ??= territoryEntryBlock(g.players, owner.id, board!.id);
|
||||
if (!blocked && g.advanced && bg && fighterCount(bg, board!.id) > 0)
|
||||
blocked =
|
||||
'Intrusion after Caladan reinforcement awaits an entry-classification ruling.';
|
||||
if (
|
||||
!blocked &&
|
||||
moritani &&
|
||||
owner.id !== moritani.ally &&
|
||||
!lowGrummanRevealBlock(g, moritani.id, 1) &&
|
||||
g.moritaniTerror?.tokens.some(
|
||||
(token) => token.status === 'placed' && token.location === board!.id,
|
||||
)
|
||||
)
|
||||
blocked =
|
||||
'Terror after Caladan reinforcement awaits an entry-classification ruling.';
|
||||
return {
|
||||
id: location(board!.id, sector),
|
||||
name: `${board!.name}${sector ? ` · sector ${sector}` : ''}`,
|
||||
|
||||
+7
-1
@@ -5410,7 +5410,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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. Under the provisional order, reinforcement joins the original winning army before any later Face Dance; its surviving counter counts in the current replacement limit and returns with that army. A return that raises Caladan does not create a second grant. Storm, mobile stronghold and unresolved entry restrictions remain.',
|
||||
'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Under the provisional order, reinforcement joins the original winning army before any later Face Dance; its surviving counter counts in the current replacement limit and returns with that army. A return that raises Caladan does not create a second grant. Provisional HMS placement adds to the surviving won interior without an ordinary shipment. Advanced Intrusion resolves before the original Ambassador/Terror entry; Terror classification is provisional. Storm-sector entry stays unavailable, and nested module interactions remain open.',
|
||||
'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.',
|
||||
'Choose weapons or defenses in the private Tupile panel at a settled action boundary. The server answers immediately without an opponent confirmation, and does not expose individual cards. Count physical held Treachery cards by their default category: Weirding Way is a weapon, Chemistry is a defense, while Worthless cards and leader substitutes count as neither. Selecting the other category later cannot renew a faction’s used entitlement.',
|
||||
'Occupation removes Tupile’s low advantage. New Homeworld games record qualifying foreign presence from setup, including brief sole occupation during ordered battle losses and presence at turn boundaries. Earlier saves without that history, or histories with a Tupile qualifier whose continuing entitlement is unresolved, cannot make a new intelligence request. Already learned observations remain readable. Recorded qualification does not itself award occupied income or settle its expiry.',
|
||||
@@ -5452,6 +5452,12 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'components/reinforcements-sources.tsx',
|
||||
'tests/nexus-world-independent-cards.test.ts',
|
||||
'tests/nexus-world-independent-battles.test.ts',
|
||||
'game/caladan-arrival.ts',
|
||||
'game/homeworld-victory-reinforcement.ts',
|
||||
'components/caladan-reinforcement.tsx',
|
||||
'tests/caladan-arrival-engine.test.ts',
|
||||
'tests/caladan-arrival-controls.test.tsx',
|
||||
'docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md',
|
||||
'game/homeworld-cards.ts',
|
||||
'game/richese-homeworld-shipment.ts',
|
||||
'game/homeworld-payment-options.ts',
|
||||
|
||||
@@ -23,6 +23,7 @@ export function terrorEntrySignature(entry: TerrorEntry): string {
|
||||
entry.ambassadorEvent ?? null,
|
||||
...(entry.nexusPlacements ? [entry.nexusPlacements] : []),
|
||||
...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)]),
|
||||
...(entry.victoryEvent === undefined ? [] : [['caladanVictory', entry.victoryEvent]]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -31,13 +32,14 @@ export function terrorSelectionSignature(entry: TerrorEntry): string {
|
||||
entry.territory, entry.sector, entry.amount, entry.elite, entry.cause,
|
||||
entry.turn, entry.phase, entry.resume, entry.ambassadorEvent ?? null,
|
||||
...(entry.nexusPlacements ? [entry.nexusPlacements] : []),
|
||||
...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)])]);
|
||||
...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)]),
|
||||
...(entry.victoryEvent === undefined ? [] : [['caladanVictory', entry.victoryEvent]])]);
|
||||
}
|
||||
|
||||
/** Older saves without a receipt remain explicit legacy state. Never invent
|
||||
* their original entry count from the destination's present occupants. */
|
||||
export function validateTerrorEntrySignature(entry: TerrorEntry): void {
|
||||
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.nexusPlacements === undefined && entry.revivalArrival === undefined && entry.stage !== 'select') return;
|
||||
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.nexusPlacements === undefined && entry.revivalArrival === undefined && entry.victoryEvent === undefined && entry.cause !== 'caladanReinforcement' && entry.stage !== 'select') return;
|
||||
if (
|
||||
typeof entry.entrySignature !== 'string' ||
|
||||
typeof entry.token !== 'string' ||
|
||||
@@ -72,6 +74,8 @@ export function validateTerrorEntrySignature(entry: TerrorEntry): void {
|
||||
entry.phase < 0 ||
|
||||
entry.phase > 8 ||
|
||||
(entry.revivalArrival !== undefined && !validRevivalEntryParent(entry.revivalArrival)) ||
|
||||
(entry.victoryEvent !== undefined && (typeof entry.victoryEvent !== 'string' || !entry.victoryEvent ||
|
||||
entry.cause !== 'caladanReinforcement' || entry.phase !== 6 || entry.revivalArrival !== undefined)) ||
|
||||
(![
|
||||
'shipment',
|
||||
'movement',
|
||||
@@ -79,7 +83,8 @@ export function validateTerrorEntrySignature(entry: TerrorEntry): void {
|
||||
'advisor',
|
||||
'wormRide',
|
||||
'ambassador',
|
||||
].includes(entry.cause) && !(entry.cause === 'homeworldRevival' && entry.revivalArrival !== undefined)) ||
|
||||
].includes(entry.cause) && !(entry.cause === 'homeworldRevival' && entry.revivalArrival !== undefined) &&
|
||||
!(entry.cause === 'caladanReinforcement' && entry.victoryEvent !== undefined)) ||
|
||||
!['none', 'wormRide', 'ambassador'].includes(entry.resume) ||
|
||||
(entry.ambassadorEvent !== undefined &&
|
||||
(typeof entry.ambassadorEvent !== 'string' || !entry.ambassadorEvent)) ||
|
||||
|
||||
Reference in new issue
Block a user