Resume worm rides after advanced Sneak arrivals
Verify and publish checkpoint container / container (push) Successful in 10m40s

This commit is contained in:
Quintine committed 2026-10-29 19:53:42 +11:00
1 parent b79e448deb
commit 57321c2c8d
8 files changed
+363 -36

No files matched your search

+83 -10
View File
@@ -2334,7 +2334,18 @@ export type Game = {
caladanSneakAdvisor?: true;
/** An ordinary Sneak group waits for an existing Ambassador/Terror parent. */
nativeSneakAdvisor?: true;
/** Binds this physical group to an independently retained worm continuation. */
sneakWormEvent?: string;
}[] | null;
/** The original worm ride is held while a committed Sneak arrival resolves. */
pendingSneakWorm?: {
event: string;
turn: number;
phase: number;
rider: string;
parent: {kind: 'terror' | 'ambassador' | 'overlap'; event: string};
stage: 'parent' | 'queued' | 'resolving';
} | null;
/** One committed entry with two independently optional expansion reactions. */
pendingArrivalOverlap?: {
victoryEvent?: string;
@@ -15103,7 +15114,29 @@ function finishSpicePass(g: Game) {
nextPhase(g);
}
}
function holdSneakWormParent(g: Game, kind: 'terror' | 'ambassador' | 'overlap', event: string): boolean {
const frame = g.pendingSneakWorm;
if (!frame || frame.parent.kind !== kind || frame.parent.event !== event) return false;
requireRule(frame.stage === 'parent' && frame.turn === g.turn && frame.phase === g.phase,
'The Sneak arrival lost its original worm parent.');
frame.stage = 'queued';
return true;
}
function sneakWormIntegrity(g: Game): void {
const frame = g.pendingSneakWorm;
if (!frame) return;
const queued = (g.pendingArrivalReaction ?? []).filter(row => row.sneakWormEvent === frame.event);
requireRule(g.status === 'playing' && g.advanced && frame.turn === g.turn && frame.phase === g.phase && g.phase === 1 &&
typeof frame.event === 'string' && frame.event.length > 0 &&
g.players.some(player => player.id === frame.rider && player.faction === 'fremen') &&
!!frame.parent && ['terror','ambassador','overlap'].includes(frame.parent.kind) &&
typeof frame.parent.event === 'string' && frame.parent.event.length > 0 &&
(frame.stage === 'parent' || frame.stage === 'queued' ? queued.length === 1 :
frame.stage === 'resolving' && queued.length === 0),
'The saved Sneak arrival lost its original worm continuation.');
}
function nextWormRide(g: Game) {
requireRule(!g.pendingSneakWorm, 'Finish the committed Sneak arrival before resuming another worm continuation.');
if (g.greatMaker?.stage === 'arrival') { completeGreatMaker(g); return; }
if (g.summonedNexusBeforeRides) {
g.summonedNexusBeforeRides = false;
@@ -25277,7 +25310,7 @@ function finishArrivalOverlap(g: Game, overlap: NonNullable<Game['pendingArrival
'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);
if (overlap.resume === 'wormRide' && !holdSneakWormParent(g, 'overlap', overlap.event)) nextWormRide(g);
}
/** Open the first original reaction once its public ordering window is complete. */
function openFirstOrderedArrival(g: Game) {
@@ -25460,7 +25493,7 @@ function finishAmbassador(g: Game, deferResume = false) {
finishArrivalOverlap(g, overlap);
return;
}
if (!deferResume && pending.resume === 'wormRide') nextWormRide(g);
if (!deferResume && pending.resume === 'wormRide' && !holdSneakWormParent(g, 'ambassador', pending.event)) nextWormRide(g);
}
function currentFremenAmbassador(g: Game, event?: string, completed = false) {
const entry = g.pendingAmbassador;
@@ -27831,7 +27864,7 @@ function finishTerrorEntry(g: Game) {
finishArrivalOverlap(g, overlap);
return;
}
if (pending.resume === 'wormRide') nextWormRide(g);
if (pending.resume === 'wormRide' && !holdSneakWormParent(g, 'terror', pending.entrySignature ?? terrorEntrySignature(pending))) nextWormRide(g);
else if (pending.resume === 'ambassador')
continueAmbassadorArrival(g, pending.ambassadorEvent!);
}
@@ -27887,9 +27920,8 @@ function sneakAttackOptions(
let blocked: string | null = null;
try {
allowedEntry(g, owner, entry.territory, entry.sector);
if (g.advanced && (entry.resume === 'wormRide' || g.pendingArrivalOverlap?.resume === 'wormRide' ||
g.pendingAmbassador?.resume === 'wormRide'))
blocked = 'Sneak Attack during a worm ride needs its original ride continuation; send no forces or leave the token hidden.';
if (g.advanced && g.greatMaker?.stage === 'arrival')
blocked = 'Sneak Attack during a Great Maker arrival needs its original continuation; send no forces or leave the token hidden.';
} catch (error) {
if (!(error instanceof RuleError)) throw error;
blocked = error.message;
@@ -28171,10 +28203,26 @@ function decideTerror(g: Game, p: Player, action: Action) {
const revivalArrival: RevivalEntryParent | undefined = amount > 0 && entry.revivalArrival
? {event:entry.revivalArrival.event,terror:{event:entry.entrySignature!,owner:p.id,
territory:entry.territory,sector:entry.sector,amount,elite:0}} : undefined;
// Worm continuations can advance the phase synchronously; their child
// arrival needs a separate retained worm cursor before it can be queued.
const wormParent = g.pendingArrivalOverlap?.resume === 'wormRide'
? {kind:'overlap' as const, event:g.pendingArrivalOverlap.event}
: entry.resume === 'ambassador' && g.pendingAmbassador?.resume === 'wormRide'
? {kind:'ambassador' as const, event:g.pendingAmbassador.event}
: entry.resume === 'wormRide'
? {kind:'terror' as const, event:entry.entrySignature ?? terrorEntrySignature(entry)}
: undefined;
const advancedNativeArrival = g.advanced && entry.resume !== 'wormRide' &&
g.pendingArrivalOverlap?.resume !== 'wormRide' && g.pendingAmbassador?.resume !== 'wormRide';
const wormSneak = amount > 0 && g.advanced && !!wormParent && !caladan && !revivalArrival;
if (wormSneak) {
const rider = byFaction(g, 'fremen');
requireRule(rider && !g.pendingSneakWorm && g.phase === 1,
'This Sneak arrival cannot retain its original worm rider.');
const event = crypto.randomUUID();
g.pendingSneakWorm = {event, turn:g.turn, phase:g.phase, rider:rider.id, parent:wormParent!, stage:'parent'};
(g.pendingArrivalReaction ??= []).push({entrant:p.id,territory:entry.territory,
sector:entry.sector,amount,elite:0,cause:'shipment',resume:'none',turn:g.turn,
phase:g.phase,nativeSneakAdvisor:true,sneakWormEvent:event});
}
if (caladan) (g.homeworldVictoryReinforcement!.terrorArrivals ??= []).push(caladan.receipt);
finishTerrorEntry(g);
if (caladan) {
@@ -28191,6 +28239,7 @@ function decideTerror(g: Game, p: Player, action: Action) {
phase:g.phase,revivalArrival,revivalSneakAdvisor:true});
return;
}
if (wormSneak) return;
if (amount > 0 && (advancedNativeArrival || !g.advanced &&
(g.pendingArrivalOverlap || g.pendingAmbassador || g.pendingTerrorEntry || g.response || g.decision))) {
// The child may have opened the original Terror decision. Let it finish
@@ -43860,8 +43909,9 @@ function settleAutomaticContinuations(g: Game) {
if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return;
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
if (!g.response && !g.decision && g.pendingAutomaticAdvisorFlips?.length) openAutomaticAdvisorFlip(g);
if (g.pendingArrivalReaction?.length && !g.response && !g.decision &&
!g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap && !g.pendingSpiritualAdvisor) {
if (g.pendingArrivalReaction?.length && !g.response && !g.decision && !g.summonedWorm && !g.pendingKull &&
!g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap && !g.pendingSpiritualAdvisor &&
!g.pendingTreacheryDiscard) {
const deferred = g.pendingArrivalReaction.shift()!;
if (!g.pendingArrivalReaction.length) g.pendingArrivalReaction = null;
if (deferred.victoryEvent !== undefined || deferred.cause === 'caladanReinforcement')
@@ -43895,6 +43945,14 @@ function settleAutomaticContinuations(g: Game) {
deferred.cause === 'shipment' && deferred.resume === 'none' &&
entrant!.faction === 'moritani' && deferred.amount > 0 && deferred.elite === 0,
'The queued Sneak reactions have lost their committed native entry.');
if (deferred.sneakWormEvent) {
const frame = g.pendingSneakWorm;
requireRule(frame && frame.event === deferred.sneakWormEvent && frame.stage === 'queued' &&
frame.turn === g.turn && frame.phase === g.phase &&
g.players.some(player => player.id === frame.rider && player.faction === 'fremen'),
'The queued Sneak group lost its original worm continuation.');
frame.stage = 'resolving';
}
const bg = byFaction(g, 'beneGesserit');
const followup = bg && bg.id !== entrant!.id && spiritualAdvisorMaximum(g, bg.id) > 0
? {shipment:entrant!.id,destination:location(deferred.territory,deferred.sector)} : undefined;
@@ -43906,6 +43964,19 @@ function settleAutomaticContinuations(g: Game) {
if (deferred.revivalArrival) resumeHomeworldRevivalReturn(g);
continue;
}
if (g.pendingSneakWorm?.stage === 'resolving' && !g.response && !g.decision &&
!g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap &&
!g.pendingArrivalReaction?.length && !g.pendingSpiritualAdvisor &&
!g.pendingAutomaticAdvisorFlips?.length && !g.pendingTreacheryDiscard && !g.pendingKarama &&
!g.summonedWorm && !g.pendingKull) {
const frame = g.pendingSneakWorm;
requireRule(frame.turn === g.turn && frame.phase === g.phase && g.phase === 1 &&
g.players.some(player => player.id === frame.rider && player.faction === 'fremen'),
'The completed Sneak arrival lost its original worm rider.');
g.pendingSneakWorm = null;
nextWormRide(g);
continue;
}
const response = g.response;
const before = JSON.stringify(g);
const bonus = g.pendingHomeworldOccupiedBonus;
@@ -43980,6 +44051,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
sneakWormIntegrity(state);
emperorPhaseOpeningGiftIntegrity(state);
emperorExtortionGiftIntegrity(state);
emperorAuctionGiftIntegrity(state);
@@ -44150,6 +44222,7 @@ function applyActionInner(
execution: 'live' | 'shipmentPreparation' | 'movementPreparation' = 'live',
initiator = id,
): Game {
sneakWormIntegrity(state);
tupileSlotsIntegrity(state);
const tupileBlock = tupileSourceBlock(state);
requireRule(!tupileBlock, tupileBlock ?? 'The occupied Tupile source is unresolved.');