From d66b594770b10af796fddf103583f7ad9c2b162b Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:19:18 +1100 Subject: [PATCH] Resume storm traversal after surviving advisor conversion Preserve current-sector losses and original Fremen casualty priority before the mandatory BG counter. Bind public sector facts to a private continuation and resume only the remaining storm once. --- components/game-table.tsx | 4 +- docs/ADVISOR_AUTOMATIC_FLIPS.md | 79 +++++++ docs/CURRENT_STATUS.md | 28 ++- docs/IMPLEMENTATION_STATUS.md | 44 ++++ docs/RULE_DECISIONS.md | 15 ++ game/advisor-automatic-flip.ts | 52 ++++- game/engine.ts | 79 ++++++- game/karama-context.ts | 1 + game/reference.ts | 19 +- tests/advisor-automatic-flip-quote.test.ts | 169 ++++++++++++++- tests/advisor-storm-flip-engine.test.ts | 100 +++++++++ tests/fixture-advisor-storm-flip.ts | 235 +++++++++++++++++++++ tests/storm-worm-cancellation.test.ts | 61 ++++++ 13 files changed, 869 insertions(+), 17 deletions(-) create mode 100644 tests/advisor-storm-flip-engine.test.ts create mode 100644 tests/fixture-advisor-storm-flip.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 0779f60c..4381ff42 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -2483,7 +2483,9 @@ export function GameTable({ ? 'The Homeworld transport and payment are complete. These advisors are now alone and must become fighters before any remaining arrival.' : g.response.automaticAdvisorFlip.producer === 'faceDance' ? 'Face Dance is complete: the winning army has returned, the replacement is placed and the Face Dancer is revealed. These surviving advisors are now alone and must become fighters before aftermath continues.' - : 'The ground movement is complete. These advisors are now alone and must become fighters.'} + : g.response.automaticAdvisorFlip.producer === 'storm' + ? `Storm casualties in sector ${g.response.automaticAdvisorFlip.storm.sector} are complete. These surviving advisors are now alone and must become fighters before the remaining storm traversal.` + : 'The ground movement is complete. These advisors are now alone and must become fighters.'} {' '}Karama prevents only this conversion; the completed action and its costs stay committed. {' '}Provisional rule: a prevented group stays advisors while continuously alone, until another faction returns or the group leaves. diff --git a/docs/ADVISOR_AUTOMATIC_FLIPS.md b/docs/ADVISOR_AUTOMATIC_FLIPS.md index 634a2ed4..e8b5d218 100644 --- a/docs/ADVISOR_AUTOMATIC_FLIPS.md +++ b/docs/ADVISOR_AUTOMATIC_FLIPS.md @@ -1,5 +1,84 @@ # Mandatory advisor conversions +## Storm casualties before remaining traversal — 24 October 2026 + +**Prototyped / Partial.** Compose the supplied Advanced source's physical17 +group-flip counter with physical28 E's mandatory conversion when alone. A storm +can destroy the last outsider in one sector while advisors survive in another +sector of the same exposed territory. Capture the surviving group after the +actual sector's losses, before settlement can erase its advisor stance. +Advisors destroyed in that same sector leave no conversion opportunity. + +Under the 14 October recommended-policy authority, the selected priority is +after all current-sector casualties, including original Fremen allocation +choices, and before the next sector or final storm completion. This composition +is provisional, not a new combined publisher timing ruling. Initial CHOAM and +Fremen protection retain their original priority. Hold an already-alone group +through another territory's Fremen choice; an empty pending queue alone does +not mean the last popped casualty choice is answered. + +The phase0 public receipt contains exactly a fresh event, starting sector, +distance, traversed count and current sector. Bind the exact surviving physical +group to that occurrence. The private parent separately records whether all +current-sector choices are ready and the unchanged original traversal/source +cursor. Never project that cursor, a private forecast, protection state or +Fremen pending keys into the response or Karama source selection. + +Karama prevents only the BG conversion. The completed losses and spent card +stay committed, with the existing provisional continuously-alone hold. Consume +the parent before resuming the original traversal once, without replaying the +current sector. Remaining sectors can still destroy the surviving group, +whether it converted or remained advisors. Ecological Storm completion, +final position/order and the next phase's forecast keep their original place. + +[Native fixture](../tests/fixture-advisor-storm-flip.ts) starts a public Advanced +BG/Atreides/Emperor table with original base33, no Fremen or optional module. +Actual setup places BG's original counter in empty Oh Gap10; it initially +becomes a fighter. Actual first Storm dials2/3 move from1 to6. Atreides later +pays2 to ship one original reserve into Oh Gap9, and BG accepts the resulting +Intrusion and original advisor-flip response to become peaceful. Original +starting Karama remains in Atreides' hand. + +Actual first Mentat readiness draws the original turn-two Storm Card. Scoped +entropy selects only the original six-card shuffle, revealing3 or4 with its +native source signature and logged card face. No played force, phase, wallet, +hand, Tanks, stance or forecast is installed. Stop before the final real Storm +Ready. A three-sector traversal kills Atreides9 while BG10 survives; a +four-sector traversal must pause at9 and then destroy BG10 after the counter. +The same-sector variant earns the same Intrusion at9, then both forces die +without manufacturing a conversion. + +[Engine cases](../tests/advisor-storm-flip-engine.test.ts) cover both responses +for ending/continuing traversal, same-sector destruction, source mismatch and +all four existing legal profiles. The separately labelled controlled regression +in [storm/worm cancellation](../tests/storm-worm-cancellation.test.ts) checks +both outcomes after a real mixed Fremen casualty choice; its conserved placement +is not a native setup journey. Frozen **17 files220/220**, types/lint/build, +five native Storm/response/GameTable SSR journeys and eight existing-profile +paths pass. All five genuine sources take48 original actions. Ending traversal +finishes9 with BG10 alive; continuing traversal resumes from9 to10 and destroys +that counter after either conversion outcome. Atreides loses one force once; +same-sector placement loses both without offering a conversion. The original +Karama is spent once only when used. Standard controls render; this is not a +new callback/browser/mobile/deployed certificate. + +Initial220/220 passes; typecheck then catches an empty-array assertion narrowing +the native fixture's readiness array to never[]. An equivalent length assertion +repairs only the fixture. Final frozen220/220, types/lint/build and runtime pass +without changing rule mechanics or weakening checks. Private proof and native +HTML are in +`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-storm-flips/`. +`frozen-integrated-2/report.json` binds tree +`3efca2d3ef25d0090bc1c992411c4bbda0cf55265019306fed5761a4745221f9`, +2745 files on base `9959f1b347f1e0e5bc96c2468cc6cfd97d13d4f3`. +Prose/reference checkpoint overlays are separately reviewed and hashed after +proof. No saved-room/D1, reset, restart or deployment operation was performed. + +Broader Fremen/CHOAM/Jubba/Ecological Storm/Homeworld/Nexus combinations with +this conversion, special Ambassador/Discovery relocation and matching-group +interpretations remain open. These prototypes do not certify complete modes, +callbacks/browser/deployed behavior or final assurance. + ## Completed Face Dance replacement — 24 October 2026 **Prototyped / Partial.** Compose the same supplied diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 9c386db5..2f5266d6 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,30 @@ open mode gates. ## Current checkpoint and work +**Advisor conversion during storm traversal — 24 October. Prototyped / Partial.** +Current-sector losses now capture surviving alone advisors before the original +traversal continues. Retain them through unresolved Fremen casualty choices; +an empty pending queue alone does not mean ready. The selected post-casualty, +pre-next-sector priority is explicitly provisional. Original protection remains +earlier; final storm bookkeeping remains later. Public sector evidence omits +forecasts/protection/private continuation. Prevent only conversion and consume +the private parent before the original traversal resumes once. +[Source and native routes](ADVISOR_AUTOMATIC_FLIPS.md#storm-casualties-before-remaining-traversal--24-october-2026). + +Disjoint helper/native-fixture lanes, bounded continuation review and root +integration pass **17 files220/220**, types/lint/build, five native Storm/response/ +GameTable SSR journeys and eight legal existing-profile paths. Genuine48-action +public Advanced BG/Atreides/Emperor sources earn original base33, actual first +dials2/3, paid shipment/Intrusion and native turn-two Storm Cards3/4. Both +responses preserve Atreides' sector9 loss. Ending9 leaves BG10 alive; continuing +through10 then destroys it after either response. Same-sector destruction leaves +no conversion. A controlled mixed-Fremen case preserves original casualty +priority. Type feedback repairs only an empty-array fixture assertion. Wider +Fremen/CHOAM/Jubba/Ecological Storm/Homeworld/Nexus combinations, special +relocation and matching-group interpretation stay open. No AI refinement, +comprehensive/browser/deployed assurance or saved-room/D1/reset/restart/deployment +operation. + **Advisor conversion after Face Dance — 24 October. Prototyped / Partial.** Completed replacement now captures surviving alone advisors at remote board sources and zero-replacement targets. Original army return, replacement, leader @@ -40,8 +64,8 @@ Homeworld/Nexus combinations with this counter remain open. No AI refinement, comprehensive/browser/deployed assurance or saved-room/D1/reset/restart/deployment operation. -Next conversion work includes sector-specific storm losses and special -Ambassador/Discovery relocation. Matching-group interpretation and complete +Next conversion work includes special Ambassador/Discovery relocation. +Matching-group interpretation and complete Advanced/mode acceptance remain unfinished. **Advisor conversion after Homeworld/Junction transport — 24 October. Prototyped / Partial.** diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 147c7bda..e75af032 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,49 @@ # Dune implementation status +## 24 October 2026 — Advisor conversion between storm sectors + +Supplied physical17/28 E's counter now follows actual current-sector losses +that leave surviving advisors alone elsewhere in the same territory. Original +CHOAM/Fremen protection remains earlier. Preserve the group through unresolved +Fremen casualty choices, including when the last pending item has already been +popped. Under the 14 October authority, finishing all current-sector losses +before conversion and remaining traversal is explicit provisional composition. + +A public five-field event/from/distance/traversed/sector receipt selects only +current movement facts. The private parent separately binds readiness and the +original traversal/source cursor. Forecasts, protection and private continuation +stay out of the response and Karama source. Prevention changes only conversion; +retain actual losses and the existing continuously-alone hold. Consume the +parent before the next sector/final storm bookkeeping once. Later sectors can +still destroy either stance; a group destroyed in the same sector cannot flip. + +Disjoint helper/native-fixture lanes, bounded timing review and root integration +pass **17 files220/220**, types/lint/build, five native Storm/response/GameTable +SSR journeys and eight existing-profile paths. Each source takes48 original +public Advanced BG/Atreides/Emperor actions with base33, genuine first dials2/3, +paid shipment2, actual Intrusion and native turn-two Storm Cards3/4. Ending9 +kills the last Atreides while BG10 survives. Continuing resumes9→10 after both +response outcomes, killing BG once; same-sector9 kills both without conversion. +Original Karama is spent once only when used. A separately labelled conserved +controlled case checks original mixed-Fremen casualty priority in both outcomes. +No played force, phase, wallet, hand, Tanks, stance or forecast is installed in +the native sources. + +Initial220/220 passes; typecheck catches an empty-array fixture assertion's +never[] narrowing. Its equivalent length assertion repairs only that fixture; +final frozen220/220, quick, build and runtime pass without weakening rules or +checks. Private proof and actual-source HTML: +`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-storm-flips/`. +`frozen-integrated-2/report.json` binds tree +`3efca2d3ef25d0090bc1c992411c4bbda0cf55265019306fed5761a4745221f9`, +2745 files on base `9959f1b347f1e0e5bc96c2468cc6cfd97d13d4f3`. +Prose/reference overlays are separately reviewed/hashed. Standard controls render; +no new callback/browser/mobile/deployed or complete-mode claim. Broader +Fremen/CHOAM/Jubba/Ecological Storm/Homeworld/Nexus combinations with this counter, +special relocation, matching-group interpretation, comprehensive assurance and +AI refinement remain due. No saved-room/D1, reset, restart or deployment operation. +[Connected source and limits](ADVISOR_AUTOMATIC_FLIPS.md#storm-casualties-before-remaining-traversal--24-october-2026). + ## 24 October 2026 — Advisor conversion after completed Face Dance Supplied physical17/28 E's counter now follows physical38 A's completed native diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 26c6951c..5129f20d 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,20 @@ # Rule decision index +## Advisor conversion during storm traversal — 24 October 2026 + +Compose supplied Advanced physical17/28 E with actual current-sector storm +losses that leave advisors alive and alone elsewhere in the same territory. +Under the 14 October authority, provisionally finish all current-sector losses, +including Fremen allocation choices, before the conversion counter and remaining +traversal. Protection keeps its original earlier priority. Hold surviving +advisors through unresolved casualty choices; an empty queue is not sufficient. +Cancel only conversion, preserving actual losses and the existing continuously- +alone duration. A public five-field sector receipt omits forecasts, protection +and private continuation. Consume its private parent before traversing the next +sector or completing the storm once. Later sectors can still kill the group; +same-sector destruction creates no conversion. +[Source, continuation and limits](ADVISOR_AUTOMATIC_FLIPS.md#storm-casualties-before-remaining-traversal--24-october-2026). + ## Advisor conversion after Face Dance — 24 October 2026 Compose supplied Advanced physical17/28 E with physical38 A's completed diff --git a/game/advisor-automatic-flip.ts b/game/advisor-automatic-flip.ts index 31f66d01..e2752a91 100644 --- a/game/advisor-automatic-flip.ts +++ b/game/advisor-automatic-flip.ts @@ -1,6 +1,7 @@ import type { FactionId } from './catalog'; import { gameTerritories, splitLocation, type MobileBoard } from './board'; import { presenceAt, type ForcePresence } from './force-presence'; +import { stormExposesTerritory, stormSectorAfter } from './disaster-rules'; export type AdvisorStance = { lockedTurn?: number; @@ -59,6 +60,14 @@ export type AutomaticAdvisorFaceDance = { returned: Record; }; +export type AutomaticAdvisorStorm = { + event: string; + from: number; + distance: number; + traversed: number; + sector: number; +}; + export type AutomaticAdvisorFlipSource = { event: string; owner: string; @@ -73,6 +82,7 @@ export type AutomaticAdvisorFlipSource = { | { producer: 'gamont'; gamont: AutomaticAdvisorGamont } | { producer: 'battleLosses'; battle: AutomaticAdvisorBattle } | { producer: 'faceDance'; faceDance: AutomaticAdvisorFaceDance } + | { producer: 'storm'; storm: AutomaticAdvisorStorm } ); export type AutomaticAdvisorFlipPlayer = ForcePresence & { @@ -104,6 +114,14 @@ export type AutomaticAdvisorFlipContext = AdvisorSettlementContext & MobileBoard } | null; battle?: unknown; homeworlds?: unknown; + storm?: number; + shieldWallDestroyed?: boolean; + stormResolution?: { + from: number; + distance: number; + traversed: number; + pending: readonly string[]; + } | null; }; export class AutomaticAdvisorFlipError extends Error { @@ -137,7 +155,7 @@ function otherPresence(ctx: AdvisorSettlementContext, player: AutomaticAdvisorFl function currentWindow(ctx: AutomaticAdvisorFlipContext) { return ctx.advanced === true && ctx.status === 'playing' && - (ctx.phase === 5 || ctx.phase === 6 || ctx.phase === 8) && positive(ctx.turn); + (ctx.phase === 0 || ctx.phase === 5 || ctx.phase === 6 || ctx.phase === 8) && positive(ctx.turn); } /** A supported committed producer opens a response for these current groups. @@ -367,6 +385,29 @@ function validateFaceDanceSource( } } +function validateStormSource( + ctx: AutomaticAdvisorFlipContext, + source: Extract, +) { + const receipt = source.storm; + requireFlip(record(receipt) && + Object.keys(receipt).sort().join(',') === 'distance,event,from,sector,traversed' && + identifier(receipt.event) && nonnegative(receipt.from) && receipt.from <= 18 && + ctx.storm === receipt.from && positive(receipt.distance) && receipt.distance <= 40 && + positive(receipt.traversed) && receipt.traversed <= receipt.distance && + positive(receipt.sector) && receipt.sector <= 18 && + receipt.sector === stormSectorAfter(receipt.from, receipt.traversed), + 'Automatic advisor conversion lost its exact public current-sector storm receipt.'); + const current = ctx.stormResolution; + requireFlip(record(current) && current.from === receipt.from && current.distance === receipt.distance && + current.traversed === receipt.traversed && Array.isArray(current.pending) && current.pending.length === 0, + 'Automatic advisor conversion must finish the original current-sector storm casualties first.'); + const place = gameTerritories(ctx).find((candidate) => candidate.id === source.territory); + requireFlip(place && place.sectors.includes(receipt.sector) && + stormExposesTerritory(place.id, ctx.shieldWallDestroyed === true), + 'Automatic advisor conversion needs its current exposed storm territory.'); +} + /** Validate the same committed producer and exact held group before either response outcome. */ export function validateAutomaticAdvisorFlip( ctx: AutomaticAdvisorFlipContext, @@ -384,10 +425,12 @@ export function validateAutomaticAdvisorFlip( (source.producer === 'battleLosses' && Object.keys(source).sort().join(',') === 'battle,event,forces,owner,phase,producer,territory,turn') || (source.producer === 'faceDance' && - Object.keys(source).sort().join(',') === 'event,faceDance,forces,owner,phase,producer,territory,turn')) && + Object.keys(source).sort().join(',') === 'event,faceDance,forces,owner,phase,producer,territory,turn') || + (source.producer === 'storm' && + Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,storm,territory,turn')) && identifier(source.event) && identifier(source.owner) && identifier(source.territory) && positive(source.turn) && source.turn === ctx.turn && - source.phase === (source.producer === 'gamont' ? 8 : + source.phase === (source.producer === 'storm' ? 0 : source.producer === 'gamont' ? 8 : source.producer === 'battleLosses' || source.producer === 'faceDance' ? 6 : 5) && currentWindow(ctx) && source.phase === ctx.phase, 'Automatic advisor conversion lost its original current-turn committed producer.'); @@ -406,7 +449,8 @@ export function validateAutomaticAdvisorFlip( else if (source.producer === 'homeworldTransport') validateHomeworldTransportSource(ctx, source); else if (source.producer === 'gamont') validateGamontSource(ctx, source); else if (source.producer === 'battleLosses') validateBattleSource(ctx, source); - else validateFaceDanceSource(ctx, source); + else if (source.producer === 'faceDance') validateFaceDanceSource(ctx, source); + else validateStormSource(ctx, source); const forces = physicalGroup(owner, source.territory); requireFlip(record(source.forces) && Object.keys(forces).length > 0 && Object.values(forces).every(positive) && Object.values(source.forces).every(positive) && diff --git a/game/engine.ts b/game/engine.ts index 0e522d77..16dae753 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -12,7 +12,7 @@ import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, h import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; import { quoteAtreidesFullPlan } from './atreides-full-plan'; import { quoteTleilaxuSpecialKarama } from './tleilaxu-special-karama'; -import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont, type AutomaticAdvisorBattle, type AutomaticAdvisorHomeworldTransport, type AutomaticAdvisorFaceDance } from './advisor-automatic-flip'; +import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont, type AutomaticAdvisorBattle, type AutomaticAdvisorHomeworldTransport, type AutomaticAdvisorFaceDance, type AutomaticAdvisorStorm } from './advisor-automatic-flip'; import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; import { isStormCardDistance, type StormCardComponent } from './storm-cards'; import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills'; @@ -1277,6 +1277,7 @@ export type Game = { signature: string; }; pendingAutomaticAdvisorFaceDance?: AutomaticAdvisorFaceDance; + pendingAutomaticAdvisorStorm?: { receipt: AutomaticAdvisorStorm; ready: boolean; cursor?: string }; advisorPreparation?: AdvisorPreparationWindow; ecazOccupyPreview?: boolean; /** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */ @@ -11539,6 +11540,8 @@ function continueStorm(g: Game) { if (g.decision) return; } } + // All current-sector casualties finish before conversion or the next step. + if (openStormAdvisorFlips(g)) return; if (resolution.traversed === resolution.distance) break; resolution.traversed++; const s = stormSectorAfter(resolution.from, resolution.traversed); @@ -11578,6 +11581,8 @@ function continueStorm(g: Game) { } for (const key of Object.keys(g.spice)) if (splitLocation(key).sector === s) delete g.spice[key]; + // Retain surviving advisor stances through any pending Fremen choices. + prepareStormAdvisorFlips(g); } if (g.ecologicalStorm?.stage === 'traversal') g.ecologicalStorm = nexusRule(() => finishDiscoveryStorm(g.ecologicalStorm!)); @@ -20329,6 +20334,12 @@ function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) { 'The mandatory conversion has lost its original completed Face Dance.'); validateCompletedFaceDance(g, parent); } + if (source.producer === 'storm') { + const parent = g.pendingAutomaticAdvisorStorm; + requireRule(parent && JSON.stringify(source.storm) === JSON.stringify(parent.receipt), + 'The mandatory conversion has lost its original storm sector.'); + validateStormAdvisorCursor(g, parent); + } automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source)); return source; } @@ -20338,6 +20349,7 @@ function openAutomaticAdvisorFlip(g: Game) { if (!source) return; // Hold immediate losses through the original mandatory discard/casualty work. if (source.producer === 'battleLosses' && !g.pendingAutomaticAdvisorBattle?.resume) return; + if (source.producer === 'storm' && !g.pendingAutomaticAdvisorStorm?.ready) return; automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source)); g.response = { kind: 'advisorFlip', owner: source.owner, location: source.territory, advisors: false, passed: [], automaticAdvisorFlip: source, @@ -20593,6 +20605,66 @@ function finishAutomaticAdvisorFaceDance(g: Game) { delete g.pendingAutomaticAdvisorFaceDance; finishBattle(g); } +function stormAdvisorCursor(g: Game) { + return JSON.stringify([g.stormResolution, g.stormMovementSource ?? null]); +} +function prepareStormAdvisorFlips(g: Game) { + const resolution = g.stormResolution; + if (!g.advanced || g.status !== 'playing' || g.phase !== 0 || !resolution || resolution.traversed <= 0) return; + const sector = stormSectorAfter(resolution.from, resolution.traversed); + const groups = automaticAdvisorRule(() => quoteAutomaticAdvisorFlips(g)).filter(group => + gameTerritories(g).some(place => place.id === group.territory && place.sectors.includes(sector) && + stormExposesTerritory(place.id, g.shieldWallDestroyed))); + if (!groups.length) return; + let parent = g.pendingAutomaticAdvisorStorm; + if (parent) requireRule(!parent.ready && parent.receipt.from === resolution.from && + parent.receipt.distance === resolution.distance && parent.receipt.traversed === resolution.traversed, + 'The storm conversion must retain its original current sector.'); + else { + requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport && + !g.pendingAutomaticAdvisorHomeworldTransport && !g.pendingAutomaticAdvisorGamont && + !g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorFaceDance, + 'Finish the original advisor conversion before another storm sector.'); + parent = { receipt: { event: crypto.randomUUID(), from: resolution.from, + distance: resolution.distance, traversed: resolution.traversed, sector }, ready: false }; + g.pendingAutomaticAdvisorStorm = parent; + } + for (const group of groups) { + const source: AutomaticAdvisorFlipSource = { event: crypto.randomUUID(), owner: group.player, + territory: group.territory, turn: g.turn, phase: g.phase, forces: group.forces, + producer: 'storm', storm: parent.receipt }; + getPlayer(g, group.player).advisors![group.territory].aloneFlipHold = source.event; + (g.pendingAutomaticAdvisorFlips ??= []).push(source); + } +} +function openStormAdvisorFlips(g: Game) { + prepareStormAdvisorFlips(g); + const parent = g.pendingAutomaticAdvisorStorm; + if (!parent) return false; + requireRule(!parent.ready && !g.response && !g.decision && g.stormResolution?.pending.length === 0 && + !!g.pendingAutomaticAdvisorFlips?.length, + 'Finish all original current-sector casualties before advisor conversion.'); + parent.ready = true; + parent.cursor = stormAdvisorCursor(g); + openAutomaticAdvisorFlip(g); + return true; +} +function validateStormAdvisorCursor(g: Game, parent: NonNullable) { + const resolution = g.stormResolution, receipt = parent.receipt; + requireRule(g.status === 'playing' && g.phase === 0 && parent.ready && resolution && + resolution.pending.length === 0 && resolution.from === receipt.from && + resolution.distance === receipt.distance && resolution.traversed === receipt.traversed && + parent.cursor === stormAdvisorCursor(g), + 'The mandatory conversion lost its original completed-sector storm cursor.'); +} +function finishAutomaticAdvisorStorm(g: Game) { + const parent = g.pendingAutomaticAdvisorStorm; + if (!parent) return; + requireRule(!g.pendingAutomaticAdvisorFlips?.length, 'Finish all original sector conversions before storm traversal.'); + validateStormAdvisorCursor(g, parent); + delete g.pendingAutomaticAdvisorStorm; + continueStorm(g); +} function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) { for (const entry of released) delete getPlayer(g, entry.player).advisors?.[entry.territory]; @@ -22613,7 +22685,7 @@ function decideSharedSpice(g: Game, id: string, action: Action) { function victory(g: Game, quote?: VictoryQuote) { requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport && !g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorHomeworldTransport && - !g.pendingAutomaticAdvisorFaceDance, + !g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm, 'Resolve mandatory advisor conversions before checking victory.'); quote ??= currentVictoryQuote(g); applyAdvisorReleases(g, quote.released); @@ -28435,7 +28507,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl } if (automaticAdvisorFlip) { if (canceled) - log(g, `${bg.name}'s mandatory conversion in ${territory(to).name} was prevented. The completed ${automaticAdvisorFlip.producer === 'guildTransport' || automaticAdvisorFlip.producer === 'homeworldTransport' ? 'transport and payment stay' : automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont effect and card use stay' : automaticAdvisorFlip.producer === 'battleLosses' ? 'battle losses and costs stay' : automaticAdvisorFlip.producer === 'faceDance' ? 'Face Dance replacement and reveal stay' : 'movement stays'} committed; this group remains advisors while continuously alone.`, + log(g, `${bg.name}'s mandatory conversion in ${territory(to).name} was prevented. The completed ${automaticAdvisorFlip.producer === 'guildTransport' || automaticAdvisorFlip.producer === 'homeworldTransport' ? 'transport and payment stay' : automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont effect and card use stay' : automaticAdvisorFlip.producer === 'battleLosses' ? 'battle losses and costs stay' : automaticAdvisorFlip.producer === 'faceDance' ? 'Face Dance replacement and reveal stay' : automaticAdvisorFlip.producer === 'storm' ? 'current-sector storm casualties stay' : 'movement stays'} committed; this group remains advisors while continuously alone.`, { faction: bg.faction, name: 'Mandatory advisor conversion prevented' }); g.pendingAutomaticAdvisorFlips!.shift(); if (!g.pendingAutomaticAdvisorFlips!.length) delete g.pendingAutomaticAdvisorFlips; @@ -28445,6 +28517,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl finishAutomaticAdvisorBattle(g); finishAutomaticAdvisorHomeworldTransport(g); finishAutomaticAdvisorFaceDance(g); + finishAutomaticAdvisorStorm(g); } else openAutomaticAdvisorFlip(g); } else finishAdvisorReaction(g, response); } diff --git a/game/karama-context.ts b/game/karama-context.ts index 355e9052..1b19d96a 100644 --- a/game/karama-context.ts +++ b/game/karama-context.ts @@ -186,6 +186,7 @@ const cancellationSources = { battle: response.automaticAdvisorFlip.producer === 'battleLosses' ? g.pendingAutomaticAdvisorBattle?.receipt ?? null : null, homeworld: response.automaticAdvisorFlip.producer === 'homeworldTransport' ? g.pendingAutomaticAdvisorHomeworldTransport?.receipt ?? null : null, faceDance: response.automaticAdvisorFlip.producer === 'faceDance' ? g.pendingAutomaticAdvisorFaceDance ?? null : null, + storm: response.automaticAdvisorFlip.producer === 'storm' ? g.pendingAutomaticAdvisorStorm?.receipt ?? null : null, group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null } : response.advisorPreparationSource ? { preparation: response.advisorPreparationSource, window: g.advisorPreparation ?? null } diff --git a/game/reference.ts b/game/reference.ts index 691eae76..4fff2269 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4320,7 +4320,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'After actual battle losses leave surviving bystanding advisors alone, Karama may prevent their conversion without undoing losses or costs. Mandatory card disposal, original skills and substitution finish first. Provisionally resolve this before optional winner-card cleanup and later aftermath, then resume the original cleanup once. An explosion that destroys the advisors creates no conversion.', 'Native Guild board-to-Homeworld and Junction board transfers can leave advisors alone after payment and physical transfer. Karama prevents only the resulting BG conversion; Junction permission and tariff stay immune. Original income resolves first, then any Junction board arrival resumes once. Original declaration snapshots stay private.', 'A completed Face Dance can leave advisors alone at remote board sources or, with zero replacement, at the battle target. Original force returns, replacement, leader death, revelation and any card cycle finish first. Karama can then prevent only the BG conversion; native Face Dance remains immune. Afterward the original aftermath resumes once.', - 'Provisional cancellation duration: a prevented advisor group stays advisors while continuously alone, including across phase and turn changes. Another faction returning or the group becoming empty ends that held occurrence. Later arrivals joining that same group match its stance. Other non-ground automatic conversions and matching-group flip counters remain unfinished.', + 'After a storm sector removes the last outsider, surviving advisors elsewhere in that territory must convert. Provisionally finish every current-sector casualty choice first, then offer Karama prevention before the remaining traversal. Completed losses stay committed. Later sectors can still destroy advisors or fighters; advisors destroyed in the same sector create no conversion.', + 'Provisional cancellation duration: a prevented advisor group stays advisors while continuously alone, including across phase and turn changes. Another faction returning or the group becoming empty ends that held occurrence. Later arrivals joining that same group match its stance. Special Ambassador/Discovery relocation and matching-group flip counters remain unfinished.', 'When another faction ships, moves or worm rides into your fighters, immediately choose whether to become advisors. The choice opens a Karama response. Repeated entry can offer a new choice.', 'After another faction’s off-planet shipment, choose a free force in the Polar Sink or an accompanying force at the shipment destination. An accompanying advisor group cannot flip to fighters in that turn while other factions remain.', 'After Spice Blow and Nexus, use Prepare advisors for battle during Charity, Bidding, Revival or before the first actual shipment. Quiet boundaries retain current mandatory priority. Each whole-territory flip has its original Karama response; its direct continuation preserves the current phase, auction, readiness and active movement. Declining the initial phase-five chooser leaves this window open. Ordinary allied presence, storm and fresh-advisor locks still restrict eligibility; the recorded supported Ecaz co-side composition remains separate.', @@ -4331,7 +4332,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "Implementation", "status": "Partial", - "detail": "Completed Face Dance now captures alone advisors at all positive board sources and its target, preserving return/replacement, leader death and revelation/cycling before one original aftermath. Its public physical/battle receipt omits Face Dancer identities and does not reuse pre-reveal availability. Existing ground, Guild/Homeworld/Junction transport, Gamont, battle and preparation sources retain their contracts. Storm and other special relocation/matching-group causes remain unfinished.", + "detail": "Storm traversal now captures surviving alone advisors after current-sector losses and retains them through original Fremen casualty choices. A public sector receipt binds a private ready/cursor parent; consume it before continuing once. Existing ground, transport, Gamont, battle and Face Dance contracts remain. Special relocation and matching-group causes remain unfinished.", "evidence": [ "game/advisor-automatic-flip.ts", "game/advisors.ts", @@ -4346,7 +4347,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "Player controls", "status": "Partial", - "detail": "Standard Karama/Allow controls distinguish completed Face Dance from the pending BG conversion. Existing transport, ground, Gamont, battle and phase2–5 preparation guidance remain. No new confirmation or timer.", + "detail": "Standard Karama/Allow controls identify completed current-sector casualties and the remaining traversal. Prevention keeps actual losses; later sectors may still destroy the group. Existing producer and preparation guidance remains, without a new confirmation or timer.", "evidence": [ "docs/ADVISOR_AUTOMATIC_FLIPS.md", "components/advisor-preparation.tsx", @@ -4357,8 +4358,11 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "AI", "status": "Partial", - "detail": "Eight existing-profile native remote/zero Face Dance response paths resolve legally through actual Collection, preserving completed return, death, revelation and replacement. Earlier producer paths retain separate evidence. No strategy refinement or difficulty calibration.", + "detail": "Eight existing-profile native ending/continuing Storm conversion paths resolve legally through the original next phase. Completed current-sector losses remain; a later sector can still destroy BG. Earlier producer paths retain separate evidence. No strategy refinement or difficulty calibration.", "evidence": [ + "tests/storm-worm-cancellation.test.ts", + "tests/fixture-advisor-storm-flip.ts", + "tests/advisor-storm-flip-engine.test.ts", "tests/advisor-face-dance-flip-engine.test.ts", "tests/fixture-advisor-face-dance-flip.ts", "tests/advisor-homeworld-flip-engine.test.ts", @@ -4375,7 +4379,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "Documentation", "status": "Partial", - "detail": "Physical17/28 E composes the group counter with physical38 A after completed return/replacement. Face Dance immunity, actual returns, remote sources, zero replacement, original card cycle and private holdings are distinguished from the BG response. Continuously-alone duration remains provisional; other producers/combinations stay explicit.", + "detail": "Physical17/28 E composes the mandatory counter with current-sector losses. The selected priority after all current-sector casualties and before remaining traversal is explicitly provisional, as is continuously-alone cancellation duration. Public sector facts stay separate from private forecasts/protection/continuation.", "evidence": [ "docs/ADVISOR_AUTOMATIC_FLIPS.md", "docs/ADVISOR_PREPARATION.md", @@ -4386,8 +4390,11 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "Verification", "status": "Partial", - "detail": "Face Dance passes frozen16-file201/201, types/lint/build, five native Face Dance/response/GameTable SSR journeys and eight profile paths. Genuine67/66-action sources earn original Ix47, native draw, shipment, moves and battle. Both outcomes preserve nine returned survivors and one leader death; actual BG city income is2/0. Original decline stays intact. A fixture deck-count correction changes no cards/rules. Full cycle/Skills/Ecaz co-side/Homeworld/Nexus combinations, storm/special relocation, complete modes and browser/mobile/deployed/comprehensive acceptance remain open.", + "detail": "Storm conversion passes frozen17-file220/220, types/lint/build, five native Storm/response/GameTable SSR journeys and eight profile paths. Genuine48-action sources earn base33, first dials, paid shipment/Intrusion and native turn-two Cards3/4. Ending9 preserves BG10; continuing10 destroys it after either response. Same-sector destruction has no conversion; controlled mixed-Fremen choices retain priority. Type feedback repairs only an empty-array fixture assertion. Wider combinations, special relocation/matching-group interpretation, complete modes and callback/browser/mobile/deployed/comprehensive acceptance remain open.", "evidence": [ + "tests/storm-worm-cancellation.test.ts", + "tests/fixture-advisor-storm-flip.ts", + "tests/advisor-storm-flip-engine.test.ts", "tests/advisor-face-dance-flip-engine.test.ts", "tests/fixture-advisor-face-dance-flip.ts", "tests/advisor-homeworld-flip-engine.test.ts", diff --git a/tests/advisor-automatic-flip-quote.test.ts b/tests/advisor-automatic-flip-quote.test.ts index a9454e99..392d5b4d 100644 --- a/tests/advisor-automatic-flip-quote.test.ts +++ b/tests/advisor-automatic-flip-quote.test.ts @@ -13,6 +13,7 @@ import { type AutomaticAdvisorBattle, type AutomaticAdvisorHomeworldTransport, type AutomaticAdvisorFaceDance, + type AutomaticAdvisorStorm, } from '../game/advisor-automatic-flip'; import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board'; @@ -22,6 +23,7 @@ type GamontSource = Extract; type BattleSource = Extract; type HomeworldSource = Extract; type FaceDanceSource = Extract; +type StormSource = Extract; function context(): AutomaticAdvisorFlipContext { return { @@ -72,7 +74,7 @@ void test('quotes exclude held, occupied and physically empty advisor groups whi ctx.players[1].forces = { ...ctx.players[1].forces, 'arrakeen:10': 1 }; assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), []); for (const patch of [ - { advanced: false }, { status: 'lobby' }, { phase: 0 }, { phase: 4 }, { phase: 7 }, + { advanced: false }, { status: 'lobby' }, { status: 'setup', phase: 0 }, { phase: 1 }, { phase: 4 }, { phase: 7 }, ]) { assert.deepEqual(quoteAutomaticAdvisorFlips({ ...context(), ...patch }), []); } @@ -1005,3 +1007,168 @@ void test('completed Face Dance validation never rereads availability, current d } assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source)); }); + +function storm() { + const ctx = context(); + ctx.phase = 0; + ctx.storm = 8; + ctx.stormResolution = { from: 8, distance: 2, traversed: 1, pending: [] }; + ctx.players[0].forces = { 'oh_gap:10': 2 }; + ctx.players[1].forces = {}; + ctx.players[2].forces = {}; + const receipt: AutomaticAdvisorStorm = { + event: 'public-current-sector-storm-event', from: 8, distance: 2, traversed: 1, sector: 9, + }; + const source: StormSource = { + event: 'automatic-after-current-sector-casualties', owner: 'b', territory: 'oh_gap', + turn: ctx.turn, phase: 0, forces: { 'oh_gap:10': 2 }, producer: 'storm', storm: receipt, + }; + ctx.players[0].advisors = { oh_gap: { lockedTurn: 4, aloneFlipHold: source.event } }; + return { ctx, source }; +} + +void test('the five-field public storm source binds current Oh Gap sector nine while surviving advisors remain in sector ten', () => { + const original = storm(); + const before = structuredClone(original); + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source)); + assert.deepEqual(original, before); + assert.deepEqual(Object.keys(original.source).sort(), ['event', 'forces', 'owner', 'phase', 'producer', 'storm', 'territory', 'turn']); + assert.deepEqual(Object.keys(original.source.storm).sort(), ['distance', 'event', 'from', 'sector', 'traversed']); + const quiet = storm().ctx; + quiet.players[0].advisors = { oh_gap: { lockedTurn: 4 } }; + assert.deepEqual(quoteAutomaticAdvisorFlips(quiet), [ + { player: 'b', territory: 'oh_gap', forces: { 'oh_gap:10': 2 } }, + ]); + quiet.status = 'setup'; + assert.deepEqual(quoteAutomaticAdvisorFlips(quiet), []); + assert.equal(advisorStanceAfterSettlement({ advanced: true, players: quiet.players }, 'b', 'oh_gap'), undefined); + for (const from of [0, 18]) { + const wrapped = storm(); + wrapped.ctx.storm = from; + wrapped.source.storm = { ...wrapped.source.storm, from, distance: 40, traversed: 9, sector: 9 }; + wrapped.ctx.stormResolution = { from, distance: 40, traversed: 9, pending: [] }; + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(wrapped.ctx, wrapped.source)); + } +}); + +void test('storm conversion rejects stale public cursor, unfinished current-sector casualties and malformed traversal bounds', () => { + const changes: ((ctx: AutomaticAdvisorFlipContext, source: StormSource) => void)[] = [ + (ctx) => { ctx.storm = 9; }, + (ctx) => { delete ctx.storm; }, + (ctx) => { ctx.stormResolution = null; }, + (ctx) => { delete ctx.stormResolution; }, + (ctx) => { ctx.stormResolution!.from = 9; }, + (ctx) => { ctx.stormResolution!.distance = 3; }, + (ctx) => { ctx.stormResolution!.traversed = 2; }, + (ctx) => { ctx.stormResolution!.pending = ['private-fremen-casualty-key']; }, + (ctx) => { ctx.status = 'setup'; }, + (ctx) => { ctx.turn++; }, + (_ctx, source) => { source.storm.event = ''; }, + (_ctx, source) => { source.storm.from = -1; }, + (_ctx, source) => { source.storm.from = 19; }, + (_ctx, source) => { source.storm.from = 8.5; }, + (_ctx, source) => { source.storm.distance = 0; }, + (_ctx, source) => { source.storm.distance = 41; }, + (_ctx, source) => { source.storm.distance = 2.5; }, + (_ctx, source) => { source.storm.traversed = 0; }, + (_ctx, source) => { source.storm.traversed = 3; }, + (_ctx, source) => { source.storm.traversed = 1.5; }, + (_ctx, source) => { source.storm.sector = 0; }, + (_ctx, source) => { source.storm.sector = 19; }, + (_ctx, source) => { source.storm.sector = 10; }, + ]; + for (const change of changes) { + const original = storm(); + change(original.ctx, original.source); + const before = structuredClone(original); + assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError); + assert.deepEqual(original, before); + } + const { ctx, source } = storm(); + for (const malformed of [ + { ...source, storm: null }, + { ...source, storm: { ...source.storm, sector: '9' } }, + { ...source, storm: { ...source.storm, forecast: 2 } }, + { ...source, protected: true }, + ]) { + assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as unknown as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError); + } +}); + +void test('protected Imperial Basin and unrelated or unknown territories cannot supply a storm occurrence', () => { + const imperial = storm(); + imperial.source.territory = 'imperial_basin'; + imperial.source.forces = { 'imperial_basin:10': 2 }; + imperial.ctx.players[0].forces = { 'imperial_basin:10': 2 }; + imperial.ctx.players[0].advisors = { imperial_basin: { aloneFlipHold: imperial.source.event } }; + assert.throws(() => validateAutomaticAdvisorFlip(imperial.ctx, imperial.source), AutomaticAdvisorFlipError); + imperial.ctx.shieldWallDestroyed = true; + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(imperial.ctx, imperial.source)); + for (const territory of ['arrakeen', 'missing']) { + const invalid = storm(); + invalid.source.territory = territory; + invalid.source.forces = { [`${territory}:10`]: 2 }; + invalid.ctx.players[0].forces = { [`${territory}:10`]: 2 }; + invalid.ctx.players[0].advisors = { [territory]: { aloneFlipHold: invalid.source.event } }; + invalid.ctx.shieldWallDestroyed = true; + assert.throws(() => validateAutomaticAdvisorFlip(invalid.ctx, invalid.source), AutomaticAdvisorFlipError); + } +}); + +void test('later storm traversal cannot reuse the old occurrence or restore an advisor group killed in its next sector', () => { + const { ctx, source } = storm(); + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source)); + ctx.stormResolution!.traversed = 2; + assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError); + ctx.players[0].forces = {}; + const before = structuredClone({ ctx, source }); + assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), []); + assert.equal(advisorStanceAfterSettlement(ctx, 'b', 'oh_gap'), undefined); + assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError); + assert.deepEqual({ ctx, source }, before); +}); + +void test('storm public validation never reads forecasts, protection choices, private cards or pending Fremen keys', () => { + const { ctx, source } = storm(); + for (const field of ['stormCard', 'stormCardKnown', 'stormForecast', 'discoveryChoices', 'pendingAutomaticAdvisorStorm']) { + Object.defineProperty(ctx, field, { + get() { throw new Error(`Storm advisor rule read private or future ${field}`); }, + }); + } + for (const field of ['protected', 'choamProtected', 'discoveryChoices', 'forecast']) { + Object.defineProperty(ctx.stormResolution, field, { + get() { throw new Error(`Storm advisor rule read private cursor ${field}`); }, + }); + } + for (const player of ctx.players) { + for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'stormForecast']) { + Object.defineProperty(player, field, { + get() { throw new Error(`Storm advisor rule read ${player.id} private ${field}`); }, + }); + } + } + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source)); + const pending = ['private-fremen-key']; + Object.defineProperty(pending, '0', { + get() { throw new Error('Storm advisor rule inspected a pending Fremen key'); }, + }); + ctx.stormResolution!.pending = pending; + assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError); +}); + +void test('storm stays phase-zero-only while every existing producer keeps its original receipt and phase', () => { + for (const original of [held(), transported(), gamont(false), battleResult(), homeworldTransport(), faceDance()]) { + const before = JSON.stringify(original.source); + assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source)); + assert.equal(JSON.stringify(original.source), before); + original.ctx.phase = 0; + original.source.phase = 0; + assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError); + } + for (const phase of [5, 6, 8]) { + const { ctx, source } = storm(); + ctx.phase = phase; + source.phase = phase; + assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError); + } +}); diff --git a/tests/advisor-storm-flip-engine.test.ts b/tests/advisor-storm-flip-engine.test.ts new file mode 100644 index 00000000..725a68d5 --- /dev/null +++ b/tests/advisor-storm-flip-engine.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; +import { isAdvisor, fighterCount } from '../game/advisors'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { + createAdvisorStormFlipFixture as fixture, actAdvisorStormFlip as act, + commitAdvisorStormFlip as commit, finishAdvisorStormFlip as finish, + advisorStormFlipPlayer as player, advisorStormFlipInventory as inventory, +} from './fixture-advisor-storm-flip'; + +function allow(g: Game) { + for (let count = 0; g.response?.automaticAdvisorFlip && count < 30; count++) { + const actor = g.players.find(p => !g.response!.passed.includes(p.id)); assert.ok(actor); + g = act(g, actor.id, { type: 'passResponse' }); + } + assert.ok(!g.response?.automaticAdvisorFlip); return g; +} +function rejected(g: Game, actor: string, action: Action) { + const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before); +} +const physical = (g: Game) => g.players.map(p => ({ id: p.id, forces: p.forces, + reserves: p.reserves, tanks: p.tanks, spice: p.spice })); + +for (const continues of [false, true]) for (const canceled of [false, true]) { + void test(`native ${continues ? 'continuing' : 'ending'} storm ${canceled ? 'prevention' : 'allowance'} preserves current losses and resumes only remaining sectors`, () => { + const f = fixture({ continues }), before = structuredClone(f.game), pending = commit(f.game, f).game; + assert.deepEqual(f.game, before); + assert.equal(pending.phase, 0); assert.equal(pending.storm, f.from); + assert.equal(pending.stormResolution?.traversed, 3); assert.deepEqual(pending.stormResolution?.pending, []); + assert.equal(pending.decision, null); + const source = pending.response?.automaticAdvisorFlip; assert.ok(source?.producer === 'storm'); + assert.equal(source.storm.from, f.from); assert.equal(source.storm.distance, f.distance); + assert.equal(source.storm.traversed, 3); assert.equal(source.storm.sector, 9); + assert.deepEqual(Object.keys(source.storm).sort(), ['distance', 'event', 'from', 'sector', 'traversed']); + assert.deepEqual(pending.stormMovementSource, f.stormSource); + assert.equal(player(pending, f.holder).forces[f.outsiderLocation], undefined); + assert.equal(player(pending, f.holder).tanks, player(before, f.holder).tanks + 1); + assert.equal(player(pending, f.bg).forces[f.location], 1); + assert.equal(fighterCount(player(pending, f.bg), f.territory), 0); + assert.equal(pending.pendingAutomaticAdvisorStorm?.ready, true); + assert.ok(viewGame(pending, f.holder).responseControls?.cancelCards.includes(f.karama)); + assert.ok(!JSON.stringify(viewGame(pending, f.holder).response).includes('cursor')); + const committed = physical(pending); + for (let read = 0; read < 3; read++) { + const stable = normalizeAutomaticGame(pending); + assert.deepEqual(stable.stormResolution, pending.stormResolution); + assert.deepEqual(physical(stable), committed); + assert.equal(fighterCount(player(stable, f.bg), f.territory), 0); + } + let done = canceled + ? act(pending, f.holder, { type: 'card', card: f.karama, mode: 'cancel' }) : allow(pending); + assert.equal(done.pendingAutomaticAdvisorStorm, undefined); assert.equal(done.pendingAutomaticAdvisorFlips, undefined); + done = finish(done); + assert.equal(done.phase, 1); assert.equal(done.storm, continues ? 10 : 9); + assert.equal(done.stormResolution, null); assert.equal(done.stormMovementSource, undefined); + assert.equal(player(done, f.holder).tanks, player(before, f.holder).tanks + 1); + assert.equal(player(done, f.holder).forces[f.outsiderLocation], undefined); + assert.equal(player(done, f.bg).forces[f.location] ?? 0, continues ? 0 : 1); + assert.equal(player(done, f.bg).tanks, player(before, f.bg).tanks + (continues ? 1 : 0)); + assert.equal(isAdvisor(player(done, f.bg), f.territory), !continues && canceled); + assert.equal(done.log.filter(entry => entry.text === `Storm moved ${f.distance} sectors to sector ${continues ? 10 : 9}.`).length, 1); + assert.equal(done.discard.filter(c => c.id === f.karama).length, canceled ? 1 : 0); + assert.deepEqual(inventory(done), f.originalInventory); + }); +} + +void test('a native same-sector storm destroys both counters without manufacturing a conversion', () => { + const f = fixture({ sameSector: true }), done = finish(commit(f.game, f).game); + assert.equal(done.pendingAutomaticAdvisorStorm, undefined); assert.equal(done.pendingAutomaticAdvisorFlips, undefined); + assert.equal(done.response?.automaticAdvisorFlip, undefined); + assert.equal(player(done, f.bg).forces[f.location], undefined); + assert.equal(player(done, f.bg).tanks, player(f.game, f.bg).tanks + 1); + assert.equal(player(done, f.holder).tanks, player(f.game, f.holder).tanks + 1); + assert.ok(player(done, f.holder).hand.some(c => c.id === f.karama)); + assert.equal(done.phase, 1); assert.equal(done.storm, 9); + assert.deepEqual(inventory(done), f.originalInventory); +}); + +void test('a changed private storm cursor rejects before a conversion counter spends its card', () => { + const f = fixture(), pending = commit(f.game, f).game; + pending.pendingAutomaticAdvisorStorm!.cursor = 'different-original-sector'; + rejected(pending, f.holder, { type: 'card', card: f.karama, mode: 'cancel' }); + assert.ok(player(pending, f.holder).hand.some(c => c.id === f.karama)); + assert.equal(player(pending, f.holder).tanks, player(f.game, f.holder).tanks + 1); +}); + +void test('all four existing profiles resolve ending and continuing storm conversions legally', () => { + for (const continues of [false, true]) for (const profile of DIFFICULTIES) { + const f = fixture({ continues }), pending = commit(f.game, f).game, own = viewGame(pending, f.holder); + own.players.find(p => p.id === f.holder)!.bot = profile; + const actions = botActions(own); assert.ok(actions.length); + const done = finish(allow(act(pending, f.holder, actions[0]))); + assert.equal(done.pendingAutomaticAdvisorStorm, undefined); + assert.equal(done.phase, 1); assert.equal(done.storm, continues ? 10 : 9); + assert.equal(player(done, f.holder).tanks, player(pending, f.holder).tanks); + assert.deepEqual(inventory(done), f.originalInventory); + } +}); diff --git a/tests/fixture-advisor-storm-flip.ts b/tests/fixture-advisor-storm-flip.ts new file mode 100644 index 00000000..47730451 --- /dev/null +++ b/tests/fixture-advisor-storm-flip.ts @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { + actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory, + advisorPreparationPlayer, nextAdvisorPreparationStep, +} from './fixture-advisor-preparation'; + +export type AdvisorStormFlipStep = { actor: string; action: Action }; +export type AdvisorStormFlipTransition = { before: Game; steps: AdvisorStormFlipStep[]; after: Game }; +export const actAdvisorStormFlip = actAdvisorPreparation; +export const advisorStormFlipClean = advisorPreparationClean; +export const advisorStormFlipInventory = advisorPreparationInventory; +export const advisorStormFlipPlayer = advisorPreparationPlayer; + +/** Native first Storm uses real 2/3 dials. Setup's empty Oh Gap group becomes + * fighters normally; later original Intrusion really changes it to advisors. */ +export function nextAdvisorStormFlip(game: Game): AdvisorStormFlipStep | null { + if (game.response?.automaticAdvisorFlip) return null; + if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) { + if (game.status === 'setup' && game.setupStage === 'forces' && + !advisorStormFlipPlayer(game, 'beneGesserit').advisorSetup) + return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'oh_gap', sector: 10 } }; + if (game.status === 'playing' && game.turn === 1 && game.phase === 0 && game.stormPending === null) { + const actor = game.stormDialers.find(id => game.stormDials[id] === undefined); + if (actor) return { actor, action: { type: 'stormDial', amount: game.stormDialers.indexOf(actor) === 0 ? 2 : 3 } }; + } + if (game.decision?.kind === 'intrusion') + return { actor: game.decision.player, action: { type: 'decision', accept: true } }; + if (game.decision?.kind === 'advisorBattle') + return { actor: game.decision.player, action: { type: 'decision', accept: false } }; + } + return nextAdvisorPreparationStep(game); +} +export function advanceAdvisorStormFlip(game: Game, until: (state: Game) => boolean, + steps: AdvisorStormFlipStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The genuine Storm chronology ended before its requested boundary'); + const next = nextAdvisorStormFlip(game); + assert.ok(next, `Choose the original advisor conversion at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actAdvisorStormFlip(game, next.actor, next.action, steps); + } + throw Error('The native advisor Storm source did not reach its finite boundary'); +} +export function passAdvisorStormFlip(game: Game, steps: AdvisorStormFlipStep[] = []): Game { + for (let count = 0; game.response && !game.response.automaticAdvisorFlip && count < 60; count++) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); + game = actAdvisorStormFlip(game, actor.id, { type: 'passResponse' }, steps); + } + assert.ok(!game.response || game.response.automaticAdvisorFlip); return game; +} + +/** Select just the six original Storm Cards' native Fisher-Yates shuffle on + * the real last Mentat readiness action. No Storm Card or clock is assigned. */ +function drawOriginalStormCard(game: Game, step: AdvisorStormFlipStep, distance: 3 | 4, + steps: AdvisorStormFlipStep[]) { + assert.equal(game.phase, 8); assert.equal(game.turn, 1); assert.ok(advisorStormFlipClean(game)); + assert.equal(game.ready.length, game.players.length - 1); assert.equal(step.action.type, 'ready'); + const original = [1, 2, 3, 4, 5, 6], order = [distance, ...original.filter(value => value !== distance)]; + const current = [...original], scalars: number[] = []; + for (let index = current.length - 1; index > 0; index--) { + const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index); + scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1))); + [current[index], current[from]] = [current[from], current[index]]; + } + assert.deepEqual(current, order); + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) { + assert.ok(cursor < scalars.length, 'Only the original turn-two Storm shuffle consumes selected entropy'); + array[0] = scalars[cursor++]; + } else Reflect.apply(native, crypto, [array]); + return array; + }; + try { + const next = applyAction(game, step.actor, step.action); + assert.equal(cursor, scalars.length, 'The real six-card Storm shuffle consumes its original permutation once'); + steps.push(structuredClone(step)); return { game: next, original, order, scalars }; + } finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +/** Public Advanced BG/Atreides/Emperor base33, no Fremen or optional module. + * Original setup, first dials, paid shipment and Intrusion earn a split-sector + * peaceful group. Stop before the last actual turn-two Storm Ready action. */ +export function createAdvisorStormFlipFixture(options: { continues?: boolean; sameSector?: boolean } = {}) { + const continues = options.continues ?? false, distance: 3 | 4 = continues ? 4 : 3; + const sameSector = options.sameSector ?? false; + const bg = 'beneGesserit', holder = 'atreides', emperor = 'emperor'; + const territory = 'oh_gap', location = sameSector ? 'oh_gap:9' : 'oh_gap:10', outsiderLocation = 'oh_gap:9'; + let game = createGame('ADVISORSTORMFLIP', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true); + joinGame(game, newPlayer(holder, 'Atreides', 'atreides')); + joinGame(game, newPlayer(emperor, 'Emperor', 'emperor')); + const initial = structuredClone(game), steps: AdvisorStormFlipStep[] = []; + const act = (actor: string, action: Action) => { game = actAdvisorStormFlip(game, actor, action, steps); }; + const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorStormFlip(game, until, steps); }; + const transition = (run: () => void): AdvisorStormFlipTransition => { + const before = structuredClone(game), start = steps.length; run(); + return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) }; + }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true }); + const setup = structuredClone(game); + assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.deepEqual(game.expansions, []); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + assert.ok(game.players.every(player => !player.hand.length)); + const originalInventory = advisorStormFlipInventory(game); assert.equal(originalInventory.length, 33); + const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama); + const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2); + assert.equal(ordinary.length, 2); + const selected = [ordinary[0], karama, ordinary[1]]; + const dealOrder = selected.map((card, position) => { + const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: card.id }; + }); + const beforeStartingDeal = structuredClone(game); + let advisorSetup: AdvisorStormFlipTransition | null = null; + for (let count = 0; game.status === 'setup' && count < 100; count++) { + const next = nextAdvisorStormFlip(game); assert.ok(next); + const before = next.action.type === 'advisorSetup' ? structuredClone(game) : null; + if (before && sameSector) next.action = { type: 'advisorSetup', territory, sector: 9 }; + act(next.actor, next.action); + if (before) advisorSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) }; + } + assert.equal(game.status, 'playing'); assert.ok(advisorSetup); + const afterSetup = structuredClone(game); + assert.equal(game.storm, 1); + assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id)); + assert.equal(advisorStormFlipPlayer(game, bg).forces[location], 1); + assert.ok(!advisorStormFlipPlayer(game, bg).advisors?.[territory]); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder = [0, 1].map(position => { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery); + assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; + game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) }; + }); + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); + const firstStorm = transition(() => { + reach(state => state.turn === 1 && state.phase === 1 && state.storm === 6); + }); + const firstStormDialSteps = firstStorm.steps.filter(step => step.action.type === 'stormDial'); + assert.deepEqual(firstStormDialSteps.map(step => step.action.amount), [2, 3]); + reach(state => state.turn === 1 && state.phase === 5 && state.active === bg && advisorStormFlipClean(state)); + const beforeMovement = structuredClone(game); + const bgEnd = transition(() => { + act(bg, { type: 'endMovement' }); + reach(state => state.phase === 5 && state.active === holder && advisorStormFlipClean(state)); + }); + const shipment = transition(() => { + act(holder, { type: 'ship', territory, sector: 9, amount: 1, elite: 0, allyPayment: 0 }); + reach(state => !state.phaseOpening && !state.response && state.decision?.kind === 'intrusion'); + }); + const beforeIntrusion = structuredClone(game); + const intrusion = transition(() => { + act(bg, { type: 'decision', accept: true }); + assert.equal(game.response?.kind, 'advisorFlip'); assert.equal(game.response?.advisors, true); + reach(advisorStormFlipClean); + }); + assert.equal(advisorStormFlipPlayer(shipment.before, holder).spice - advisorStormFlipPlayer(game, holder).spice, 2); + assert.equal(advisorStormFlipPlayer(game, holder).forces[outsiderLocation], 1); + assert.equal(advisorStormFlipPlayer(game, bg).forces[location], 1); + assert.ok(advisorStormFlipPlayer(game, bg).advisors?.[territory]); + const afterIntrusion = structuredClone(game); + reach(state => state.turn === 1 && state.phase === 8 && advisorStormFlipClean(state)); + const mentat = structuredClone(game); + const mentatReadiness = transition(() => { + for (const player of game.players.slice(0, -1)) act(player.id, { type: 'ready' }); + }); + const drawStep: AdvisorStormFlipStep = { actor: game.players.at(-1)!.id, action: { type: 'ready' } }; + const beforeDraw = structuredClone(game), drawStart = steps.length; + const draw = drawOriginalStormCard(game, drawStep, distance, steps); game = draw.game; + const stormDraw: AdvisorStormFlipTransition = { + before: beforeDraw, steps: structuredClone(steps.slice(drawStart)), after: structuredClone(game), + }; + assert.equal(game.turn, 2); assert.equal(game.phase, 0); assert.equal(game.storm, 6); + assert.equal(game.stormPending, distance); assert.equal(game.stormMovementSource?.kind, 'card'); + assert.ok(advisorStormFlipClean(game)); assert.equal(game.ready.length, 0); + const stormSource = structuredClone(game.stormMovementSource); + const stormCardLog = structuredClone(game.log.findLast(entry => entry.component?.kind === 'stormCard' && entry.component.distance === distance)); + assert.ok(stormCardLog, 'The original native Storm Card reveal is actually logged'); + const beforeStormReadiness = structuredClone(game); + const stormReadiness = transition(() => { + for (const player of game.players.slice(0, -1)) act(player.id, { type: 'ready' }); + }); + const triggerActor = game.players.at(-1)!.id, action: Action = { type: 'ready' }; + assert.equal(game.ready.length, 2); assert.ok(!game.ready.includes(triggerActor)); + assert.equal(game.stormResolution, null); + assert.equal(advisorStormFlipPlayer(game, holder).forces[outsiderLocation], 1); + assert.equal(advisorStormFlipPlayer(game, bg).forces[location], 1); + assert.ok(advisorStormFlipPlayer(game, bg).advisors?.[territory]); + assert.ok(advisorStormFlipPlayer(game, holder).hand.some(card => card.id === karama.id)); + assert.deepEqual(advisorStormFlipInventory(game), originalInventory); + return { game, source: structuredClone(game), beforeAction: structuredClone(game), initial, lobby, setup, + beforeStartingDeal, afterSetup, advisorSetup, firstStorm, firstStormDialSteps, + beforeMovement, bgEnd, shipment, beforeIntrusion, intrusion, afterIntrusion, + mentat, mentatReadiness, beforeDraw, drawStep, stormDraw, beforeStormReadiness, stormReadiness, + originalStormCards: draw.original, stormCardOrder: draw.order, stormCardScalars: draw.scalars, + stormSource, stormCardLog, continues, sameSector, bg, owner: bg, holder, atreides: holder, emperor, + karama: karama.id, card: karama.id, territory, location, outsiderLocation, from: 6, distance, + triggerActor, actor: triggerActor, action, + triggerStep: { actor: triggerActor, action } satisfies AdvisorStormFlipStep, + steps, actions: steps, sourceActionCount: steps.length, stepCount: steps.length, + originalInventory, originalSpice, dealOrder, spiceOrder, + staging: [ + `Actual public Advanced BG/Atreides/Emperor ready/startPreview, original base33 and no Fremen or optional module. Original prediction/private Traitor choices and actual advisorSetup place BG’s original counter in empty exposed Oh Gap${sameSector ? 9 : 10}, which native setup normally makes a fighter.`, + 'Select only original scalar shuffle draws, three original unopened Treachery positions and two original unopened ordinary Spice positions. BG/Emperor receive ordinary cards; Atreides receives its original Karama. No played hand, force, phase, wallet, Tanks or stance is installed.', + 'Actually submit first native secret Storm dials two and three. Original Storm moves from sector1 to6 without crossing BG10. Both Spice blows, Charity, normal Bidding and Revival execute normally; actual original circle order finishes Emperor before BG’s movement turn.', + `BG actually ends movement; Atreides pays two spice to ship one original reserve to Oh Gap9. BG actually accepts the resulting original Intrusion, all standard advisorFlip responses allow it, and separate Spiritual Advisors accompaniment is declined. A1 at sector9 and BG’s original peaceful advisor1 at sector${sameSector ? 9 : 10} remain physical neighbours in one territory.`, + 'Actual remaining movement, peaceful Battle completion, Collection and first Mentat readiness run normally. Scope exactly the native six-card Storm shuffle on the real last Mentat Ready to reveal three/four, with its original source signature and real Storm Card log; no Storm Pending/Card/phase/forecast is assigned.', + sameSector + ? 'Actually submit two turn-two Storm Ready actions, stopping before traversal from6 through9 destroys both colocated counters without a surviving advisor conversion.' + : 'Actually submit only two of the three turn-two Storm Ready actions. Stop before the final original Ready triggers traversal from6: three sectors ends9 with BG10 safe; four sectors continues through10. The original counter, casualties, traversal cursor and later Spice reveal remain caller-owned outcomes.', + ], + }; +} +export type AdvisorStormFlipFixture = ReturnType; + +/** Submit only the final real Ready. The new conversion response is untouched. */ +export function commitAdvisorStormFlip(source: Game, fixture: AdvisorStormFlipFixture, + steps: AdvisorStormFlipStep[] = []) { + const before = structuredClone(source), start = steps.length; + const game = actAdvisorStormFlip(source, fixture.triggerActor, fixture.action, steps); + return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)), + resolution: structuredClone(game.stormResolution), source: structuredClone(game.stormMovementSource) }; +} +/** Call after the caller's conversion choice. Stop as soon as native traversal + * opens phase1; its ordinary windows may remain, and no Spice is revealed. */ +export function finishAdvisorStormFlip(source: Game, steps: AdvisorStormFlipStep[] = []): Game { + return advanceAdvisorStormFlip(source, game => game.phase === 1, steps); +} diff --git a/tests/storm-worm-cancellation.test.ts b/tests/storm-worm-cancellation.test.ts index 26490c5c..22b359ab 100644 --- a/tests/storm-worm-cancellation.test.ts +++ b/tests/storm-worm-cancellation.test.ts @@ -226,6 +226,67 @@ void test('printed counter-cancellation of BG storm protection restores genuine assert.equal(player(g, 'f').elites?.tanks, 1); assert.equal(g.stormResolution, null); }); +void test('controlled storm staging holds alone advisors until the original mixed Fremen casualties finish', () => { + for (const prevented of [false, true]) { + const f = fixture(); + // Controlled conserved placement, not an earned native setup journey: + // the existing mixed Red Chasm group still owns the real casualty choice. + const bg = player(f.g, 'b'), emperor = player(f.g, 'e'); + bg.forces['the_minor_erg:8'] = 1; + bg.reserves--; + bg.advisors = { the_minor_erg: {} }; + emperor.forces['the_minor_erg:7'] = 1; + emperor.reserves--; + + let g = pass(storm(f), 'stormProtection'); + assert.equal(player(g, 'e').forces['the_minor_erg:7'], undefined); + assert.equal(player(g, 'e').tanks, 1); + assert.equal(g.decision?.kind, 'stormLosses'); + if (g.decision?.kind !== 'stormLosses') assert.fail('The original Fremen casualty choice must remain first.'); + assert.equal(g.decision.key, 'red_chasm:7'); + assert.equal(g.decision.amount, 2); + assert.equal(g.response, null, 'The held conversion must not interrupt mandatory Fremen losses.'); + const held = g.pendingAutomaticAdvisorFlips?.find(source => + source.owner === 'b' && source.territory === 'the_minor_erg'); + assert.ok(held, 'The storm must capture the already-alone group before exposing the casualty choice.'); + assert.equal(held.producer, 'storm'); + assert.equal(held.phase, 0); + assert.deepEqual(held.forces, { 'the_minor_erg:8': 1 }); + assert.equal(player(g, 'b').advisors?.the_minor_erg?.aloneFlipHold, held.event); + assert.equal(g.stormResolution?.traversed, 1); + assert.equal(g.storm, 6); + assert.deepEqual(g.stormResolution?.pending, [], 'The last casualty choice has already popped its queue item.'); + assert.equal(g.pendingAutomaticAdvisorStorm?.ready, false, 'An unresolved casualty choice is not a ready sector.'); + + g = act(g, 'f', { type: 'decision', elite: 1 }); + assert.equal(g.decision, null); + assert.equal(g.response?.kind, 'advisorFlip'); + assert.equal(g.response?.automaticAdvisorFlip?.event, held.event); + assert.equal(g.pendingAutomaticAdvisorStorm?.ready, true); + assert.deepEqual(g.stormResolution?.pending, []); + assert.equal(g.stormResolution?.traversed, 1); + assert.equal(player(g, 'f').forces['red_chasm:7'], 2); + assert.equal(player(g, 'f').elites?.forces['red_chasm:7'], 1); + assert.equal(player(g, 'f').tanks, 2); + assert.equal(player(g, 'f').elites?.tanks, 1); + const physical = g.players.map(p => ({ id: p.id, forces: structuredClone(p.forces), + reserves: p.reserves, tanks: p.tanks, elites: structuredClone(p.elites) })); + + g = prevented ? cancel(g, f, false) : pass(g, 'advisorFlip'); + assert.notEqual(g.response?.kind, 'advisorFlip'); + assert.equal(g.storm, 7); + assert.equal(g.stormResolution, null); + assert.equal(g.phase, 1); + assert.ok(!g.pendingAutomaticAdvisorFlips?.length); + assert.deepEqual(g.players.map(p => ({ id: p.id, forces: structuredClone(p.forces), + reserves: p.reserves, tanks: p.tanks, elites: structuredClone(p.elites) })), physical, + 'Finishing the conversion must not repeat any storm casualty or physical transfer.'); + assert.equal(!!player(g, 'b').advisors?.the_minor_erg, prevented); + if (prevented) + assert.equal(player(g, 'b').advisors?.the_minor_erg?.aloneFlipHold, held.event); + assert.equal(g.discard.filter(card => card.id === f.printed).length, Number(prevented)); + } +}); void test('both forms cancel only a newly shipped typed Fremen cohort in the storm, leaving earlier occupants intact', () => { for (const bg of [false, true]) { const f = fixture();