Connect bounded arrival overlap and Fremen Nexus movement Betrayal

This commit is contained in:
Quintine committed 2026-09-29 18:01:14 +10:00
1 parent 8ddd138e87
commit b2603f6715
26 files changed
+1644 -118

No files matched your search

+278 -53
View File
@@ -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,