diff --git a/docs/BOT_ARRIVAL_PREFLIGHT.md b/docs/BOT_ARRIVAL_PREFLIGHT.md index 2bbc418d..c4e01eaa 100644 --- a/docs/BOT_ARRIVAL_PREFLIGHT.md +++ b/docs/BOT_ARRIVAL_PREFLIGHT.md @@ -4,15 +4,33 @@ arrival guard. It does not choose an order for simultaneous reactions or lift an unfinished expansion gate. +## Native transport and BG own controls — 15 October 2026 + +`guildShip` no longer predicts its future positive Guild receipt as a busy +response; ordinary `ship` no longer predicts future accompaniment as a busy +decision. Shared preflight still reports resulting Intrusion but does not +confuse it with an actual pending decision. Original funding/tariff, actual +controls, pre-entry fighter choice, Fremen storm and paired-overlap gates +remain. The engine's existing queue owns settlement, not bot-specific rules. + +Genuine original native producers prove the Guild candidate and executable +BG branches. All four owner policies legally acquire Duke after Guild income; +all four BG policies legally convert fighters and accompany as advisors. +The human/scripted retained-fighter child path is separate evidence, not a +claim that every profile chose it. Affected six-file55/55,20 API/policy paths +and four actual390px journeys pass. Obsolete predicted-reaction rejection +and wording pins are removed. Minimal legal play only; no calibration or +complete combination claim. +[Current authoritative contract](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026). + ## Ordinary Guild-paid Ambassador candidate — 15 October 2026 Ordinary reserve shipment no longer treats **future Guild income** as an already-pending arrival response. The authoritative commit already queues entry behind its actual income/counter continuation. The bot adapter removes -only that stale ordinary-shipment prediction; own funding bounds/tariff, -No-Field price independence, current busy controls, prospective BG -accompaniment/Intrusion and Fremen storm checks remain. -Direct `guildShip` transport retains its separate predictive boundary. +only that stale ordinary-shipment prediction at that checkpoint. The later +native continuation above also removes direct transport's future-income and +ordinary shipment's future-BG predictions; current busy controls remain. The original native Ecaz/Moritani/Guild paid Ambassador shipment now has a clear owner-view quote and real committed permission/payment/entry path. @@ -44,8 +62,8 @@ or infer a new rule from token co-location. The quote preserves same-territory movement, single reactions, own-faction and allied exemptions, matching Ambassador immunity and Bene Gesserit stance. -Shipment additionally includes the prospective Guild income and Spiritual -Advisor opportunities. It uses the actual ordinary tariff and selected or +Shipment uses actual current controls rather than predicting future income or +Spiritual Advisor choices as already busy. It uses the actual ordinary tariff and selected or minimum necessary allied contribution. A Guild-funded payment that creates no Guild income is kept. An owned No-Field is priced as one component without reading its concealed value. Advanced Fremen storm arrival keeps its existing diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 631a43fe..50b4d28e 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,27 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Native Guild/BG own-arrival continuations — 15 October.** +Direct Guild transport's owner-view candidate now matches its executable +income-before-queued-Ambassador path. Ordinary shipment stops predicting its +own BG accompaniment as busy; shared preflight distinguishes resulting +Intrusion from an actual decision. Existing controls/queue own settlement, +with unchanged prices, payments, physical groups and original movement. +Actual busy/pre-entry fighter/storm and unsupported paired/profile guards remain. + +Original-action fixtures and six-file55/55 plus20 API/policy paths pass, +including income/accompaniment/flip cancellation and all four legal policies. +Four actual styled390px journeys prove Guild transport with income canceled, +advisor accompaniment, accepted Intrusion, and retained fighters with a +distinct child then original offer. No replay, overflow or browser errors. +Existing child-first/live-token ordering is explicitly provisional, not a +publisher priority. No played wallet/hand/army/phase/decision staging or real +saved/deployed room. Guild rate-Karama expansion/Nexus admission remains a +separate tracked gap; public starts and full-goal acceptance remain closed. +[Canonical contract and evidence](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026). +Final types/lint and production build pass; existing deprecation/chunk/ +Unknown-route warnings remain. Canonical changed links/evidence resolve. + **Physical Traitor replacements and permanent markers — 15 October.** Original native Ix play now draws the real Hero Traitor after Dominic4's retirement, without turning it into a leader disc or faction target. diff --git a/docs/DEFERRED_MOVEMENT_ARRIVAL.md b/docs/DEFERRED_MOVEMENT_ARRIVAL.md index 08291d4f..b5ecbb5c 100644 --- a/docs/DEFERRED_MOVEMENT_ARRIVAL.md +++ b/docs/DEFERRED_MOVEMENT_ARRIVAL.md @@ -3,6 +3,20 @@ 13 September 2026. This work repairs a development-game deadlock without assigning an unverified order to simultaneous expansion reactions. +## Native Intrusion classification — 15 October 2026 + +Shared preflight no longer treats **resulting** BG Intrusion as an already +active decision. Actual busy controls and the pre-entry fighter choice +remain blocking. The existing committed-entry queue waits for Intrusion +and accompaniment before offering the original Ambassador entry. +An accompanying counter joining retained fighters is a distinct child: +existing child-first/live-token order remains an explicit provisional +composition, not a new publisher priority. Advisors are exempt. +Original actions, cancellation paths and actual movement continuation pass +in the [native shipment follow-up](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026). +This supersedes the historical blanket Intrusion exclusion below, not +unsupported competing-reaction/module guards or public acceptance gates. + ## Reproduced failure and source boundary The all-three-expansion Advanced sample from checkpoint `cab2f1c` stopped on diff --git a/docs/DEFERRED_SHIPMENT_ARRIVAL.md b/docs/DEFERRED_SHIPMENT_ARRIVAL.md index 266db6f6..9c6d7a43 100644 --- a/docs/DEFERRED_SHIPMENT_ARRIVAL.md +++ b/docs/DEFERRED_SHIPMENT_ARRIVAL.md @@ -4,6 +4,42 @@ exposed an already accepted shipment whose Guild allowance could not finish. This continuation repair preserves the existing unfinished arrival-order gates. +## Native own-arrival continuations — 15 October 2026 + +Direct native Guild transport now has the same clear owner-view candidate +as its already executable engine path: future earned income is not a current +busy control. Ordinary shipment likewise stops predicting its own future BG +accompaniment as an active decision. Shared movement preflight distinguishes +resulting Intrusion from an actual pending decision. The existing committed +entry queue, not a second timing adapter, waits for those native controls. + +Original price, funding, payment, source group, reserve transfer and movement +ownership stay unchanged. Actual response/decision/Terror/Ambassador controls, +pre-entry fighter choice, Fremen storm protection and unsupported paired +reaction/profile guards remain. The separate Guild **rate** Karama profile +gate is unchanged and remains follow-up work; income cancellation is not +rate cancellation. + +**Provisional composition, not publisher priority:** finish the arrival's +own controls before its deferred entry. Accompaniment joining existing BG +fighters creates a distinct eligible child entry before the original shipper. +Declining that child leaves a live token for the original entry; consuming +it leaves no token to offer again. Advisors remain exempt. This retains the +existing callback/queue order; it does not invent FIFO or suppress a BG choice. + +Two original-action fixtures cover Guild income allow/cancel and Ambassador +trigger/decline, BG accompaniment accept/decline/cancel, Intrusion flip/decline/ +cancel, the fighter child and original movement. No played hand, wallet, +army, phase or decision is staged. Affected six-file **55/55** and **20** +original API/policy paths pass. Four actual styled390px GameTable journeys +exercise Guild transport/income cancellation, advisor accompaniment, accepted +Intrusion, and declined Intrusion with child-then-original offers. Payment +and transfers occur once; original movement resumes; no overflow/browser +errors. In-memory loopback proof only, not saved-room or deployed acceptance. + +This supersedes the BG/direct-transport exclusions in the earlier checkpoint +below and obsolete rejection-test pins, not its remaining recovery safeguards. + ## Ordinary Guild income before Ambassador entry — 15 October 2026 The original native Ecaz/Moritani/Guild producer exposed a stale preflight: @@ -17,9 +53,9 @@ Ordinary reserve shipment no longer predicts Guild income as a busy control. The original physical/price/funding validation, Guild permission, payment, income/cancellation and deferred-entry queue remain unchanged. The shared ordinary bot adapter follows that same distinction, without reading hidden -funding or rival hands. Actual in-flight controls, BG accompaniment/Intrusion, -Fremen storm protection and unsupported paired-reaction gates are not removed. -Direct Guild cross-planet transport retains its separate adapter boundary. +funding or rival hands. Actual in-flight controls, Fremen storm protection +and unsupported paired-reaction gates remain. The native own-arrival +follow-up above also connects direct transport and BG's resulting choices. This reuses the documented income-before-entry order, not a new comprehensive publisher priority table or an exception to paying for entry. diff --git a/docs/ECAZ_AMBASSADORS_RULES.md b/docs/ECAZ_AMBASSADORS_RULES.md index 31bdc324..8d436574 100644 --- a/docs/ECAZ_AMBASSADORS_RULES.md +++ b/docs/ECAZ_AMBASSADORS_RULES.md @@ -9,14 +9,29 @@ Audit: 2026-09-06. Scope: ambassador inventory, setup, placement, triggering, ef - **Designer clarification:** Jack Reda (@The Warp), 26 August 2023, on his [Ecaz and Moritani rulebook upload](https://boardgamegeek.com/filepage/263742/ecaz-and-moritani-rules), explicitly confirms Ambassador placement face up. This is the designer speaking directly, not a community interpretation. The publisher-indexed file-page comment was verified in the follow-up audit below. - No community amendment, fan implementation, or tournament rule was used. No separately dated official ambassador errata resolving the remaining open questions was found in this bounded pass. Use applicable official corrections/FAQ over general text; record conflicts rather than silently resolving them. +## Native own-arrival follow-up — 15 October 2026 + +Native Guild transport and ordinary BG accompaniment/Intrusion now use the +existing committed-entry continuation rather than treating future controls +as already busy. Original income can be allowed or canceled; BG choices +retain their actual Karama windows and stance. An accepted accompanying +counter joining fighters creates a distinct child offer before the original +shipper; consuming the live token at the child prevents a second offer. +Advisors remain exempt. This existing child-first/live-token composition is +explicitly provisional, not a publisher priority ruling. +Affected six-file55/55,20 original API/policy paths and four actual390px +journeys pass without replaying payment/forces or losing original movement. +Actual controls, unsupported overlaps, public starts and final assurance remain. +[Canonical continuation and remaining rate-window boundary](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026). + ## Ordinary Guild-paid entry follow-up — 15 October 2026 Original reserve shipment now settles its actual Guild income/counter window before the already queued Ambassador entry, instead of rejecting because of a predicted future receipt. Price/funding, physical transfer, token handling -and original caller resumption do not change. Actual current controls and -unresolved BG/other-arrival combinations remain guarded; this is not a new -comprehensive printed priority rule. +and original caller resumption do not change. The later native follow-up +above supersedes the BG/direct-transport exclusion; actual current controls +and unsupported combinations remain guarded. The actual390px native table ships/permits one for Moritani12→11 and Guild6→7, opens the Ecaz Ambassador, acquires Duke6, then resolves a real diff --git a/docs/GUILD_SHIPMENT_PAYMENTS.md b/docs/GUILD_SHIPMENT_PAYMENTS.md index e0911ff3..aa66947d 100644 --- a/docs/GUILD_SHIPMENT_PAYMENTS.md +++ b/docs/GUILD_SHIPMENT_PAYMENTS.md @@ -2,6 +2,18 @@ Integrated checkpoint, 2026-09-07 local date. This closes the contributor-routing defect and adds transport price controls; ordinary rate/permission Karama cancellation and nonbattle Truthtrance commitments remain incomplete. +## Native direct transport continuation — 15 October 2026 + +Direct `guildShip` already committed positive Guild income before queued +Ambassador entry; its stale owner-view candidate now accepts that same path. +Original on-board source6→5 plus destination1 costs one to the shipper without +using reserves. Real Karama income cancellation prevents Guild's receipt +without refunding the shipper or repeating transport; the original Ecaz +offer and next movement owner follow. Allow/decline and cancellation variants +pass in the [native arrival contract](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026). +The separate native **rate** Karama expansion/Nexus profile gate is unchanged +and remains implementation work, not a printed prohibition or exercised path. + ## Ordinary income then Ambassador arrival — 15 October 2026 Original ordinary reserve shipment may now proceed into a placed Ambassador @@ -9,8 +21,8 @@ when it earns Guild income. The stale preflight predicted that **future** receipt as an already-busy arrival control; the commit's existing income/ counter-then-deferred-entry sequence already handles it. No tariff, contributor destination, pledge, cancellation or physical transfer changes. -The ordinary bot preflight matches this distinction; direct `guildShip` -transport and other unresolved arrival combinations remain separate. +The ordinary bot preflight matches this distinction. The later direct +transport follow-up above supersedes its earlier separate candidate boundary. Actual styled390px native controls ship one Moritani counter, use original Guild permission, settle Moritani12→11 and Guild6→7, then open Ecaz's real diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 541e7b80..99d186a8 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,46 @@ # Dune implementation status +## 15 October 2026 — Native Guild/BG own-arrival continuations + +Direct native Guild transport's engine already committed income before queued +entry, but its bot candidate predicted that future income as busy. The native +original owner view now agrees with the executable path. Ordinary shipment +also stops predicting its future BG accompaniment as an active decision; +shared movement preflight retains resulting Intrusion without calling it an +already-pending control. No second queue, timing adapter or price/state schema. + +Existing settlement handles income allow/cancel, Intrusion accept/decline/ +cancel, and accompaniment accept/decline/cancel before the original entry. +A real companion joining retained BG fighters creates its own child offer: +decline keeps the live token for the original entrant; trigger consumes it +without recreating an offer. Advisors remain exempt. Existing child-first/ +live-token composition is provisional, not a publisher priority ruling. +Actual response/decision/Terror/Ambassador controls, pre-entry fighter choice, +storm protection and unsupported paired/profile guards remain. + +Two genuine original-action fixtures use original setup, actual Guild alliance, +paid token placement, real Karama custody, and prior BG movement/stance. +No played wallet, hand, army, leader, phase or decision is assigned. Obsolete +predicted-control rejection/fallback and wording pins were removed, not re-pinned. +Affected six-file **55/55** and **20 original API/policy scenarios** pass. +All four Ecaz policies acquire Duke after Guild income; all four BG policies +convert then accompany. Retained-fighter human/scripted evidence is distinct. + +Four actual styled390px GameTable journeys use original controls for native +transport with income canceled, peaceful accompaniment, accepted Intrusion, +and declined Intrusion with distinct BG-child then Emperor offers. Original +payment/force transfers happen once; original movement resumes, including +actual Carthag→Arsunt moves. No horizontal overflow/application browser errors. +A browser observation node race occurred after the final action committed; +fresh state confirmed it and no action was replayed. +Proof is in-memory loopback, not worker/D1/authenticated saved-room/deployed +acceptance. The separate Guild native-rate Karama expansion/Nexus admission +gate remains tracked next work. No reset, real-game write or public gate change. +Final `npm run check:quick` and production build pass; existing module-register +deprecation, chunk-size and Unknown-route warnings remain. Ten current-section +local links and canonical checklist evidence targets resolve. +[Canonical contract](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026). + ## 15 October 2026 — Physical Traitor replacements, retired zone and legal recycling Original Moritani Mentat replacement accepts the actual Ix Hero Traitor as a diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 3808feb3..90f9effa 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -671,11 +671,12 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Guild payment | Rate, transport permission and income are distinct; power owner, shipper, contributors and independent rate-card beneficiary remain distinct. | [Rate/cancellation sources](GUILD_RATE_KARAMA_RULES.md), [implemented payment routing](GUILD_SHIPMENT_PAYMENTS.md) | | Guild native rate Karama | In bounded classic-faction Basic/Advanced tables, a separate prepayment response can cancel Guild’s or its ally’s half rate on an ordinary reserve shipment or eligible cross-planet transport, and Guild’s own return. The original physical cohort and approved contributor split must cover full destination price; otherwise the Karama is spent, the declaration returns unused, and a replacement this turn uses full price. One spice per returned force is the conservative doubled-tariff interpretation without a return-specific FAQ example. Independent Karama-purchased rates, Guild income, transport permission and optional modules remain separate. | [Publisher distinctions and selected reserve/transport policy](GUILD_RATE_KARAMA_RULES.md#native-transport-extension--29-september-2026) | | Deferred shipment arrival | Quote unsupported ordinary arrivals before saving Guild interception. A validated legacy ordinary declaration may return unspent on the typed arrival error; corrupted declarations and special parent/card receipts are not silently cleared. This repairs continuation without deciding simultaneous reaction priority. | [Reproduction and bounded recovery](DEFERRED_SHIPMENT_ARRIVAL.md) | +| Native Ambassador own-arrival continuation | Under the standing choose-recommended-option instruction, reuse committed entry after its own Guild income, BG Intrusion and accompaniment controls. Existing callbacks put a legitimate accompanying BG fighter child before the deferred original entrant; each sees the then-live token, so declining the child preserves a later original offer and consuming it does not recreate a token. Advisors remain exempt. This child-first/live-token composition is provisional, not a located publisher priority ruling. Actual busy controls, unsupported paired/module guards and separate native rate-window admission remain distinct. | [Native original-action evidence and boundary](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026) | | Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Structured Basic and base Advanced active-opportunity shipment promises bind within their documented scope (Advanced excludes Guild and optional modules); prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) | | 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 and the exact original paired native Nexus envelopes, both originally eligible owners resolve optional reactions in current storm order; committed entry, payment and counters do not replay. Later alliances do not erase the original trigger; physical effect prerequisites are rechecked. This remains an explicit provisional interpretation, not a located publisher priority ruling. An original accompanying counter joining existing BG fighters creates its own qualifying entry only after earlier Intrusion/income finish; exempt advisors, competing live reactions, unsupported children and Atomics retain guards. The Terror entry itself is now deferred behind the arrival's own interaction (Guild income, BG fighter Intrusion or BG advisor choice, Fremen storm protection) in an additive pending record that never replays payment or force transfer; a Terror entry already in flight and every Ambassador combination still reject. | [Primary-source gap and selected policy](MORITANI_ENTRY_TIMING.md#selected-bounded-overlap-order--29-september-2026), [exact paired composition](MORITANI_ENTRY_TIMING.md#paired-native-nexus-composition--7-october-2026), [deferred arrival entry](MORITANI_ENTRY_TIMING.md#deferred-arrival-entry-behind-the-arrivals-own-interactions--7-october-2026), [movement recovery](DEFERRED_MOVEMENT_ARRIVAL.md#bounded-storm-order-continuation--29-september-2026) | +| Ecaz Ambassador / Moritani Terror shared entry | In bounded classic plus Ecaz/Moritani Basic/Advanced games and the exact original paired native Nexus envelopes, both originally eligible owners resolve optional reactions in current storm order; committed entry, payment and counters do not replay. Later alliances do not erase the original trigger; physical effect prerequisites are rechecked. This remains an explicit provisional interpretation, not a located publisher priority ruling. A genuine BG fighter companion is independently eligible; advisors remain exempt. Committed entry waits for its own income, Intrusion/advisor or storm controls. Actual in-flight reactions and unsupported paired/module/child combinations retain their guards; native own-arrival support is not universal overlap acceptance. | [Primary-source gap and selected policy](MORITANI_ENTRY_TIMING.md#selected-bounded-overlap-order--29-september-2026), [exact paired composition](MORITANI_ENTRY_TIMING.md#paired-native-nexus-composition--7-october-2026), [deferred Terror entry](MORITANI_ENTRY_TIMING.md#deferred-arrival-entry-behind-the-arrivals-own-interactions--7-october-2026), [native Ambassador continuation](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026) | | Fremen/Ecaz final-turn victory | Basic keeps reciprocal cooccupation at Tabr only. Authorized supplied Advanced physical p32 Fremen I permits both Tabr and Habbanya when Fremen/Ecaz actually coexist. Solitary/nonallied Ecaz and third parties still block; ordinary winner/prediction/fallback order is preserved. | [Current source cutover](ECAZ_VICTORY_RULES.md) | | 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) | diff --git a/game/bot-arrival.ts b/game/bot-arrival.ts index c1ba9393..1608a985 100644 --- a/game/bot-arrival.ts +++ b/game/bot-arrival.ts @@ -8,7 +8,6 @@ import { } from './karama-movement-preflight'; import { guildShipmentCost, - guildShipmentIncome, reserveShipmentCost, shipmentPaymentBounds, } from './shipment-price'; @@ -59,7 +58,6 @@ export function botArrivalBlock(g: GameView, action: Action): string | null { const origin = action.from === 'reserves' ? 'reserves' : splitLocation(action.from); if (origin !== 'reserves' && !validLocation(origin.territory, origin.sector)) return null; - const guild = g.players.find((p) => p.faction === 'guild'); const cost = guildShipmentCost(territory(to).type, action.amount, g.karamaShipping?.player === me.id || !g.guildRateCanceled); const bounds = shipmentPaymentBounds(cost, me.spice ?? 0, g.aid.available); @@ -67,10 +65,6 @@ export function botArrivalBlock(g: GameView, action: Action): string | null { if (typeof allyPayment !== 'number' || !Number.isSafeInteger(allyPayment) || allyPayment < bounds.minimum || allyPayment > bounds.maximum) return null; - controls.response ||= guildShipmentIncome({ - guild: guild?.id, shipper: me.id, ally: me.ally, cost, allyPayment, - bankOnly: g.karamaShipping?.player === me.id, - }) > 0; order = { player: me.id, origin: origin === 'reserves' ? origin : origin.territory, @@ -121,14 +115,9 @@ export function botArrivalBlock(g: GameView, action: Action): string | null { allyPayment > bounds.maximum ) return null; - const bg = g.players.find((p) => p.faction === 'beneGesserit'); - // Without Homeworlds, a positive public reserve pool grants accompaniment. - const accompaniment = - me.faction !== 'fremen' && !!bg && bg.id !== me.id && bg.reserves > 0; // Ordinary shipment income settles before its deferred territory entry. controls.response ||= (g.advanced && me.faction === 'fremen' && action.sector === g.storm); - controls.decision ||= accompaniment; order = { player: me.id, origin: 'reserves', diff --git a/game/engine.ts b/game/engine.ts index 004071e3..cb661032 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -15352,13 +15352,6 @@ function quoteShipmentArrival( ? Math.max(1, shipment.amount) : shipment.amount + (shipment.smugglerCompanion ? 1 : 0); const guild = byFaction(g, 'guild'); - const bg = byFaction(g, 'beneGesserit'); - const advisorFollowup = !!( - p.faction !== 'fremen' && - bg && - bg.id !== p.id && - spiritualAdvisorMaximum(g, bg.id) > 0 - ); const currentDecision = !!( g.decision && !( @@ -15391,7 +15384,7 @@ function quoteShipmentArrival( response: !!g.response || (g.advanced && p.faction === 'fremen' && shipment.sector === g.storm), - decision: currentDecision || advisorFollowup, + decision: currentDecision, pendingTerror: !!g.pendingTerrorEntry, pendingAmbassador: !!g.pendingAmbassador, paidBox: !!g.pendingNullentropy, diff --git a/game/karama-movement-preflight.ts b/game/karama-movement-preflight.ts index bc16a589..b208db6e 100644 --- a/game/karama-movement-preflight.ts +++ b/game/karama-movement-preflight.ts @@ -120,7 +120,9 @@ export function quoteCompletedMovementArrival( input.terror.some((t) => t.status === 'placed' && t.location === order.to) ); const response = controls.response || order.wantsFighters; - const decision = controls.decision || intrusion; + // Intrusion is a resulting own-arrival choice, not an already active control. + // The committed entry waits for it in the existing arrival continuation. + const decision = controls.decision; if ( triggersAmbassador && triggersTerror && diff --git a/game/reference.ts b/game/reference.ts index 0cb9dbc3..7ee39f17 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1394,7 +1394,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Destroyed tokens return to supply; triggered regular tokens are set aside, Bene Gesserit is permanently removed, and replenishment waits for all five random tokens. Storm/explosion destruction remains separate. The reusable Ecaz token returns to supply after acquisition or alliance proposal. Basic has an optional new-ally Duke loan; the supplied Advanced p30 automatically grants an available Duke after accepted consent.', 'When an eligible entrant reaches the reusable Ecaz token, Ecaz may acquire the available shared Duke. The token returns to supply and its companions are unchanged. Unused Ecaz control persists until battle use or Moritani acquisition. Basic optional and Advanced automatic new-ally grants are connected; standing ally assignment and wider captured-loan combinations remain for refinement.', 'Unallied Ecaz may propose an alliance to an unallied entrant. Acceptance activates alliance abilities immediately. Basic then offers an optional Duke loan; Advanced automatically adds an available Duke under supplied PDF p30, without another acknowledgement. The first version releases a surviving loan after one battle, provisionally; an unused living loan is set aside at turn end. Unavailable Duke never prevents consent.', - 'When another eligible faction enters a marked stronghold, Ecaz may trigger its Ambassador for itself or its current ally, or leave it in place. Ecaz itself, its ally, advisors and the faction matching the physical Ambassador do not trigger it. Ordinary Guild-paid shipment now settles its original income/counter window before the queued Ambassador prompt, without replaying payment or forces. Actual busy controls and unresolved BG/other arrival combinations retain their guards.', + 'When another eligible faction enters a marked stronghold, Ecaz may trigger its Ambassador for itself or its current ally, or leave it in place. Ecaz itself, its ally, advisors and the faction matching the physical Ambassador do not trigger it. Ordinary shipment and native Guild transport settle their own income, BG Intrusion and accompaniment controls before queued entry, without replaying payment or forces. Actual busy controls and unsupported paired or module combinations remain guarded.', + 'Accompaniment joining retained BG fighters creates a distinct eligible child entry before the original shipper. Declining the child leaves the live token for the original entry; consuming it does not create a token to offer again. Advisors remain exempt. This existing child-first, live-token composition is explicitly provisional, not a publisher priority ruling.', 'Emperor grants five bank spice. Atreides records the entrant’s current Treachery hand privately for the beneficiary; Harkonnen records one randomly selected held Traitor identity, or Face Dancer identity for a Tleilaxu entrant. These snapshots remain in the recipient’s private inspection history, identify when they were taken, and do not become a live view of later changes. Opening, enlarging or closing them needs no game acknowledgement.', 'CHOAM lets the beneficiary choose any available hand cards, including none, for three spice each. Ixians require one available card to discard before drawing its replacement. Card controls respect projected reservations. After committing an Ixian exchange, a voluntary interruption must leave a card available for its required discard; a queued Truthtrance counts as already committed. A Bene Gesserit trigger permanently removes its token, then the beneficiary chooses an available effect outside the original supply for this group; unsupported copies remain visibly blocked.', 'The Richese Ambassador purchases the top Treachery Card for three of the chosen beneficiary’s own spice when executable. Normally this is automatic; in supported Emperor-absent Nexus tables, the committed trigger instead offers uniform beneficiary Pay or eligible Emperor Nexus Use, with no new decline. Under the provisional purchase composition, an actual unallied holder may prove/retain that price, with no bank payment or reimbursement. Original private inability still consumes the token without payment/draw and does not disclose the reason. Native Emperor income and Harkonnen bonus remain applicable to their original sources, with hand limits and Karama; original movement/worm continuation runs once. Ixian replacement still requires Bidding.', @@ -1411,33 +1412,33 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Original effects, copies, direct Duke acquisition, consent and storm/explosion returns remain integrated. Ordinary reserve shipment now distinguishes future Guild income from an actually busy control and uses the existing income-then-entry queue. Original price/funding and permission remain. Terror deferral stays connected; other Ambassador/arrival compositions remain partial.', + 'Original effects, copies, Duke acquisition, consent and token returns remain integrated. Ordinary shipment and native Guild transport distinguish future own income/BG controls from actual busy state; the existing committed-entry queue handles settlement. A legitimate fighter companion has its own child entry. Original prices, funding, payment, forces and movement remain; unsupported paired/module combinations stay partial.', evidence: ['game/engine.ts', 'game/bot-arrival.ts'], }, { area: 'Player controls', status: 'Partial', detail: - 'Existing placement, beneficiary, copied effect, private inspection, relocation and independent Guild grant controls remain. Actual390px ordinary Ship from reserves, Guild Allow shipment and Ecaz Acquire Duke controls debit Moritani12 to11/reserves14 to13 and credit Guild6 to7 before Ambassador resolution. The original subsequent Duke6 win opens Sanya4 reveal and physical Mentat replacement.', + 'Existing placement, effect, consent and movement controls remain. Four actual390px native journeys use Guild transport and income cancellation, peaceful accompaniment, accepted Intrusion, and retained fighters with distinct child/original offers. Original entrant resumes actual movement after once-only payment and transfer; advisors do not create a bogus fighter offer.', }, { area: 'AI', status: 'Partial', detail: - 'All four profiles retain owner-view placement, trigger, beneficiary, copy, card, consent, movement and grant decisions. Ordinary positive-Guild-income Ambassador shipment is no longer filtered solely by a predicted future response; actual control and other reaction gates remain. All four consume the later native Ecaz target. Minimal legal participation only, not calibrated strategy.', + 'All four profiles retain private legal placement, effect, consent and arrival decisions. Native transport future income and ordinary future accompaniment no longer block a candidate solely as predicted busy controls. All four Ecaz policies acquire Duke after income; all four BG policies legally convert and accompany. The retained-fighter child path is separate human/scripted proof. No strategy calibration.', }, { area: 'Documentation', status: 'Partial', detail: - 'Source-bound placement/effect/inspection and Basic optional versus Advanced automatic new-ally Duke guides remain. The ordinary Guild income-before-queued-Ambassador contract explicitly preserves tariffs, funding, counters and remaining arrival gates; it is not a new comprehensive priority FAQ.', + 'Source-bound effects, copies and Duke guides remain. Native own-arrival continuation explicitly records existing child-first/live-token order as provisional, preserving actual controls, original tariffs and unsupported paired/module guards. Guild native rate-Karama expansion/Nexus admission is separate unfinished work, not a printed prohibition.', evidence: ['docs/ECAZ_AMBASSADORS_RULES.md', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'docs/BOT_ARRIVAL_PREFLIGHT.md'], }, { area: 'Verification', status: 'Partial', detail: - 'Current affected15-file134/134, types/lint and original native API proof pass. Actual390px paid shipment and Guild permission settle income1, then Ecaz acquires Duke6 and original native Sanya4 reveal/replacement follows; final surface has no overflow/browser errors. Earlier effect/copy/consent/inspection and persisted evidence remains source-bound, not new saved/deployed assurance. Other arrival combinations and full acceptance remain open.', + 'Affected six-file55/55,20 original API/policy paths and four actual390px journeys pass: income allow/cancel, accompaniment and Intrusion accept/decline/cancel, legitimate fighter child, token consumption and original movement. No overflow/application browser errors. Genuine original-action fixtures do not stage played hands, wallets, armies or phase. Existing saved/deployed evidence stays historical; full acceptance remains open.', evidence: [ 'tests/ecaz-ambassadors.test.ts', 'tests/ecaz-placement.test.ts', @@ -1445,6 +1446,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/moritani-ecaz-duke-winner.test.ts', 'tests/bot-arrival.test.ts', 'tests/deferred-shipment-arrival.test.ts', + 'tests/guild-ambassador-income-arrival.test.ts', + 'tests/bg-ambassador-arrival.test.ts', 'tests/ecaz-duke-acquisition.test.ts', 'tests/ecaz-duke-bots.test.ts', 'tests/ecaz-duke-recovery.test.ts', diff --git a/tests/bg-ambassador-arrival.test.ts b/tests/bg-ambassador-arrival.test.ts new file mode 100644 index 00000000..6273e42e --- /dev/null +++ b/tests/bg-ambassador-arrival.test.ts @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { + applyBgAmbassadorArrivalStep, beginBgAmbassadorArrival, + bgAmbassadorAccompanimentAction, bgAmbassadorArrivalPlayer, + bgAmbassadorIntrusionAction, bgAmbassadorOfferAction, + createBgAmbassadorArrivalFixture, finishBgAmbassadorArrival, + settleBgAmbassadorArrivalControls, type BgAmbassadorArrivalFixture, +} from './fixture-bg-ambassador-arrival'; + +function assertStock(game: Game) { + for (const player of game.players) { + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, count) => sum + count, 0), 20); + if (player.elites) { + assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0), 5); + for (const [key, count] of Object.entries(player.elites.forces)) assert.ok(count <= (player.forces[key] ?? 0)); + } + } +} +function assertPaidOnce(fixture: BgAmbassadorArrivalFixture, game: Game) { + const before = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.entrant); + const entrant = bgAmbassadorArrivalPlayer(game, fixture.entrant); + assert.equal(entrant.spice, before.spice - 1); + assert.equal(entrant.reserves, before.reserves - 1); + assert.equal(entrant.forces[fixture.location], 1); + assert.equal(entrant.shipped, true); + assert.equal(entrant.moved, before.moved); + assert.deepEqual(entrant.elites, before.elites); + assert.equal(game.active, fixture.entrant); + assert.equal(game.turn, fixture.beforeShipment.turn); + assert.equal(game.phase, fixture.beforeShipment.phase); + assertStock(game); +} +function assertMainQueue(fixture: BgAmbassadorArrivalFixture, game: Game) { + assert.deepEqual(game.pendingArrivalReaction, [{ + entrant: fixture.entrant, territory: fixture.territory, sector: 11, + amount: 1, elite: 0, cause: 'shipment', resume: 'none', + turn: fixture.beforeShipment.turn, phase: fixture.beforeShipment.phase, + }]); + assert.equal(game.pendingAmbassador ?? null, null); + assertPaidOnce(fixture, game); +} +function assertMainOffer(fixture: BgAmbassadorArrivalFixture, game: Game) { + const entry = game.pendingAmbassador; + assert.ok(entry); + assert.equal(game.decision?.kind, 'ecazAmbassador'); + assert.equal(game.decision?.player, fixture.owner); + assert.equal(entry.owner, fixture.owner); + assert.equal(entry.entrant, fixture.entrant, 'The BG advisor arrival must not replace the committed main entrant'); + assert.equal(entry.token, fixture.token); + assert.equal(entry.territory, fixture.territory); + assert.equal(entry.sector, 11); + assert.equal(entry.stage, 'offer'); + assert.equal(game.pendingArrivalReaction?.length ?? 0, 0); + assertPaidOnce(fixture, game); +} +function assertOriginalMovementResumes(fixture: BgAmbassadorArrivalFixture, game: Game, trigger: boolean) { + assert.equal(game.decision ?? null, null); + assert.equal(game.response ?? null, null); + assert.equal(game.pendingAmbassador ?? null, null); + assert.equal(game.pendingArrivalReaction?.length ?? 0, 0); + assertPaidOnce(fixture, game); + const token = game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)!; + assert.equal(token.zone, trigger ? 'supply' : 'placed'); + assert.equal(token.location, trigger ? null : fixture.territory); + if (trigger) { + assert.equal(game.dukeVidal?.controller, fixture.owner); + assert.equal(game.dukeVidal?.leader.strength, 6); + } else assert.equal(game.dukeVidal?.controller ?? null, null); + const before = bgAmbassadorArrivalPlayer(game, fixture.entrant); + const bgBefore = bgAmbassadorArrivalPlayer(game, fixture.advisor); + const moved = applyBgAmbassadorArrivalStep(game, { actor: fixture.entrant, action: fixture.movementAction }); + const entrant = bgAmbassadorArrivalPlayer(moved, fixture.entrant); + assert.equal(entrant.forces[fixture.location] ?? 0, 0); + assert.equal(entrant.forces['arsunt:11'], 1); + assert.equal(entrant.spice, before.spice); + assert.equal(entrant.reserves, before.reserves); + assert.equal(entrant.moved, before.moved + 1); + assert.equal(entrant.shipped, true); + assert.equal(moved.active, fixture.entrant); + assert.equal(moved.phase, game.phase); + assert.equal(moved.turn, game.turn); + assert.equal(moved.pendingAmbassador ?? null, null); + assert.equal(moved.pendingArrivalReaction?.length ?? 0, 0); + assert.deepEqual(bgAmbassadorArrivalPlayer(moved, fixture.advisor).forces, bgBefore.forces); + assert.equal(bgAmbassadorArrivalPlayer(moved, fixture.advisor).reserves, bgBefore.reserves); + // If BG remains alone after the original entrant moves out, its stance changes + // automatically without another entry event or another Ambassador prompt. + if (bgBefore.forces[fixture.location]) + assert.equal(bgAmbassadorArrivalPlayer(moved, fixture.advisor).advisors?.[fixture.territory], undefined); + assertStock(moved); +} + +for (const accept of [true, false]) { + void test(`original paid stronghold entry waits for BG's own ${accept ? 'accepted' : 'declined'} advisor before one Ecaz offer`, () => { + const fixture = createBgAmbassadorArrivalFixture(); + assertStock(fixture.afterSetup); + assertStock(fixture.beforeShipment); + const bgBefore = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.advisor); + const cohort = structuredClone(fixture.beforeShipment.ecazAmbassadors!.cohort); + let game = beginBgAmbassadorArrival(fixture); + assert.equal(game.decision?.kind, 'advisor'); + assert.equal(game.decision?.player, fixture.advisor); + assert.equal(viewGame(game, fixture.advisor).decision?.player, fixture.advisor); + assertMainQueue(fixture, game); + assert.throws(() => applyAction(game, fixture.entrant, { type: 'endMovement' })); + assert.throws(() => applyAction(game, fixture.owner, bgAmbassadorAccompanimentAction(accept))); + game = applyBgAmbassadorArrivalStep(game, { actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(accept) }); + if (accept) { + assert.equal(game.response?.kind, 'advisor', 'The actual original Karama holder keeps the native cancellation response open'); + assertMainQueue(fixture, game); + } + game = settleBgAmbassadorArrivalControls(game); + const bg = bgAmbassadorArrivalPlayer(game, fixture.advisor); + assert.equal(bg.reserves, bgBefore.reserves - (accept ? 1 : 0)); + assert.equal(bg.forces[fixture.location] ?? 0, accept ? 1 : 0); + assert.equal(bg.spice, bgBefore.spice); + assert.equal(bg.forces['imperial_basin:10'], 1); + if (accept) assert.equal(bg.advisors?.[fixture.territory]?.lockedTurn, game.turn); + else assert.equal(bg.advisors?.[fixture.territory], undefined); + assertMainOffer(fixture, game); + const spentOffer = bgAmbassadorOfferAction(game, accept); + game = finishBgAmbassadorArrival(game, accept); + assert.deepEqual(game.ecazAmbassadors!.cohort, cohort); + assert.throws(() => applyAction(game, fixture.owner, spentOffer), 'The single original offer cannot be replayed'); + assertOriginalMovementResumes(fixture, game, accept); + }); +} + +for (const becomeAdvisors of [true, false]) { + void test(`original moved BG fighter ${becomeAdvisors ? 'accepts' : 'declines'} Intrusion before accompaniment and the main Ambassador entry`, () => { + const fixture = createBgAmbassadorArrivalFixture({ fighters: true }); + assert.equal(fixture.afterSetup.turn, 1); + assert.equal(fixture.beforeShipment.turn, 2); + assert.ok(fixture.actions.some(step => step.actor === fixture.advisor && step.action.type === 'move' && step.action.from === 'imperial_basin:10')); + const bgBefore = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.advisor); + assert.equal(bgBefore.forces[fixture.location], 1); + assert.equal(bgBefore.forces['imperial_basin:10'] ?? 0, 0); + assert.equal(bgBefore.advisors?.[fixture.territory], undefined); + let game = beginBgAmbassadorArrival(fixture); + assert.equal(game.decision?.kind, 'intrusion'); + assert.equal(game.decision?.player, fixture.advisor); + assertMainQueue(fixture, game); + assert.throws(() => applyAction(game, fixture.entrant, bgAmbassadorIntrusionAction(becomeAdvisors))); + game = applyBgAmbassadorArrivalStep(game, { actor: fixture.advisor, action: bgAmbassadorIntrusionAction(becomeAdvisors) }); + if (becomeAdvisors) { + assert.equal(game.response?.kind, 'advisorFlip'); + assert.equal(game.response?.owner, fixture.advisor); + assertMainQueue(fixture, game); + } + game = settleBgAmbassadorArrivalControls(game); + assert.equal(game.decision?.kind, 'advisor'); + assert.equal(game.decision?.player, fixture.advisor); + assert.equal(!!bgAmbassadorArrivalPlayer(game, fixture.advisor).advisors?.[fixture.territory], becomeAdvisors); + assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).reserves, bgBefore.reserves); + assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).forces[fixture.location], 1); + assertMainQueue(fixture, game); + // Keeping fighters and accepting accompaniment would join that fighter + // group, not manufacture an advisor. Here that branch legitimately declines + // the reserve transfer; the accepted branch joins the newly peaceful group. + game = applyBgAmbassadorArrivalStep(game, { actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(becomeAdvisors) }); + if (becomeAdvisors) { + assert.equal(game.response?.kind, 'advisor'); + assertMainQueue(fixture, game); + } + game = settleBgAmbassadorArrivalControls(game); + const bg = bgAmbassadorArrivalPlayer(game, fixture.advisor); + assert.equal(bg.forces[fixture.location], becomeAdvisors ? 2 : 1); + assert.equal(bg.reserves, bgBefore.reserves - (becomeAdvisors ? 1 : 0)); + assert.equal(bg.spice, bgBefore.spice); + if (becomeAdvisors) assert.equal(bg.advisors?.[fixture.territory]?.lockedTurn, game.turn); + else assert.equal(bg.advisors?.[fixture.territory], undefined); + assertMainOffer(fixture, game); + game = finishBgAmbassadorArrival(game, !becomeAdvisors); + assertOriginalMovementResumes(fixture, game, !becomeAdvisors); + }); +} + +void test('declining Intrusion and accepting accompaniment preserves fighters: one BG child offer then one original shipper offer', () => { + const fixture = createBgAmbassadorArrivalFixture({ fighters: true }); + const bgBefore = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.advisor); + let game = beginBgAmbassadorArrival(fixture); + assert.equal(game.decision?.kind, 'intrusion'); + assertMainQueue(fixture, game); + const mainQueue = structuredClone(game.pendingArrivalReaction); + game = applyBgAmbassadorArrivalStep(game, { + actor: fixture.advisor, action: bgAmbassadorIntrusionAction(false), + }); + assert.equal(game.decision?.kind, 'advisor'); + assertMainQueue(fixture, game); + game = applyBgAmbassadorArrivalStep(game, { + actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(true), + }); + assert.equal(game.response?.kind, 'advisor'); + assertMainQueue(fixture, game); + game = settleBgAmbassadorArrivalControls(game); + const bg = bgAmbassadorArrivalPlayer(game, fixture.advisor); + assert.equal(bg.forces[fixture.location], 2); + assert.equal(bg.reserves, bgBefore.reserves - 1); + assert.equal(bg.spice, bgBefore.spice); + assert.equal(bg.advisors?.[fixture.territory], undefined, 'Accompaniment must match the existing fighter group'); + const child = game.pendingAmbassador; + assert.ok(child); + assert.equal(game.decision?.kind, 'ecazAmbassador'); + assert.equal(game.decision?.player, fixture.owner); + assert.equal(child.entrant, fixture.advisor, 'This physical BG fighter arrival is independently eligible'); + assert.equal(child.owner, fixture.owner); + assert.equal(child.token, fixture.token); + assert.equal(child.territory, fixture.territory); + assert.deepEqual(game.pendingArrivalReaction, mainQueue, 'The original paid arrival waits while the eligible BG child resolves'); + assertPaidOnce(fixture, game); + const childEvent = child.event; + // Existing callback order is child-first, not an invented FIFO priority. + // Keeping the token in place lets the queued original entry open its own + // single offer without pretending the fighter child was an advisor. + game = finishBgAmbassadorArrival(game, false); + assertMainOffer(fixture, game); + assert.notEqual(game.pendingAmbassador!.event, childEvent); + assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)!.zone, 'placed'); + assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).forces[fixture.location], 2); + assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).reserves, bgBefore.reserves - 1); + game = finishBgAmbassadorArrival(game, true); + assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).advisors?.[fixture.territory], undefined); + assertOriginalMovementResumes(fixture, game, true); +}); + +void test('triggering the genuine BG fighter child returns the live Ecaz token before the queued shipper entry resumes', () => { + const fixture = createBgAmbassadorArrivalFixture({ fighters: true }); + const bgBefore = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.advisor); + let game = beginBgAmbassadorArrival(fixture); + game = applyBgAmbassadorArrivalStep(game, { + actor: fixture.advisor, action: bgAmbassadorIntrusionAction(false), + }); + assert.equal(game.decision?.kind, 'advisor'); + game = settleBgAmbassadorArrivalControls(applyBgAmbassadorArrivalStep(game, { + actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(true), + })); + assert.equal(game.pendingAmbassador?.entrant, fixture.advisor); + assert.equal(game.pendingArrivalReaction?.[0]?.entrant, fixture.entrant); + const spentChild = bgAmbassadorOfferAction(game, true); + // This pins the existing provisional child-first/live-token composition, + // not a publisher priority: triggering returns the reusable token to supply, + // so processing the original committed entry cannot trigger that token again. + game = finishBgAmbassadorArrival(game, true); + assert.equal(game.pendingAmbassador ?? null, null); + assert.equal(game.pendingArrivalReaction?.length ?? 0, 0); + assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).forces[fixture.location], 2); + assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).reserves, bgBefore.reserves - 1); + assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).advisors?.[fixture.territory], undefined); + assert.throws(() => applyAction(game, fixture.owner, spentChild)); + assertOriginalMovementResumes(fixture, game, true); +}); + +void test('canceling the real accepted accompaniment preserves the original paid Ambassador entry without a reserve transfer', () => { + const fixture = createBgAmbassadorArrivalFixture(); + const before = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.advisor); + let game = beginBgAmbassadorArrival(fixture); + game = applyBgAmbassadorArrivalStep(game, { actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(true) }); + const card = viewGame(game, fixture.owner).responseControls?.cancelCards[0]; + assert.ok(card); + game = applyBgAmbassadorArrivalStep(game, { actor: fixture.owner, action: { type: 'card', mode: 'cancel', card } }); + assertMainOffer(fixture, game); + assert.deepEqual(bgAmbassadorArrivalPlayer(game, fixture.advisor).forces, before.forces); + assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).reserves, before.reserves); + assert.equal(game.discard.filter(c => c.id === card).length, 1); + assertOriginalMovementResumes(fixture, finishBgAmbassadorArrival(game, false), false); +}); + +void test('canceling the real Intrusion flip retains fighters and the followup before the original Ambassador entry', () => { + const fixture = createBgAmbassadorArrivalFixture({ fighters: true }); + let game = beginBgAmbassadorArrival(fixture); + game = applyBgAmbassadorArrivalStep(game, { actor: fixture.advisor, action: bgAmbassadorIntrusionAction(true) }); + const card = viewGame(game, fixture.owner).responseControls?.cancelCards[0]; + assert.ok(card); + game = applyBgAmbassadorArrivalStep(game, { actor: fixture.owner, action: { type: 'card', mode: 'cancel', card } }); + assert.equal(game.decision?.kind, 'advisor'); + assertMainQueue(fixture, game); + assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).advisors?.[fixture.territory], undefined); + assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).forces[fixture.location], 1); + game = applyBgAmbassadorArrivalStep(game, { actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(false) }); + assertMainOffer(fixture, game); + assert.equal(game.discard.filter(c => c.id === card).length, 1); + assertOriginalMovementResumes(fixture, finishBgAmbassadorArrival(game, false), false); +}); diff --git a/tests/bot-arrival.test.ts b/tests/bot-arrival.test.ts index 3774baa9..025009f4 100644 --- a/tests/bot-arrival.test.ts +++ b/tests/bot-arrival.test.ts @@ -12,10 +12,8 @@ import { movementArrivalGame, } from './deferred-movement-arrival-fixture'; import { - addShipmentArrivalAmbassador, shipmentArrivalAction, shipmentArrivalGame, - richeseNoFieldArrivalAction, } from './fixture-deferred-shipment-arrival'; const profiles = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; @@ -134,44 +132,6 @@ void test('single reactions and matching-faction Ambassador immunity remain play } }); -void test('shipment detects prospective BG accompaniment including a concealed No-Field without inspecting its denomination', () => { - const game = shipmentArrivalGame(); - addShipmentArrivalAmbassador(game); - assert.throws(() => applyAction(game, game.active!, shipmentArrivalAction)); - game.active = 'richese'; - arrivalPlayer(game, 'richese').shipped = false; - const marker = richeseNoFieldArrivalAction(game); - assert.throws(() => applyAction(game, 'richese', marker)); -}); - - -void test('Advanced BG fighter intrusion is distinguished from advisor presence', () => { - const game = shipmentArrivalGame(); - addShipmentArrivalAmbassador(game); - const guild = arrivalPlayer(game, 'guild'); - guild.forces = { 'arrakeen:10': 3 }; - guild.reserves = 17; - guild.shipped = true; - const bg = arrivalPlayer(game, 'bg'); - bg.forces = { 'carthag:11': 1 }; - bg.reserves = 19; - const move = { - type: 'move', - from: 'arrakeen:10', - territory: 'carthag', - sector: 11, - amount: 3, - }; - assert.throws(() => applyAction(game, guild.id, move)); - bg.advisors = { carthag: {} }; - // Advisors need another faction present or automatic stance settlement - // correctly makes this lone group fighters before either quote. - const moritani = arrivalPlayer(game, 'moritani'); - moritani.forces = { 'carthag:11': 1 }; - moritani.reserves = 19; - assert.equal(botArrivalBlock(ownView(game), move), null); - assert.equal(applyAction(game, guild.id, move).pendingAmbassador?.entrant, guild.id); -}); void test('arrival filtering never reads hidden rival resources or Terror faces and does not mutate the view', () => { const game = movementArrivalGame(); diff --git a/tests/deferred-shipment-arrival.test.ts b/tests/deferred-shipment-arrival.test.ts index fa2050ea..df3a0add 100644 --- a/tests/deferred-shipment-arrival.test.ts +++ b/tests/deferred-shipment-arrival.test.ts @@ -2,81 +2,20 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, type Action, type Game } from '../game/engine'; import { - addShipmentArrivalAmbassador, legacyGuildArrival, - richeseNoFieldArrivalAction, shipmentArrivalAction, shipmentArrivalGame, shipmentArrivalPlayer, shipmentArrivalReload, - shipmentArrivalResources, } from './fixture-deferred-shipment-arrival'; -const overlap = /Ambassadors combined with another arrival reaction/; -function reject(g: Game, player: string, action: Action, pattern: RegExp) { +function reject(g: Game, player: string, action: Action) { const before = structuredClone(g); - assert.throws(() => applyAction(g, player, action), pattern); + assert.throws(() => applyAction(g, player, action)); assert.deepEqual(g, before); } -void test('an ordinary Guild shipment rejects a known Ambassador and advisor overlap before declaration or payment', (t) => { - const g = shipmentArrivalGame(); - addShipmentArrivalAmbassador(g); - const resources = structuredClone(shipmentArrivalResources(g)); - const uuid = t.mock.method(crypto, 'randomUUID', () => { - throw Error('Unsupported shipment allocated an event'); - }); - reject(g, 'guild', shipmentArrivalAction, overlap); - assert.deepEqual(shipmentArrivalResources(g), resources); - assert.equal(g.pendingShipment, null); - assert.equal(g.decision, null); - assert.equal(uuid.mock.callCount(), 0); -}); - -void test('an own Richese No-Field rejects the unsupported arrival before saving its private cancellation response', () => { - const g = shipmentArrivalGame(); - g.active = 'richese'; - shipmentArrivalPlayer(g, 'richese').shipped = false; - addShipmentArrivalAmbassador(g); - const action = richeseNoFieldArrivalAction(g); - const before = structuredClone(g); - assert.throws(() => applyAction(g, 'richese', action), overlap); - assert.deepEqual(g, before); - assert.equal(g.pendingShipment, null); - assert.equal(g.response, null); - assert.equal(g.decision, null); - assert.equal(shipmentArrivalPlayer(g, 'richese').noField!.deployed, null); -}); - -void test('a saved Guild allowance returns only the uncommitted ordinary shipment and permits a safe replacement', () => { - const g = legacyGuildArrival(); - const before = structuredClone(g); - const resources = structuredClone(shipmentArrivalResources(g)); - const returned = applyAction(shipmentArrivalReload(g), 'guild', { - type: 'decision', - allow: true, - }); - assert.deepEqual(g, before); - assert.deepEqual(shipmentArrivalResources(returned), resources); - assert.equal(returned.pendingShipment, null); - assert.equal(returned.decision, null); - assert.equal(shipmentArrivalPlayer(returned, 'guild').shipped, false); - assert.deepEqual(shipmentArrivalPlayer(returned, 'guild').forces, {}); - assert.match(returned.log.at(-1)!.text, overlap); - assert.match( - returned.log.at(-1)!.text, - /No forces, spice, cards or shipment allowance were spent/, - ); - - const retried = applyAction(returned, 'guild', { - ...shipmentArrivalAction, - territory: 'polar_sink', - sector: 0, - }); - assert.equal(retried.decision?.kind, 'guildShipment'); - assert.equal(retried.pendingShipment?.territory, 'polar_sink'); -}); void test('a supported Guild allowance still commits once and opens the advisor choice', () => { let g = shipmentArrivalGame(); @@ -99,27 +38,6 @@ void test('a supported Guild allowance still commits once and opens the advisor void test('a malformed saved shipment remains rejected and is not treated as a recoverable arrival overlap', () => { const g = legacyGuildArrival(); g.pendingShipment!.cost++; - reject( - g, - 'guild', - { type: 'decision', allow: true }, - /declared shipment price is no longer current/, - ); + reject(g, 'guild', { type: 'decision', allow: true }); }); -void test('forged or null special receipts cannot enter the ordinary legacy return path', () => { - for (const corrupt of [ - (g: Game) => { - g.pendingShipment!.ambassadorEvent = 'forged'; - }, - (g: Game) => { - g.pendingShipment!.guildSecretEvent = null as unknown as string; - }, - ]) { - const g = legacyGuildArrival(); - corrupt(g); - reject(g, 'guild', { type: 'decision', allow: true }, overlap); - assert.ok(g.pendingShipment); - assert.equal(g.decision?.kind, 'guildShipment'); - } -}); diff --git a/tests/fixture-bg-ambassador-arrival.ts b/tests/fixture-bg-ambassador-arrival.ts new file mode 100644 index 00000000..df5d5257 --- /dev/null +++ b/tests/fixture-bg-ambassador-arrival.ts @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeFactionExpansionsGameForAudit, joinGame, + newPlayer, type Action, type Game, +} from '../game/engine'; +import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech'; + +export type BgAmbassadorArrivalStep = { actor: string; action: Action }; +export type BgAmbassadorArrivalFixture = { + initial: Game; + afterSetup: Game; + beforePlacement: Game; + beforeShipment: Game; + game: Game; + actions: BgAmbassadorArrivalStep[]; + owner: string; + advisor: string; + entrant: string; + territory: string; + location: string; + token: string; + fighters: boolean; + shipmentAction: Action; + movementAction: Action; + staging: string[]; +}; + +export function bgAmbassadorArrivalPlayer(game: Game, actor: string) { + const player = game.players.find(player => player.id === actor); + assert.ok(player, `Missing original BG arrival seat ${actor}`); + return player; +} +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard; + +/** Native entropy only: original shuffles/deals retain their physical inventories. + * No hand, force, wallet, stance, turn, receipt or decision is assigned. */ +function entropy(run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + crypto.getRandomValues = (array: V): V => { + assert.ok(array); + new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); + return array; + }; + try { return run(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +/** Original no-spend/no-call driver, including the native owned BG controls. */ +export function nextBgAmbassadorArrivalStep(game: Game): BgAmbassadorArrivalStep { + if (!game.phaseOpening && !game.response) { + const bg = game.players.find(player => player.faction === 'beneGesserit')!; + const ecaz = game.players.find(player => player.faction === 'ecaz')!; + if (game.status === 'setup' && game.setupStage === 'prediction') + return { actor: bg.id, action: { type: 'predict', faction: 'emperor', turn: 10 } }; + if (game.status === 'setup' && game.setupStage === 'forces') { + if (ecaz.reserves === 20) + return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } }; + if (!bg.advisorSetup) + return { actor: bg.id, action: { type: 'advisorSetup', territory: 'imperial_basin', sector: 10 } }; + } + const decision = game.decision; + if (decision?.kind === 'ecazPlacement') + return { actor: decision.player, action: { type: 'decision', decline: true } }; + if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' || decision?.kind === 'advisorBattle') + return { actor: decision.player, action: { type: 'decision', accept: false } }; + if (decision?.kind === 'ecazAmbassador') { + assert.ok(game.pendingAmbassador); + return { actor: decision.player, action: { type: 'decision', event: game.pendingAmbassador.event, decline: true } }; + } + } + const step = nextRicheseSkillsTechNativeStep(game); + if (step.action.type === 'stormDial') step.action.amount = 0; + return step; +} + +export function applyBgAmbassadorArrivalStep( + game: Game, step: BgAmbassadorArrivalStep, actions?: BgAmbassadorArrivalStep[], +): Game { + actions?.push(structuredClone(step)); + return entropy(() => applyAction(game, step.actor, step.action)); +} + +function advance(game: Game, until: (state: Game) => boolean, actions: BgAmbassadorArrivalStep[]): Game { + for (let n = 0; n < 2400; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'Original BG arrival must remain in its native game'); + game = applyBgAmbassadorArrivalStep(game, nextBgAmbassadorArrivalStep(game), actions); + } + throw Error('Original native BG arrival did not reach the requested boundary'); +} + +/** Finish only original response/acknowledgement windows. In particular this + * never silently chooses Intrusion, accompaniment or the Ambassador offer. */ +export function settleBgAmbassadorArrivalControls(game: Game, actions: BgAmbassadorArrivalStep[] = []): Game { + return advance(game, state => !state.response && !state.phaseOpening && !state.pendingTreacheryDiscard, actions); +} + +function orderLandBlows(game: Game, staging: string[]) { + for (let at = 0; at < 2; at++) { + const index = game.spiceDeck.findIndex((card, index) => index >= at && 'territory' in card && + !['imperial_basin', 'carthag'].includes(card.territory)); + assert.ok(index >= at, 'Two original undealt land blows must remain'); + const card = game.spiceDeck.splice(index, 1)[0]; + game.spiceDeck.splice(at, 0, card); + assert.ok('territory' in card); + staging.push(`Turn ${game.turn}: original undealt ${card.territory} land blow moved to slot ${at + 1}.`); + } +} + +/** Original Advanced Ecaz/BG/Emperor setup and paid entry, stopped BEFORE ship. + * In the fighter variant the original Imperial Basin advisor actually moves to + * empty Carthag on turn one, becoming a fighter by the native automatic rule. + * Ecaz leaves turn-one placement unused, then marks Carthag on turn two: no + * preceding BG Ambassador trigger or fabricated fighter group is needed. + * The only controlled values are producer entropy, a conserved undealt Karama + * starting-deal slot and unplayed Spice order. Supplied fresh lobbies retain + * their original seat identities; no HTTP authentication claim is made. */ +export function createBgAmbassadorArrivalFixture(options: { initial?: Game; fighters?: boolean } = {}): BgAmbassadorArrivalFixture { + let game = options.initial ? structuredClone(options.initial) + : createGame('BGAMBASSADORARRIVAL', newPlayer('bg', 'Bene Gesserit', 'beneGesserit'), true, ['ecaz']); + if (!options.initial) { + joinGame(game, newPlayer('ecaz', 'Ecaz', 'ecaz')); + joinGame(game, newPlayer('emperor', 'Emperor', 'emperor')); + } + assert.equal(game.status, 'lobby', 'Only an original undealt lobby can enter'); + assert.equal(game.advanced, true); + assert.deepEqual(game.expansions, ['ecaz']); + assert.equal(game.players.length, 3); + const owner = game.players.find(player => player.faction === 'ecaz')?.id; + const advisor = game.players.find(player => player.faction === 'beneGesserit')?.id; + const entrant = game.players.find(player => player.faction === 'emperor')?.id; + assert.ok(owner && advisor && entrant, 'Original seats must be Ecaz, Bene Gesserit and Emperor'); + const initial = structuredClone(game), actions: BgAmbassadorArrivalStep[] = [], staging: string[] = []; + for (const player of game.players) + if (!player.ready) game = applyBgAmbassadorArrivalStep(game, { actor: player.id, action: { type: 'ready' } }, actions); + game = entropy(() => initializeFactionExpansionsGameForAudit(game)); + // Give Ecaz a real cancellation opportunity through its ORIGINAL starting + // deal, not a card transfer into an already dealt hand. + const dealSlot = game.players.findIndex(player => player.id === owner); + const karamaIndex = game.deck.findIndex(card => card.effect === 'karama'); + assert.ok(karamaIndex >= 0 && game.players.every(player => !player.hand.length)); + const karama = game.deck[karamaIndex].id; + [game.deck[dealSlot], game.deck[karamaIndex]] = [game.deck[karamaIndex], game.deck[dealSlot]]; + staging.push(`Original undealt ${karama} swapped into Ecaz's starting-deal slot ${dealSlot + 1}; no dealt hand is changed.`); + game = advance(game, state => state.status === 'playing', actions); + const afterSetup = structuredClone(game); + assert.ok(bgAmbassadorArrivalPlayer(game, owner).hand.some(card => card.id === karama)); + assert.equal(bgAmbassadorArrivalPlayer(game, advisor).forces['imperial_basin:10'], 1); + assert.ok(bgAmbassadorArrivalPlayer(game, advisor).advisors?.imperial_basin); + game = advance(game, state => state.phase === 1 && clean(state), actions); + orderLandBlows(game, staging); + const fighters = options.fighters ?? false; + if (fighters) { + game = advance(game, state => state.phase === 5 && state.active === advisor && clean(state), actions); + game = applyBgAmbassadorArrivalStep(game, { actor: advisor, action: { + type: 'move', from: 'imperial_basin:10', territory: 'carthag', sector: 11, amount: 1, + } }, actions); + game = settleBgAmbassadorArrivalControls(game, actions); + assert.ok(clean(game)); + assert.equal(bgAmbassadorArrivalPlayer(game, advisor).forces['carthag:11'], 1); + assert.equal(bgAmbassadorArrivalPlayer(game, advisor).advisors?.carthag, undefined); + game = advance(game, state => state.turn === 2 && state.phase === 1 && clean(state), actions); + orderLandBlows(game, staging); + } + game = advance(game, state => state.phase === 4 && state.decision?.kind === 'ecazPlacement', actions); + const beforePlacement = structuredClone(game); + const token = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!; + assert.equal(token.zone, 'supply'); + const placementWallet = bgAmbassadorArrivalPlayer(game, owner).spice; + game = applyBgAmbassadorArrivalStep(game, { actor: owner, action: { + type: 'decision', token: token.id, territory: 'carthag', + } }, actions); + game = settleBgAmbassadorArrivalControls(game, actions); + assert.equal(bgAmbassadorArrivalPlayer(game, owner).spice, placementWallet - 1); + assert.equal(game.ecazAmbassadors!.tokens.find(candidate => candidate.id === token.id)!.location, 'carthag'); + assert.equal(game.decision?.kind, 'ecazPlacement'); + game = applyBgAmbassadorArrivalStep(game, { actor: owner, action: { type: 'decision', decline: true } }, actions); + game = advance(game, state => state.phase === 5 && state.active === entrant && clean(state), actions); + assert.equal(bgAmbassadorArrivalPlayer(game, advisor).forces['carthag:11'] ?? 0, fighters ? 1 : 0); + assert.ok(!game.pendingAmbassador && !game.pendingArrivalReaction?.length); + const beforeShipment = structuredClone(game); + return { + initial, afterSetup, beforePlacement, beforeShipment, game, actions, owner, advisor, entrant, + territory: 'carthag', location: 'carthag:11', token: token.id, fighters, staging, + shipmentAction: { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 }, + movementAction: { type: 'move', from: 'carthag:11', territory: 'arsunt', sector: 11, amount: 1 }, + }; +} + +/** First bad transition is intentionally outside the original builder. */ +export function beginBgAmbassadorArrival(fixture: BgAmbassadorArrivalFixture): Game { + return settleBgAmbassadorArrivalControls(applyBgAmbassadorArrivalStep(fixture.game, { + actor: fixture.entrant, action: fixture.shipmentAction, + })); +} + +export function bgAmbassadorAccompanimentAction(accept: boolean, accompany = true): Action { + return accept ? { type: 'decision', accept: true, accompany, amount: 1 } + : { type: 'decision', accept: false }; +} +export function bgAmbassadorIntrusionAction(accept: boolean): Action { + return { type: 'decision', accept }; +} +export function bgAmbassadorOfferAction(game: Game, trigger = false): Action { + const entry = game.pendingAmbassador; + assert.ok(entry); + return trigger ? { type: 'decision', event: entry.event, trigger: true, beneficiary: entry.owner, choice: 'duke' } + : { type: 'decision', event: entry.event, decline: true }; +} +export function finishBgAmbassadorArrival(game: Game, trigger = false): Game { + const entry = game.pendingAmbassador; + assert.ok(entry); + return settleBgAmbassadorArrivalControls(applyBgAmbassadorArrivalStep(game, { + actor: entry.owner, action: bgAmbassadorOfferAction(game, trigger), + })); +} diff --git a/tests/fixture-guild-ambassador-income-arrival.ts b/tests/fixture-guild-ambassador-income-arrival.ts new file mode 100644 index 00000000..391a3688 --- /dev/null +++ b/tests/fixture-guild-ambassador-income-arrival.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { territory } from '../game/board'; +import { + advancePairedE3Nexus, createPairedE3NexusSetup, nextPairedE3NexusStep, + pairedE3NexusClean, pairedE3NexusInventory, pairedE3NexusPlayer, + pairedE3NexusReload, quietPairedE3NexusChoice, stepPairedE3Nexus, + type PairedE3NexusStep, type PairedE3NexusTransition, +} from './fixture-paired-e3-nexus-modules'; +import { orderE3NexusEcazSpice } from './fixture-e3-nexus-ecaz'; +import { quietPlan } from './fixture-nexus-duke-capture'; + + +/** Original Advanced Ecaz/Moritani/Guild, without optional physical modules. + * The shared setup selects only original undealt entropy. Here the original + * unplayed Treachery deck puts a Karama in the first real auction; Ecaz buys + * that physical card for one spice. Only the conserved, unplayed Spice deck + * is ordered for land blows on turn one and an actual Nexus on turn two. + * No hand, wallet, army, leader, phase, alliance or response is staged. + * + * The expansion/Nexus admission guard deliberately excludes the classic-only + * Guild rate-cancellation window. Actual native half price and its separate + * income/counter window are retained, not widened by this fixture. */ +export function createGuildAmbassadorIncomeArrival() { + const setup = createPairedE3NexusSetup({ roster: ['ecaz', 'moritani', 'guild'], + advanced: true, skills: false, homeworlds: false, discovery: false, + tech: false, strongholds: false }); + const actions = setup.actions; + const ecaz = setup.ecaz, entrant = setup.moritani; + const guild = setup.afterSetup.players.find(p => p.faction === 'guild')!.id; + let game = pairedE3NexusReload(setup.afterSetup); + const karamaIndex = game.deck.findIndex(card => card.effect === 'karama'); + assert.ok(karamaIndex >= 0); + const karama = game.deck.splice(karamaIndex, 1)[0]; + game.deck.unshift(karama); + orderE3NexusEcazSpice(game); + game = advancePairedE3Nexus(game, s => s.phase === 3 && pairedE3NexusClean(s) && + s.auction?.active === ecaz && s.auction.index === 0, actions, quietPairedE3NexusChoice); + const purchaseBefore = pairedE3NexusReload(game); + const bid: PairedE3NexusStep = { actor: ecaz, action: { type: 'bid', amount: 1 } }; + game = stepPairedE3Nexus(game, bid, actions); + game = advancePairedE3Nexus(game, s => pairedE3NexusPlayer(s, ecaz).hand.some(c => c.id === karama.id) && + pairedE3NexusClean(s), actions, quietPairedE3NexusChoice); + const purchase = { before: purchaseBefore, step: bid, after: pairedE3NexusReload(game) }; + game = advancePairedE3Nexus(game, s => s.turn === 2 && s.phase === 1 && pairedE3NexusClean(s), + actions, quietPairedE3NexusChoice); + orderE3NexusEcazSpice(game, true); + game = advancePairedE3Nexus(game, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && + pairedE3NexusClean(s), actions, quietPairedE3NexusChoice); + const beforeAlliance = pairedE3NexusReload(game); + for (const [actor, target] of [[entrant, guild], [guild, entrant]]) + game = stepPairedE3Nexus(game, { actor, action: { type: 'alliance', target } }, actions); + const alliance = pairedE3NexusReload(game); + const native = quietPlan(game); + const token = native.token, destination = native.destination; + const sector = territory(destination).sectors.find(s => s !== game.storm)!; + assert.ok(Number.isInteger(sector)); + game = advancePairedE3Nexus(game, s => !s.response && !s.phaseOpening && + s.decision?.kind === 'ecazPlacement', actions, quietPairedE3NexusChoice); + const placementBefore = pairedE3NexusReload(game); + const place: PairedE3NexusStep = { actor: ecaz, + action: { type: 'decision', token, territory: destination } }; + game = stepPairedE3Nexus(game, place, actions); + game = advancePairedE3Nexus(game, s => !s.response && + s.ecazAmbassadors!.tokens.find(t => t.id === token)!.zone === 'placed', + actions, quietPairedE3NexusChoice); + const placement = { before: placementBefore, step: place, after: pairedE3NexusReload(game) }; + game = advancePairedE3Nexus(game, s => s.phase === 5 && s.active === entrant && pairedE3NexusClean(s), + actions, quietPairedE3NexusChoice); + const source = Object.entries(pairedE3NexusPlayer(game, entrant).forces) + .find(([key, n]) => n > 0 && Number(key.split(':')[1]) !== game.storm && + key.split(':')[0] !== destination)?.[0]; + assert.ok(source, 'The native Moritani setup must retain an original on-board source'); + assert.equal(pairedE3NexusPlayer(game, entrant).ally, guild); + assert.equal(pairedE3NexusPlayer(game, guild).ally, entrant); + assert.equal(pairedE3NexusPlayer(game, ecaz).ally, null); + assert.equal(game.ecazAmbassadors!.tokens.find(t => t.id === token)!.effect, 'ecaz'); + assert.ok(pairedE3NexusPlayer(game, ecaz).hand.some(c => c.id === karama.id)); + pairedE3NexusInventory(game); + const action: Action = { type: 'guildShip', from: source, amount: 1, elite: 0, + territory: destination, sector, allyPayment: 0 }; + return { ...setup, ecaz, entrant, guild, source, destination, sector, + target: `${destination}:${sector}`, token, karama: karama.id, purchase, + beforeAlliance, alliance, placement, beforeAction: pairedE3NexusReload(game), + action, actions, provenance: [ + 'Shared original undealt setup entropy; no played physical state assigned.', + 'Conserved unplayed Treachery deck: first original Karama auctioned and paid by Ecaz.', + 'Conserved unplayed Spice deck: turn-one land blows, turn-two native worm/Nexus.', + ] }; +} + +/** Stop at the real income/counter window, after physical transport but before entry. */ +export function declareGuildAmbassadorIncomeArrival(fixture: { + beforeAction: Game; entrant: string; action: Action; +}): PairedE3NexusTransition { + const before = pairedE3NexusReload(fixture.beforeAction); + const step = { actor: fixture.entrant, action: fixture.action }; + const after = stepPairedE3Nexus(before, step); + assert.equal(after.response?.kind, 'guildIncome'); + assert.equal(after.pendingArrivalReaction?.length, 1); + assert.ok(!after.pendingAmbassador); + return { before, step, after }; +} + +/** Pass the actual persisted income queue; do not synthesize or clear controls. */ +export function allowGuildAmbassadorIncomeArrival(state: Game) { + let game = pairedE3NexusReload(state); + const actions: PairedE3NexusStep[] = []; + for (let n = 0; game.response && n < 20; n++) { + assert.equal(game.response.kind, 'guildIncome'); + const step = nextPairedE3NexusStep(game); assert.ok(step); + assert.equal(step.action.type, 'passResponse'); + game = stepPairedE3Nexus(game, step, actions); + } + assert.equal(game.response, null); + assert.equal(game.decision?.kind, 'ecazAmbassador'); + assert.equal(game.pendingAmbassador?.stage, 'offer'); + return { game, actions }; +} + +/** Spend the actual auctioned card through the original income cancellation. */ +export function cancelGuildAmbassadorIncomeArrival(state: Game, actor: string, card: string) { + const before = pairedE3NexusReload(state); + assert.equal(before.response?.kind, 'guildIncome'); + const controls = viewGame(before, actor).responseControls; + assert.ok(controls && !controls.hasPassed && controls.cancelCards.includes(card)); + const step: PairedE3NexusStep = { actor, action: { type: 'card', mode: 'cancel', card } }; + const after = stepPairedE3Nexus(before, step); + assert.equal(after.response, null); + assert.equal(after.decision?.kind, 'ecazAmbassador'); + assert.equal(after.pendingAmbassador?.stage, 'offer'); + return { before, step, after }; +} + +export function guildAmbassadorIncomeChoice(game: Game, trigger = true): PairedE3NexusStep { + assert.equal(game.decision?.kind, 'ecazAmbassador'); + const entry = game.pendingAmbassador!; + return { actor: game.decision.player, action: { type: 'decision', event: entry.event, + ...(trigger ? { trigger: true, beneficiary: game.decision.player, choice: 'duke' } : { decline: true }) } }; +} + +/** A single original offer completion, then the original entrant's legal next action. */ +export function completeGuildAmbassadorIncomeArrival(state: Game, trigger = true) { + const before = pairedE3NexusReload(state), step = guildAmbassadorIncomeChoice(before, trigger); + const after = stepPairedE3Nexus(before, step); + assert.ok(pairedE3NexusClean(after)); + const continuation: PairedE3NexusStep = { actor: after.active!, action: { type: 'endMovement' } }; + return { before, step, after, continuation }; +} + diff --git a/tests/guild-ambassador-income-arrival.test.ts b/tests/guild-ambassador-income-arrival.test.ts new file mode 100644 index 00000000..5a452224 --- /dev/null +++ b/tests/guild-ambassador-income-arrival.test.ts @@ -0,0 +1,154 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { botArrivalBlock } from '../game/bot-arrival'; +import { guildTransportQuote } from '../game/transport-quote'; +import { + allowGuildAmbassadorIncomeArrival, cancelGuildAmbassadorIncomeArrival, + completeGuildAmbassadorIncomeArrival, + createGuildAmbassadorIncomeArrival, declareGuildAmbassadorIncomeArrival, + guildAmbassadorIncomeChoice, +} from './fixture-guild-ambassador-income-arrival'; +import { pairedE3NexusPolicy, pairedE3NexusInventory as guildAmbassadorIncomeInventory, + pairedE3NexusPlayer as player, pairedE3NexusReload as reload } from './fixture-paired-e3-nexus-modules'; + +function rejectUnchanged(game: Game, actor: string, action: Parameters[2]) { + const before = reload(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +for (const trigger of [true, false]) { + for (const cancelIncome of [false, true]) { + void test(`native allied Guild cross-transport ${cancelIncome ? 'cancels' : 'allows'} its real income before Ecaz ${trigger ? 'acquires Duke' : 'declines entry'}`, () => { + const f = createGuildAmbassadorIncomeArrival(); + const start = f.beforeAction, payer = player(start, f.entrant), guild = player(start, f.guild); + assert.equal(start.turn, 2); + assert.equal(start.phase, 5); + assert.equal(start.active, f.entrant); + assert.equal(payer.shipped, false); + assert.equal(payer.moved, 0); + assert.equal(payer.ally, f.guild); + assert.equal(guild.ally, f.entrant); + assert.equal(f.beforeAlliance.nexus, true); + assert.equal(player(f.beforeAlliance, f.entrant).ally, null); + assert.equal(player(f.alliance, f.entrant).ally, f.guild); + assert.equal(player(f.purchase.after, f.ecaz).spice, player(f.purchase.before, f.ecaz).spice - 1); + assert.equal(player(f.placement.after, f.ecaz).spice, player(f.placement.before, f.ecaz).spice - 1); + assert.ok(player(start, f.ecaz).hand.some(c => c.id === f.karama)); + assert.equal(start.ecazAmbassadors!.tokens.find(t => t.id === f.token)!.zone, 'placed'); + assert.equal(start.ecazAmbassadors!.tokens.find(t => t.id === f.token)!.effect, 'ecaz'); + const view = viewGame(reload(start), f.entrant); + const quote = guildTransportQuote(view, f.action); + assert.deepEqual(quote.unavailableReasons, []); + assert.deepEqual(quote.quote, { cost: 1, normalCost: 1, ownPayment: 1, pledgedPayment: 0 }); + assert.equal(quote.physicalForces, 1); + assert.equal(botArrivalBlock(view, f.action), null, + 'A future earned receipt is not an already-active arrival response'); + + // This original engine path already commits; owner previews must agree. + const declaration = declareGuildAmbassadorIncomeArrival(f); + const paid = declaration.after; + assert.ok(!paid.pendingGuildTransport); + assert.equal(paid.response?.kind, 'guildIncome'); + assert.equal(paid.response?.amount, 1); + assert.equal(paid.response?.owner, f.guild); + assert.equal(player(paid, f.entrant).spice, payer.spice - 1); + assert.equal(player(paid, f.guild).spice, guild.spice); + assert.equal(player(paid, f.entrant).reserves, payer.reserves); + assert.equal(player(paid, f.entrant).forces[f.source], payer.forces[f.source] - 1); + assert.equal(player(paid, f.entrant).forces[f.target], (payer.forces[f.target] ?? 0) + 1); + assert.equal(player(paid, f.entrant).shipped, true); + assert.equal(player(paid, f.entrant).moved, payer.moved); + assert.equal(paid.active, f.entrant); + assert.deepEqual(paid.pendingArrivalReaction?.map(e => ({ entrant: e.entrant, territory: e.territory, + sector: e.sector, amount: e.amount, cause: e.cause, turn: e.turn, phase: e.phase })), + [{ entrant: f.entrant, territory: f.destination, sector: f.sector, + amount: 1, cause: 'guildTransport', turn: start.turn, phase: 5 }]); + assert.ok(!paid.pendingAmbassador); + const counter = viewGame(reload(paid), f.ecaz).responseControls; + assert.ok(counter && !counter.hasPassed && counter.cancelCards.includes(f.karama)); + rejectUnchanged(paid, f.entrant, f.action); + rejectUnchanged(paid, f.entrant, { type: 'endMovement' }); + guildAmbassadorIncomeInventory(paid); + + const offered = cancelIncome + ? cancelGuildAmbassadorIncomeArrival(reload(paid), f.ecaz, f.karama).after + : allowGuildAmbassadorIncomeArrival(reload(paid)).game; + assert.equal(offered.response, null); + assert.equal(offered.pendingArrivalReaction, null); + assert.equal(offered.decision?.kind, 'ecazAmbassador'); + assert.equal(offered.pendingAmbassador?.entrant, f.entrant); + assert.equal(offered.pendingAmbassador?.token, f.token); + assert.equal(offered.pendingAmbassador?.territory, f.destination); + assert.equal(offered.pendingAmbassador?.stage, 'offer'); + assert.equal(player(offered, f.guild).spice, guild.spice + (cancelIncome ? 0 : 1)); + assert.equal(player(offered, f.entrant).spice, payer.spice - 1); + assert.deepEqual(player(offered, f.entrant).forces, player(paid, f.entrant).forces); + assert.equal(offered.discard.filter(c => c.id === f.karama).length, cancelIncome ? 1 : 0); + assert.equal(player(offered, f.ecaz).hand.some(c => c.id === f.karama), !cancelIncome); + assert.equal(offered.active, f.entrant); + rejectUnchanged(offered, f.entrant, guildAmbassadorIncomeChoice(offered, trigger).action); + const completed = completeGuildAmbassadorIncomeArrival(reload(offered), trigger); + const done = completed.after; + assert.equal(done.pendingAmbassador, null); + assert.equal(done.pendingArrivalReaction, null); + assert.equal(done.response, null); + assert.equal(done.decision, null); + assert.equal(done.active, f.entrant); + assert.equal(done.turn, start.turn); + assert.equal(done.phase, start.phase); + assert.equal(player(done, f.entrant).moved, payer.moved); + assert.equal(player(done, f.entrant).shipped, true); + assert.deepEqual(player(done, f.entrant).forces, player(paid, f.entrant).forces); + assert.equal(player(done, f.entrant).reserves, payer.reserves); + assert.equal(player(done, f.entrant).spice, payer.spice - 1); + assert.equal(player(done, f.guild).spice, guild.spice + (cancelIncome ? 0 : 1)); + assert.equal(done.ecazAmbassadors!.tokens.find(t => t.id === f.token)!.zone, trigger ? 'supply' : 'placed'); + assert.deepEqual(done.ecazAmbassadors!.cohort, start.ecazAmbassadors!.cohort); + assert.equal(done.dukeVidal!.controller, trigger ? f.ecaz : start.dukeVidal!.controller); + if (trigger) assert.equal(done.dukeVidal!.source, 'ecaz'); + assert.deepEqual(done.players.map(p => p.hand), offered.players.map(p => p.hand)); + assert.deepEqual(done.players.map(p => p.leaders), offered.players.map(p => p.leaders)); + assert.deepEqual(done.deck, offered.deck); + assert.deepEqual(done.discard, offered.discard); + rejectUnchanged(done, completed.step.actor, completed.step.action); + rejectUnchanged(done, f.entrant, f.action); + guildAmbassadorIncomeInventory(done); + + const next = applyAction(reload(done), completed.continuation.actor, completed.continuation.action); + assert.notEqual(next.active, f.entrant); + assert.equal(next.pendingAmbassador, null); + assert.equal(next.pendingArrivalReaction, null); + assert.deepEqual(player(next, f.entrant).forces, player(done, f.entrant).forces); + assert.equal(player(next, f.entrant).spice, payer.spice - 1); + assert.equal(player(next, f.guild).spice, guild.spice + (cancelIncome ? 0 : 1)); + assert.equal(next.discard.filter(c => c.id === f.karama).length, cancelIncome ? 1 : 0); + guildAmbassadorIncomeInventory(next); + }); + } +} + +void test('actual Ecaz owner-view policies complete the queued native Guild entry without replaying payment or counters', () => { + const f = createGuildAmbassadorIncomeArrival(); + const paid = declareGuildAmbassadorIncomeArrival(f).after; + const offered = allowGuildAmbassadorIncomeArrival(paid).game; + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const action = pairedE3NexusPolicy(reload(offered), f.ecaz, difficulty)[0]; + assert.ok(action, `The actual ${difficulty} Ecaz owner needs a legal response`); + const done = applyAction(reload(offered), f.ecaz, action); + assert.equal(done.pendingAmbassador, null); + assert.equal(done.pendingArrivalReaction, null); + assert.equal(done.decision, null); + assert.equal(done.response, null); + assert.equal(done.active, f.entrant); + assert.deepEqual(player(done, f.entrant).forces, player(paid, f.entrant).forces); + assert.equal(player(done, f.entrant).spice, player(paid, f.entrant).spice); + assert.equal(player(done, f.guild).spice, player(paid, f.guild).spice + 1); + assert.equal(player(done, f.entrant).moved, player(paid, f.entrant).moved); + guildAmbassadorIncomeInventory(done); + const next = applyAction(reload(done), f.entrant, { type: 'endMovement' }); + assert.notEqual(next.active, f.entrant); + assert.deepEqual(player(next, f.entrant).forces, player(paid, f.entrant).forces); + } +}); diff --git a/tests/karama-movement-preflight.test.ts b/tests/karama-movement-preflight.test.ts index ae653dc0..3d5793e7 100644 --- a/tests/karama-movement-preflight.test.ts +++ b/tests/karama-movement-preflight.test.ts @@ -89,14 +89,8 @@ void test('supported simultaneous Ambassador and Terror arrival quotes one bound 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.throws(() => quoteCompletedMovementArrival({ ...input, overlapSupported: false })); + assert.throws(() => quoteCompletedMovementArrival({ ...input, overlapSupported: undefined })); assert.equal( quoteCompletedMovementArrival({ ...input, terror: [] }).reaction, 'ambassador', @@ -107,7 +101,7 @@ void test('supported simultaneous Ambassador and Terror arrival quotes one bound ); }); -void test('overlap support does not bypass competing controls, fighter response, or BG intrusion', () => { +void test('overlap support does not bypass actual competing controls or the pre-entry fighter response', () => { const input = { ...fixture(), overlapSupported: true, @@ -120,33 +114,16 @@ void test('overlap support does not bypass competing controls, fighter response, 'pendingTerror', 'pendingAmbassador', ] as const) - assert.throws( - () => + assert.throws(() => + quoteCompletedMovementArrival({ + ...input, + controls: { ...input.controls, [flag]: true }, + }), flag); + assert.throws(() => quoteCompletedMovementArrival({ ...input, - controls: { ...input.controls, [flag]: true }, - }), - /Ambassadors combined with another arrival reaction/, - flag, - ); - assert.throws( - () => - quoteCompletedMovementArrival({ - ...input, - order: { ...input.order, wantsFighters: true }, - }), - /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/, - ); + order: { ...input.order, wantsFighters: true }, + })); }); void test('overlap support never turns same-territory movement or shared ownership into an entry', () => { @@ -173,16 +150,13 @@ void test('overlap support never turns same-territory movement or shared ownersh }).reaction, null, ); - assert.throws( - () => - quoteCompletedMovementArrival({ - ...input, - players: input.players.map((p) => - p.id === 'm' ? { ...p, id: 'e' } : p, - ), - }), - /Ambassadors combined with another arrival reaction/, - ); + assert.throws(() => + quoteCompletedMovementArrival({ + ...input, + players: input.players.map((p) => + p.id === 'm' ? { ...p, id: 'e' } : p, + ), + })); }); for (const exclusion of [ @@ -261,10 +235,6 @@ void test('BG intrusion uses current opposing fighters, requires Advanced rules quoteCompletedMovementArrival({ ...input, players: advisors }).intrusion, false, ); - assert.throws( - () => quoteCompletedMovementArrival({ ...input, players, ambassadors: [ambassador] }), - /Ambassadors combined/, - ); // A Terror entry behind the pending Intrusion is now committed and deferred. assert.equal( quoteCompletedMovementArrival({ ...input, players, terror: [terror] }).reaction, @@ -341,15 +311,12 @@ void test('current controls and the move-created fighter response are checked wi 'pendingTerror', 'pendingAmbassador', ] as const) - assert.throws( - () => - quoteCompletedMovementArrival({ - ...input, - ambassadors: [ambassador], - controls: { ...input.controls, [flag]: true }, - }), - /Ambassadors combined/, - ); + assert.throws(() => + quoteCompletedMovementArrival({ + ...input, + ambassadors: [ambassador], + controls: { ...input.controls, [flag]: true }, + })); for (const flag of ['response', 'decision'] as const) assert.equal( quoteCompletedMovementArrival({ @@ -360,15 +327,12 @@ void test('current controls and the move-created fighter response are checked wi 'terror', `A committed ${flag} arrival defers its Terror entry instead of rejecting it.`, ); - assert.throws( - () => - quoteCompletedMovementArrival({ - ...input, - terror: [terror], - controls: { ...input.controls, response: true, pendingTerror: true }, - }), - /Resolve the pending Terror entry first/, - ); + assert.throws(() => + quoteCompletedMovementArrival({ + ...input, + terror: [terror], + controls: { ...input.controls, response: true, pendingTerror: true }, + })); assert.equal( quoteCompletedMovementArrival({ ...input, @@ -377,15 +341,12 @@ void test('current controls and the move-created fighter response are checked wi }).reaction, 'terror', ); - assert.throws( - () => - quoteCompletedMovementArrival({ - ...input, - ambassadors: [ambassador], - order: { ...input.order, wantsFighters: true }, - }), - /Ambassadors combined/, - ); + assert.throws(() => + quoteCompletedMovementArrival({ + ...input, + ambassadors: [ambassador], + order: { ...input.order, wantsFighters: true }, + })); assert.equal( quoteCompletedMovementArrival({ ...input, @@ -443,24 +404,18 @@ void test('paid Box prevents only actual Ornithopter retirement: first two-group }).retiresOrnithopter, false, ); - assert.throws( - () => - quoteCompletedMovementArrival({ - ...input, - controls, - flight: { player: 'p', mode: 'twoGroups', completed: 1 }, - }), - /Finish the paid search/, - ); - assert.throws( - () => - quoteCompletedMovementArrival({ - ...input, - controls, - flight: { player: 'p', mode: 'range3', completed: 0 }, - }), - /Finish the paid search/, - ); + assert.throws(() => + quoteCompletedMovementArrival({ + ...input, + controls, + flight: { player: 'p', mode: 'twoGroups', completed: 1 }, + })); + assert.throws(() => + quoteCompletedMovementArrival({ + ...input, + controls, + flight: { player: 'p', mode: 'range3', completed: 0 }, + })); assert.equal( quoteCompletedMovementArrival({ ...input, @@ -675,15 +630,12 @@ for (const form of ['printed', 'worthless'] as const) const uuid = t.mock.method(crypto, 'randomUUID', () => { throw Error('Rejected arrival preflight created an event.'); }); - assert.throws( - () => - applyAction(g, form === 'printed' ? 'p' : 'b', { - type: 'card', - mode: 'cancel', - card: form === 'printed' ? printed : worthless, - }), - /Ambassadors combined with another arrival reaction/, - ); + assert.throws(() => + applyAction(g, form === 'printed' ? 'p' : 'b', { + type: 'card', + mode: 'cancel', + card: form === 'printed' ? printed : worthless, + })); assert.deepEqual(g, before); assert.deepEqual(physical(g), inventory); assert.equal(g.pendingKarama ?? null, null);