Connect bounded arrival overlap and Fremen Nexus movement Betrayal
This commit is contained in:
1 parent
8ddd138e87
commit
b2603f6715
26 files changed
+1644
-118
No files matched your search
@@ -0,0 +1,31 @@
|
||||
import type { Game } from './engine';
|
||||
|
||||
type Overlap = NonNullable<Game['pendingArrivalOverlap']>;
|
||||
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']>;
|
||||
|
||||
/** The immutable trigger, not later owner choices or child stages. */
|
||||
export function arrivalOverlapSignature(record: Overlap): string {
|
||||
return JSON.stringify([record.event, record.turn, record.phase, record.entrant,
|
||||
record.territory, record.sector, record.amount, record.elite, record.cause,
|
||||
record.resume, record.ecaz, record.moritani, record.first,
|
||||
record.ambassadorToken, record.terrorTokens]);
|
||||
}
|
||||
|
||||
/** The overlap owns the worm return while either serialized Terror prompt is live. */
|
||||
export function overlapOwnsWormTerror(
|
||||
g: Pick<Game, 'pendingArrivalOverlap' | 'turn' | 'phase'>,
|
||||
entry: Pick<TerrorEntry, 'cause' | 'resume' | 'entrant' | 'territory' |
|
||||
'sector' | 'amount' | 'elite' | 'turn' | 'phase' | 'token'>,
|
||||
): boolean {
|
||||
const overlap = g.pendingArrivalOverlap;
|
||||
return !!overlap && overlap.signature === arrivalOverlapSignature(overlap) &&
|
||||
overlap.resume === 'wormRide' && overlap.cause === 'wormRide' &&
|
||||
entry.cause === 'wormRide' && entry.resume === 'none' &&
|
||||
overlap.turn === g.turn && overlap.phase === g.phase &&
|
||||
entry.turn === overlap.turn && entry.phase === overlap.phase &&
|
||||
entry.entrant === overlap.entrant && entry.territory === overlap.territory &&
|
||||
entry.sector === overlap.sector && entry.amount === overlap.amount &&
|
||||
entry.elite === overlap.elite && overlap.terrorTokens.includes(entry.token) &&
|
||||
((overlap.first === 'terror' && overlap.stage === 'first') ||
|
||||
(overlap.first === 'ambassador' && overlap.stage === 'second'));
|
||||
}
|
||||
+4
-3
@@ -56,9 +56,9 @@ export function botArrivalBlock(g: GameView, action: Action): string | null {
|
||||
return null;
|
||||
const guild = g.players.find((p) => p.faction === 'guild');
|
||||
const halfRate =
|
||||
me.faction === 'guild' ||
|
||||
guild?.id === me.ally ||
|
||||
g.karamaShipping?.player === me.id;
|
||||
g.karamaShipping?.player === me.id ||
|
||||
(!g.guildRateCanceled &&
|
||||
(me.faction === 'guild' || guild?.id === me.ally));
|
||||
// A No-Field's concealed value and optional companion never change its price.
|
||||
const smuggler =
|
||||
action.noField === undefined && action.smuggler === true
|
||||
@@ -162,6 +162,7 @@ export function botArrivalBlock(g: GameView, action: Action): string | null {
|
||||
try {
|
||||
quoteCompletedMovementArrival({
|
||||
advanced: g.advanced,
|
||||
overlapSupported: g.arrivalOverlapMode === true,
|
||||
players: g.players,
|
||||
order,
|
||||
ambassadors: (g.ambassadors?.tokens ?? []).map(
|
||||
|
||||
+11
-3
@@ -17,9 +17,11 @@ import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction,
|
||||
import { nexusMoritaniBotActions } from './nexus-moritani-options';
|
||||
import { moritaniBetrayalBotActions } from './nexus-moritani-betrayal-options';
|
||||
import { ecazBetrayalBotActions } from './nexus-ecaz-betrayal-options';
|
||||
import { fremenBetrayalBotActions } from './nexus-fremen-betrayal-options';
|
||||
import { ecazInquiryBotActions } from './nexus-ecaz-inquiry-options';
|
||||
import { nexusEcazDukeBotActions } from './nexus-ecaz-duke-options';
|
||||
import { ecazBetrayalOffer } from './nexus-ecaz-betrayal';
|
||||
import { fremenBetrayalOffer } from './nexus-fremen-betrayal';
|
||||
import { ECAZ_START_FORCES, ECAZ_START_LOCATIONS, quoteEcazStartingForces } from './ecaz-setup';
|
||||
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
|
||||
import { nexusCardBotActions } from './nexus-card-options';
|
||||
@@ -3907,6 +3909,8 @@ export function botActions(g: GameView): Action[] {
|
||||
if (moritaniBetrayal.length) return moritaniBetrayal;
|
||||
const ecazBetrayal = ecazBetrayalBotActions(g);
|
||||
if (ecazBetrayal.length) return ecazBetrayal;
|
||||
const fremenBetrayal = fremenBetrayalBotActions(g);
|
||||
if (fremenBetrayal.length) return fremenBetrayal;
|
||||
const ecazInquiry = ecazInquiryBotActions(g);
|
||||
if (ecazInquiry.length) return ecazInquiry;
|
||||
const sardaukar = nexusSardaukarBotActions(g);
|
||||
@@ -4043,9 +4047,13 @@ export function runBots(state: Game, limit = 96): Game {
|
||||
? actors.find(actor => g.nexusCards!.cards!.hands[actor.id] === 'ecaz' &&
|
||||
ecazBetrayalOffer(g, actor.id)?.blocked === null)
|
||||
: undefined;
|
||||
const priorityEcaz = ecazDukeHolder ?? ecazHolder;
|
||||
if (priorityEcaz)
|
||||
actors.sort((a, b) => Number(b.id === priorityEcaz.id) - Number(a.id === priorityEcaz.id));
|
||||
const fremenHolder = g.phase === 5 && g.nexusCards?.cards
|
||||
? actors.find(actor => g.nexusCards!.cards!.hands[actor.id] === 'fremen' &&
|
||||
fremenBetrayalOffer(g, actor.id)?.blocked === null)
|
||||
: undefined;
|
||||
const priorityNexus = ecazDukeHolder ?? ecazHolder ?? fremenHolder;
|
||||
if (priorityNexus)
|
||||
actors.sort((a, b) => Number(b.id === priorityNexus.id) - Number(a.id === priorityNexus.id));
|
||||
else if (g.phase === 5 && junctionSponsor(g) && !currentJunctionOffer(g))
|
||||
actors.sort((a, b) => Number(b.faction === 'guild') - Number(a.faction === 'guild'));
|
||||
for (const p of actors) {
|
||||
|
||||
+278
-53
@@ -57,6 +57,7 @@ import { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceip
|
||||
import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement, type NexusMoritaniReceipt } from './nexus-moritani';
|
||||
import { moritaniBetrayalOffer } from './nexus-moritani-betrayal';
|
||||
import { ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot, type EcazBetrayalSnapshot } from './nexus-ecaz-betrayal';
|
||||
import { fremenBetrayalOffer, fremenBetrayalSignature, validateFremenBetrayal, type FremenNexusBetrayal } from './nexus-fremen-betrayal';
|
||||
import { nexusMoritaniRecordSignature, terrorLocationAllowed, terrorEntryLocationAllowed } from './terror-location';
|
||||
import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam';
|
||||
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
|
||||
@@ -108,6 +109,7 @@ import {
|
||||
} from './homeworld-revival-return';
|
||||
import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations';
|
||||
import { terrorEntrySignature, terrorSelectionSignature, validateTerrorEntrySignature } from './terror-entry-receipt';
|
||||
import { arrivalOverlapSignature, overlapOwnsWormTerror } from './arrival-overlap';
|
||||
import {
|
||||
homeworldLowBonus,
|
||||
homeworldRevivalKaramaBlock,
|
||||
@@ -422,6 +424,7 @@ import {
|
||||
copiedAmbassadorEffects,
|
||||
canTriggerAmbassador,
|
||||
type AmbassadorEffect,
|
||||
type AmbassadorToken,
|
||||
type AmbassadorState,
|
||||
} from './ecaz-ambassadors';
|
||||
import {
|
||||
@@ -587,6 +590,7 @@ export type Player = {
|
||||
gholaBlocked?: Record<string, number>;
|
||||
ixMovementBlocked?: { turn: number; move: number };
|
||||
fremenMovementBlocked?: { turn: number; move: number };
|
||||
fremenNexusMovementBlockedTurn?: number;
|
||||
elites?: {
|
||||
reserves: number;
|
||||
tanks: number;
|
||||
@@ -1388,6 +1392,27 @@ export type Game = {
|
||||
resume: 'none' | 'wormRide' | 'ambassador';
|
||||
ambassadorEvent?: string;
|
||||
} | null;
|
||||
/** One committed entry with two independently optional expansion reactions. */
|
||||
pendingArrivalOverlap?: {
|
||||
event: string;
|
||||
signature: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
entrant: string;
|
||||
territory: string;
|
||||
sector: number;
|
||||
amount: number;
|
||||
elite: number;
|
||||
cause: NonNullable<Game['pendingTerrorEntry']>['cause'];
|
||||
resume: 'none' | 'wormRide';
|
||||
ecaz: string;
|
||||
moritani: string;
|
||||
first: 'ambassador' | 'terror';
|
||||
stage: 'first' | 'second';
|
||||
ambassadorToken: string;
|
||||
terrorTokens: string[];
|
||||
ambassadorEvent?: string;
|
||||
} | null;
|
||||
moritaniTerror?: TerrorState;
|
||||
moritaniExtortion?: ExtortionState;
|
||||
grummanCollection?: GrummanCollection;
|
||||
@@ -1534,6 +1559,8 @@ export type Game = {
|
||||
}[];
|
||||
/** A spent Ecaz card binds one exact allied force group until Karama settles. */
|
||||
nexusEcazBetrayalHistory?: { snapshot: EcazBetrayalSnapshot; stage: 'pending' | 'complete' }[];
|
||||
/** A spent Fremen card suppresses the native two-territory advantage through its turn. */
|
||||
nexusFremenBetrayalHistory?: FremenNexusBetrayal[];
|
||||
/** One actual Sardaukar battle advantage suppressed by a spent Emperor card. */
|
||||
nexusEmperorBetrayalHistory?: { event: string; turn: number; territory: string; owner: string; target: string }[];
|
||||
/** Original native Voice canceled by a spent Bene Gesserit Nexus card. */
|
||||
@@ -2094,6 +2121,47 @@ function playNexusEmperorBetrayal(g: Game, p: Player, action: Action) {
|
||||
log(g, `${p.name} spent Emperor Nexus Betrayal to suppress ${getPlayer(g, record.target).name}'s Sardaukar strength in this battle. Actual starred counters remain Sardaukar.`,
|
||||
{ faction: p.faction, name: 'Emperor Nexus Betrayal' });
|
||||
}
|
||||
function nexusFremenBetrayalIntegrity(g: Game) {
|
||||
const history = g.nexusFremenBetrayalHistory;
|
||||
const fremen = byFaction(g, 'fremen');
|
||||
if (history === undefined) {
|
||||
requireRule(fremen?.fremenNexusMovementBlockedTurn === undefined,
|
||||
'Fremen movement suppression has no spent Nexus card receipt.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.nexusCards?.cards && fremen && Array.isArray(history) &&
|
||||
history.length > 0, 'Fremen Betrayal has lost its physical card module.');
|
||||
const turns = new Set<number>();
|
||||
for (const record of history) {
|
||||
nexusRule(() => validateFremenBetrayal(g, record));
|
||||
requireRule(!turns.has(record.turn),
|
||||
'Fremen Betrayal cannot suppress movement twice in one turn.');
|
||||
turns.add(record.turn);
|
||||
}
|
||||
const last = history.at(-1)!;
|
||||
requireRule(fremen.fremenNexusMovementBlockedTurn === last.turn,
|
||||
'Fremen Betrayal has lost its turn-long movement suppression.');
|
||||
if (last.turn === g.turn)
|
||||
requireRule(g.nexusCards.cards.discard.includes('fremen'),
|
||||
'Fremen Betrayal has lost its spent physical card.');
|
||||
}
|
||||
function playNexusFremenBetrayal(g: Game, p: Player, action: Action) {
|
||||
const offer = fremenBetrayalOffer(g, p.id);
|
||||
requireRule(offer && !offer.blocked && action.event === offer.event &&
|
||||
Object.keys(action).sort().join(',') === 'event,type',
|
||||
'Use Fremen Betrayal before Fremen movement begins.');
|
||||
const record: FremenNexusBetrayal = {
|
||||
event: offer.event, owner: p.id, target: offer.target, turn: g.turn,
|
||||
phase: 5, signature: '',
|
||||
};
|
||||
record.signature = fremenBetrayalSignature(record);
|
||||
g.nexusCards!.cards = nexusRule(() =>
|
||||
discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
getPlayer(g, offer.target).fremenNexusMovementBlockedTurn = g.turn;
|
||||
(g.nexusFremenBetrayalHistory ??= []).push(record);
|
||||
log(g, `${p.name} spent Fremen Nexus Betrayal to suppress the Fremen two-territory movement advantage this turn. Independent ornithopters and movement abilities remain available.`,
|
||||
{ faction: p.faction, name: 'Fremen Nexus Betrayal' });
|
||||
}
|
||||
function bgBetrayalWindow(g: Game) {
|
||||
const response = g.response, battle = g.battle;
|
||||
return !!(g.nexusCards?.cards && g.status === 'playing' && g.phase === 6 &&
|
||||
@@ -5114,7 +5182,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
(entry.cause === 'ambassador'
|
||||
? ambassadorTerrorEntryMatches(g, entry)
|
||||
: entry.cause === 'wormRide'
|
||||
? entry.resume === 'wormRide' &&
|
||||
? (entry.resume === 'wormRide' || overlapOwnsWormTerror(g, entry)) &&
|
||||
g.phase === 1 &&
|
||||
entrant.faction === 'fremen'
|
||||
: entry.resume === 'none') &&
|
||||
@@ -5919,7 +5987,8 @@ function requireFreshSetup(g: Game, allowIxElites = false) {
|
||||
!p.specialKaramaUsed && p.kwisatz === undefined && p.charityTurn === undefined &&
|
||||
p.noField === undefined && p.noFieldEvent === undefined && p.noFieldBlockedTurn === undefined &&
|
||||
p.advisors === undefined && p.gholaBlocked === undefined && p.ixMovementBlocked === undefined &&
|
||||
p.fremenMovementBlocked === undefined && p.faceDancers === undefined &&
|
||||
p.fremenMovementBlocked === undefined && p.fremenNexusMovementBlockedTurn === undefined &&
|
||||
p.faceDancers === undefined &&
|
||||
p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined;
|
||||
}), 'Starting setup requires unused native leaders and no prior alliance, revival or battle history.');
|
||||
requireRule(Object.keys(g.playerPositions ?? {}).every(id => g.players.some(p => p.id === id)),
|
||||
@@ -7355,6 +7424,7 @@ function afterWorm(g: Game) {
|
||||
} else if (
|
||||
g.pendingAmbassador?.resume === 'wormRide' ||
|
||||
g.pendingTerrorEntry?.resume === 'wormRide' ||
|
||||
g.pendingArrivalOverlap?.resume === 'wormRide' ||
|
||||
(g.pendingKarama?.use.kind === 'cancel' &&
|
||||
g.pendingKarama.use.response.kind === 'advisorFlip' &&
|
||||
g.pendingKarama.use.response.advisorResume === 'wormRide') ||
|
||||
@@ -10630,7 +10700,8 @@ function movementRange(g: Game, p: Player, elite: number) {
|
||||
faction: p.faction,
|
||||
cityOrnithopters: !!(fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')),
|
||||
selectedElites: elite,
|
||||
nativeBlocked: blocked?.turn === g.turn && blocked.move === p.moved,
|
||||
nativeBlocked: (blocked?.turn === g.turn && blocked.move === p.moved) ||
|
||||
(p.faction === 'fremen' && p.fremenNexusMovementBlockedTurn === g.turn),
|
||||
choamBonus: p.faction === 'choam' && g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0,
|
||||
});
|
||||
}
|
||||
@@ -10817,6 +10888,7 @@ function quoteMovementArrival(
|
||||
) {
|
||||
return quoteCompletedMovementArrival({
|
||||
advanced: g.advanced,
|
||||
overlapSupported: arrivalOverlapModeSupported(g),
|
||||
players: g.players,
|
||||
order: move,
|
||||
ambassadors: g.ecazAmbassadors?.tokens ?? [],
|
||||
@@ -10883,6 +10955,7 @@ function quoteShipmentArrival(
|
||||
);
|
||||
return quoteCompletedMovementArrival({
|
||||
advanced: g.advanced,
|
||||
overlapSupported: arrivalOverlapModeSupported(g),
|
||||
players: g.players,
|
||||
order: {
|
||||
player: p.id,
|
||||
@@ -11153,6 +11226,8 @@ function ambassadorEffectBlock(
|
||||
beneficiary: Player,
|
||||
entrant: Player,
|
||||
): string | null {
|
||||
if (g.pendingArrivalOverlap && (effect === 'fremen' || effect === 'guild'))
|
||||
return 'Finish the overlapping Terror before another Ambassador-created arrival; decline this effect.';
|
||||
if (!implementedAmbassadorEffects.includes(effect))
|
||||
return 'This Ambassador effect is still being implemented.';
|
||||
if (effect === 'ecaz') {
|
||||
@@ -11179,7 +11254,101 @@ function ambassadorEffectBlock(
|
||||
return 'The entrant has no held Traitor Card to inspect.';
|
||||
return null;
|
||||
}
|
||||
/** The original entry is committed once. Competing arrival ordering remains gated. */
|
||||
function arrivalOverlapModeSupported(g: Game): boolean {
|
||||
return !!byFaction(g, 'ecaz') && !!byFaction(g, 'moritani') &&
|
||||
g.players.every(player => CLASSIC_FACTIONS[player.faction] === true ||
|
||||
player.faction === 'ecaz' || player.faction === 'moritani') &&
|
||||
g.expansions.every(expansion => expansion === 'ecaz') &&
|
||||
!g.homeworlds && !g.nexusCards && !g.leaderSkills &&
|
||||
!g.discoveryEnabled && !g.techTokens && !g.strongholdCards &&
|
||||
!g.ecazTreachery;
|
||||
}
|
||||
function arrivalOverlapIntegrity(g: Game) {
|
||||
const record = g.pendingArrivalOverlap;
|
||||
if (!record) return;
|
||||
const ecazIndex = g.order.indexOf(record.ecaz);
|
||||
const moritaniIndex = g.order.indexOf(record.moritani);
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const ambassador = g.pendingAmbassador ??
|
||||
(continuation?.kind === 'ambassador' ? continuation.entry : null);
|
||||
const terror = g.pendingTerrorEntry ??
|
||||
(continuation?.kind === 'terrorDiscard' ? continuation.entry : null);
|
||||
const active = record.stage === 'first' ? record.first :
|
||||
record.first === 'ambassador' ? 'terror' : 'ambassador';
|
||||
requireRule(g.status === 'playing' && arrivalOverlapModeSupported(g) &&
|
||||
record.turn === g.turn && record.phase === g.phase &&
|
||||
typeof record.event === 'string' && record.event.length > 0 &&
|
||||
typeof record.signature === 'string' &&
|
||||
record.signature === arrivalOverlapSignature(record) &&
|
||||
g.players.some(p => p.id === record.ecaz && p.faction === 'ecaz') &&
|
||||
g.players.some(p => p.id === record.moritani && p.faction === 'moritani') &&
|
||||
g.players.some(p => p.id === record.entrant &&
|
||||
p.id !== record.ecaz && p.id !== record.moritani) &&
|
||||
ecazIndex >= 0 && moritaniIndex >= 0 && ecazIndex !== moritaniIndex &&
|
||||
g.order.lastIndexOf(record.ecaz) === ecazIndex &&
|
||||
g.order.lastIndexOf(record.moritani) === moritaniIndex &&
|
||||
record.first === (ecazIndex < moritaniIndex ? 'ambassador' : 'terror') &&
|
||||
(record.stage === 'first' || record.stage === 'second') &&
|
||||
Number.isSafeInteger(record.sector) &&
|
||||
TERROR_STRONGHOLDS.includes(record.territory) &&
|
||||
territory(record.territory).sectors.includes(record.sector) &&
|
||||
Number.isSafeInteger(record.amount) && record.amount >= 0 &&
|
||||
Number.isSafeInteger(record.elite) && record.elite >= 0 &&
|
||||
record.elite <= record.amount &&
|
||||
(record.resume === 'none' || record.resume === 'wormRide') &&
|
||||
typeof record.ambassadorToken === 'string' && record.ambassadorToken.length > 0 &&
|
||||
Array.isArray(record.terrorTokens) && record.terrorTokens.length > 0 &&
|
||||
record.terrorTokens.every(id => typeof id === 'string' && id.length > 0) &&
|
||||
new Set(record.terrorTokens).size === record.terrorTokens.length &&
|
||||
(active === 'ambassador'
|
||||
? !!ambassador && !terror &&
|
||||
ambassador.event === record.ambassadorEvent &&
|
||||
ambassador.owner === record.ecaz &&
|
||||
ambassador.entrant === record.entrant &&
|
||||
ambassador.token === record.ambassadorToken &&
|
||||
ambassador.territory === record.territory &&
|
||||
ambassador.turn === record.turn && ambassador.phase === record.phase
|
||||
: !!terror && !ambassador &&
|
||||
terror.entrant === record.entrant &&
|
||||
terror.territory === record.territory &&
|
||||
terror.turn === record.turn && terror.phase === record.phase &&
|
||||
terror.cause === record.cause &&
|
||||
record.terrorTokens.includes(terror.token)),
|
||||
'The saved Ambassador/Terror overlap lost its original ordered arrival.');
|
||||
}
|
||||
function openAmbassadorEntry(
|
||||
g: Game, owner: Player, token: AmbassadorToken, entrant: Player, to: string,
|
||||
sector: number, resume: 'none' | 'wormRide',
|
||||
): boolean {
|
||||
g.pendingAmbassador = {
|
||||
event: `ambassador:${g.turn}:${g.phase}:${(g.log.at(-1)?.seq ?? 0) + 1}`,
|
||||
owner: owner.id,
|
||||
entrant: entrant.id,
|
||||
token: token.id,
|
||||
territory: to,
|
||||
sector,
|
||||
turn: g.turn,
|
||||
phase: g.phase,
|
||||
stage: 'offer',
|
||||
resume,
|
||||
...(resume === 'wormRide' ? { wormRider: entrant.id } : {}),
|
||||
copyChoices:
|
||||
token.effect === 'beneGesserit'
|
||||
? copiedAmbassadorEffects(g.ecazAmbassadors!)
|
||||
: [],
|
||||
};
|
||||
g.decision = { kind: 'ecazAmbassador', player: owner.id };
|
||||
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;
|
||||
}
|
||||
function finishArrivalOverlap(g: Game, overlap: NonNullable<Game['pendingArrivalOverlap']>) {
|
||||
requireRule(!g.pendingAmbassador && !g.pendingTerrorEntry,
|
||||
'Both reactions must finish before resuming the original arrival.');
|
||||
g.pendingArrivalOverlap = null;
|
||||
log(g, 'Both optional Ambassador and Terror reactions to the original entry have finished.');
|
||||
if (overlap.resume === 'wormRide') nextWormRide(g);
|
||||
}
|
||||
/** The original entry is committed once; eligible overlapping owners act in storm order. */
|
||||
function openTerritoryEntry(
|
||||
g: Game,
|
||||
entrant: Player,
|
||||
@@ -11217,44 +11386,34 @@ function openTerritoryEntry(
|
||||
resume,
|
||||
);
|
||||
const moritani = byFaction(g, 'moritani');
|
||||
const terror =
|
||||
moritani &&
|
||||
const terrorTokens = moritani &&
|
||||
entrant.id !== moritani.id &&
|
||||
entrant.id !== moritani.ally &&
|
||||
!homeworldTerrorEntryBlock(g, amount) &&
|
||||
g.moritaniTerror?.tokens.some(
|
||||
(t) => t.status === 'placed' && t.location === to,
|
||||
);
|
||||
requireRule(
|
||||
!g.response &&
|
||||
!g.decision &&
|
||||
!g.pendingTerrorEntry &&
|
||||
!g.pendingAmbassador &&
|
||||
!terror,
|
||||
'Ambassadors combined with another arrival reaction are still being implemented. This entry has not been committed.',
|
||||
);
|
||||
g.pendingAmbassador = {
|
||||
event: `ambassador:${g.turn}:${g.phase}:${(g.log.at(-1)?.seq ?? 0) + 1}`,
|
||||
owner: owner.id,
|
||||
entrant: entrant.id,
|
||||
token: token.id,
|
||||
territory: to,
|
||||
sector,
|
||||
turn: g.turn,
|
||||
phase: g.phase,
|
||||
stage: 'offer',
|
||||
resume,
|
||||
...(resume === 'wormRide' ? { wormRider: entrant.id } : {}),
|
||||
copyChoices:
|
||||
token.effect === 'beneGesserit'
|
||||
? copiedAmbassadorEffects(g.ecazAmbassadors!)
|
||||
: [],
|
||||
!homeworldTerrorEntryBlock(g, amount)
|
||||
? g.moritaniTerror?.tokens.filter(t => t.status === 'placed' && t.location === to) ?? []
|
||||
: [];
|
||||
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);
|
||||
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.');
|
||||
const first = g.order.indexOf(owner.id) < g.order.indexOf(moritani.id)
|
||||
? 'ambassador' : 'terror';
|
||||
const overlap: NonNullable<Game['pendingArrivalOverlap']> = {
|
||||
event: crypto.randomUUID(), signature: '', turn: g.turn, phase: g.phase,
|
||||
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),
|
||||
};
|
||||
g.decision = { kind: 'ecazAmbassador', player: owner.id };
|
||||
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.`,
|
||||
);
|
||||
overlap.signature = arrivalOverlapSignature(overlap);
|
||||
g.pendingArrivalOverlap = overlap;
|
||||
log(g, `${owner.name} and ${moritani.name} both have optional reactions to ${entrant.name}'s entry in ${territory(to).name}; ${first === 'ambassador' ? owner.name : moritani.name} acts first in storm order.`);
|
||||
if (first === 'terror')
|
||||
return openTerrorEntry(g, entrant, to, sector, amount, elite, cause, 'none');
|
||||
openAmbassadorEntry(g, owner, token, entrant, to, sector, 'none');
|
||||
overlap.ambassadorEvent = g.pendingAmbassador!.event;
|
||||
return true;
|
||||
}
|
||||
function finishAmbassador(g: Game, deferResume = false) {
|
||||
@@ -11281,6 +11440,26 @@ function finishAmbassador(g: Game, deferResume = false) {
|
||||
);
|
||||
}
|
||||
g.pendingAmbassador = null;
|
||||
const overlap = g.pendingArrivalOverlap;
|
||||
if (overlap?.ambassadorEvent === pending.event) {
|
||||
requireRule(pending.entrant === overlap.entrant &&
|
||||
pending.territory === overlap.territory &&
|
||||
pending.turn === overlap.turn && pending.phase === overlap.phase &&
|
||||
pending.resume === 'none',
|
||||
'The Ambassador has lost its original overlapping arrival.');
|
||||
if (overlap.stage === 'first' && overlap.first === 'ambassador') {
|
||||
overlap.stage = 'second';
|
||||
requireRule(openTerrorEntry(g, getPlayer(g, overlap.entrant),
|
||||
overlap.territory, overlap.sector, overlap.amount, overlap.elite,
|
||||
overlap.cause, 'none'),
|
||||
'The originally triggered Terror token is no longer in the stronghold.');
|
||||
return;
|
||||
}
|
||||
requireRule(overlap.stage === 'second' && overlap.first === 'terror',
|
||||
'The second Ambassador reaction is out of order.');
|
||||
finishArrivalOverlap(g, overlap);
|
||||
return;
|
||||
}
|
||||
if (!deferResume && pending.resume === 'wormRide') nextWormRide(g);
|
||||
}
|
||||
function currentFremenAmbassador(g: Game, event?: string, completed = false) {
|
||||
@@ -12806,17 +12985,19 @@ function openTerrorEntry(
|
||||
ambassadorEvent?: string,
|
||||
) {
|
||||
const moritani = byFaction(g, 'moritani');
|
||||
const tokens = g.moritaniTerror?.tokens.filter(
|
||||
(t) => t.status === 'placed' && t.location === to,
|
||||
) ?? [];
|
||||
const overlap = g.pendingArrivalOverlap;
|
||||
const originallyTriggered = !!overlap &&
|
||||
overlap.stage === 'second' && overlap.first === 'ambassador' &&
|
||||
overlap.entrant === entrant.id && overlap.territory === to &&
|
||||
overlap.turn === g.turn && overlap.phase === g.phase &&
|
||||
overlap.cause === cause;
|
||||
const tokens = g.moritaniTerror?.tokens.filter(t =>
|
||||
t.status === 'placed' && t.location === to &&
|
||||
(!originallyTriggered || overlap!.terrorTokens.includes(t.id))) ?? [];
|
||||
const token = tokens[0];
|
||||
if (
|
||||
!moritani ||
|
||||
!token ||
|
||||
entrant.id === moritani.id ||
|
||||
entrant.id === moritani.ally ||
|
||||
homeworldTerrorEntryBlock(g, amount)
|
||||
)
|
||||
if (!moritani || !token || entrant.id === moritani.id ||
|
||||
(entrant.id === moritani.ally && !originallyTriggered) ||
|
||||
homeworldTerrorEntryBlock(g, amount))
|
||||
return false;
|
||||
requireRule(!g.pendingTerrorEntry, 'Resolve the pending Terror entry first.');
|
||||
requireRule(
|
||||
@@ -12862,6 +13043,27 @@ function openTerrorEntry(
|
||||
function finishTerrorEntry(g: Game) {
|
||||
const pending = g.pendingTerrorEntry!;
|
||||
g.pendingTerrorEntry = null;
|
||||
const overlap = g.pendingArrivalOverlap;
|
||||
if (overlap && pending.entrant === overlap.entrant &&
|
||||
pending.territory === overlap.territory && pending.turn === overlap.turn &&
|
||||
pending.phase === overlap.phase && pending.cause === overlap.cause) {
|
||||
requireRule(pending.resume === 'none', 'The overlapping Terror must not resume its parent twice.');
|
||||
if (overlap.stage === 'first' && overlap.first === 'terror') {
|
||||
overlap.stage = 'second';
|
||||
const token = g.ecazAmbassadors?.tokens.find(t =>
|
||||
t.id === overlap.ambassadorToken && t.zone === 'placed' &&
|
||||
t.location === overlap.territory);
|
||||
requireRule(token, 'The originally triggered Ambassador token is no longer present.');
|
||||
openAmbassadorEntry(g, getPlayer(g, overlap.ecaz), token,
|
||||
getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, 'none');
|
||||
overlap.ambassadorEvent = g.pendingAmbassador!.event;
|
||||
return;
|
||||
}
|
||||
requireRule(overlap.stage === 'second' && overlap.first === 'ambassador',
|
||||
'The second Terror reaction is out of order.');
|
||||
finishArrivalOverlap(g, overlap);
|
||||
return;
|
||||
}
|
||||
if (pending.resume === 'wormRide') nextWormRide(g);
|
||||
else if (pending.resume === 'ambassador')
|
||||
continueAmbassadorArrival(g, pending.ambassadorEvent!);
|
||||
@@ -15542,6 +15744,7 @@ function marketGholaIntegrity(g: Game) {
|
||||
nexusCardsIntegrity(g);
|
||||
nexusEmperorSecretIntegrity(g);
|
||||
nexusEmperorBetrayalIntegrity(g);
|
||||
nexusFremenBetrayalIntegrity(g);
|
||||
nexusBgBetrayalIntegrity(g);
|
||||
nexusFremenRevivalIntegrity(g);
|
||||
nexusFremenCunningIntegrity(g);
|
||||
@@ -20393,7 +20596,7 @@ function validateGuildShipmentDecision(
|
||||
validatePhysicalShipment(g, shipment);
|
||||
return shipment;
|
||||
}
|
||||
const GUILD_RATE_CLASSIC_FACTIONS: Partial<Record<FactionId, true>> = {
|
||||
const CLASSIC_FACTIONS: Partial<Record<FactionId, true>> = {
|
||||
atreides: true, harkonnen: true, emperor: true,
|
||||
fremen: true, guild: true, beneGesserit: true,
|
||||
};
|
||||
@@ -20402,7 +20605,7 @@ const GUILD_RATE_CLASSIC_FACTIONS: Partial<Record<FactionId, true>> = {
|
||||
function guildRateModeSupported(g: Game): boolean {
|
||||
return !g.expansions.length && !g.homeworlds && !g.leaderSkills &&
|
||||
!g.nexusCards && !g.discoveryEnabled && !g.techTokens && !g.strongholdCards &&
|
||||
g.players.every(player => GUILD_RATE_CLASSIC_FACTIONS[player.faction] === true);
|
||||
g.players.every(player => CLASSIC_FACTIONS[player.faction] === true);
|
||||
}
|
||||
function ordinaryGuildRateFullCost(g: Game, shipment: PendingShipment): number | null {
|
||||
const guild = byFaction(g, 'guild');
|
||||
@@ -22509,6 +22712,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
leaderSkillsIntegrity(state);
|
||||
moritaniExtortionIntegrity(state);
|
||||
guildRateIntegrity(state);
|
||||
arrivalOverlapIntegrity(state);
|
||||
discoveryIntegrity(state);
|
||||
greatMakerIntegrity(state);
|
||||
nexusChoamTradeIntegrity(state);
|
||||
@@ -22692,6 +22896,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
harkonnenExchangeIntegrity(g);
|
||||
moritaniExtortionIntegrity(g);
|
||||
guildRateIntegrity(g);
|
||||
arrivalOverlapIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
function finishActionContinuations(g: Game) {
|
||||
@@ -22801,6 +23006,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
leaderSkillsIntegrity(state);
|
||||
moritaniExtortionIntegrity(state);
|
||||
guildRateIntegrity(state);
|
||||
arrivalOverlapIntegrity(state);
|
||||
discoveryIntegrity(state);
|
||||
greatMakerIntegrity(state);
|
||||
nexusChoamTradeIntegrity(state);
|
||||
@@ -22856,6 +23062,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
harkonnenExchangeIntegrity(g);
|
||||
moritaniExtortionIntegrity(g);
|
||||
guildRateIntegrity(g);
|
||||
arrivalOverlapIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
@@ -22956,6 +23163,7 @@ function applyActionInner(
|
||||
if (t === 'nexusFremenRevive') { playNexusFremenRevive(g,p,action); return g; }
|
||||
if (t === 'nexusMoritaniBetrayal') { playMoritaniBetrayal(g, p, action); return g; }
|
||||
if (t === 'nexusEcazBetrayal') { playEcazBetrayal(g, p, action); return g; }
|
||||
if (t === 'nexusFremenBetrayal') { playNexusFremenBetrayal(g, p, action); return g; }
|
||||
if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; }
|
||||
requireRule(
|
||||
!(
|
||||
@@ -26156,6 +26364,7 @@ function applyActionInner(
|
||||
} else if (
|
||||
!move.ornithopterRange && !move.discoveryFlight &&
|
||||
p.faction === 'fremen' &&
|
||||
p.fremenNexusMovementBlockedTurn !== g.turn &&
|
||||
!(p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved) &&
|
||||
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
|
||||
(move.sandmaster
|
||||
@@ -26626,6 +26835,7 @@ export function viewGame(state: Game, id: string) {
|
||||
leaderSkillsIntegrity(state);
|
||||
moritaniExtortionIntegrity(state);
|
||||
guildRateIntegrity(state);
|
||||
arrivalOverlapIntegrity(state);
|
||||
discoveryFlightIntegrity(state);
|
||||
discoveryIntegrity(state);
|
||||
greatMakerIntegrity(state);
|
||||
@@ -26753,6 +26963,17 @@ export function viewGame(state: Game, id: string) {
|
||||
moritaniTerror: g.moritaniTerror
|
||||
? projectTerror(g.moritaniTerror, me.faction === 'moritani')
|
||||
: null,
|
||||
arrivalOverlapMode: arrivalOverlapModeSupported(g),
|
||||
arrivalOverlap: g.pendingArrivalOverlap
|
||||
? {
|
||||
first: g.pendingArrivalOverlap.first,
|
||||
active: g.pendingArrivalOverlap.stage === 'first'
|
||||
? g.pendingArrivalOverlap.first
|
||||
: g.pendingArrivalOverlap.first === 'ambassador' ? 'terror' : 'ambassador',
|
||||
entrant: g.pendingArrivalOverlap.entrant,
|
||||
territory: g.pendingArrivalOverlap.territory,
|
||||
}
|
||||
: null,
|
||||
extortion: {
|
||||
deferred: g.moritaniExtortion?.stage === 'reserved' ? 5 : 0,
|
||||
pending: g.moritaniExtortion?.stage === 'payment' && g.decision?.kind === 'moritaniExtortion'
|
||||
@@ -26988,6 +27209,8 @@ export function viewGame(state: Game, id: string) {
|
||||
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
|
||||
nexusEcazBetrayal: ecazBetrayalOffer(g, id,
|
||||
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
|
||||
nexusFremenBetrayal: fremenBetrayalOffer(g, id,
|
||||
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
|
||||
nexusRichese: nexusRicheseOffer(g,id),
|
||||
nexusRicheseCunning: nexusRicheseCunningOffer(g, id),
|
||||
nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id),
|
||||
@@ -27336,8 +27559,10 @@ export function viewGame(state: Game, id: string) {
|
||||
advisorSetup: p.advisorSetup,
|
||||
specialKaramaUsed: p.specialKaramaUsed ?? false,
|
||||
fremenMovementBlocked:
|
||||
p.fremenMovementBlocked?.turn === g.turn &&
|
||||
p.fremenMovementBlocked.move === p.moved,
|
||||
p.fremenNexusMovementBlockedTurn === g.turn ||
|
||||
(p.fremenMovementBlocked?.turn === g.turn &&
|
||||
p.fremenMovementBlocked.move === p.moved),
|
||||
fremenNexusMovementBlocked: p.fremenNexusMovementBlockedTurn === g.turn,
|
||||
ixMovementBlocked:
|
||||
p.ixMovementBlocked?.turn === g.turn &&
|
||||
p.ixMovementBlocked.move === p.moved,
|
||||
|
||||
@@ -23,6 +23,8 @@ export type MovementArrivalPlayer = Readonly<
|
||||
|
||||
export type CompletedMovementArrivalInput = Readonly<{
|
||||
advanced: boolean;
|
||||
/** The caller supports resolving both optional arrival windows on this entry. */
|
||||
overlapSupported?: boolean;
|
||||
players: readonly MovementArrivalPlayer[];
|
||||
order: Readonly<{
|
||||
player: string;
|
||||
@@ -54,7 +56,7 @@ export type CompletedMovementArrivalInput = Readonly<{
|
||||
|
||||
export type CompletedMovementArrivalQuote = Readonly<{
|
||||
intrusion: boolean;
|
||||
reaction: 'ambassador' | 'terror' | null;
|
||||
reaction: 'ambassador' | 'terror' | 'overlap' | null;
|
||||
retiresOrnithopter: boolean;
|
||||
}>;
|
||||
|
||||
@@ -119,6 +121,17 @@ export function quoteCompletedMovementArrival(
|
||||
);
|
||||
const response = controls.response || order.wantsFighters;
|
||||
const decision = controls.decision || intrusion;
|
||||
if (
|
||||
triggersAmbassador &&
|
||||
triggersTerror &&
|
||||
input.overlapSupported &&
|
||||
ecaz!.id !== moritani!.id &&
|
||||
!response &&
|
||||
!decision &&
|
||||
!controls.pendingTerror &&
|
||||
!controls.pendingAmbassador
|
||||
)
|
||||
return { ...base, reaction: 'overlap' };
|
||||
if (triggersAmbassador) {
|
||||
if (
|
||||
response ||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './karama-movement-cancellation';
|
||||
import { TERROR_KINDS } from './moritani-terror';
|
||||
import { validateTerrorEntrySignature } from './terror-entry-receipt';
|
||||
import { overlapOwnsWormTerror } from './arrival-overlap';
|
||||
|
||||
export class MoritaniAllianceCancellationError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -25,6 +26,7 @@ export type MoritaniAllianceCancellationContext = Pick<
|
||||
| 'phase'
|
||||
| 'players'
|
||||
| 'pendingTerrorEntry'
|
||||
| 'pendingArrivalOverlap'
|
||||
| 'moritaniTerror'
|
||||
| 'pendingAmbassador'
|
||||
| 'ecazAmbassadors'
|
||||
@@ -164,7 +166,7 @@ export function quoteMoritaniAllianceCancellation(
|
||||
requireSource(
|
||||
entry.cause === 'wormRide'
|
||||
? g.phase === 1 &&
|
||||
entry.resume === 'wormRide' &&
|
||||
(entry.resume === 'wormRide' || overlapOwnsWormTerror(g, entry)) &&
|
||||
entrants[0].faction === 'fremen'
|
||||
: g.phase === 5 &&
|
||||
entry.resume === 'none' &&
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** Only the holder's private offer authorizes spending the physical Nexus card. */
|
||||
export function fremenBetrayalAction(game: GameView): Action | null {
|
||||
const offer = game.nexusFremenBetrayal;
|
||||
const owner = game.players.find(player => player.id === game.me);
|
||||
if (!offer || offer.blocked || !owner || owner.ally ||
|
||||
owner.faction === 'fremen' || game.nexusCards?.card !== 'fremen' ||
|
||||
game.automaticContinuationPending) return null;
|
||||
return { type: 'nexusFremenBetrayal', event: offer.event };
|
||||
}
|
||||
|
||||
export function fremenBetrayalBotActions(game: GameView): Action[] {
|
||||
const action = fremenBetrayalAction(game);
|
||||
return action ? [action] : [];
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Game } from './engine';
|
||||
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
|
||||
|
||||
export type FremenNexusBetrayal = {
|
||||
event: string;
|
||||
owner: string;
|
||||
target: string;
|
||||
turn: number;
|
||||
phase: 5;
|
||||
signature: string;
|
||||
};
|
||||
export const fremenBetrayalEvent = (turn: number, owner: string, target: string) =>
|
||||
JSON.stringify(['nexusFremenBetrayal', turn, owner, target]);
|
||||
export const fremenBetrayalSignature = (record: FremenNexusBetrayal) =>
|
||||
JSON.stringify({ ...record, signature: undefined });
|
||||
|
||||
/** Clean pre-movement use prevents retroactive suppression and never tests
|
||||
* secret card custody of another seat when constructing public turn state. */
|
||||
export function fremenBetrayalOffer(g: Game, owner: string, automaticPending = false) {
|
||||
const holder = g.players.find(p => p.id === owner);
|
||||
const fremen = g.players.find(p => p.faction === 'fremen');
|
||||
if (!holder || !fremen || g.nexusCards?.cards?.hands[owner] !== 'fremen' ||
|
||||
holder.faction === 'fremen') return null;
|
||||
let blocked: string | null = null;
|
||||
if (holder.ally) blocked = 'An allied player cannot use a Nexus card.';
|
||||
else if (g.status !== 'playing' || g.phase !== 5)
|
||||
blocked = 'Use Fremen Betrayal before Fremen move during Shipment and Movement.';
|
||||
else if (g.expansions.length || g.homeworlds || g.leaderSkills || g.discoveryEnabled ||
|
||||
g.discoveries || g.discoveryStash || g.greatMaker || g.ecazTreachery ||
|
||||
g.techTokens || g.strongholdCards ||
|
||||
g.players.some(p => !['atreides','harkonnen','emperor','fremen','guild','beneGesserit'].includes(p.faction)))
|
||||
blocked = 'Combined expansion and module movement interactions remain unavailable.';
|
||||
else if (nexusCleanPlayBlocked(g, automaticPending))
|
||||
blocked = 'Finish the current interaction before using Fremen Betrayal.';
|
||||
else if (!g.movementRemaining?.includes(fremen.id) || fremen.moved !== 0 ||
|
||||
fremen.fremenNexusMovementBlockedTurn === g.turn)
|
||||
blocked = 'Fremen have already moved or completed their turn.';
|
||||
return { event: fremenBetrayalEvent(g.turn,owner,fremen.id), blocked, target: fremen.id };
|
||||
}
|
||||
|
||||
export function validateFremenBetrayal(g: Game, record: FremenNexusBetrayal) {
|
||||
if (!record || Object.keys(record).sort().join(',') !== 'event,owner,phase,signature,target,turn' ||
|
||||
!Number.isSafeInteger(record.turn) || record.turn < 1 || record.turn > g.turn ||
|
||||
record.phase !== 5 || record.owner === record.target ||
|
||||
!g.players.some(p => p.id === record.owner && p.faction !== 'fremen') ||
|
||||
!g.players.some(p => p.id === record.target && p.faction === 'fremen') ||
|
||||
record.event !== fremenBetrayalEvent(record.turn,record.owner,record.target) ||
|
||||
record.signature !== fremenBetrayalSignature(record))
|
||||
throw new Error('Fremen Betrayal has lost its original movement restriction.');
|
||||
}
|
||||
+4
-3
@@ -1281,7 +1281,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The mobile stronghold can receive a non-Ixian group only from the territory it points to. Ecaz and its ally may co-occupy under Occupy. Advisors keep their applicable stance and accompaniment locks; an eligible arrival can request fighters. CHOAM may use Baliset during Shipment and Movement; if allowed, choose another legal destination. Intrusion or Terror resolves before the original entrant continues. Destinations causing both remain visibly unavailable pending a table interpretation. There is no extra decline after triggering; if no legal move remains, the effect finishes without moving pieces and its token stays consumed.',
|
||||
'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.',
|
||||
'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.',
|
||||
'No-Field substitution into the free four-force grant awaits the user’s interpretation and is unavailable. Simultaneous Intrusion/Terror and Ambassador/Terror combinations remain blocked. Moritani Atomics Aftermath, Homeworlds and Discovery locations require their still-gated producers or modules; this checkpoint does not claim those combinations.',
|
||||
'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. Competing Intrusion and Ambassador-created Fremen/Guild child arrivals remain gated. Atomics Aftermath, Homeworlds, Nexus and Discovery combinations do not gain coverage from this first overlap path.',
|
||||
'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. The remaining effects, combined Occupy combat, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.',
|
||||
],
|
||||
checklist: [
|
||||
@@ -4047,6 +4047,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.',
|
||||
'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported.',
|
||||
'Ecaz Cunning lets an unallied native holder spend the physical Nexus card at a quiet Battle boundary to take the existing living Duke Vidal for the turn, even from Moritani. The Duke can be selected through ordinary Battle plans and is set aside after use or at turn end. This first version is limited to paired Ecaz/Moritani with other classic factions and no combined optional modules; Advanced Harkonnen is excluded because the existing battle path cannot select Duke there. Taking Duke from the Tanks, capture or Ghola custody remains unimplemented. The Battle-only timing is a product boundary, not printed text.',
|
||||
'Fremen Betrayal currently supports its movement-range alternative for an unallied holder in a classic Nexus table. Spend the card before Fremen move during Shipment and Movement to suppress their native two-territory advantage for that turn; ordinary one-territory movement and independent ornithopters remain. The separate printed worm-riding prevention is not yet connected. The owner-only control shows this boundary and a spent card cannot be used again.',
|
||||
'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.',
|
||||
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.',
|
||||
'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games without other optional modules. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. Other module combinations and the full faceup-component display remain gated.',
|
||||
@@ -4274,8 +4275,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Ixians keep their private starting-card selection and physical cyborg/suboid forces. Tleilaxu receives three private Face Dancers. Advanced CHOAM receives its Auditor. Richese has a separate ten-card cache and three private No-Fields. Ecaz and Moritani receive their Ambassador and Terror inventories and share one Duke Vidal disc.',
|
||||
'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has Recruits, bounded Harass & Withdraw and bounded Reinforcements development controls for classic-faction games.',
|
||||
'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.',
|
||||
'Fresh unsupported movement arrivals reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. Simultaneous Ambassador/Terror order remains unresolved.',
|
||||
'Ordinary shipment checks unfinished arrival combinations before saving the Guild interception choice. An already saved ordinary shipment may return unspent to its shipper when allowance encounters that arrival guard, so the shipper can choose another destination. Older No-Field, Smuggler, Nexus and Ambassador shipments are excluded from this recovery. Simultaneous arrival order remains unfinished.',
|
||||
'Fresh unsupported movement arrivals still reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. The bounded Ecaz/Moritani same-entry Ambassador/Terror overlap instead snapshots both eligible owners in storm order without replaying the original move.',
|
||||
'Ordinary shipment checks unfinished arrival combinations before saving the Guild interception choice. An already saved ordinary shipment may return unspent when its still-gated arrival cannot commit; older No-Field, Smuggler, Nexus and Ambassador shipments retain their separate recovery boundaries. Classic plus Ecaz/Moritani Ambassador/Terror overlap has a provisional saved storm-order sequence, while other simultaneous priorities remain unfinished.',
|
||||
'Remaining abilities and combined timing can still encounter explicit development guards. Setup and saved-continuation evidence does not certify complete expansion play.',
|
||||
],
|
||||
related: ['setup', 'ix-modules', 'choam-modules', 'ecaz-modules', 'implementation-checklist'],
|
||||
|
||||
Reference in new issue
Block a user