From b2603f671546ee532ac36c8f5fdf941c62c9e4d6 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:01:14 +1000 Subject: [PATCH] Connect bounded arrival overlap and Fremen Nexus movement Betrayal --- components/game-table.tsx | 21 +- components/nexus-fremen-betrayal.tsx | 27 ++ components/ornithopter-movement.tsx | 2 +- docs/CURRENT_STATUS.md | 15 + docs/DEFERRED_MOVEMENT_ARRIVAL.md | 26 +- docs/IMPLEMENTATION_STATUS.md | 54 +++ docs/MORITANI_ENTRY_TIMING.md | 8 + docs/NEXUS_FREMEN_RULES.md | 21 ++ docs/RULE_DECISIONS.md | 2 +- game/arrival-overlap.ts | 31 ++ game/bot-arrival.ts | 7 +- game/bots.ts | 14 +- game/engine.ts | 331 +++++++++++++++--- game/karama-movement-preflight.ts | 15 +- game/moritani-alliance-cancellation.ts | 4 +- game/nexus-fremen-betrayal-options.ts | 16 + game/nexus-fremen-betrayal.ts | 50 +++ game/reference.ts | 7 +- .../ambassador-terror-overlap-engine.test.ts | 250 +++++++++++++ ...ambassador-terror-overlap-recovery.test.ts | 281 +++++++++++++++ tests/ambassador-terror-overlap-worm.test.ts | 194 ++++++++++ tests/fixture-nexus-fremen-betrayal.ts | 78 +++++ tests/karama-movement-preflight.test.ts | 113 +++++- tests/nexus-fremen-betrayal-recovery.test.ts | 51 +++ tests/nexus-fremen-betrayal.test.ts | 107 ++++++ tests/reference.test.ts | 37 -- 26 files changed, 1644 insertions(+), 118 deletions(-) create mode 100644 components/nexus-fremen-betrayal.tsx create mode 100644 game/arrival-overlap.ts create mode 100644 game/nexus-fremen-betrayal-options.ts create mode 100644 game/nexus-fremen-betrayal.ts create mode 100644 tests/ambassador-terror-overlap-engine.test.ts create mode 100644 tests/ambassador-terror-overlap-recovery.test.ts create mode 100644 tests/ambassador-terror-overlap-worm.test.ts create mode 100644 tests/fixture-nexus-fremen-betrayal.ts create mode 100644 tests/nexus-fremen-betrayal-recovery.test.ts create mode 100644 tests/nexus-fremen-betrayal.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index f0d1d75c..d7c3a59e 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -33,6 +33,7 @@ import { NexusChoamBetrayal } from './nexus-choam-betrayal'; import { NexusMoritaniBetrayal } from './nexus-moritani-betrayal'; import { buildRevision, buildRevisionLabel } from '@/lib/build-revision'; import { NexusEcazBetrayal } from './nexus-ecaz-betrayal'; +import { NexusFremenBetrayal } from './nexus-fremen-betrayal'; import { NexusEcazInquiry } from './nexus-ecaz-inquiry'; import { NexusEcazDuke } from './nexus-ecaz-duke'; import { NexusTleilaxu } from './nexus-tleilaxu'; @@ -1050,6 +1051,18 @@ export function GameTable({ + {g.arrivalOverlap && ( +
+

+ {g.players.find((player) => player.id === g.arrivalOverlap!.entrant)?.name ?? 'The entrant'} + {' '}entered {territory(g.arrivalOverlap.territory).name}. Ecaz Ambassador + and Moritani Terror resolve in storm order:{' '} + {g.arrivalOverlap.first === 'ambassador' ? 'Ecaz, then Moritani' : 'Moritani, then Ecaz'}. + {' '}{g.arrivalOverlap.active === 'ambassador' ? 'Ecaz' : 'Moritani'} decides now; + the original entry is not repeated. +

+
+ )} @@ -1607,6 +1620,7 @@ export function GameTable({ + @@ -4044,10 +4058,9 @@ export function GameTable({ me.faction === 'fremen' && me.fremenMovementBlocked && (

- Karama limited this move to one territory. Your forces have - not moved yet. Available ornithopters still allow their - normal range; a later extra move can use your two-territory - advantage again. + {me.fremenNexusMovementBlocked + ? 'Fremen Nexus Betrayal suppresses your two-territory movement advantage for this turn. Available ornithopters still use their normal range.' + : 'Karama limited this move to one territory. Your forces have not moved yet. Available ornithopters still allow their normal range; a later extra move can use your two-territory advantage again.'}

)} {g.phase === 5 && me.faction === 'ixians' && ( diff --git a/components/nexus-fremen-betrayal.tsx b/components/nexus-fremen-betrayal.tsx new file mode 100644 index 00000000..86d87ea4 --- /dev/null +++ b/components/nexus-fremen-betrayal.tsx @@ -0,0 +1,27 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { nexusCardReference } from '@/game/nexus-card-reference'; +import { fremenBetrayalAction } from '@/game/nexus-fremen-betrayal-options'; +import { Button } from './ui/button'; + +type Props = { game: GameView; act: (action: Action) => void; busy: boolean }; + +export function NexusFremenBetrayal({ game, act, busy }: Props) { + const offer = game.nexusFremenBetrayal; + if (!offer || game.nexusCards?.card !== 'fremen') return null; + const action = fremenBetrayalAction(game); + const fremen = game.players.find(player => player.id === offer.target); + return ( +
+

Fremen Nexus · Betrayal

+

{nexusCardReference('fremen').betrayal}

+ {fremen &&

This bounded use suppresses {fremen.name}’s native two-territory movement advantage for the rest of this turn. Independent ornithopters remain available. Suppressing worm riding is not yet connected.

} + {offer.blocked &&

{offer.blocked}

} + +
+ ); +} diff --git a/components/ornithopter-movement.tsx b/components/ornithopter-movement.tsx index 42757472..b805fecf 100644 --- a/components/ornithopter-movement.tsx +++ b/components/ornithopter-movement.tsx @@ -57,7 +57,7 @@ export function OrnithopterMovement({ if (Object.entries(selectedForces).some(([from, count]) => count > 0 && !botGroundMoveAllowed(game, me, from, location(String(move.territory), Number(move.sector)), 0))) - reason = 'Karama removed this group’s two-territory advantage. Choose a reachable destination within the remaining normal range.'; + reason = 'The Fremen two-territory advantage is unavailable. Choose a reachable destination within the remaining normal range.'; } const physicalSource = typeof move?.from === 'string' diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index cedbe573..ff32151a 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,6 +6,21 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work +A bounded [Ecaz Ambassador/Moritani Terror same-entry continuation](MORITANI_ENTRY_TIMING.md#selected-bounded-overlap-order--29-september-2026) +now snapshots both original optional triggers and offers their owners sequential +choices in current storm order on classic plus Ecaz/Moritani Basic/Advanced +tables. This order is a conservative provisional table interpretation, not +a publisher ruling. The original shipment, move or Fremen worm ride transfers +forces only once; JSON/SQLite recovery keeps token custody private and proceeds +to the second owner even after the first declines or resolves a supported effect. +A summoned worm during the overlap retains its Nexus before the queued ride; +Robbery overflow and Sabotage discards preserve physical custody. The table +shows public owner order without disclosing the hidden Terror face; existing +owned controls and minimal legal AI remain usable. Competing BG Intrusion, +Fremen/Guild Ambassador child arrivals, Atomics and optional-module combinations +remain gated, as do normal public expansion starts. This source is not in the +last verified live image. + A bounded [Guild native rate Karama path](GUILD_RATE_KARAMA_RULES.md#native-transport-extension--29-september-2026) now interrupts classic Guild/reciprocal-ally ordinary reserve shipments and eligible cross-planet transport before payment, including Guild's own return diff --git a/docs/DEFERRED_MOVEMENT_ARRIVAL.md b/docs/DEFERRED_MOVEMENT_ARRIVAL.md index 54557ece..08291d4f 100644 --- a/docs/DEFERRED_MOVEMENT_ARRIVAL.md +++ b/docs/DEFERRED_MOVEMENT_ARRIVAL.md @@ -28,13 +28,14 @@ forces in the marked territory. The current gate remains; no storm-order policy or lost-opportunity rule is inferred from tests. [GF9 Ecaz & Moritani, pp. 5, 7 and 15](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=5) -The user has been asked whether this overlap should stay gated pending an -official ruling or use clearly labeled provisional storm order. No answer -has been applied. The broader [Ambassador](ECAZ_AMBASSADORS_RULES.md) and -[Terror](MORITANI_TERROR_RULES.md) contracts retain other arrival-order gaps, -including Bene Gesserit reactions and child arrivals. An unavailable reaction -combination is unfinished implementation, not a rule forbidding that move in -the physical game. +At this 13 September checkpoint, the user clarification about a possible +storm-order policy was pending, so the gate below remained. The user's later +standing instruction to choose the conservative recommended interpretation +instead of asking again led to a bounded storm-order continuation on +29 September; see [the source distinction and selected policy](MORITANI_ENTRY_TIMING.md#selected-bounded-overlap-order--29-september-2026). +The gate remains for competing BG Intrusion, child-producing Ambassador +effects, other modules and unfinished Atomics. An unavailable combination is +unfinished software scope, not a physical-game prohibition. ## Continuation contract @@ -82,3 +83,14 @@ Richese auction, private cards and spice after reload. This checks outage recove the new movement-return path is covered by engine and SQLite tests, not a staged browser claim. All 632 opening saved games and original seat records matched the backup, and the healthy development server was reused. + +## Bounded storm-order continuation — 29 September 2026 + +Classic plus Ecaz/Moritani development tables now snapshot a genuine shared +entry and offer each eligible owner one optional response in current storm +order, with the same original arrival receipt across JSON/SQLite restart. +The first resolution precedes the next prompt; neither response replays +shipment cost, physical force arrival or the mover's action. Public owner +order and current actor are visible without projecting a concealed Terror face. +This provisional table order does not certify Atomics, Intrusion, Ambassador +child arrivals or all expansion combinations, and no existing game is reset. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index fe5f932b..02107ce7 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,59 @@ # Dune implementation status +## 29 September 2026 — bounded Fremen Nexus Betrayal movement alternative + +An unallied holder may spend the one physical Fremen Nexus card before Fremen +move during Shipment and Movement in classic-only Nexus games. The native +two-territory advantage stays suppressed through that turn; ordinary movement, +city ornithopters and other independent movement remain available. The +server-bound event and saved receipt reject duplicate or malformed plays. +The holder-only control and all four AI profiles share the same private offer, +with bot priority before Fremen's first move. Basic/Advanced real second-turn +Nexus draws, JSON continuation, expiry, corrupted receipt rejection and a +concurrent authenticated SQLite CAS/reconnect check pass seven focused cases. +The actual React control rendered an owner button and no rival control in a +read-only static preview; no room was written. Typecheck, lint, the app build, +5,735/5,735 offline tests and 54/54 local HTTP integration cases passed. +Independent read-only review found no actionable issue in bounded timing, +privacy, custody, receipt or movement. +The card's separate worm-riding suppression remains +unimplemented, and combined modules/public Nexus starts remain gated. See +[Fremen Nexus rules](NEXUS_FREMEN_RULES.md#bounded-betrayal-movement-range). + +## 29 September 2026 — bounded Ambassador/Terror storm-order continuation + +An original entry into a shared Ecaz Ambassador and Moritani Terror stronghold +is now committed once, then two distinct optional owner reactions are saved +in current storm order. Ecaz and Moritani can each decline or use a supported +face/effect; the second owner still receives the original entrant, territory, +token entitlement and turn after JSON restoration. The signed receipt remains +server-only, the public notice shows current/next faction without hidden +Terror identity, and physical shipment spice, counters and movement allowance +never replay on continuation. This is a provisional conservative table +priority chosen after a targeted source review found no publisher ruling, +not a certified simultaneous-effects rule. + +The first focused engine/quote/in-memory SQLite group passed 41 cases across +three files. A direct non-test Basic game smoke put Moritani before Ecaz: +the entrant shipped two forces for two spice once, Moritani revealed Robbery +and collected nine of the remaining eighteen, then Ecaz's Emperor Ambassador +collected five bank spice. JSON reloads between responses preserved owner +order and the original two forces; rival views kept Robbery's face hidden +before revelation. BG fighter Intrusion, Fremen/Guild Ambassador child +arrivals, Atomics, optional modules and public expansion starts remain +separate unfinished work. The table order and its limits are in +[the source-bound policy](MORITANI_ENTRY_TIMING.md#selected-bounded-overlap-order--29-september-2026). + +Follow-up regression coverage now exercises Basic/Advanced Fremen worm rides +through both saved arrival stages in SQLite, Robbery hand overflow, Sabotage +discard/gift, and a special-Karama summon whose Nexus precedes the queued +ride. The bot owner-view quote accepts a supported ordinary overlap. The +focused overlap/guide suite passed 30 cases. The obsolete prose-matching +reference test was removed rather than re-pinned; full typecheck, lint, the +app build and 5,735/5,735 offline tests now pass with the separate Fremen +Nexus movement alternative integrated. This local coverage does not certify +an expansion start or deployment. + ## 29 September 2026 — native Guild cross/return rate response The classic Basic/Advanced Guild rate interruption now owns a typed, persisted diff --git a/docs/MORITANI_ENTRY_TIMING.md b/docs/MORITANI_ENTRY_TIMING.md index 29092983..25e9545c 100644 --- a/docs/MORITANI_ENTRY_TIMING.md +++ b/docs/MORITANI_ENTRY_TIMING.md @@ -15,6 +15,14 @@ Reviewed 2026-09-06. This supplements `MORITANI_TERROR_RULES.md`; it does not en Consequently, the exact relationship between paying the shipment, transferring forces, opening Terror and completing other arrival reactions remains an implementation policy requiring an explicit qualification. It should not be described as a publisher-certified interrupt-before-arrival or a generic simultaneous-effects rule. +## Selected bounded overlap order — 29 September 2026 + +No targeted publisher/designer clarification establishes which of Ecaz's Ambassador and Moritani's Terror acts first when both were eligible on one entry. Under the user's standing instruction to use the recommended option rather than reopen a missing ruling, this table chooses **the current storm order `g.order` of those two owners** in Basic/Advanced classic plus Ecaz/Moritani development games. This is a provisional implementation interpretation, **not** a GF9 simultaneous-effects ruling. + +Both optional trigger opportunities belong to the **same already committed entry**. Snapshot the original entrant, token identities, physical arrival amount and turn; finish the first owner's choice and its allowable continuations before offering the second. Revalidate the second effect's physical requirements without replaying the original shipment payment, movement or force transfer. Owner-private Terror identity remains concealed until revealed. If the first reaction changes an alliance, the other owner keeps the trigger earned at the original entry; it does not gain a new trigger from a later action. + +This bounded path excludes competing BG fighter Intrusion, Guild income/advisor and CHOAM prevention windows, Homeworlds, Nexus, Discoveries, Leader Skills and the still-unfinished Atomics effect. Fremen relocation or Guild free-shipment Ambassador effects can create nested arrivals before the second owner; they remain declineable but cannot be activated in this overlap until their child continuation is integrated. These guards are development boundaries, not physical-game prohibitions. + ## Atomics and later allies The current official indexed p. 5 still says: “From this turn forward, your hand limit is reduced by 1 (as well as your ally’s)”. It does not specify how a later alliance change affects either former or new allies. Both a continuing current-ally modifier and a detonation-time recipient remain interpretations. No targeted clarification was found. [GF9 E3, p. 5](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=5) diff --git a/docs/NEXUS_FREMEN_RULES.md b/docs/NEXUS_FREMEN_RULES.md index d1fb84bf..9bd5cd9c 100644 --- a/docs/NEXUS_FREMEN_RULES.md +++ b/docs/NEXUS_FREMEN_RULES.md @@ -12,6 +12,27 @@ The [November 2020 FAQ, pp.2–3](https://www.gf9games.com/dune/wp-content/uploa The [E1 FAQ, p.11](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=11) describes Sandtrout's anti-Nexus and immediate replacement-card behavior. Its Karama table distinguishes destroying Fremen at a worm from stopping additional-worm placement. The [E3 rules, pp.11–12 and 16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=12), reread in the cached publisher-authored PDF text and fresh publisher-indexed excerpts, give common Nexus timing and distinguish Great Maker's reserve ride from ordinary board-origin riding. Great Maker remains a separate integration boundary. +## Bounded Betrayal: movement range + +An unallied non-Fremen holder of the physical Fremen Nexus card may spend it +during Shipment and Movement **before Fremen move** to suppress their native +two-territory movement advantage for the rest of that turn. This bounded path +uses a clean action boundary in classic-only tables with no other optional +modules. It does not suppress independent city ornithopters, Planetologist, +movement cards or the ordinary one-territory move. A move-specific Karama +interruption remains separate; no extra Karama response to the Nexus card is +invented. + +The owner-only offer binds the holder, seated Fremen target and turn. The +physical card enters the Nexus discard once; a saved receipt and player turn +marker preserve suppression through JSON/SQLite recovery and reject replay or +malformed ownership. The public movement view shows the restriction without +revealing who held the card before play. All four AI profiles can spend the +card before an active Fremen bot moves; the owner control labels this +alternative and keeps the printed worm-riding suppression alternative visibly +unavailable. The latter is **not** covered by this prototype. This does not +open the complete Nexus module or public expansion starts. + ## Cunning: source-backed scope and implementation composition The following are implementation consequences of the inspected text, not additional quoted rules: diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 12121550..70f59c8b 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -29,6 +29,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi | Truthtrance hand categories | Count current physical hand custody by explicitly named primary role; alternate battle slots and faction powers do not reclassify cards. Only an aggregate answer is disclosed. | [Hand inventory](TRUTHTRANCE_HAND_INVENTORY.md) | | Moritani Advanced assassination preview | After an eligible battle loss, a different opposing-faction Traitor Card may be revealed even if its leader is already dead (zero bounty). Face-up set-aside and one private replacement occur during Mentat; one use per faction. Normal-call duration, uniform hidden-eligibility UX and exceptional combinations remain explicit. | [Runtime/source contract](MORITANI_ASSASSINATE_LEADERS.md) | | Moritani Extortion at Mentat | The user selected normal Terror placement/relocation first, including any Karama response; only then collect the reserved five bank spice and approach other players in storm order for a possible three-spice payment. Payment removes the token; all declines recover it with rotated hidden supply IDs. The relative placement timing is a user-selected interpretation, not a publisher ruling. | [Publisher sequence and selected timing](MORITANI_EXTORTION_RULES.md#mentat-ordering-selected-interpretation-not-a-publisher-ruling) | +| Ecaz Ambassador / Moritani Terror shared entry | In bounded classic plus Ecaz/Moritani Basic/Advanced games, both originally eligible owners resolve their optional reactions in current storm order; the committed entry, payment and counters are not replayed. Later alliance changes do not erase the original trigger, while physical effect prerequisites are rechecked. This is an explicit provisional table interpretation because no publisher priority ruling was found. BG Intrusion, child arrivals, Atomics and optional modules remain guarded. | [Primary-source gap and selected policy](MORITANI_ENTRY_TIMING.md#selected-bounded-overlap-order--29-september-2026), [movement recovery](DEFERRED_MOVEMENT_ARRIVAL.md#bounded-storm-order-continuation--29-september-2026) | | Fremen/Ecaz final-turn victory | Reciprocal allied Ecaz and Fremen with fighters in Sietch Tabr do not block the Fremen special victory. Ecaz alone/non-allied or a third faction still blocks. The Habbanya extension remains unsupported; ordinary winner/prediction and fallback order are preserved. | [Explicit FAQ and runtime](ECAZ_VICTORY_RULES.md#fremen-final-turn-runtime-follow-up) | | Truthtrance recorded knowledge | Questions compare an already recorded BG prediction, active submitted storm dial or actually known native Fremen forecast. Missing knowledge is unknown; only the aggregate answer is published. No future promise or unknown deck peek is created. | [Connected predicates and evidence](TRUTHTRANCE_KNOWLEDGE.md) | | Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) | @@ -76,7 +77,6 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | Harass / Stone Burner | A user question has been sent: keep the combination gated or compare forces remaining after withdrawal. No answer is assumed. The Richese card-family configuration is guarded publicly; hidden Stone Burner custody never controls eligibility. Ambiguous physical allocations and other optional modules remain unfinished. | [Current boundaries](HARASS_WITHDRAW_RUNTIME.md#current-boundaries) | | Recruits late accounting and rates | A user question has been sent for two free plus one paid Atreides return before Recruits: keep the disputed case gated, or one/two further free returns without refund. No answer is assumed. Late-changing free rates, pending repricing and same-turn recovered-card replay also stay gated. The connected clean-case preview retains the independent seven-force cap after faction-advantage cancellation as an explicit inference, not a direct combined FAQ. | [Source audit](RECRUITS_RULES.md), [runtime scope](RECRUITS_RUNTIME.md) | | Sandmaster / native HMS relocation | No official source located establishes that passengers remaining inside the HMS enter outside territories for this skill. Extra collection eligibility is pending a user ruling; native sector collection and ordinary force entry/exit remain available. | [Source comparison and question](SANDMASTER_MOVEMENT.md#21-september-native-hms-relocation-eligibility) | -| Ambassador/Terror arrival priority | Both optionally react to the same entry, but no publisher/designer priority was found. The order can change Fremen relocation versus Atomics losses. A user question has been sent: keep the overlap gated pending an official ruling, or use explicitly provisional storm order. No answer is assumed; Baliset remains a separate pre-entry prevention effect. | [Reproduction and source boundary](DEFERRED_MOVEMENT_ARRIVAL.md) | | Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and later battle timing stay guarded. The existing pre-plan opportunity supports [early aggressor](SAPHO_AGGRESSOR.md). Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) | | Semuta Drug | Full-hand take-before-discard and the privacy-neutral immediate reaction protocol remain unresolved. Typed discard continuations already exist; they are not activation. | [Source update](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) | | Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler modifier/allocation boundaries, Mentat empty-hand fallback, modified Diplomat defenses and simultaneous optional timing remain source gaps. After an online publisher/designer/tournament check found no explicit priority ruling, the bounded standalone Diplomat retreat prototype explicitly chooses printed/copied disc strength and a surviving-loser retreat before ordinary casualties; other combinations remain guarded. This is an implementation interpretation, not an official clarification. | [Material boundaries and selected retreat interpretation](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation), [Diplomat defense boundary](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) | diff --git a/game/arrival-overlap.ts b/game/arrival-overlap.ts new file mode 100644 index 00000000..5e50c4d0 --- /dev/null +++ b/game/arrival-overlap.ts @@ -0,0 +1,31 @@ +import type { Game } from './engine'; + +type Overlap = NonNullable; +type TerrorEntry = NonNullable; + +/** The immutable trigger, not later owner choices or child stages. */ +export function arrivalOverlapSignature(record: Overlap): string { + return JSON.stringify([record.event, record.turn, record.phase, record.entrant, + record.territory, record.sector, record.amount, record.elite, record.cause, + record.resume, record.ecaz, record.moritani, record.first, + record.ambassadorToken, record.terrorTokens]); +} + +/** The overlap owns the worm return while either serialized Terror prompt is live. */ +export function overlapOwnsWormTerror( + g: Pick, + entry: Pick, +): boolean { + const overlap = g.pendingArrivalOverlap; + return !!overlap && overlap.signature === arrivalOverlapSignature(overlap) && + overlap.resume === 'wormRide' && overlap.cause === 'wormRide' && + entry.cause === 'wormRide' && entry.resume === 'none' && + overlap.turn === g.turn && overlap.phase === g.phase && + entry.turn === overlap.turn && entry.phase === overlap.phase && + entry.entrant === overlap.entrant && entry.territory === overlap.territory && + entry.sector === overlap.sector && entry.amount === overlap.amount && + entry.elite === overlap.elite && overlap.terrorTokens.includes(entry.token) && + ((overlap.first === 'terror' && overlap.stage === 'first') || + (overlap.first === 'ambassador' && overlap.stage === 'second')); +} diff --git a/game/bot-arrival.ts b/game/bot-arrival.ts index a5eb0992..95b241b3 100644 --- a/game/bot-arrival.ts +++ b/game/bot-arrival.ts @@ -56,9 +56,9 @@ export function botArrivalBlock(g: GameView, action: Action): string | null { return null; const guild = g.players.find((p) => p.faction === 'guild'); const halfRate = - me.faction === 'guild' || - guild?.id === me.ally || - g.karamaShipping?.player === me.id; + g.karamaShipping?.player === me.id || + (!g.guildRateCanceled && + (me.faction === 'guild' || guild?.id === me.ally)); // A No-Field's concealed value and optional companion never change its price. const smuggler = action.noField === undefined && action.smuggler === true @@ -162,6 +162,7 @@ export function botArrivalBlock(g: GameView, action: Action): string | null { try { quoteCompletedMovementArrival({ advanced: g.advanced, + overlapSupported: g.arrivalOverlapMode === true, players: g.players, order, ambassadors: (g.ambassadors?.tokens ?? []).map( diff --git a/game/bots.ts b/game/bots.ts index 6888f7ab..266697cc 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -17,9 +17,11 @@ import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, import { nexusMoritaniBotActions } from './nexus-moritani-options'; import { moritaniBetrayalBotActions } from './nexus-moritani-betrayal-options'; import { ecazBetrayalBotActions } from './nexus-ecaz-betrayal-options'; +import { fremenBetrayalBotActions } from './nexus-fremen-betrayal-options'; import { ecazInquiryBotActions } from './nexus-ecaz-inquiry-options'; import { nexusEcazDukeBotActions } from './nexus-ecaz-duke-options'; import { ecazBetrayalOffer } from './nexus-ecaz-betrayal'; +import { fremenBetrayalOffer } from './nexus-fremen-betrayal'; import { ECAZ_START_FORCES, ECAZ_START_LOCATIONS, quoteEcazStartingForces } from './ecaz-setup'; import { choamPowerAction, choamPowerBotPlay } from './choam-power-options'; import { nexusCardBotActions } from './nexus-card-options'; @@ -3907,6 +3909,8 @@ export function botActions(g: GameView): Action[] { if (moritaniBetrayal.length) return moritaniBetrayal; const ecazBetrayal = ecazBetrayalBotActions(g); if (ecazBetrayal.length) return ecazBetrayal; + const fremenBetrayal = fremenBetrayalBotActions(g); + if (fremenBetrayal.length) return fremenBetrayal; const ecazInquiry = ecazInquiryBotActions(g); if (ecazInquiry.length) return ecazInquiry; const sardaukar = nexusSardaukarBotActions(g); @@ -4043,9 +4047,13 @@ export function runBots(state: Game, limit = 96): Game { ? actors.find(actor => g.nexusCards!.cards!.hands[actor.id] === 'ecaz' && ecazBetrayalOffer(g, actor.id)?.blocked === null) : undefined; - const priorityEcaz = ecazDukeHolder ?? ecazHolder; - if (priorityEcaz) - actors.sort((a, b) => Number(b.id === priorityEcaz.id) - Number(a.id === priorityEcaz.id)); + const fremenHolder = g.phase === 5 && g.nexusCards?.cards + ? actors.find(actor => g.nexusCards!.cards!.hands[actor.id] === 'fremen' && + fremenBetrayalOffer(g, actor.id)?.blocked === null) + : undefined; + const priorityNexus = ecazDukeHolder ?? ecazHolder ?? fremenHolder; + if (priorityNexus) + actors.sort((a, b) => Number(b.id === priorityNexus.id) - Number(a.id === priorityNexus.id)); else if (g.phase === 5 && junctionSponsor(g) && !currentJunctionOffer(g)) actors.sort((a, b) => Number(b.faction === 'guild') - Number(a.faction === 'guild')); for (const p of actors) { diff --git a/game/engine.ts b/game/engine.ts index c08ec070..1d6be265 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -57,6 +57,7 @@ import { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceip import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement, type NexusMoritaniReceipt } from './nexus-moritani'; import { moritaniBetrayalOffer } from './nexus-moritani-betrayal'; import { ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot, type EcazBetrayalSnapshot } from './nexus-ecaz-betrayal'; +import { fremenBetrayalOffer, fremenBetrayalSignature, validateFremenBetrayal, type FremenNexusBetrayal } from './nexus-fremen-betrayal'; import { nexusMoritaniRecordSignature, terrorLocationAllowed, terrorEntryLocationAllowed } from './terror-location'; import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam'; import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations'; @@ -108,6 +109,7 @@ import { } from './homeworld-revival-return'; import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations'; import { terrorEntrySignature, terrorSelectionSignature, validateTerrorEntrySignature } from './terror-entry-receipt'; +import { arrivalOverlapSignature, overlapOwnsWormTerror } from './arrival-overlap'; import { homeworldLowBonus, homeworldRevivalKaramaBlock, @@ -422,6 +424,7 @@ import { copiedAmbassadorEffects, canTriggerAmbassador, type AmbassadorEffect, + type AmbassadorToken, type AmbassadorState, } from './ecaz-ambassadors'; import { @@ -587,6 +590,7 @@ export type Player = { gholaBlocked?: Record; ixMovementBlocked?: { turn: number; move: number }; fremenMovementBlocked?: { turn: number; move: number }; + fremenNexusMovementBlockedTurn?: number; elites?: { reserves: number; tanks: number; @@ -1388,6 +1392,27 @@ export type Game = { resume: 'none' | 'wormRide' | 'ambassador'; ambassadorEvent?: string; } | null; + /** One committed entry with two independently optional expansion reactions. */ + pendingArrivalOverlap?: { + event: string; + signature: string; + turn: number; + phase: number; + entrant: string; + territory: string; + sector: number; + amount: number; + elite: number; + cause: NonNullable['cause']; + resume: 'none' | 'wormRide'; + ecaz: string; + moritani: string; + first: 'ambassador' | 'terror'; + stage: 'first' | 'second'; + ambassadorToken: string; + terrorTokens: string[]; + ambassadorEvent?: string; + } | null; moritaniTerror?: TerrorState; moritaniExtortion?: ExtortionState; grummanCollection?: GrummanCollection; @@ -1534,6 +1559,8 @@ export type Game = { }[]; /** A spent Ecaz card binds one exact allied force group until Karama settles. */ nexusEcazBetrayalHistory?: { snapshot: EcazBetrayalSnapshot; stage: 'pending' | 'complete' }[]; + /** A spent Fremen card suppresses the native two-territory advantage through its turn. */ + nexusFremenBetrayalHistory?: FremenNexusBetrayal[]; /** One actual Sardaukar battle advantage suppressed by a spent Emperor card. */ nexusEmperorBetrayalHistory?: { event: string; turn: number; territory: string; owner: string; target: string }[]; /** Original native Voice canceled by a spent Bene Gesserit Nexus card. */ @@ -2094,6 +2121,47 @@ function playNexusEmperorBetrayal(g: Game, p: Player, action: Action) { log(g, `${p.name} spent Emperor Nexus Betrayal to suppress ${getPlayer(g, record.target).name}'s Sardaukar strength in this battle. Actual starred counters remain Sardaukar.`, { faction: p.faction, name: 'Emperor Nexus Betrayal' }); } +function nexusFremenBetrayalIntegrity(g: Game) { + const history = g.nexusFremenBetrayalHistory; + const fremen = byFaction(g, 'fremen'); + if (history === undefined) { + requireRule(fremen?.fremenNexusMovementBlockedTurn === undefined, + 'Fremen movement suppression has no spent Nexus card receipt.'); + return; + } + requireRule(g.nexusCards?.cards && fremen && Array.isArray(history) && + history.length > 0, 'Fremen Betrayal has lost its physical card module.'); + const turns = new Set(); + for (const record of history) { + nexusRule(() => validateFremenBetrayal(g, record)); + requireRule(!turns.has(record.turn), + 'Fremen Betrayal cannot suppress movement twice in one turn.'); + turns.add(record.turn); + } + const last = history.at(-1)!; + requireRule(fremen.fremenNexusMovementBlockedTurn === last.turn, + 'Fremen Betrayal has lost its turn-long movement suppression.'); + if (last.turn === g.turn) + requireRule(g.nexusCards.cards.discard.includes('fremen'), + 'Fremen Betrayal has lost its spent physical card.'); +} +function playNexusFremenBetrayal(g: Game, p: Player, action: Action) { + const offer = fremenBetrayalOffer(g, p.id); + requireRule(offer && !offer.blocked && action.event === offer.event && + Object.keys(action).sort().join(',') === 'event,type', + 'Use Fremen Betrayal before Fremen movement begins.'); + const record: FremenNexusBetrayal = { + event: offer.event, owner: p.id, target: offer.target, turn: g.turn, + phase: 5, signature: '', + }; + record.signature = fremenBetrayalSignature(record); + g.nexusCards!.cards = nexusRule(() => + discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); + getPlayer(g, offer.target).fremenNexusMovementBlockedTurn = g.turn; + (g.nexusFremenBetrayalHistory ??= []).push(record); + log(g, `${p.name} spent Fremen Nexus Betrayal to suppress the Fremen two-territory movement advantage this turn. Independent ornithopters and movement abilities remain available.`, + { faction: p.faction, name: 'Fremen Nexus Betrayal' }); +} function bgBetrayalWindow(g: Game) { const response = g.response, battle = g.battle; return !!(g.nexusCards?.cards && g.status === 'playing' && g.phase === 6 && @@ -5114,7 +5182,7 @@ function treacheryDiscardIntegrity(g: Game) { (entry.cause === 'ambassador' ? ambassadorTerrorEntryMatches(g, entry) : entry.cause === 'wormRide' - ? entry.resume === 'wormRide' && + ? (entry.resume === 'wormRide' || overlapOwnsWormTerror(g, entry)) && g.phase === 1 && entrant.faction === 'fremen' : entry.resume === 'none') && @@ -5919,7 +5987,8 @@ function requireFreshSetup(g: Game, allowIxElites = false) { !p.specialKaramaUsed && p.kwisatz === undefined && p.charityTurn === undefined && p.noField === undefined && p.noFieldEvent === undefined && p.noFieldBlockedTurn === undefined && p.advisors === undefined && p.gholaBlocked === undefined && p.ixMovementBlocked === undefined && - p.fremenMovementBlocked === undefined && p.faceDancers === undefined && + p.fremenMovementBlocked === undefined && p.fremenNexusMovementBlockedTurn === undefined && + p.faceDancers === undefined && p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined; }), 'Starting setup requires unused native leaders and no prior alliance, revival or battle history.'); requireRule(Object.keys(g.playerPositions ?? {}).every(id => g.players.some(p => p.id === id)), @@ -7355,6 +7424,7 @@ function afterWorm(g: Game) { } else if ( g.pendingAmbassador?.resume === 'wormRide' || g.pendingTerrorEntry?.resume === 'wormRide' || + g.pendingArrivalOverlap?.resume === 'wormRide' || (g.pendingKarama?.use.kind === 'cancel' && g.pendingKarama.use.response.kind === 'advisorFlip' && g.pendingKarama.use.response.advisorResume === 'wormRide') || @@ -10630,7 +10700,8 @@ function movementRange(g: Game, p: Player, elite: number) { faction: p.faction, cityOrnithopters: !!(fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')), selectedElites: elite, - nativeBlocked: blocked?.turn === g.turn && blocked.move === p.moved, + nativeBlocked: (blocked?.turn === g.turn && blocked.move === p.moved) || + (p.faction === 'fremen' && p.fremenNexusMovementBlockedTurn === g.turn), choamBonus: p.faction === 'choam' && g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0, }); } @@ -10817,6 +10888,7 @@ function quoteMovementArrival( ) { return quoteCompletedMovementArrival({ advanced: g.advanced, + overlapSupported: arrivalOverlapModeSupported(g), players: g.players, order: move, ambassadors: g.ecazAmbassadors?.tokens ?? [], @@ -10883,6 +10955,7 @@ function quoteShipmentArrival( ); return quoteCompletedMovementArrival({ advanced: g.advanced, + overlapSupported: arrivalOverlapModeSupported(g), players: g.players, order: { player: p.id, @@ -11153,6 +11226,8 @@ function ambassadorEffectBlock( beneficiary: Player, entrant: Player, ): string | null { + if (g.pendingArrivalOverlap && (effect === 'fremen' || effect === 'guild')) + return 'Finish the overlapping Terror before another Ambassador-created arrival; decline this effect.'; if (!implementedAmbassadorEffects.includes(effect)) return 'This Ambassador effect is still being implemented.'; if (effect === 'ecaz') { @@ -11179,7 +11254,101 @@ function ambassadorEffectBlock( return 'The entrant has no held Traitor Card to inspect.'; return null; } -/** The original entry is committed once. Competing arrival ordering remains gated. */ +function arrivalOverlapModeSupported(g: Game): boolean { + return !!byFaction(g, 'ecaz') && !!byFaction(g, 'moritani') && + g.players.every(player => CLASSIC_FACTIONS[player.faction] === true || + player.faction === 'ecaz' || player.faction === 'moritani') && + g.expansions.every(expansion => expansion === 'ecaz') && + !g.homeworlds && !g.nexusCards && !g.leaderSkills && + !g.discoveryEnabled && !g.techTokens && !g.strongholdCards && + !g.ecazTreachery; +} +function arrivalOverlapIntegrity(g: Game) { + const record = g.pendingArrivalOverlap; + if (!record) return; + const ecazIndex = g.order.indexOf(record.ecaz); + const moritaniIndex = g.order.indexOf(record.moritani); + const continuation = g.pendingTreacheryDiscard?.continuation; + const ambassador = g.pendingAmbassador ?? + (continuation?.kind === 'ambassador' ? continuation.entry : null); + const terror = g.pendingTerrorEntry ?? + (continuation?.kind === 'terrorDiscard' ? continuation.entry : null); + const active = record.stage === 'first' ? record.first : + record.first === 'ambassador' ? 'terror' : 'ambassador'; + requireRule(g.status === 'playing' && arrivalOverlapModeSupported(g) && + record.turn === g.turn && record.phase === g.phase && + typeof record.event === 'string' && record.event.length > 0 && + typeof record.signature === 'string' && + record.signature === arrivalOverlapSignature(record) && + g.players.some(p => p.id === record.ecaz && p.faction === 'ecaz') && + g.players.some(p => p.id === record.moritani && p.faction === 'moritani') && + g.players.some(p => p.id === record.entrant && + p.id !== record.ecaz && p.id !== record.moritani) && + ecazIndex >= 0 && moritaniIndex >= 0 && ecazIndex !== moritaniIndex && + g.order.lastIndexOf(record.ecaz) === ecazIndex && + g.order.lastIndexOf(record.moritani) === moritaniIndex && + record.first === (ecazIndex < moritaniIndex ? 'ambassador' : 'terror') && + (record.stage === 'first' || record.stage === 'second') && + Number.isSafeInteger(record.sector) && + TERROR_STRONGHOLDS.includes(record.territory) && + territory(record.territory).sectors.includes(record.sector) && + Number.isSafeInteger(record.amount) && record.amount >= 0 && + Number.isSafeInteger(record.elite) && record.elite >= 0 && + record.elite <= record.amount && + (record.resume === 'none' || record.resume === 'wormRide') && + typeof record.ambassadorToken === 'string' && record.ambassadorToken.length > 0 && + Array.isArray(record.terrorTokens) && record.terrorTokens.length > 0 && + record.terrorTokens.every(id => typeof id === 'string' && id.length > 0) && + new Set(record.terrorTokens).size === record.terrorTokens.length && + (active === 'ambassador' + ? !!ambassador && !terror && + ambassador.event === record.ambassadorEvent && + ambassador.owner === record.ecaz && + ambassador.entrant === record.entrant && + ambassador.token === record.ambassadorToken && + ambassador.territory === record.territory && + ambassador.turn === record.turn && ambassador.phase === record.phase + : !!terror && !ambassador && + terror.entrant === record.entrant && + terror.territory === record.territory && + terror.turn === record.turn && terror.phase === record.phase && + terror.cause === record.cause && + record.terrorTokens.includes(terror.token)), + 'The saved Ambassador/Terror overlap lost its original ordered arrival.'); +} +function openAmbassadorEntry( + g: Game, owner: Player, token: AmbassadorToken, entrant: Player, to: string, + sector: number, resume: 'none' | 'wormRide', +): boolean { + g.pendingAmbassador = { + event: `ambassador:${g.turn}:${g.phase}:${(g.log.at(-1)?.seq ?? 0) + 1}`, + owner: owner.id, + entrant: entrant.id, + token: token.id, + territory: to, + sector, + turn: g.turn, + phase: g.phase, + stage: 'offer', + resume, + ...(resume === 'wormRide' ? { wormRider: entrant.id } : {}), + copyChoices: + token.effect === 'beneGesserit' + ? copiedAmbassadorEffects(g.ecazAmbassadors!) + : [], + }; + g.decision = { kind: 'ecazAmbassador', player: owner.id }; + log(g, `${owner.name} may trigger the ${faction(token.effect).name} Ambassador because ${entrant.name} entered ${territory(to).name}. The entrant’s remaining actions wait for this opportunity.`); + return true; +} +function finishArrivalOverlap(g: Game, overlap: NonNullable) { + requireRule(!g.pendingAmbassador && !g.pendingTerrorEntry, + 'Both reactions must finish before resuming the original arrival.'); + g.pendingArrivalOverlap = null; + log(g, 'Both optional Ambassador and Terror reactions to the original entry have finished.'); + if (overlap.resume === 'wormRide') nextWormRide(g); +} +/** The original entry is committed once; eligible overlapping owners act in storm order. */ function openTerritoryEntry( g: Game, entrant: Player, @@ -11217,44 +11386,34 @@ function openTerritoryEntry( resume, ); const moritani = byFaction(g, 'moritani'); - const terror = - moritani && + const terrorTokens = moritani && entrant.id !== moritani.id && entrant.id !== moritani.ally && - !homeworldTerrorEntryBlock(g, amount) && - g.moritaniTerror?.tokens.some( - (t) => t.status === 'placed' && t.location === to, - ); - requireRule( - !g.response && - !g.decision && - !g.pendingTerrorEntry && - !g.pendingAmbassador && - !terror, - 'Ambassadors combined with another arrival reaction are still being implemented. This entry has not been committed.', - ); - g.pendingAmbassador = { - event: `ambassador:${g.turn}:${g.phase}:${(g.log.at(-1)?.seq ?? 0) + 1}`, - owner: owner.id, - entrant: entrant.id, - token: token.id, - territory: to, - sector, - turn: g.turn, - phase: g.phase, - stage: 'offer', - resume, - ...(resume === 'wormRide' ? { wormRider: entrant.id } : {}), - copyChoices: - token.effect === 'beneGesserit' - ? copiedAmbassadorEffects(g.ecazAmbassadors!) - : [], + !homeworldTerrorEntryBlock(g, amount) + ? g.moritaniTerror?.tokens.filter(t => t.status === 'placed' && t.location === to) ?? [] + : []; + requireRule(!g.response && !g.decision && !g.pendingTerrorEntry && !g.pendingAmbassador, + 'Finish the other arrival reaction before opening this entry.'); + if (!terrorTokens.length) + return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume); + requireRule(arrivalOverlapModeSupported(g) && !g.pendingArrivalOverlap && + moritani && g.order.includes(owner.id) && g.order.includes(moritani.id), + 'Ambassador and Terror overlap still needs a supported classic Ecaz/Moritani table.'); + const first = g.order.indexOf(owner.id) < g.order.indexOf(moritani.id) + ? 'ambassador' : 'terror'; + const overlap: NonNullable = { + event: crypto.randomUUID(), signature: '', turn: g.turn, phase: g.phase, + entrant: entrant.id, territory: to, sector, amount, elite, cause, resume, + ecaz: owner.id, moritani: moritani.id, first, stage: 'first', + ambassadorToken: token.id, terrorTokens: terrorTokens.map(t => t.id), }; - g.decision = { kind: 'ecazAmbassador', player: owner.id }; - log( - g, - `${owner.name} may trigger the ${faction(token.effect).name} Ambassador because ${entrant.name} entered ${territory(to).name}. The entrant’s remaining actions wait for this opportunity.`, - ); + overlap.signature = arrivalOverlapSignature(overlap); + g.pendingArrivalOverlap = overlap; + log(g, `${owner.name} and ${moritani.name} both have optional reactions to ${entrant.name}'s entry in ${territory(to).name}; ${first === 'ambassador' ? owner.name : moritani.name} acts first in storm order.`); + if (first === 'terror') + return openTerrorEntry(g, entrant, to, sector, amount, elite, cause, 'none'); + openAmbassadorEntry(g, owner, token, entrant, to, sector, 'none'); + overlap.ambassadorEvent = g.pendingAmbassador!.event; return true; } function finishAmbassador(g: Game, deferResume = false) { @@ -11281,6 +11440,26 @@ function finishAmbassador(g: Game, deferResume = false) { ); } g.pendingAmbassador = null; + const overlap = g.pendingArrivalOverlap; + if (overlap?.ambassadorEvent === pending.event) { + requireRule(pending.entrant === overlap.entrant && + pending.territory === overlap.territory && + pending.turn === overlap.turn && pending.phase === overlap.phase && + pending.resume === 'none', + 'The Ambassador has lost its original overlapping arrival.'); + if (overlap.stage === 'first' && overlap.first === 'ambassador') { + overlap.stage = 'second'; + requireRule(openTerrorEntry(g, getPlayer(g, overlap.entrant), + overlap.territory, overlap.sector, overlap.amount, overlap.elite, + overlap.cause, 'none'), + 'The originally triggered Terror token is no longer in the stronghold.'); + return; + } + requireRule(overlap.stage === 'second' && overlap.first === 'terror', + 'The second Ambassador reaction is out of order.'); + finishArrivalOverlap(g, overlap); + return; + } if (!deferResume && pending.resume === 'wormRide') nextWormRide(g); } function currentFremenAmbassador(g: Game, event?: string, completed = false) { @@ -12806,17 +12985,19 @@ function openTerrorEntry( ambassadorEvent?: string, ) { const moritani = byFaction(g, 'moritani'); - const tokens = g.moritaniTerror?.tokens.filter( - (t) => t.status === 'placed' && t.location === to, - ) ?? []; + const overlap = g.pendingArrivalOverlap; + const originallyTriggered = !!overlap && + overlap.stage === 'second' && overlap.first === 'ambassador' && + overlap.entrant === entrant.id && overlap.territory === to && + overlap.turn === g.turn && overlap.phase === g.phase && + overlap.cause === cause; + const tokens = g.moritaniTerror?.tokens.filter(t => + t.status === 'placed' && t.location === to && + (!originallyTriggered || overlap!.terrorTokens.includes(t.id))) ?? []; const token = tokens[0]; - if ( - !moritani || - !token || - entrant.id === moritani.id || - entrant.id === moritani.ally || - homeworldTerrorEntryBlock(g, amount) - ) + if (!moritani || !token || entrant.id === moritani.id || + (entrant.id === moritani.ally && !originallyTriggered) || + homeworldTerrorEntryBlock(g, amount)) return false; requireRule(!g.pendingTerrorEntry, 'Resolve the pending Terror entry first.'); requireRule( @@ -12862,6 +13043,27 @@ function openTerrorEntry( function finishTerrorEntry(g: Game) { const pending = g.pendingTerrorEntry!; g.pendingTerrorEntry = null; + const overlap = g.pendingArrivalOverlap; + if (overlap && pending.entrant === overlap.entrant && + pending.territory === overlap.territory && pending.turn === overlap.turn && + pending.phase === overlap.phase && pending.cause === overlap.cause) { + requireRule(pending.resume === 'none', 'The overlapping Terror must not resume its parent twice.'); + if (overlap.stage === 'first' && overlap.first === 'terror') { + overlap.stage = 'second'; + const token = g.ecazAmbassadors?.tokens.find(t => + t.id === overlap.ambassadorToken && t.zone === 'placed' && + t.location === overlap.territory); + requireRule(token, 'The originally triggered Ambassador token is no longer present.'); + openAmbassadorEntry(g, getPlayer(g, overlap.ecaz), token, + getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, 'none'); + overlap.ambassadorEvent = g.pendingAmbassador!.event; + return; + } + requireRule(overlap.stage === 'second' && overlap.first === 'ambassador', + 'The second Terror reaction is out of order.'); + finishArrivalOverlap(g, overlap); + return; + } if (pending.resume === 'wormRide') nextWormRide(g); else if (pending.resume === 'ambassador') continueAmbassadorArrival(g, pending.ambassadorEvent!); @@ -15542,6 +15744,7 @@ function marketGholaIntegrity(g: Game) { nexusCardsIntegrity(g); nexusEmperorSecretIntegrity(g); nexusEmperorBetrayalIntegrity(g); + nexusFremenBetrayalIntegrity(g); nexusBgBetrayalIntegrity(g); nexusFremenRevivalIntegrity(g); nexusFremenCunningIntegrity(g); @@ -20393,7 +20596,7 @@ function validateGuildShipmentDecision( validatePhysicalShipment(g, shipment); return shipment; } -const GUILD_RATE_CLASSIC_FACTIONS: Partial> = { +const CLASSIC_FACTIONS: Partial> = { atreides: true, harkonnen: true, emperor: true, fremen: true, guild: true, beneGesserit: true, }; @@ -20402,7 +20605,7 @@ const GUILD_RATE_CLASSIC_FACTIONS: Partial> = { function guildRateModeSupported(g: Game): boolean { return !g.expansions.length && !g.homeworlds && !g.leaderSkills && !g.nexusCards && !g.discoveryEnabled && !g.techTokens && !g.strongholdCards && - g.players.every(player => GUILD_RATE_CLASSIC_FACTIONS[player.faction] === true); + g.players.every(player => CLASSIC_FACTIONS[player.faction] === true); } function ordinaryGuildRateFullCost(g: Game, shipment: PendingShipment): number | null { const guild = byFaction(g, 'guild'); @@ -22509,6 +22712,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { leaderSkillsIntegrity(state); moritaniExtortionIntegrity(state); guildRateIntegrity(state); + arrivalOverlapIntegrity(state); discoveryIntegrity(state); greatMakerIntegrity(state); nexusChoamTradeIntegrity(state); @@ -22692,6 +22896,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { harkonnenExchangeIntegrity(g); moritaniExtortionIntegrity(g); guildRateIntegrity(g); + arrivalOverlapIntegrity(g); return g; } function finishActionContinuations(g: Game) { @@ -22801,6 +23006,7 @@ export function normalizeAutomaticGame(state: Game): Game { leaderSkillsIntegrity(state); moritaniExtortionIntegrity(state); guildRateIntegrity(state); + arrivalOverlapIntegrity(state); discoveryIntegrity(state); greatMakerIntegrity(state); nexusChoamTradeIntegrity(state); @@ -22856,6 +23062,7 @@ export function normalizeAutomaticGame(state: Game): Game { harkonnenExchangeIntegrity(g); moritaniExtortionIntegrity(g); guildRateIntegrity(g); + arrivalOverlapIntegrity(g); return g; } @@ -22956,6 +23163,7 @@ function applyActionInner( if (t === 'nexusFremenRevive') { playNexusFremenRevive(g,p,action); return g; } if (t === 'nexusMoritaniBetrayal') { playMoritaniBetrayal(g, p, action); return g; } if (t === 'nexusEcazBetrayal') { playEcazBetrayal(g, p, action); return g; } + if (t === 'nexusFremenBetrayal') { playNexusFremenBetrayal(g, p, action); return g; } if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; } requireRule( !( @@ -26156,6 +26364,7 @@ function applyActionInner( } else if ( !move.ornithopterRange && !move.discoveryFlight && p.faction === 'fremen' && + p.fremenNexusMovementBlockedTurn !== g.turn && !(p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved) && !fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') && (move.sandmaster @@ -26626,6 +26835,7 @@ export function viewGame(state: Game, id: string) { leaderSkillsIntegrity(state); moritaniExtortionIntegrity(state); guildRateIntegrity(state); + arrivalOverlapIntegrity(state); discoveryFlightIntegrity(state); discoveryIntegrity(state); greatMakerIntegrity(state); @@ -26753,6 +26963,17 @@ export function viewGame(state: Game, id: string) { moritaniTerror: g.moritaniTerror ? projectTerror(g.moritaniTerror, me.faction === 'moritani') : null, + arrivalOverlapMode: arrivalOverlapModeSupported(g), + arrivalOverlap: g.pendingArrivalOverlap + ? { + first: g.pendingArrivalOverlap.first, + active: g.pendingArrivalOverlap.stage === 'first' + ? g.pendingArrivalOverlap.first + : g.pendingArrivalOverlap.first === 'ambassador' ? 'terror' : 'ambassador', + entrant: g.pendingArrivalOverlap.entrant, + territory: g.pendingArrivalOverlap.territory, + } + : null, extortion: { deferred: g.moritaniExtortion?.stage === 'reserved' ? 5 : 0, pending: g.moritaniExtortion?.stage === 'payment' && g.decision?.kind === 'moritaniExtortion' @@ -26988,6 +27209,8 @@ export function viewGame(state: Game, id: string) { g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), nexusEcazBetrayal: ecazBetrayalOffer(g, id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), + nexusFremenBetrayal: fremenBetrayalOffer(g, id, + g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), nexusRichese: nexusRicheseOffer(g,id), nexusRicheseCunning: nexusRicheseCunningOffer(g, id), nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id), @@ -27336,8 +27559,10 @@ export function viewGame(state: Game, id: string) { advisorSetup: p.advisorSetup, specialKaramaUsed: p.specialKaramaUsed ?? false, fremenMovementBlocked: - p.fremenMovementBlocked?.turn === g.turn && - p.fremenMovementBlocked.move === p.moved, + p.fremenNexusMovementBlockedTurn === g.turn || + (p.fremenMovementBlocked?.turn === g.turn && + p.fremenMovementBlocked.move === p.moved), + fremenNexusMovementBlocked: p.fremenNexusMovementBlockedTurn === g.turn, ixMovementBlocked: p.ixMovementBlocked?.turn === g.turn && p.ixMovementBlocked.move === p.moved, diff --git a/game/karama-movement-preflight.ts b/game/karama-movement-preflight.ts index 0b2b0167..4334228c 100644 --- a/game/karama-movement-preflight.ts +++ b/game/karama-movement-preflight.ts @@ -23,6 +23,8 @@ export type MovementArrivalPlayer = Readonly< export type CompletedMovementArrivalInput = Readonly<{ advanced: boolean; + /** The caller supports resolving both optional arrival windows on this entry. */ + overlapSupported?: boolean; players: readonly MovementArrivalPlayer[]; order: Readonly<{ player: string; @@ -54,7 +56,7 @@ export type CompletedMovementArrivalInput = Readonly<{ export type CompletedMovementArrivalQuote = Readonly<{ intrusion: boolean; - reaction: 'ambassador' | 'terror' | null; + reaction: 'ambassador' | 'terror' | 'overlap' | null; retiresOrnithopter: boolean; }>; @@ -119,6 +121,17 @@ export function quoteCompletedMovementArrival( ); const response = controls.response || order.wantsFighters; const decision = controls.decision || intrusion; + if ( + triggersAmbassador && + triggersTerror && + input.overlapSupported && + ecaz!.id !== moritani!.id && + !response && + !decision && + !controls.pendingTerror && + !controls.pendingAmbassador + ) + return { ...base, reaction: 'overlap' }; if (triggersAmbassador) { if ( response || diff --git a/game/moritani-alliance-cancellation.ts b/game/moritani-alliance-cancellation.ts index 78b4879b..23fa7561 100644 --- a/game/moritani-alliance-cancellation.ts +++ b/game/moritani-alliance-cancellation.ts @@ -11,6 +11,7 @@ import { } from './karama-movement-cancellation'; import { TERROR_KINDS } from './moritani-terror'; import { validateTerrorEntrySignature } from './terror-entry-receipt'; +import { overlapOwnsWormTerror } from './arrival-overlap'; export class MoritaniAllianceCancellationError extends Error { constructor(message: string) { @@ -25,6 +26,7 @@ export type MoritaniAllianceCancellationContext = Pick< | 'phase' | 'players' | 'pendingTerrorEntry' + | 'pendingArrivalOverlap' | 'moritaniTerror' | 'pendingAmbassador' | 'ecazAmbassadors' @@ -164,7 +166,7 @@ export function quoteMoritaniAllianceCancellation( requireSource( entry.cause === 'wormRide' ? g.phase === 1 && - entry.resume === 'wormRide' && + (entry.resume === 'wormRide' || overlapOwnsWormTerror(g, entry)) && entrants[0].faction === 'fremen' : g.phase === 5 && entry.resume === 'none' && diff --git a/game/nexus-fremen-betrayal-options.ts b/game/nexus-fremen-betrayal-options.ts new file mode 100644 index 00000000..099c0ff1 --- /dev/null +++ b/game/nexus-fremen-betrayal-options.ts @@ -0,0 +1,16 @@ +import type { Action, GameView } from './engine'; + +/** Only the holder's private offer authorizes spending the physical Nexus card. */ +export function fremenBetrayalAction(game: GameView): Action | null { + const offer = game.nexusFremenBetrayal; + const owner = game.players.find(player => player.id === game.me); + if (!offer || offer.blocked || !owner || owner.ally || + owner.faction === 'fremen' || game.nexusCards?.card !== 'fremen' || + game.automaticContinuationPending) return null; + return { type: 'nexusFremenBetrayal', event: offer.event }; +} + +export function fremenBetrayalBotActions(game: GameView): Action[] { + const action = fremenBetrayalAction(game); + return action ? [action] : []; +} diff --git a/game/nexus-fremen-betrayal.ts b/game/nexus-fremen-betrayal.ts new file mode 100644 index 00000000..85219c73 --- /dev/null +++ b/game/nexus-fremen-betrayal.ts @@ -0,0 +1,50 @@ +import type { Game } from './engine'; +import { nexusCleanPlayBlocked } from './nexus-play-boundary'; + +export type FremenNexusBetrayal = { + event: string; + owner: string; + target: string; + turn: number; + phase: 5; + signature: string; +}; +export const fremenBetrayalEvent = (turn: number, owner: string, target: string) => + JSON.stringify(['nexusFremenBetrayal', turn, owner, target]); +export const fremenBetrayalSignature = (record: FremenNexusBetrayal) => + JSON.stringify({ ...record, signature: undefined }); + +/** Clean pre-movement use prevents retroactive suppression and never tests + * secret card custody of another seat when constructing public turn state. */ +export function fremenBetrayalOffer(g: Game, owner: string, automaticPending = false) { + const holder = g.players.find(p => p.id === owner); + const fremen = g.players.find(p => p.faction === 'fremen'); + if (!holder || !fremen || g.nexusCards?.cards?.hands[owner] !== 'fremen' || + holder.faction === 'fremen') return null; + let blocked: string | null = null; + if (holder.ally) blocked = 'An allied player cannot use a Nexus card.'; + else if (g.status !== 'playing' || g.phase !== 5) + blocked = 'Use Fremen Betrayal before Fremen move during Shipment and Movement.'; + else if (g.expansions.length || g.homeworlds || g.leaderSkills || g.discoveryEnabled || + g.discoveries || g.discoveryStash || g.greatMaker || g.ecazTreachery || + g.techTokens || g.strongholdCards || + g.players.some(p => !['atreides','harkonnen','emperor','fremen','guild','beneGesserit'].includes(p.faction))) + blocked = 'Combined expansion and module movement interactions remain unavailable.'; + else if (nexusCleanPlayBlocked(g, automaticPending)) + blocked = 'Finish the current interaction before using Fremen Betrayal.'; + else if (!g.movementRemaining?.includes(fremen.id) || fremen.moved !== 0 || + fremen.fremenNexusMovementBlockedTurn === g.turn) + blocked = 'Fremen have already moved or completed their turn.'; + return { event: fremenBetrayalEvent(g.turn,owner,fremen.id), blocked, target: fremen.id }; +} + +export function validateFremenBetrayal(g: Game, record: FremenNexusBetrayal) { + if (!record || Object.keys(record).sort().join(',') !== 'event,owner,phase,signature,target,turn' || + !Number.isSafeInteger(record.turn) || record.turn < 1 || record.turn > g.turn || + record.phase !== 5 || record.owner === record.target || + !g.players.some(p => p.id === record.owner && p.faction !== 'fremen') || + !g.players.some(p => p.id === record.target && p.faction === 'fremen') || + record.event !== fremenBetrayalEvent(record.turn,record.owner,record.target) || + record.signature !== fremenBetrayalSignature(record)) + throw new Error('Fremen Betrayal has lost its original movement restriction.'); +} diff --git a/game/reference.ts b/game/reference.ts index 64a3b735..48c92499 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1281,7 +1281,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The mobile stronghold can receive a non-Ixian group only from the territory it points to. Ecaz and its ally may co-occupy under Occupy. Advisors keep their applicable stance and accompaniment locks; an eligible arrival can request fighters. CHOAM may use Baliset during Shipment and Movement; if allowed, choose another legal destination. Intrusion or Terror resolves before the original entrant continues. Destinations causing both remain visibly unavailable pending a table interpretation. There is no extra decline after triggering; if no legal move remains, the effect finishes without moving pieces and its token stays consumed.', 'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.', 'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.', - 'No-Field substitution into the free four-force grant awaits the user’s interpretation and is unavailable. Simultaneous Intrusion/Terror and Ambassador/Terror combinations remain blocked. Moritani Atomics Aftermath, Homeworlds and Discovery locations require their still-gated producers or modules; this checkpoint does not claim those combinations.', + 'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. Competing Intrusion and Ambassador-created Fremen/Guild child arrivals remain gated. Atomics Aftermath, Homeworlds, Nexus and Discovery combinations do not gain coverage from this first overlap path.', 'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. The remaining effects, combined Occupy combat, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.', ], checklist: [ @@ -4047,6 +4047,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.', 'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported.', 'Ecaz Cunning lets an unallied native holder spend the physical Nexus card at a quiet Battle boundary to take the existing living Duke Vidal for the turn, even from Moritani. The Duke can be selected through ordinary Battle plans and is set aside after use or at turn end. This first version is limited to paired Ecaz/Moritani with other classic factions and no combined optional modules; Advanced Harkonnen is excluded because the existing battle path cannot select Duke there. Taking Duke from the Tanks, capture or Ghola custody remains unimplemented. The Battle-only timing is a product boundary, not printed text.', + 'Fremen Betrayal currently supports its movement-range alternative for an unallied holder in a classic Nexus table. Spend the card before Fremen move during Shipment and Movement to suppress their native two-territory advantage for that turn; ordinary one-territory movement and independent ornithopters remain. The separate printed worm-riding prevention is not yet connected. The owner-only control shows this boundary and a spent card cannot be used again.', 'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.', 'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.', 'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games without other optional modules. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. Other module combinations and the full faceup-component display remain gated.', @@ -4274,8 +4275,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Ixians keep their private starting-card selection and physical cyborg/suboid forces. Tleilaxu receives three private Face Dancers. Advanced CHOAM receives its Auditor. Richese has a separate ten-card cache and three private No-Fields. Ecaz and Moritani receive their Ambassador and Terror inventories and share one Duke Vidal disc.', 'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has Recruits, bounded Harass & Withdraw and bounded Reinforcements development controls for classic-faction games.', 'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.', - 'Fresh unsupported movement arrivals reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. Simultaneous Ambassador/Terror order remains unresolved.', - 'Ordinary shipment checks unfinished arrival combinations before saving the Guild interception choice. An already saved ordinary shipment may return unspent to its shipper when allowance encounters that arrival guard, so the shipper can choose another destination. Older No-Field, Smuggler, Nexus and Ambassador shipments are excluded from this recovery. Simultaneous arrival order remains unfinished.', + 'Fresh unsupported movement arrivals still reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. The bounded Ecaz/Moritani same-entry Ambassador/Terror overlap instead snapshots both eligible owners in storm order without replaying the original move.', + 'Ordinary shipment checks unfinished arrival combinations before saving the Guild interception choice. An already saved ordinary shipment may return unspent when its still-gated arrival cannot commit; older No-Field, Smuggler, Nexus and Ambassador shipments retain their separate recovery boundaries. Classic plus Ecaz/Moritani Ambassador/Terror overlap has a provisional saved storm-order sequence, while other simultaneous priorities remain unfinished.', 'Remaining abilities and combined timing can still encounter explicit development guards. Setup and saved-continuation evidence does not certify complete expansion play.', ], related: ['setup', 'ix-modules', 'choam-modules', 'ecaz-modules', 'implementation-checklist'], diff --git a/tests/ambassador-terror-overlap-engine.test.ts b/tests/ambassador-terror-overlap-engine.test.ts new file mode 100644 index 00000000..cd3521d2 --- /dev/null +++ b/tests/ambassador-terror-overlap-engine.test.ts @@ -0,0 +1,250 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + createGame, + newPlayer, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; +import { createTerrorState, placeTerror } from '../game/moritani-terror'; + +type Owner = 'ambassador' | 'terror'; +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!; +const shipment: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 }; +const movement: Action = { + type: 'move', + from: 'imperial_basin:10', + territory: 'arrakeen', + sector: 10, + amount: 2, +}; + +/** Both inventories are physical, legally placed in the same stronghold. No optional module competes for arrival. */ +function table(first: Owner, advanced: boolean, cause: 'shipment' | 'movement' = 'shipment') { + const g = createGame('OVERLAP', newPlayer('ec', 'Ecaz', 'ecaz'), advanced, ['ecaz']); + g.players.push(newPlayer('m', 'Moritani', 'moritani'), newPlayer('in', 'Entrant', 'harkonnen')); + Object.assign(g, { + status: 'playing', phase: 5, turn: 3, storm: 18, active: 'in', + order: first === 'ambassador' ? ['ec', 'm', 'in'] : ['m', 'ec', 'in'], + deck: baseDeck(), + }); + g.movementRemaining = [...g.order]; + for (const player of g.players) { + Object.assign(player, { hand: [], spice: 20, forces: {}, reserves: 20, shipped: false, moved: 0 }); + } + if (cause === 'movement') { + Object.assign(seat(g, 'in'), { + shipped: true, + forces: { 'imperial_basin:10': 3 }, + reserves: 17, + }); + } + const ambassadors = createAmbassadors(() => 0.2); + const emperor = ambassadors.tokens.find((token) => token.effect === 'emperor')!; + ambassadors.cohort = [ + emperor.id, + ...ambassadors.tokens.filter((token) => token.effect !== 'ecaz' && token.id !== emperor.id) + .slice(0, 4).map((token) => token.id), + ]; + for (const token of ambassadors.tokens) { + token.zone = token.effect === 'ecaz' || ambassadors.cohort.includes(token.id) ? 'supply' : 'pool'; + token.location = null; + } + g.ecazAmbassadors = placeAmbassador(ambassadors, emperor.id, { + turn: 2, + availableSpice: 20, + destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true }, + }).state; + const terror = createTerrorState(() => 0.2); + const robbery = terror.tokens.find((token) => token.kind === 'robbery')!; + g.moritaniTerror = placeTerror(terror, robbery.id, 'arrakeen', 2); + return { g, ambassadorToken: emperor.id, terrorToken: robbery.id }; +} +function unchangedRejection(g: Game, id: string, action: Action) { + const before = structuredClone(g); + assert.throws(() => applyAction(g, id, action)); + assert.deepEqual(g, before); +} +function overlap(g: Game, first: Owner, active: Owner) { + const pending = g.pendingArrivalOverlap; + assert.ok(pending); + assert.equal(pending.first, first); + assert.equal(pending.stage, first === active ? 'first' : 'second'); + assert.equal(pending.entrant, 'in'); + assert.equal(pending.territory, 'arrakeen'); + assert.equal(pending.sector, 10); + assert.equal(pending.amount, 2); + assert.equal(pending.elite, 0); + assert.equal(pending.turn, 3); + assert.equal(pending.phase, 5); + assert.equal(pending.ecaz, 'ec'); + assert.equal(pending.moritani, 'm'); + assert.ok(pending.signature); + for (const id of ['ec', 'm', 'in']) { + const view = viewGame(g, id); + assert.deepEqual(view.arrivalOverlap, { + first, active, entrant: 'in', territory: 'arrakeen', + }); + assert.equal(JSON.stringify(view.arrivalOverlap).includes(pending.signature), false); + assert.equal('pendingArrivalOverlap' in view, false); + } + return pending; +} +function privateTerror(g: Game, token: string) { + const owner = viewGame(g, 'm'); + const entrant = viewGame(g, 'in'); + const ecaz = viewGame(g, 'ec'); + const ownToken = owner.moritaniTerror!.tokens.find((t) => t.id === token)!; + const foreignTokens = [entrant, ecaz].map((v) => v.moritaniTerror!.tokens.find((t) => t.id === token)!); + assert.equal('kind' in ownToken && ownToken.kind, 'robbery'); + for (const foreignToken of foreignTokens) assert.equal('kind' in foreignToken, false); + for (const view of [entrant, ecaz]) { + assert.equal('pendingTerrorEntry' in view, false); + assert.equal(JSON.stringify(view).includes('"kind":"robbery"'), false); + } + if (g.pendingTerrorEntry) { + assert.equal(owner.terrorEntry && 'kind' in owner.terrorEntry && owner.terrorEntry.kind, 'robbery'); + for (const view of [entrant, ecaz]) assert.equal('kind' in (view.terrorEntry ?? {}), false); + } +} +function decideAmbassador(g: Game, trigger: boolean) { + return applyAction(g, 'ec', { + type: 'decision', event: g.pendingAmbassador!.event, + ...(trigger ? { trigger: true, beneficiary: 'ec' } : { decline: true }), + }); +} +function decideTerror(g: Game, reveal: boolean) { + return applyAction(g, 'm', { type: 'decision', ...(reveal ? { reveal: true } : { decline: true }) }); +} + +for (const advanced of [false, true]) + for (const first of ['ambassador', 'terror'] as const) + for (const cause of ['shipment', 'movement'] as const) { + void test(`${advanced ? 'Advanced' : 'Basic'} ${cause}: ${first} first, both optional offers and original entry survive JSON`, () => { + const { g: initial, ambassadorToken, terrorToken } = table(first, advanced, cause); + const action = cause === 'shipment' ? shipment : movement; + const offered = applyAction(initial, 'in', action); + const witness = overlap(offered, first, first); + assert.equal(witness.cause, cause); + assert.equal(witness.resume, 'none'); + assert.equal(witness.ambassadorToken, ambassadorToken); + assert.deepEqual(witness.terrorTokens, [terrorToken]); + assert.equal(offered.decision?.kind, first === 'ambassador' ? 'ecazAmbassador' : 'moritaniTerror'); + assert.equal(offered.decision?.player, first === 'ambassador' ? 'ec' : 'm'); + assert.equal(offered.pendingAmbassador?.entrant ?? offered.pendingTerrorEntry?.entrant, 'in'); + assert.equal(seat(offered, 'in').forces['arrakeen:10'], 2); + assert.equal(seat(offered, 'in').reserves, cause === 'shipment' ? 18 : 17); + assert.equal(seat(offered, 'in').spice, cause === 'shipment' ? 18 : 20); + if (cause === 'shipment') assert.equal(seat(offered, 'in').shipped, true); + else { + assert.equal(seat(offered, 'in').forces['imperial_basin:10'], 1); + assert.equal(seat(offered, 'in').moved, 1); + } + privateTerror(offered, terrorToken); + unchangedRejection(offered, 'in', { type: 'endMovement' }); + unchangedRejection(offered, first === 'ambassador' ? 'm' : 'ec', { + type: 'decision', decline: true, + }); + let second = first === 'ambassador' + ? decideAmbassador(reload(offered), false) + : decideTerror(reload(offered), false); + const next = first === 'ambassador' ? 'terror' : 'ambassador'; + const secondWitness = overlap(second, first, next); + assert.equal(secondWitness.signature, witness.signature); + assert.equal(secondWitness.cause, cause); + assert.equal(secondWitness.entrant, witness.entrant); + assert.equal(second.decision?.kind, next === 'ambassador' ? 'ecazAmbassador' : 'moritaniTerror'); + assert.equal(second.decision?.player, next === 'ambassador' ? 'ec' : 'm'); + assert.equal(second.pendingAmbassador?.entrant ?? second.pendingTerrorEntry?.entrant, 'in'); + assert.equal(seat(second, 'in').forces['arrakeen:10'], 2); + privateTerror(second, terrorToken); + unchangedRejection(second, first === 'ambassador' ? 'ec' : 'm', { + type: 'decision', + ...(first === 'ambassador' ? { event: offered.pendingAmbassador!.event } : {}), + decline: true, + }); + second = next === 'ambassador' + ? decideAmbassador(reload(second), false) + : decideTerror(reload(second), false); + assert.equal(second.pendingArrivalOverlap ?? null, null); + assert.equal(second.pendingAmbassador ?? null, null); + assert.equal(second.pendingTerrorEntry ?? null, null); + assert.equal(second.decision, null); + assert.equal(viewGame(second, 'in').arrivalOverlap, null); + assert.equal(seat(second, 'in').forces['arrakeen:10'], 2); + assert.equal(seat(second, 'in').spice, cause === 'shipment' ? 18 : 20); + assert.equal(second.ecazAmbassadors!.tokens.find((t) => t.id === ambassadorToken)!.zone, 'placed'); + assert.equal(second.moritaniTerror!.tokens.find((t) => t.id === terrorToken)!.status, 'placed'); + unchangedRejection(second, 'in', action); + }); + } + +for (const first of ['ambassador', 'terror'] as const) { + void test(`${first} first: persisted arrival witness rejects changed entry and owner order`, () => { + const offered = applyAction(table(first, false).g, 'in', shipment); + for (const change of [ + (g: Game) => { g.pendingArrivalOverlap!.amount = 3; }, + (g: Game) => { g.pendingArrivalOverlap!.entrant = 'ec'; }, + (g: Game) => { g.pendingArrivalOverlap!.terrorTokens = []; }, + (g: Game) => { g.pendingArrivalOverlap!.first = first === 'ambassador' ? 'terror' : 'ambassador'; }, + (g: Game) => { g.order.reverse(); }, + ]) { + const corrupt = reload(offered); + change(corrupt); + unchangedRejection(corrupt, first === 'ambassador' ? 'ec' : 'm', first === 'ambassador' + ? { type: 'decision', event: corrupt.pendingAmbassador!.event, decline: true } + : { type: 'decision', decline: true }); + } + }); +} + +for (const first of ['ambassador', 'terror'] as const) { + void test(`${first} first: Emperor and Robbery settle exactly once with original entrant and private token custody`, () => { + const { g, ambassadorToken, terrorToken } = table(first, true); + let state = applyAction(g, 'in', shipment); + const signature = overlap(state, first, first).signature; + if (first === 'ambassador') { + const event = state.pendingAmbassador!.event; + state = decideAmbassador(reload(state), true); + assert.equal(seat(state, 'ec').spice, 25); + assert.equal(state.ecazAmbassadors!.tokens.find((t) => t.id === ambassadorToken)!.zone, 'used'); + overlap(state, first, 'terror'); + privateTerror(state, terrorToken); + unchangedRejection(state, 'ec', { type: 'decision', event, trigger: true, beneficiary: 'ec' }); + state = decideTerror(reload(state), true); + assert.equal(state.pendingTerrorEntry?.stage, 'robbery'); + assert.equal(state.pendingTerrorEntry?.entrant, 'in'); + assert.equal(state.pendingArrivalOverlap?.signature, signature); + state = applyAction(reload(state), 'm', { type: 'decision', choice: 'spice' }); + } else { + state = decideTerror(reload(state), true); + assert.equal(state.pendingTerrorEntry?.stage, 'robbery'); + assert.equal(state.pendingTerrorEntry?.entrant, 'in'); + assert.equal(state.pendingArrivalOverlap?.signature, signature); + state = applyAction(reload(state), 'm', { type: 'decision', choice: 'spice' }); + const event = state.pendingAmbassador!.event; + overlap(state, first, 'ambassador'); + assert.equal(state.pendingAmbassador?.entrant, 'in'); + state = decideAmbassador(reload(state), true); + unchangedRejection(state, 'ec', { type: 'decision', event, trigger: true, beneficiary: 'ec' }); + } + assert.equal(state.pendingArrivalOverlap ?? null, null); + assert.equal(state.decision, null); + assert.equal(seat(state, 'in').spice, 9); + assert.equal(seat(state, 'm').spice, 29); + assert.equal(seat(state, 'ec').spice, 25); + assert.equal(seat(state, 'in').reserves, 18); + assert.equal(seat(state, 'in').forces['arrakeen:10'], 2); + assert.equal(state.ecazAmbassadors!.tokens.find((t) => t.id === ambassadorToken)!.zone, 'used'); + assert.equal(state.moritaniTerror!.tokens.find((t) => t.id === terrorToken)!.status, 'removed'); + assert.equal(state.log.filter((line) => line.automatic?.name === 'Emperor Ambassador').length, 1); + unchangedRejection(state, 'm', { type: 'decision', choice: 'spice' }); + unchangedRejection(state, 'in', shipment); + assert.deepEqual(reload(state).players, state.players); + }); +} diff --git a/tests/ambassador-terror-overlap-recovery.test.ts b/tests/ambassador-terror-overlap-recovery.test.ts new file mode 100644 index 00000000..b8ab2ceb --- /dev/null +++ b/tests/ambassador-terror-overlap-recovery.test.ts @@ -0,0 +1,281 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import type * as Rooms from '../db/rooms'; +import type { RoomsClock, SeatAuth } from '../db/rooms'; +import { viewGame, type Action } from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; +import { createTerrorState, placeTerror } from '../game/moritani-terror'; +import { unitStore } from './fixture-nexus-room-store'; + +const clock: RoomsClock = { now: () => 45000, sleep: async () => {} }; + +async function room(t: test.TestContext, ecazFirst: boolean) { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const created = await store.rooms.createRoom('Ecaz', 'ecaz', false, ['ecaz']); + const code = created.view.code; + const moritani = await store.rooms.joinRoom(code, 'Moritani', 'moritani'); + const entrant = await store.rooms.joinRoom(code, 'Entrant', 'harkonnen'); + const tokens = [created.token, moritani.token!, entrant.token!]; + const auth = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token))); + const [ec, m, incoming] = auth.map((seat) => seat.playerId); + const g = await store.rooms.readRoom(code); + Object.assign(g, { + status: 'playing', phase: 5, turn: 3, storm: 18, active: incoming, + order: ecazFirst ? [ec, m, incoming] : [m, ec, incoming], deck: baseDeck(), + }); + g.movementRemaining = [...g.order]; + for (const player of g.players) + Object.assign(player, { hand: [], spice: 20, forces: {}, reserves: 20, shipped: false, moved: 0 }); + const ambassadors = createAmbassadors(() => 0.2); + const emperor = ambassadors.tokens.find((token) => token.effect === 'emperor')!; + ambassadors.cohort = [emperor.id, ...ambassadors.tokens + .filter((token) => token.effect !== 'ecaz' && token.id !== emperor.id) + .slice(0, 4).map((token) => token.id)]; + for (const token of ambassadors.tokens) { + token.zone = token.effect === 'ecaz' || ambassadors.cohort.includes(token.id) ? 'supply' : 'pool'; + token.location = null; + } + g.ecazAmbassadors = placeAmbassador(ambassadors, emperor.id, { + turn: 2, availableSpice: 20, + destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true }, + }).state; + const terror = createTerrorState(() => 0.2); + const robbery = terror.tokens.find((token) => token.kind === 'robbery')!; + g.moritaniTerror = placeTerror(terror, robbery.id, 'arrakeen', 2); + store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(g), g.version, code); + const ship: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 }; + const offered = await store.rooms.act(code, auth[2], g.version, ship, clock); + assert.deepEqual(offered.arrivalOverlap, { + first: ecazFirst ? 'ambassador' : 'terror', + active: ecazFirst ? 'ambassador' : 'terror', + entrant: incoming, territory: 'arrakeen', + }); + store.writes.length = 0; + return { ...store, code, tokens, auth, ids: { ec, m, incoming }, ship, emperor: emperor.id, robbery: robbery.id }; +} +interface Fixture { + sqlite: DatabaseSync; + restart: () => typeof Rooms; + code: string; + tokens: string[]; + auth: SeatAuth[]; + ids: { ec: string; m: string; incoming: string }; + ship: Action; + emperor: string; + robbery: string; + writes: { expected: number; changes: number }[]; +} +const rows = (f: { sqlite: DatabaseSync }) => f.sqlite.prepare('SELECT state, version FROM rooms ORDER BY code').all(); + +async function saved(f: Fixture) { + const g = await f.restart().readRoom(f.code); + const before = rows(f); + for (let index = 0; index < f.tokens.length; index++) { + const rooms = f.restart(); + const auth = await rooms.authenticate(f.code, f.tokens[index]); + const view = await rooms.readSeatView(f.code, auth); + assert.deepEqual(view, viewGame(g, auth.playerId)); + assert.equal('pendingArrivalOverlap' in view, false); + assert.equal('pendingTerrorEntry' in view, false); + assert.equal('kind' in view.moritaniTerror!.tokens.find((token) => token.id === f.robbery)!, index === 1 || + g.moritaniTerror!.tokens.find((token) => token.id === f.robbery)!.status === 'removed'); + assert.equal(JSON.stringify(view.arrivalOverlap).includes(f.robbery), false); + assert.equal(JSON.stringify(view.arrivalOverlap).includes(g.pendingArrivalOverlap?.signature ?? 'unavailable'), false); + } + await f.restart().continueRoomAutomatic(f.code, clock); + assert.deepEqual(rows(f), before); + return g; +} + +for (const ecazFirst of [true, false]) { + void test(`SQLite CAS restores ${ecazFirst ? 'Ecaz-first' : 'Moritani-first'} two-stage arrival without replaying shipment`, async (t) => { + const f = await room(t, ecazFirst); + let g = await saved(f); + const version = g.version; + const signature = g.pendingArrivalOverlap!.signature; + const wrongOwner = ecazFirst ? 1 : 0; + const firstOwner = ecazFirst ? 0 : 1; + const first: Action = ecazFirst + ? { type: 'decision', event: g.pendingAmbassador!.event, trigger: true, beneficiary: f.ids.ec } + : { type: 'decision', reveal: true }; + const before = rows(f); + await assert.rejects(f.restart().act(f.code, f.auth[wrongOwner], version, first, clock)); + await assert.rejects(f.restart().act(f.code, f.auth[firstOwner], version - 1, first, clock), /table changed/); + assert.deepEqual(rows(f), before); + assert.equal(f.writes.length, 0); + await f.restart().act(f.code, f.auth[firstOwner], version, first, clock); + g = await saved(f); + assert.equal(g.pendingArrivalOverlap!.signature, signature); + assert.equal(g.pendingArrivalOverlap!.entrant, f.ids.incoming); + assert.equal(g.players[2].forces['arrakeen:10'], 2); + assert.equal(g.players[2].reserves, 18); + assert.equal(g.players[2].spice, 18); + if (!ecazFirst) { + assert.equal(g.pendingTerrorEntry?.stage, 'robbery'); + await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', choice: 'spice' }, clock); + g = await saved(f); + assert.equal(g.pendingArrivalOverlap?.stage, 'second'); + assert.equal(g.decision?.kind, 'ecazAmbassador'); + } else { + assert.equal(g.pendingArrivalOverlap?.stage, 'second'); + assert.equal(g.decision?.kind, 'moritaniTerror'); + } + const prior = rows(f); + await assert.rejects(f.restart().act(f.code, f.auth[firstOwner], g.version, first, clock)); + assert.deepEqual(rows(f), prior); + if (ecazFirst) { + await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', reveal: true }, clock); + g = await saved(f); + assert.equal(g.pendingTerrorEntry?.entrant, f.ids.incoming); + await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', choice: 'spice' }, clock); + } else { + await f.restart().act(f.code, f.auth[0], g.version, { + type: 'decision', event: g.pendingAmbassador!.event, trigger: true, beneficiary: f.ids.ec, + }, clock); + } + g = await saved(f); + assert.equal(g.pendingArrivalOverlap ?? null, null); + assert.equal(g.pendingAmbassador ?? null, null); + assert.equal(g.pendingTerrorEntry ?? null, null); + assert.equal(g.decision, null); + assert.equal(g.players[2].spice, 9); + assert.equal(g.players[1].spice, 29); + assert.equal(g.players[0].spice, 25); + assert.equal(g.players[2].forces['arrakeen:10'], 2); + assert.equal(g.ecazAmbassadors!.tokens.find((token) => token.id === f.emperor)!.zone, 'used'); + assert.equal(g.moritaniTerror!.tokens.find((token) => token.id === f.robbery)!.status, 'removed'); + const finished = rows(f); + await assert.rejects(f.restart().act(f.code, f.auth[2], g.version, f.ship, clock)); + await assert.rejects(f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', choice: 'spice' }, clock)); + assert.deepEqual(rows(f), finished); + }); +} + +const ride: Action = { + type: 'decision', accept: true, territory: 'arrakeen', sector: 10, + forces: { 'imperial_basin:10': 2 }, +}; + +/** A phase-one Fremen ride into the shared stronghold, with a second ride still queued. */ +async function wormRoom(t: test.TestContext, advanced: boolean) { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const created = await store.rooms.createRoom('Ecaz', 'ecaz', advanced, ['ecaz']); + const code = created.view.code; + const moritani = await store.rooms.joinRoom(code, 'Moritani', 'moritani'); + const rider = await store.rooms.joinRoom(code, 'Rider', 'fremen'); + const tokens = [created.token, moritani.token!, rider.token!]; + const auth = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token))); + const [ec, m, incoming] = auth.map((seat) => seat.playerId); + const g = await store.rooms.readRoom(code); + Object.assign(g, { + status: 'playing', phase: 1, turn: 3, storm: 18, active: null, + order: [m, ec, incoming], deck: baseDeck(), + }); + g.movementRemaining = [...g.order]; + for (const player of g.players) + Object.assign(player, { hand: [], spice: 20, forces: {}, reserves: 20, shipped: false, moved: 0 }); + g.players[2].forces = { 'imperial_basin:10': 3, 'hagga_basin:12': 1 }; + g.players[2].reserves = 16; + const ambassadors = createAmbassadors(() => 0.2); + const emperor = ambassadors.tokens.find((token) => token.effect === 'emperor')!; + ambassadors.cohort = [emperor.id, ...ambassadors.tokens + .filter((token) => token.effect !== 'ecaz' && token.id !== emperor.id) + .slice(0, 4).map((token) => token.id)]; + for (const token of ambassadors.tokens) { + token.zone = token.effect === 'ecaz' || ambassadors.cohort.includes(token.id) ? 'supply' : 'pool'; + token.location = null; + } + g.ecazAmbassadors = placeAmbassador(ambassadors, emperor.id, { + turn: 2, availableSpice: 20, + destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true }, + }).state; + const terror = createTerrorState(() => 0.2); + const robbery = terror.tokens.find((token) => token.kind === 'robbery')!; + g.moritaniTerror = placeTerror(terror, robbery.id, 'arrakeen', 2); + g.decision = { kind: 'wormRide', player: incoming, territory: 'imperial_basin' }; + g.wormRides = ['hagga_basin']; + store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(g), g.version, code); + store.writes.length = 0; + return { + ...store, code, tokens, auth, ids: { ec, m, incoming }, + robbery: robbery.id, emperor: emperor.id, + }; +} + +for (const advanced of [false, true]) { + void test(`SQLite CAS restores a ${advanced ? 'Advanced' : 'Basic'} Fremen worm ride through both arrival reactions`, async (t) => { + const f = await wormRoom(t, advanced); + const read = async () => f.restart().readRoom(f.code); + let g = await read(); + const version = g.version; + const rideOffered = await f.restart().act(f.code, f.auth[2], version, ride, clock); + assert.equal('pendingArrivalOverlap' in rideOffered, false); + g = await read(); + assert.equal(g.pendingArrivalOverlap?.resume, 'wormRide'); + assert.equal(g.pendingArrivalOverlap?.cause, 'wormRide'); + assert.equal(g.pendingArrivalOverlap?.first, 'terror'); + assert.equal(g.pendingTerrorEntry?.cause, 'wormRide'); + assert.equal(g.pendingTerrorEntry?.resume, 'none'); + assert.deepEqual(g.wormRides, ['hagga_basin']); + assert.equal(g.players[2].forces['arrakeen:10'], 2); + const spiceAfterRide = g.players[2].spice; + const views = await Promise.all(f.tokens.map(async (token) => { + const rooms = f.restart(); + const auth = await rooms.authenticate(f.code, token); + const view = await rooms.readSeatView(f.code, auth); + assert.deepEqual(view, viewGame(await read(), auth.playerId)); + assert.equal('pendingArrivalOverlap' in view, false); + assert.equal('pendingTerrorEntry' in view, false); + return view; + })); + assert.deepEqual(views[0].arrivalOverlap, { + first: 'terror', active: 'terror', entrant: f.ids.incoming, territory: 'arrakeen', + }); + for (const index of [0, 1, 2]) { + const token = views[index].moritaniTerror!.tokens.find( + (candidate) => candidate.id === f.robbery, + )!; + assert.equal('kind' in token, index === 1); + assert.equal('terrorEntry' in views[index], true); + assert.equal('kind' in (views[index].terrorEntry ?? {}), index === 1); + } + const before = rows(f); + await assert.rejects(f.restart().act(f.code, f.auth[2], g.version, ride, clock)); + assert.deepEqual(rows(f), before); + await f.restart().continueRoomAutomatic(f.code, clock); + assert.deepEqual(rows(f), before); + g = await read(); + await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', reveal: true }, clock); + g = await read(); + assert.equal(g.pendingTerrorEntry?.stage, 'robbery'); + await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', choice: 'spice' }, clock); + g = await read(); + assert.equal(g.pendingTerrorEntry, null); + assert.equal(g.pendingArrivalOverlap?.stage, 'second'); + assert.equal(g.decision?.kind, 'ecazAmbassador'); + await f.restart().act(f.code, f.auth[0], g.version, { + type: 'decision', event: g.pendingAmbassador!.event, decline: true, + }, clock); + g = await read(); + assert.equal(g.pendingArrivalOverlap, null); + assert.equal(g.pendingAmbassador, null); + assert.equal(g.pendingTerrorEntry, null); + assert.deepEqual(g.wormRides, []); + assert.deepEqual(g.decision, { + kind: 'wormRide', player: f.ids.incoming, territory: 'hagga_basin', + }); + assert.equal(g.players[2].forces['arrakeen:10'], 2); + assert.equal(g.players[2].forces['imperial_basin:10'], 1); + const stolen = spiceAfterRide - g.players[2].spice; + assert.equal(stolen, 10, 'Robbery takes spice once'); + assert.equal(g.players[1].spice, 30, 'Moritani keeps the stolen spice exactly once'); + assert.equal(g.moritaniTerror!.tokens.find((token) => token.kind === 'robbery')!.status, 'removed'); + assert.equal(g.ecazAmbassadors!.tokens.find((token) => token.effect === 'emperor')!.zone, 'placed'); + }); +} diff --git a/tests/ambassador-terror-overlap-worm.test.ts b/tests/ambassador-terror-overlap-worm.test.ts new file mode 100644 index 00000000..22f2ef53 --- /dev/null +++ b/tests/ambassador-terror-overlap-worm.test.ts @@ -0,0 +1,194 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + createGame, + newPlayer, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; +import { createTerrorState, placeTerror, type TerrorKind } from '../game/moritani-terror'; +import { botArrivalBlock } from '../game/bot-arrival'; + +const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!; +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +const action = (g: Game, id: string, a: Action) => applyAction(reload(g), id, a); + +/** Classic Ecaz/Moritani table where Moritani precedes Ecaz in storm order. */ +function table(kind: TerrorKind, order: string[] = ['m', 'ec', 'in']) { + const g = createGame('OVERLAPWORM', newPlayer('ec', 'Ecaz', 'ecaz'), true, ['ecaz']); + g.players.push(newPlayer('m', 'Moritani', 'moritani'), newPlayer('in', 'Rider', 'fremen')); + Object.assign(g, { + status: 'playing', phase: 1, turn: 3, storm: 18, active: null, order, + deck: baseDeck(), + }); + g.movementRemaining = ['in', 'm', 'ec']; + for (const player of g.players) + Object.assign(player, { hand: [], spice: 20, forces: {}, reserves: 20 }); + seat(g, 'in').forces = { 'imperial_basin:10': 3, 'hagga_basin:12': 1 }; + seat(g, 'in').reserves = 16; + const ambassadors = createAmbassadors(() => 0.2); + const emperor = ambassadors.tokens.find((token) => token.effect === 'emperor')!; + ambassadors.cohort = [emperor.id, ...ambassadors.tokens + .filter((token) => token.effect !== 'ecaz' && token.id !== emperor.id) + .slice(0, 4).map((token) => token.id)]; + for (const token of ambassadors.tokens) { + token.zone = token.effect === 'ecaz' || ambassadors.cohort.includes(token.id) + ? 'supply' : 'pool'; + token.location = null; + } + g.ecazAmbassadors = placeAmbassador(ambassadors, emperor.id, { + turn: 2, availableSpice: 20, + destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true }, + }).state; + const terror = createTerrorState(() => 0.2); + const token = terror.tokens.find((candidate) => candidate.kind === kind)!; + g.moritaniTerror = placeTerror(terror, token.id, 'arrakeen', 1); + return { g, terror: token.id }; +} +const ride: Action = { + type: 'decision', accept: true, territory: 'arrakeen', sector: 10, + forces: { 'imperial_basin:10': 2 }, +}; +/** A Fremen ride into the stronghold, leaving a queued second ride behind it. */ +function wormRide(g: Game) { + const state = reload(g); + state.decision = { kind: 'wormRide', player: 'in', territory: 'imperial_basin' }; + state.wormRides = ['hagga_basin']; + return applyAction(state, 'in', ride); +} +function ready(state: Game) { + let g = state; + for (const player of state.players) + if (!g.ready.includes(player.id)) g = action(g, player.id, { type: 'ready' }); + return g; +} +const queuedRide = { kind: 'wormRide', player: 'in', territory: 'hagga_basin' } as const; + +void test('Terror first keeps Robbery overflow custody and the ordered arrival intact', () => { + const { g, terror } = table('robbery'); + seat(g, 'm').hand = g.deck.splice(0, 4); + const offered = wormRide(g); + assert.equal(offered.pendingArrivalOverlap?.resume, 'wormRide'); + assert.equal(offered.pendingArrivalOverlap?.cause, 'wormRide'); + assert.equal(offered.pendingArrivalOverlap?.first, 'terror'); + assert.equal(offered.pendingArrivalOverlap?.stage, 'first'); + assert.equal(offered.pendingTerrorEntry?.cause, 'wormRide'); + assert.equal(offered.pendingTerrorEntry?.resume, 'none'); + assert.deepEqual(offered.wormRides, ['hagga_basin']); + assert.equal(seat(offered, 'in').forces['arrakeen:10'], 2); + const beforeDraw = seat(offered, 'm').hand.map((card) => card.id); + let state = action(offered, 'm', { type: 'decision', reveal: true }); + assert.equal(state.pendingTerrorEntry?.stage, 'robbery'); + state = action(state, 'm', { type: 'decision', choice: 'card' }); + assert.equal(state.pendingTerrorEntry?.stage, 'discard'); + state = action(state, 'm', { type: 'decision', card: seat(state, 'm').hand[0].id }); + assert.equal(state.pendingTerrorEntry, null); + assert.equal(state.pendingArrivalOverlap?.stage, 'second'); + assert.equal(state.decision?.kind, 'ecazAmbassador'); + assert.equal(seat(state, 'm').hand.length, 4); + assert.equal(state.discard.filter((card) => beforeDraw.includes(card.id)).length, 1); + assert.equal(state.moritaniTerror!.tokens.find((token) => token.id === terror)!.status, 'removed'); + state = action(state, 'ec', { + type: 'decision', event: state.pendingAmbassador!.event, decline: true, + }); + assert.equal(state.pendingArrivalOverlap ?? null, null); + assert.deepEqual(state.decision, queuedRide); + assert.deepEqual(state.wormRides, []); + assert.equal(seat(state, 'in').forces['arrakeen:10'], 2); + assert.equal(seat(state, 'in').forces['imperial_basin:10'], 1); +}); + +void test('Ambassador first lets the second Terror discard, gift once and resume the ride', () => { + const { g, terror } = table('sabotage', ['ec', 'm', 'in']); + seat(g, 'in').hand = g.deck.splice(0, 1); + seat(g, 'm').hand = g.deck.splice(0, 1); + const offered = wormRide(g); + assert.equal(offered.pendingArrivalOverlap?.first, 'ambassador'); + assert.equal(offered.decision?.kind, 'ecazAmbassador'); + let state = action(offered, 'ec', { + type: 'decision', event: offered.pendingAmbassador!.event, decline: true, + }); + assert.equal(state.pendingArrivalOverlap?.stage, 'second'); + assert.equal(state.decision?.kind, 'moritaniTerror'); + assert.equal(state.pendingTerrorEntry?.cause, 'wormRide'); + assert.equal(state.pendingTerrorEntry?.resume, 'none'); + state = action(state, 'm', { type: 'decision', reveal: true }); + assert.equal(state.pendingTerrorEntry?.stage, 'gift'); + state = action(state, 'm', { type: 'decision', decline: true }); + assert.equal(state.pendingTerrorEntry, null); + assert.equal(state.pendingArrivalOverlap ?? null, null); + assert.equal(state.moritaniTerror!.tokens.find((token) => token.id === terror)!.status, 'removed'); + assert.deepEqual(state.decision, queuedRide); + assert.equal(seat(state, 'in').forces['imperial_basin:10'], 1); + assert.equal(seat(state, 'in').forces['arrakeen:10'], 2); + const ended = action(state, 'in', { type: 'decision', accept: false }); + assert.deepEqual(ended.wormRides, []); + assert.equal(ended.pendingArrivalOverlap ?? null, null); + assert.equal(ended.pendingTerrorEntry ?? null, null); + assert.equal(ended.decision ?? null, null); + assert.equal(seat(ended, 'in').forces['arrakeen:10'], 2); +}); + +void test('a summoned worm during the overlap opens its Nexus before the queued ride', () => { + const { g } = table('robbery'); + const karama = g.deck.find((card) => card.effect === 'karama')!; + g.deck = g.deck.filter((card) => card.id !== karama.id); + seat(g, 'in').hand = [karama]; + const offered = wormRide(g); + assert.equal(offered.pendingArrivalOverlap?.resume, 'wormRide'); + assert.equal(offered.decision?.kind, 'moritaniTerror'); + // The summoned worm has nobody to devour on the Great Flat, so it resolves at once. + let state = action(offered, 'in', { + type: 'card', mode: 'special', card: karama.id, territory: 'the_great_flat', + }); + assert.equal(state.summonedWorm ?? null, null); + assert.equal(state.summonedNexusBeforeRides, true); + assert.equal(state.pendingArrivalOverlap?.resume, 'wormRide'); + assert.equal(state.decision?.kind, 'moritaniTerror'); + assert.equal(state.discard.filter((card) => card.id === karama.id).length, 1); + assert.equal(seat(state, 'in').specialKaramaUsed, true); + state = action(state, 'm', { type: 'decision', reveal: true }); + state = action(state, 'm', { type: 'decision', choice: 'spice' }); + assert.equal(state.pendingTerrorEntry, null); + assert.equal(state.decision?.kind, 'ecazAmbassador'); + state = action(state, 'ec', { + type: 'decision', event: state.pendingAmbassador!.event, decline: true, + }); + assert.equal(state.nexus, true); + assert.deepEqual(state.wormRides, ['hagga_basin']); + assert.equal(state.decision, null, 'the Nexus must precede the queued ride'); + assert.equal(state.pendingArrivalOverlap ?? null, null); + state = ready(state); + assert.equal(state.nexus, true, 'the Nexus follows the summoned spice blow'); + assert.deepEqual(state.wormRides, ['hagga_basin']); + assert.equal(state.decision ?? null, null); + assert.equal(state.pendingTerrorEntry ?? null, null); + assert.equal(seat(state, 'in').forces['imperial_basin:10'], 1); +}); + +void test('bots treat the supported overlapping stronghold as a legal ordinary arrival', () => { + const { g } = table('robbery'); + Object.assign(g, { phase: 5, active: 'in' }); + const mover = seat(g, 'in'); + Object.assign(mover, { shipped: true, forces: { 'imperial_basin:10': 2 }, reserves: 18 }); + const move: Action = { + type: 'move', from: 'imperial_basin:10', territory: 'arrakeen', sector: 10, amount: 2, + }; + const view = viewGame(g, 'in'); + assert.equal(view.arrivalOverlapMode, true); + assert.equal(botArrivalBlock(view, move), null); + assert.equal(botArrivalBlock({ ...view, arrivalOverlapMode: false }, move), + 'Ambassadors combined with another arrival reaction are still being implemented. This entry has not been committed.'); + const entered = applyAction(reload(g), 'in', move); + assert.equal(entered.pendingArrivalOverlap?.cause, 'movement'); + assert.equal(entered.pendingArrivalOverlap?.first, 'terror'); + assert.equal(entered.pendingArrivalOverlap?.ecaz, 'ec'); + assert.equal(entered.pendingArrivalOverlap?.moritani, 'm'); + assert.equal(entered.pendingTerrorEntry?.cause, 'movement'); + assert.equal(entered.pendingTerrorEntry?.resume, 'none'); + assert.equal(seat(entered, 'in').forces['arrakeen:10'], 2); +}); diff --git a/tests/fixture-nexus-fremen-betrayal.ts b/tests/fixture-nexus-fremen-betrayal.ts new file mode 100644 index 00000000..24e578fb --- /dev/null +++ b/tests/fixture-nexus-fremen-betrayal.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action } from '../game/engine'; +import { TERRITORIES, distance, location, splitLocation } from '../game/board'; +import { + finishNexusSpice, nexusInventory, nexusPlayer, nexusReady, nexusTurnTwo, + orderNexusSpice, +} from './fixture-nexus-cards'; + +export const fremenBetrayalSource = 'red_chasm:7'; +const destinations = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => + t.sectors.filter(sector => sector !== 18).map(sector => location(t.id, sector))); + +export function fremenBetrayalMovement(range: number): Action { + const destination = destinations.find(to => + distance(fremenBetrayalSource, to, key => splitLocation(key).sector === 18) === range); + assert.ok(destination, `range ${range} from ${fremenBetrayalSource}`); + const to = splitLocation(destination); + return { + type: 'move', from: fremenBetrayalSource, amount: 4, + territory: to.territory, sector: to.sector, + }; +} + +/** Actual setup, second-turn Nexus draw, Bidding and Revival; only final board + * force positions are chosen for a deterministic native movement boundary. */ +export function fremenBetrayalFixture( + advanced = false, + ids: [string, string, string] = ['f', 'a', 'h'], +) { + const [riderId, allyId, holderId] = ids; + let g = nexusTurnTwo({ seatIds: ids, hostFaction: 'fremen', secondFaction: 'atreides', advanced }); + orderNexusSpice(g, ['worm', 'land', 'land']); + g = nexusReady(nexusReady(g)); + assert.equal(g.nexus, true); + g = applyAction(g, riderId, { type: 'alliance', target: allyId }); + g = applyAction(g, allyId, { type: 'alliance', target: riderId }); + g = finishNexusSpice(g); + assert.equal(g.nexusCards!.phase!.stage, 'drawing'); + const cards = g.nexusCards!.cards!; + cards.deck = ['fremen', ...cards.deck.filter(card => card !== 'fremen')]; + g = applyAction(g, holderId, { + type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'draw', ownRedraws: 0, + }); + assert.equal(g.nexusCards!.cards!.hands[holderId], 'fremen'); + g = nexusReady(g); + for (let step = 0; g.phase === 3 && step < 30; step++) { + if (g.phaseOpening) { + const id = g.players.find(p => !g.phaseOpening!.passed.includes(p.id))!.id; + g = applyAction(g, id, { type: 'ready' }); + } else if (g.auction) g = applyAction(g, g.auction.active, { type: 'passBid' }); + else g = nexusReady(g); + } + assert.equal(g.phase, 4); + for (let step = 0; g.phase === 4 && step < 20; step++) { + if (g.response) { + const id = g.players.find(p => !g.response!.passed.includes(p.id))!.id; + g = applyAction(g, id, { type: 'passResponse' }); + } else g = nexusReady(g); + } + assert.equal(g.phase, 5); + const rider = nexusPlayer(g, riderId); + const total = rider.reserves + Object.values(rider.forces).reduce((sum, count) => sum + count, 0); + rider.forces = { [fremenBetrayalSource]: 4 }; + rider.reserves = total - 4; + if (rider.elites) { + const eliteTotal = rider.elites.reserves + + Object.values(rider.elites.forces).reduce((sum, count) => sum + count, 0); + rider.elites.forces = {}; + rider.elites.reserves = eliteTotal; + } + rider.shipped = true; + rider.moved = 0; + g.active = riderId; + nexusInventory(g); + const offer = viewGame(g, holderId).nexusFremenBetrayal!; + assert.equal(offer.blocked, null); + return { g, play: { type: 'nexusFremenBetrayal', event: offer.event } as Action }; +} diff --git a/tests/karama-movement-preflight.test.ts b/tests/karama-movement-preflight.test.ts index e9094251..728486db 100644 --- a/tests/karama-movement-preflight.test.ts +++ b/tests/karama-movement-preflight.test.ts @@ -2,7 +2,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { quoteCompletedMovementArrival, - MovementArrivalError, type CompletedMovementArrivalInput, } from '../game/karama-movement-preflight'; import { @@ -69,14 +68,120 @@ void test('a plain movement has no arrival reaction, and an eligible token opens quoteCompletedMovementArrival({ ...input, terror: [terror] }).reaction, 'terror', ); +}); + +void test('supported simultaneous Ambassador and Terror arrival quotes one bounded overlap without changing the original entry', () => { + const input = { + ...fixture(), + overlapSupported: true, + ambassadors: [ambassador], + terror: [terror], + }; + const before = structuredClone(input); + assert.deepEqual(quoteCompletedMovementArrival(input), { + intrusion: false, + reaction: 'overlap', + retiresOrnithopter: false, + }); + assert.deepEqual(quoteCompletedMovementArrival(input), { + intrusion: false, + reaction: 'overlap', + retiresOrnithopter: false, + }); + assert.deepEqual(input, before); + assert.throws( + () => quoteCompletedMovementArrival({ ...input, overlapSupported: false }), + /Ambassadors combined with another arrival reaction/, + ); + assert.throws( + () => quoteCompletedMovementArrival({ ...input, overlapSupported: undefined }), + /Ambassadors combined with another arrival reaction/, + ); + assert.equal( + quoteCompletedMovementArrival({ ...input, terror: [] }).reaction, + 'ambassador', + ); + assert.equal( + quoteCompletedMovementArrival({ ...input, ambassadors: [] }).reaction, + 'terror', + ); +}); + +void test('overlap support does not bypass competing controls, fighter response, or BG intrusion', () => { + const input = { + ...fixture(), + overlapSupported: true, + ambassadors: [ambassador], + terror: [terror], + }; + for (const flag of [ + 'response', + 'decision', + 'pendingTerror', + 'pendingAmbassador', + ] as const) + assert.throws( + () => + quoteCompletedMovementArrival({ + ...input, + controls: { ...input.controls, [flag]: true }, + }), + /Ambassadors combined with another arrival reaction/, + flag, + ); assert.throws( () => quoteCompletedMovementArrival({ ...input, - ambassadors: [ambassador], - terror: [terror], + order: { ...input.order, wantsFighters: true }, }), - MovementArrivalError, + /Ambassadors combined with another arrival reaction/, + ); + assert.throws( + () => + quoteCompletedMovementArrival({ + ...input, + players: input.players.map((p) => + p.id === 'b' ? { ...p, forces: { 'broken_land:9': 1 } } : p, + ), + }), + /Ambassadors combined with another arrival reaction/, + ); +}); + +void test('overlap support never turns same-territory movement or shared ownership into an entry', () => { + const input = { + ...fixture(), + overlapSupported: true, + ambassadors: [ambassador], + terror: [terror], + }; + assert.equal( + quoteCompletedMovementArrival({ + ...input, + order: { ...input.order, origin: 'broken_land' }, + }).reaction, + null, + ); + assert.equal( + quoteCompletedMovementArrival({ + ...input, + order: { + ...input.order, + origins: ['broken_land', 'broken_land'], + }, + }).reaction, + null, + ); + assert.throws( + () => + quoteCompletedMovementArrival({ + ...input, + players: input.players.map((p) => + p.id === 'm' ? { ...p, id: 'e' } : p, + ), + }), + /Ambassadors combined with another arrival reaction/, ); }); diff --git a/tests/nexus-fremen-betrayal-recovery.test.ts b/tests/nexus-fremen-betrayal-recovery.test.ts new file mode 100644 index 00000000..60c1a1c1 --- /dev/null +++ b/tests/nexus-fremen-betrayal-recovery.test.ts @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { RoomsClock } from '../db/rooms'; +import { nexusPlayer } from './fixture-nexus-cards'; +import { fremenBetrayalFixture, fremenBetrayalMovement } from './fixture-nexus-fremen-betrayal'; +import { unitStore } from './fixture-nexus-room-store'; + +const clock: RoomsClock = { now: () => 45000, sleep: async () => {} }; + +void test('concurrent authenticated Fremen Betrayal commits one physical card and restores the turn-long range limit', async t => { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const created = await store.rooms.createRoom('Fremen SQL', 'fremen', false, []); + const code = created.view.code; + const atreides = await store.rooms.joinRoom(code, 'Atreides', 'atreides'); + const harkonnen = await store.rooms.joinRoom(code, 'Harkonnen', 'harkonnen'); + const tokens = [created.token, atreides.token!, harkonnen.token!]; + const auth = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token))); + const [fremen, ally, holder] = auth.map(seat => seat.playerId) as [string, string, string]; + const { g, play } = fremenBetrayalFixture(false, [fremen, ally, holder]); + g.code = code; + g.version = (await store.rooms.readRoom(code)).version; + store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(g), g.version, code); + store.writes.length = 0; + + const before = store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code); + const rivalView = await store.rooms.readSeatView(code, auth[1]); + assert.equal(rivalView.nexusFremenBetrayal, null); + assert.equal(rivalView.nexusCards?.card, null); + const results = await Promise.allSettled([ + store.restart().act(code, auth[2], g.version, play, clock), + store.restart().act(code, auth[2], g.version, play, clock), + ]); + assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); + assert.equal(results.filter(result => result.status === 'rejected').length, 1); + assert.notDeepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), before); + const saved = await store.restart().readRoom(code); + assert.equal(saved.nexusCards!.cards!.hands[holder], null); + assert.equal(saved.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1); + assert.equal(saved.nexusFremenBetrayalHistory?.length, 1); + assert.equal(nexusPlayer(saved, fremen).fremenNexusMovementBlockedTurn, saved.turn); + const freshAuth = await store.restart().authenticate(code, tokens[0]); + const view = await store.restart().readSeatView(code, freshAuth); + assert.equal(view.players.find(player => player.id === fremen)!.fremenMovementBlocked, true); + assert.equal(view.nexusFremenBetrayal, null); + const stable = store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code); + await assert.rejects(store.restart().act(code, freshAuth, saved.version, fremenBetrayalMovement(2), clock)); + await assert.rejects(store.restart().act(code, auth[2], saved.version, play, clock)); + assert.deepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), stable); +}); diff --git a/tests/nexus-fremen-betrayal.test.ts b/tests/nexus-fremen-betrayal.test.ts new file mode 100644 index 00000000..f0d211fc --- /dev/null +++ b/tests/nexus-fremen-betrayal.test.ts @@ -0,0 +1,107 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, normalizeAutomaticGame, viewGame, type Action, type Game, +} from '../game/engine'; +import { botActions, runBots } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { nexusInventory, nexusPlayer, nexusReload } from './fixture-nexus-cards'; +import { + fremenBetrayalFixture as setup, + fremenBetrayalMovement as movement, +} from './fixture-nexus-fremen-betrayal'; + +const fremen = 'f'; +const ally = 'a'; +const holder = 'h'; + +function rejected(g: Game, id: string, action: Action) { + const before = structuredClone(g); + assert.throws(() => applyAction(g, id, action)); + assert.deepEqual(g, before); +} + +for (const advanced of [false, true]) { + void test(`${advanced ? 'Advanced' : 'Basic'} spent Betrayal suppresses only the native range through its turn and reload`, () => { + const { g, play } = setup(advanced); + assert.equal(viewGame(g, fremen).nexusFremenBetrayal, null); + assert.equal(viewGame(g, ally).nexusFremenBetrayal, null); + assert.equal(viewGame(g, holder).nexusCards?.card, 'fremen'); + rejected(g, fremen, play); + rejected(g, holder, { ...play, event: 'stale' }); + rejected(g, holder, { ...play, target: fremen }); + assert.deepEqual(botActions(viewGame(g, holder))[0], play); + let state = applyAction(nexusReload(g), holder, play); + nexusInventory(state); + assert.equal(state.nexusCards!.cards!.hands[holder], null); + assert.equal(state.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1); + assert.equal(nexusPlayer(state, fremen).fremenNexusMovementBlockedTurn, state.turn); + assert.equal(viewGame(state, fremen).players.find(p => p.id === fremen)!.fremenMovementBlocked, true); + assert.equal(viewGame(state, fremen).players.find(p => p.id === fremen)!.fremenNexusMovementBlocked, true); + rejected(state, holder, play); + rejected(state, fremen, movement(2)); + state = applyAction(nexusReload(state), fremen, movement(1)); + assert.equal(state.pendingFremenMove ?? null, null); + assert.equal(state.response?.kind, undefined); + assert.equal(nexusPlayer(state, fremen).moved, 1); + assert.equal(nexusPlayer(state, fremen).fremenNexusMovementBlockedTurn, state.turn); + const restored = normalizeAutomaticGame(nexusReload(state)); + assert.equal(nexusPlayer(restored, fremen).fremenNexusMovementBlockedTurn, restored.turn); + assert.deepEqual(restored.nexusFremenBetrayalHistory, state.nexusFremenBetrayalHistory); + }); +} + +void test('all four bot holders spend the card before an active Fremen bot moves', () => { + for (const difficulty of DIFFICULTIES) { + const { g, play } = setup(); + nexusPlayer(g, holder).bot = difficulty; + nexusPlayer(g, fremen).bot = difficulty; + assert.deepEqual(botActions(viewGame(g, holder))[0], play); + const first = runBots(g, 1); + assert.equal(first.nexusCards!.cards!.hands[holder], null); + assert.equal(nexusPlayer(first, fremen).moved, 0); + assert.equal(nexusPlayer(first, fremen).fremenNexusMovementBlockedTurn, first.turn); + } +}); + +void test('independent city ornithopters retain their range despite spent Betrayal', () => { + const { g, play } = setup(); + const state = applyAction(g, holder, play); + const f = nexusPlayer(state, fremen); + f.reserves--; + f.forces['arrakeen:10'] = 1; + const moved = applyAction(state, fremen, movement(2)); + assert.equal(moved.pendingFremenMove ?? null, null); + assert.equal(nexusPlayer(moved, fremen).moved, 1); + assert.equal(nexusPlayer(moved, fremen).fremenNexusMovementBlockedTurn, moved.turn); +}); + +void test('turn-scoped suppression expires without returning the physical card', () => { + const { g, play } = setup(); + const next = nexusReload(applyAction(g, holder, play)); + next.turn++; + next.active = fremen; + next.movementRemaining = [...next.order]; + nexusPlayer(next, fremen).moved = 0; + const view = viewGame(next, fremen); + assert.equal(view.players.find(p => p.id === fremen)!.fremenNexusMovementBlocked, false); + assert.equal(view.players.find(p => p.id === fremen)!.fremenMovementBlocked, false); + const moved = applyAction(next, fremen, movement(2)); + assert.equal(nexusPlayer(moved, fremen).moved, 1); + assert.equal(moved.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1); +}); + +void test('changed saved turn, target or suppression rejects before advancing movement', () => { + const { g, play } = setup(); + const played = applyAction(g, holder, play); + for (const change of [ + (state: Game) => { state.nexusFremenBetrayalHistory![0].target = ally; }, + (state: Game) => { state.nexusFremenBetrayalHistory![0].turn--; }, + (state: Game) => { delete nexusPlayer(state, fremen).fremenNexusMovementBlockedTurn; }, + (state: Game) => { state.nexusCards!.cards!.discard = []; }, + ]) { + const damaged = nexusReload(played); + change(damaged); + rejected(damaged, fremen, movement(1)); + } +}); diff --git a/tests/reference.test.ts b/tests/reference.test.ts index 74a583ac..ee3432a2 100644 --- a/tests/reference.test.ts +++ b/tests/reference.test.ts @@ -329,43 +329,6 @@ void test('no-choice decision checklist preserves genuine alternatives and persi ); }); -void test('Ecaz entry checklist separates eight supported effects and private historical snapshots from unopened interactions', () => { - const topic = RULE_TOPICS.find( - (candidate) => candidate.id === 'ecaz-ambassadors', - )!; - const guide = topic.steps.join(' '); - assert.equal(topic.coverage, 'Partial'); - assert.match( - guide, - /Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated/, - ); - assert.match(guide, /Bene Gesserit copies of those eight effects/); - assert.match(guide, /recipient’s private inspection history/); - assert.match(guide, /do not become a live view of later changes/); - assert.match(guide, /closing them needs no game acknowledgement/); - assert.match(guide, /including none, for three spice each/); - assert.match(guide, /Ixians require one available card/); - assert.match( - guide, - /Entries with competing arrival reactions remain blocked/, - ); - assert.match(guide, /Full Ecaz starts remain disabled/); - const evidence = topic.checklist!.find( - (item) => item.area === 'Verification', - )!.evidence!; - assert.ok(evidence.includes('tests/ecaz-entry.test.ts')); - assert.ok(evidence.includes('tests/ecaz-entry-bots.test.ts')); - assert.ok(evidence.includes('tests/guild-ambassador-bots.test.ts')); - assert.ok(evidence.includes('tests/guild-ambassador-engine.test.ts')); - assert.match( - guide, - /No-Field substitution.*awaits the user’s interpretation/, - ); - assert.match( - guide, - /Simultaneous Intrusion\/Terror and Ambassador\/Terror combinations remain blocked/, - ); -}); void test('Nullentropy checklist preserves paid-only privacy, real choices, recovery and explicit interpretation guards', () => { const topic = RULE_TOPICS.find(