Resume worm rides after advanced Sneak arrivals
Verify and publish checkpoint container / container (push) Successful in 10m40s
Verify and publish checkpoint container / container (push) Successful in 10m40s
This commit is contained in:
1 parent
b79e448deb
commit
57321c2c8d
8 files changed
+363
-36
No files matched your search
+83
-10
@@ -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.');
|
||||
|
||||
+2
-2
@@ -4227,7 +4227,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Sabotage: randomly draw and discard a Treachery Card from the entrant if possible. Then you may give that player a card of your choice from your own hand. Giving a card is optional. If the gift puts the recipient above their hand limit, the recipient provisionally chooses one held card to discard, including the gift; the original entry waits for that fresh discard and any supported Semuta reaction.',
|
||||
'Sneak Attack: send up to five of your reserve forces into the triggered territory at no cost, respecting storm and occupancy restrictions. This particular entry is allowed even when Atomics Aftermath is present.',
|
||||
'Before revealing Sneak Attack, inspect its private available maximum and any reason positive entry is blocked. You may keep it hidden. Revealing spends the token even if you then choose Send no forces. For a supported entry, select zero through the displayed maximum; forces enter the arrival’s territory and sector without consuming your ordinary shipment or movement turn. Zero remains available when positive entry is blocked. Aftermath’s printed exception allows this reserve entry even after Atomics resolves. A positive entry during Shipment and Movement is treated as an off-planet shipment for the Heighliners token, which accrues its once-per-turn income for its owner, and the committed entry finishes before Bene Gesserit may answer it with an Intrusion choice or a free spiritual-advisor shipment.',
|
||||
'In Advanced play outside a worm continuation, the separate Sneak group can also trigger an eligible Ecaz Ambassador after its Bene Gesserit reactions. A positive Sneak group during a worm continuation is temporarily unavailable until the rider continuation can be preserved; leaving the token hidden or sending zero remains possible.',
|
||||
'In Advanced play the separate Sneak group can also trigger an eligible Ecaz Ambassador after its Bene Gesserit reactions. During an ordinary worm ride the original Fremen continuation waits for the child and resumes once. A positive group during a Great Maker arrival remains temporarily unavailable; leaving the token hidden or sending zero remains possible.',
|
||||
'The random Assassination token is separate from the advanced Assassinate Leaders advantage. Neither ability supplies the other’s selection or timing rules.',
|
||||
],
|
||||
checklist: [
|
||||
@@ -4235,7 +4235,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Original actual390px Reveal Assassination resolves a captive Duke death6, displays Tanks inspection and resumes native Harkonnen movement. No selectable victim or projected pool; no overflow/browser errors.', evidence: ['components/game-table.tsx', 'components/duke-vidal.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four policies make accepted original decisions: Easy reveals, Medium/Hard/Brutal form an accepted Enemy of My Enemy alliance in the actual native position. Legal participation only; no calibrated strategy claim.', evidence: ['game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Effective-control exceptional pool, public victim and spent-empty behavior are labelled provisional and separate from Advanced battle-loss Assassinate Leaders. Native printed values and no foreign-Ghola Duke remain binding.', evidence: ['docs/MORITANI_ASSASSINATION_SNEAK.md', 'docs/MORITANI_TERROR_RULES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Advanced direct Sneak and related arrivals pass ten files and 73 cases, with genuine setup and controlled physical token placements; a staged worm case exercises its temporary guard. Earlier affected22-file195/195 and types/lint/build pass for Assassination. Pure custody/identity/value boundaries plus original capture-to-Terror runtime and real table death6 pass. Empty boundary resumes genuine Guild entry but stages prior native deaths. Full combinations/deployed acceptance open.', evidence: ['tests/advanced-sneak-direct-runtime.test.ts', 'tests/ambassador-terror-overlap-worm.test.ts', 'tests/moritani-terror-assassination.test.ts', 'tests/moritani-terror-assassination-runtime.test.ts', 'tests/moritani-force-leader.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Advanced worm Sneak and related arrivals pass ten files and 75 cases, using staged worm positions and legal bot choices. Earlier direct Sneak tests use genuine setup with controlled physical token placements. Earlier affected22-file195/195 and types/lint/build pass for Assassination. Pure custody/identity/value boundaries plus original capture-to-Terror runtime and real table death6 pass. Empty boundary resumes genuine Guild entry but stages prior native deaths. Full combinations/deployed acceptance open.', evidence: ['tests/advanced-sneak-worm-runtime.test.ts', 'tests/advanced-sneak-direct-runtime.test.ts', 'tests/ambassador-terror-overlap-worm.test.ts', 'tests/moritani-terror-assassination.test.ts', 'tests/moritani-terror-assassination-runtime.test.ts', 'tests/moritani-force-leader.test.ts'] },
|
||||
],
|
||||
related: [
|
||||
'moritani-terror',
|
||||
|
||||
Reference in new issue
Block a user