From a12250320c2d49a2c817a84f110f226602306d41 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:55:05 +1100 Subject: [PATCH] Compose native Guild rate with module and Homeworld price sources --- components/guild-homeworld-shipment.tsx | 6 +- components/homeworld-shipment.tsx | 4 +- docs/CURRENT_STATUS.md | 21 ++ docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md | 23 ++ docs/GUILD_RATE_KARAMA_RULES.md | 47 ++++ docs/GUILD_SHIPMENT_PAYMENTS.md | 2 +- docs/IMPLEMENTATION_STATUS.md | 51 +++++ docs/LEADER_SKILLS_RUNTIME.md | 23 ++ docs/NEXUS_GUILD_CUNNING_RUNTIME.md | 12 + docs/RULE_DECISIONS.md | 2 +- game/engine.ts | 135 +++++++++-- game/guild-homeworld-shipment-options.ts | 3 +- game/guild-homeworld-shipment.ts | 5 +- game/homeworld-shipment-options.ts | 6 +- game/homeworld-shipment.ts | 11 +- game/karama-context.ts | 1 + game/reference.ts | 18 +- ...fixture-guild-ambassador-income-arrival.ts | 9 +- tests/fixture-native-guild-homeworld-rate.ts | 193 ++++++++++++++++ tests/fixture-native-guild-rate-modules.ts | 186 +++++++++++++++ tests/fixture-paired-e3-nexus-modules.ts | 13 +- tests/native-guild-homeworld-rate.test.ts | 212 ++++++++++++++++++ tests/native-guild-rate-betrayal.test.ts | 51 +++++ tests/native-guild-rate-modules.test.ts | 192 ++++++++++++++++ 24 files changed, 1176 insertions(+), 50 deletions(-) create mode 100644 tests/fixture-native-guild-homeworld-rate.ts create mode 100644 tests/fixture-native-guild-rate-modules.ts create mode 100644 tests/native-guild-homeworld-rate.test.ts create mode 100644 tests/native-guild-rate-betrayal.test.ts create mode 100644 tests/native-guild-rate-modules.test.ts diff --git a/components/guild-homeworld-shipment.tsx b/components/guild-homeworld-shipment.tsx index 79a46979..13495a4a 100644 --- a/components/guild-homeworld-shipment.tsx +++ b/components/guild-homeworld-shipment.tsx @@ -63,8 +63,10 @@ export function GuildHomeworldShipment({ territory. Allied Homeworlds cannot be destinations.

- Pay one spice per two forces, rounded up. This uses your shipment; your - normal movement remains available. + {g.guildRateCanceled + ? 'The native Guild rate is canceled: pay one spice per force.' + : 'Pay one spice per two forces, rounded up.'}{' '} + This uses your shipment; your normal movement remains available.

{option.blocked && ( {option.blocked} diff --git a/components/homeworld-shipment.tsx b/components/homeworld-shipment.tsx index 1f1a7a35..0bd05063 100644 --- a/components/homeworld-shipment.tsx +++ b/components/homeworld-shipment.tsx @@ -79,7 +79,9 @@ export function HomeworldShipment({ {useNexus ? 'Selected Guild Nexus price: one spice per two forces, rounded up.' : me.faction === 'guild' - ? 'Guild pays one spice per two forces, rounded up.' + ? g.guildRateCanceled + ? 'The native Guild rate is canceled: pay one spice per force.' + : 'Guild pays one spice per two forces, rounded up.' : 'Pay one spice per force.'}{' '} This uses your shipment; your normal movement remains available.

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 22242575..c8e84c72 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 rate price sources, modules and typed Homeworlds — 15 October.** +Original price receipts replace blanket module exclusions. Smuggler's free +companion remains free; own No-Field bills one concealed marker, never its +hidden value. Native typed world/cross/return and Cunning Homeworld receipts +retain exact sources, original approved split and prepayment rate control, +including after automatic Guild interception. Immune Junction and independent +Nexus prices stay separate. Deferred Betrayal keeps full-price authority +until actual physical settlement, without replay or false early payment. + +Affected18-file198/198 and21 runtime/policy paths pass:17 original-action, +four explicitly controlled Cunning/No-Field. Four actual390px journeys prove +composed Smuggler, native return, poor world replacement and controlled +No-Field reveal; no overflow/browser errors. Original ground movement and +out-of-order turn resume. Donor escrow is consumed without another wallet debit. +All four native Homeworld policies pass here; no calibration claim. +Allied No-Field/special receipts, ordinary permission cancellation, complete +combinations, assurance and public starts remain open. +[Canonical source composition and evidence](GUILD_RATE_KARAMA_RULES.md#native-price-source-composition--15-october-2026). +Final types/lint/build pass with existing warnings; a separate controlled +Betrayal runtime also completes full3 Use. Current links/evidence resolve. + **Native Guild rate cancellation, including Cunning — 15 October.** Expansion/Nexus rosters now retain the prepayment native rate window for reserve/cross/return declarations. Original physical group, tariff and approved diff --git a/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md b/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md index 106068ab..fafc4991 100644 --- a/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md +++ b/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md @@ -2,6 +2,29 @@ 9 September 2026. Fresh primary-source audit for the next transport integration. This supplements [the transport source update](HOMEWORLD_TRANSPORT_SOURCE_UPDATE.md); it does not resolve the already recorded Junction payment, foreign-garrison departure, or ordinary Karama timing questions. +## Native rate cancellation runtime — 15 October 2026 + +Original native Guild world-to-world, Arrakis-to-foreign-world and Junction +return now pause for their separate prepayment rate response after original +manual/automatic interception. Both canonical typed quotes and owner controls +charge full one-per-counter only when the native rate is canceled. Exact +source pools, approved ally split and original allowance persist; unaffordable +release preserves a full-price replacement without debt or extra donor funds. + +Junction-sponsored transport is still an immune Homeworld source, not this +native faction discount. Actual original half/full offers remain2/3 for three +counters after native cancellation. Cunning's typed second shipment keeps its +original history and frame through the same rate stages. Deferred Betrayal +retains full-price authority until actual settlement, not merely queueing. + +Actual390px typed return selects Tuek5 source3 and approved donor1: +Guild pays own2 at full3, receives its existing donor income1, ends7→6 +with Junction15→18 and Tuek5→2; donor wallet stays11 while escrow1→0. +A poor original world shipment leaves Guild1/Junction15/escrow1 unchanged +after cancellation, shows disabled full3, then ships two for full2 with the +same split and ends Guild1/Junction13/foreign2. No overflow/browser errors. +[Canonical198-case/runtime evidence and remaining source boundaries](GUILD_RATE_KARAMA_RULES.md#native-price-source-composition--15-october-2026). + ## Route and custody contract E3 permits Guild shipment from Arrakis to a Homeworld and classifies it as cross-shipment. Guild can return to its own Junction or invade another native faction's world; an allied destination is forbidden. Guild's ordinary alliance grant does not supply this new route to its ally. Junction's separate high-face permission has its own provenance. [GF9 E3, p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10), [component audit](HOMEWORLD_COMPONENT_AUDIT.md#verified-high-side-effects). diff --git a/docs/GUILD_RATE_KARAMA_RULES.md b/docs/GUILD_RATE_KARAMA_RULES.md index 88ee0b3e..81a1d2d7 100644 --- a/docs/GUILD_RATE_KARAMA_RULES.md +++ b/docs/GUILD_RATE_KARAMA_RULES.md @@ -2,6 +2,53 @@ Initial primary-source and engine audit, 2026-09-06. The bounded ordinary-reserve cancellation policy below was selected on 2026-09-28 after a targeted publisher-source search found no affordability procedure; the later original audit remains historical evidence for unfinished transport and module combinations. +## Native price-source composition — 15 October 2026 + +Native rate eligibility is now determined by the original price/source, +not global Homeworld/Skills/Discovery/Tech/Stronghold exclusions. Original +module setup remains intact. A Smuggler ordinary shipment reprices only its +paid counters; one independently free companion stays free. An own concealed +No-Field prices one marker, never its hidden value, with any free companion +still independent. Its existing private proof follows only authenticated +rate-event and price transitions. + +Native Guild world-to-world, Arrakis-to-foreign-world and return-to-Junction +declarations retain exact typed sources and approved funding. Original manual +or automatic Guild interception precedes the separate native rate window. +The canonical quote/context and both actual owner controls honor the canceled +native tariff. Cunning Homeworld receipts retain their original second +allowance through allow/cancel or uncommitted release and replacement. + +**No immunity or alternate price is canceled by analogy.** Junction-sponsored +half/full transport keeps its printed Homeworld tariff despite an active +native cancellation; non-Guild world-to-world remains full price and the +independent Guild Secret Ally price remains separate. Allied No-Field, +paired/special price receipts and transport-permission cancellation retain +their own unfinished boundaries; this is not universal route acceptance. + +Guild Betrayal may defer actual payment after a rate cancellation. The +full-price authorization now remains until its original physical invoice +settles; it is not cleared when merely queued. Six controlled source cases +exercise reserve/cross/Homeworld Use or Pass at the full fee without replay. +Native Skills/No-Field integrity accepts one original rate response as well +as the preceding cancellation/interception stages. The shared quiet fixture +also supplies the real Ambassador event when declining; no legality is bypassed. + +Affected18-file **198/198**, including32 new source cases, and **21 runtime/ +policy paths** pass. Seventeen runtime paths use original actions; four use +explicitly controlled Cunning/No-Field sources. All four owner policies +pass the original native Homeworld rate here; strategy is not calibrated. +Four actual styled390px journeys prove composed Smuggler shipping, native +typed return, unaffordable world-to-world replacement and controlled No-Field +reveal. Payment/transfer once, original movement/turn resumes; no overflow/ +browser errors. Donor escrow was paid when pledged, not debited again. +No saved/deployed rooms, comprehensive assurance or public gates changed. +This supersedes the earlier blanket module exclusions below, not the +standing provisional affordability/return policy or remaining source limits. +Final types/lint/build pass with existing deprecation/chunk/Unknown-route +warnings. A separate controlled runtime also settles the original deferred +Betrayal full3 invoice through actual Use. Current links/evidence resolve. + ## Native expansion and Nexus admission — 15 October 2026 The native prepayment rate window now admits expansion factions and Nexus diff --git a/docs/GUILD_SHIPMENT_PAYMENTS.md b/docs/GUILD_SHIPMENT_PAYMENTS.md index 0c03339f..09ce0b10 100644 --- a/docs/GUILD_SHIPMENT_PAYMENTS.md +++ b/docs/GUILD_SHIPMENT_PAYMENTS.md @@ -9,7 +9,7 @@ reserve, cross-planet and Guild-return declarations, including Cunning's original second allowance. Affordable full repricing and unaffordable return/ full-price replacement retain original sources and approved funding. Three actual390px journeys prove these controls; income remains distinct. -[Canonical source, evidence and remaining physical-module gates](GUILD_RATE_KARAMA_RULES.md#native-expansion-and-nexus-admission--15-october-2026). +[Canonical source, evidence and remaining source-specific boundaries](GUILD_RATE_KARAMA_RULES.md#native-price-source-composition--15-october-2026). ## Native direct transport continuation — 15 October 2026 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 2a0c76cf..e795ae5d 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,56 @@ # Dune implementation status +## 15 October 2026 — Native rate source composition and typed Homeworlds + +Removed blanket physical-module rate exclusions in favor of actual native +price/source eligibility. Ordinary Smuggler bills paid counters only; own +No-Field bills one marker, never hidden value, retaining its independent free +companion and authenticated private proof. Real original training overrides +select undealt offers and actual training, not played assignments. + +Native Guild world-to-world, Arrakis-to-foreign-world and Junction return use +their existing typed quote/settlement with optional native cancellation context. +Actual owner quotes, bot budgets and conditional instructions follow full +tariff after cancellation. Original manual **and automatic** interception feed +the prepayment rate window. Cunning's typed frame retains its exact event, +price and second allowance through allow/cancel/unaffordable replacement. + +Source immunity remains explicit: genuine Junction-sponsored half/full +transport survives native cancellation; non-Guild world-to-world stays full +and independent Guild Secret Ally stays half. Deferred Guild Betrayal +exposed premature rate cleanup: retain full-price authorization until payment/ +delivery, then release it. Six controlled reserve/cross/world Use/Pass cases +verify one actual full invoice without replay or a false early-paid log. +Skills/No-Field validators now accept their real rate continuation; the shared +quiet fixture supplies the original Ambassador event instead of an invalid +eventless decline. A new donor expectation was corrected: pledge already +debited its wallet, so settlement consumes escrow without another debit. + +Affected18-file **198/198**, including32 new source cases, passes. **21 actual +runtime/policy paths** pass:17 original-action paths and four explicitly +controlled Cunning/No-Field paths. All four actual original Homeworld policies +pass in this position, not cancel. Redundant quote-forwarding/default pins and +a new unrelated private-projection loop were removed, not re-pinned. + +Four actual styled390px GameTable journeys pass: original composed Smuggler +pays4 for three physical/two paid counters then moves once; original native +return preserves donor1 and deposits3 into Junction; poor original world +invoice preserves all sources/escrow on cancellation then ships2 at full2; +controlled No-Field pays2 for one marker plus its free companion, then reveals +five into six board/14 reserves without another payment. Original turn +ownership resumes, with no overflow/browser errors. Three original scenes +and one explicitly controlled scene, not worker/D1/authenticated saved rooms. + +No real saved/deployed game, database, mode/publication gate or provider +permission changed. Allied No-Field/special receipt and native permission +cancellation integration, full combinations and final assurance remain open. +[Canonical current contract](GUILD_RATE_KARAMA_RULES.md#native-price-source-composition--15-october-2026). +Final `npm run check:quick` and production build pass; existing module-register, +chunk-size and Unknown-route warnings remain. The frozen21 runtime paths pass +again after final source typing, plus a separate controlled deferred full3 +Betrayal Use completion. Seven current local links and five-facet evidence +targets resolve. Owned QA host/tab are closed; proof remains outside Git. + ## 15 October 2026 — Native Guild rate admission and Cunning receipts Removed classic-only expansion/Nexus exclusions from native rate admission, diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index aa96a6ed..96f56dd0 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -5,6 +5,29 @@ Normal mode starts and publication remain gated. The [source contract](LEADER_SK contains all fourteen physical cards; the [capture interpretation](LEADER_SKILLS_CAPTURE.md) records the fresh publisher/designer search requested by the user. +## Native Guild rate with independent Smuggler — 15 October 2026 + +Original module selection no longer suppresses native rate cancellation. +Moritani actually selects an originally offered Smuggler through the shared +undealt permutation and normal training action. Three physical reserves into +empty desert contain two paid counters: half2 becomes full4, never6, while +the free companion remains independent. An unaffordable cancellation releases +the uncommitted group and a smaller full-price replacement keeps that companion. + +The own No-Field/companion case is explicitly controlled, not an original +action-only game: the older fixture stages phase/army/wallets, then this +extension conserves one real Karama and a mutual alliance. Hidden0/5 both +price one marker1→2; only the companion initially withdraws. Actual390px +reveal of the five-counter marker produces six at Wind Pass and14 reserves, +with the already paid two unchanged. The original composed Smuggler UI +instead pays Mori12→8, moves all three, then makes a legal ground move. + +Skills/No-Field receipts now recognize their original rate stage rather than +requiring only Guild interception. Private proof follows authenticated event/ +price changes; no hidden value enters billing. Canonical source cases, runtime +provenance and remaining limits are in [the rate contract](GUILD_RATE_KARAMA_RULES.md#native-price-source-composition--15-october-2026). +No full-module, recovery or strategic-AI certification is claimed. + ## Explicit Semuta composition — 11 October 2026 `--profile semuta --leader-skills` reuses original all14 native setup for Basic diff --git a/docs/NEXUS_GUILD_CUNNING_RUNTIME.md b/docs/NEXUS_GUILD_CUNNING_RUNTIME.md index aa496569..cec747e2 100644 --- a/docs/NEXUS_GUILD_CUNNING_RUNTIME.md +++ b/docs/NEXUS_GUILD_CUNNING_RUNTIME.md @@ -2,6 +2,18 @@ This checkpoint follows the [Guild source contract](NEXUS_GUILD_RULES.md): native Guild can spend Cunning when finishing its ordinary Shipment and Movement turn to obtain one second shipment. It preserves one combined turn and the actual history of the first shipment and movement. It does not implement Guild Secret Ally, Betrayal or complete Nexus play. +## Typed Homeworld and module rate composition — 15 October 2026 + +The later native composition removes the blanket physical-module rate +exclusions and connects Cunning's typed Homeworld second shipment. +Its exact pending world frame binds the rate event and final price; unaffordable +release clears only that uncommitted frame and retains the full-price second +attempt. Original shipped history, first payment and movement never reopen. +Three controlled Homeworld allow/cancel/unaffordable cases and matching +runtime paths use the older explicitly staged final-setup fixture; they are +not represented as an original action-only chronology. +[Current native composition, source separation and proof](GUILD_RATE_KARAMA_RULES.md#native-price-source-composition--15-october-2026). + ## Native rate response integration — 15 October 2026 Without Homeworlds, Skills, Discovery, Tech or Strongholds, the second native diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index d9186b98..48f0c6d3 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -669,7 +669,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) | | Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) | | 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 | Native Basic/Advanced reserve, eligible cross-planet and Guild-return shipments now admit expansion/Nexus rosters without Homeworlds, Skills, Discovery, Tech or Strongholds. Cunning uses its original signed second allowance and true shipped history; its reserve frame binds the rate event and settled price. The same physical cohort and approved split must fund full price after cancellation; otherwise spend Karama and return only the uncommitted attempt, retaining a full-price replacement. One spice per returned force and no-debt withdrawal remain the earlier conservative policies, not new publisher examples. Income, transport permission, borrowed prices and remaining module integration are distinct. | [Native expansion/Nexus evidence and scope](GUILD_RATE_KARAMA_RULES.md#native-expansion-and-nexus-admission--15-october-2026) | +| Guild native rate Karama | Native reserve/cross/return uses the original price source rather than blanket module exclusions. Smuggler free counters remain free; an own concealed No-Field prices one marker. Native Guild typed world-to-world/Arrakis/return uses full one-per-counter after cancellation; immune Junction sponsorship and independent Nexus prices remain separate. Cunning retains its signed second allowance and true shipped history. Same physical cohort and approved split fund full price or return only the uncommitted attempt for a full-price replacement. Deferred Betrayal retains that authorization until actual settlement. Earlier no-debt withdrawal/return interpretations remain provisional, not new publisher examples; allied No-Field, special receipts and permission cancellation retain separate boundaries. | [Current composition and original/controlled proof](GUILD_RATE_KARAMA_RULES.md#native-price-source-composition--15-october-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) | diff --git a/game/engine.ts b/game/engine.ts index 6d05feab..27ad3aeb 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1111,6 +1111,7 @@ type KaramaUse = type PendingHomeworldShipment = HomeworldShipmentIntent & { guildSecretEvent?: string; guildNexusEvent?: string; + guildRateEvent?: string; /** Absent in older world-to-world declarations. */ route?: 'arrakis'; event: string; @@ -8412,9 +8413,13 @@ function leaderSkillsIntegrity(g: Game) { } if (!noFieldShipment && (g.leaderSkills || g.pendingShipment?.smuggler)) { const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment'); - requireRule(decisions.length === (g.pendingShipment ? 1 : 0), - 'The saved Leader Skills shipment lost its unique Guild interception decision.'); + const rates = g.pendingShipment?.guildRateEvent + ? savedNoFieldResponses(g).filter(response => response.kind === 'guildRate' && + response.guildRateEvent === g.pendingShipment!.guildRateEvent) : []; + requireRule(decisions.length + rates.length === (g.pendingShipment ? 1 : 0), + 'The saved Leader Skills shipment lost its unique Guild interception or rate response.'); for (const decision of decisions) validateGuildShipmentDecision(g, decision); + for (const rate of rates) validateGuildRateResponse(g, rate); } if (!g.leaderSkills) return; requireRule(g.leaderSkills.assignments.every((assignment) => @@ -9993,6 +9998,9 @@ function decideGuildBetrayal(g: Game, p: Player, action: Action): void { continuation.transport, settlement); break; } requireRule(getPlayer(g, frame.invoice.shipper).shipped, 'The funded native shipment did not complete.'); + if (g.guildRateBlocked?.turn === g.turn && + g.guildRateBlocked.player === frame.invoice.shipper) + delete g.guildRateBlocked; const sourceReceipt = nexusGuildBetrayalSourceReceipt(frame.invoice); const receipt = nexusRule(() => createGuildBetrayalReceipt({turn: g.turn, players: g.players}, frame.invoice, {recipient: settlement.recipient, holderFaction: use ? p.faction : null, @@ -14444,7 +14452,7 @@ function finishAutomaticDecision(g: Game): boolean { if (homeworldShipmentAutomatic(g)) { const shipment = g.pendingHomeworldShipment!; g.decision = null; - commitHomeworldShipment(g, shipment); + if (!openHomeworldGuildRate(g, shipment)) commitHomeworldShipment(g, shipment); return true; } const market = g.choamMarket; @@ -24813,6 +24821,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl if (response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,response); if (response.kind === 'guildRate') { if (g.pendingGuildTransport) validateGuildTransportRateResponse(g, response); + else if (g.pendingHomeworldShipment?.guildRateEvent) validateHomeworldGuildRateResponse(g, response); else validateGuildRateResponse(g, response); } if (response.kind === 'moritaniPlacement') { @@ -24887,6 +24896,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl } if (response.kind === 'guildRate') { if (g.pendingGuildTransport) finishGuildTransportRateResponse(g, response, canceled); + else if (g.pendingHomeworldShipment?.guildRateEvent) finishHomeworldGuildRateResponse(g, response, canceled); else finishGuildRateResponse(g, response, canceled); return; } @@ -26141,6 +26151,8 @@ function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {rout requireRule(g.homeworlds?.custody, 'Homeworld shipment requires saved physical custody.'); const context = { ...homeworldContext(g), + nativeGuildRateCanceled: g.guildRateBlocked?.turn === g.turn && + g.guildRateBlocked.player === intent.player, players: homeworldContext(g).players.map((seat) => ({...seat, ally: getPlayer(g, seat.id).ally})), }; const order = {player: intent.player, destination: intent.destination, sources: intent.sources}; @@ -26184,6 +26196,13 @@ function homeworldShipmentIntegrity(g: Game) { const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'homeworldShipmentGuild'); const shipment = g.pendingHomeworldShipment; if (!shipment && !decisions.length) return; + if (shipment?.guildRateEvent) { + const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildRate'); + requireRule(!decisions.length && responses.length === 1 && !g.pendingShipment, + 'The saved Homeworld rate declaration lost its single response.'); + validateHomeworldGuildRateResponse(g, responses[0]); + return; + } requireRule(shipment && decisions.length > 0 && !g.pendingShipment, 'The saved Homeworld shipment needs its original Guild decision.'); validateHomeworldShipment(g, shipment); @@ -26243,7 +26262,78 @@ function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentI g.decision = {kind: 'homeworldShipmentGuild', player: guild.id, shipper: p.id, destination: intent.destination, amount: quote.amount, event: shipment.event}; log(g, `${p.name} declared ${quote.amount} physical forces for ${combatLocationName(g, intent.destination)}. Payment and departure await the Guild interception decision.`); - } else commitHomeworldShipment(g, shipment); + } else if (!openHomeworldGuildRate(g, shipment)) commitHomeworldShipment(g, shipment); +} +function homeworldGuildRateFullCost(g: Game, shipment: PendingHomeworldShipment): number | null { + const p = getPlayer(g, shipment.player); + if (p.faction !== 'guild' || shipment.guildSecretEvent || + g.karamaShipping?.player === p.id || + (g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id)) + return null; + return shipment.cost === Math.ceil(shipment.amount / 2) && shipment.cost < shipment.amount + ? shipment.amount : null; +} +function openHomeworldGuildRate(g: Game, shipment: PendingHomeworldShipment): boolean { + const fullCost = homeworldGuildRateFullCost(g, shipment); + if (fullCost === null) return false; + validateHomeworldShipment(g, shipment); + // The Homeworld validator already authenticated this exact Cunning frame. + const cunning = shipment.guildNexusEvent + ? currentGuildCunning(g, shipment.player) as ReturnType + : null; + const event = crypto.randomUUID(); + shipment.guildRateEvent = event; + if (cunning) { + cunning.shipment!.frame = JSON.stringify(shipment); + saveGuildCunning(g, cunning); + } + g.pendingHomeworldShipment = shipment; + g.response = { kind: 'guildRate', owner: shipment.player, passed: [], guildRateEvent: event, + intent: `${getPlayer(g, shipment.player).name} declared ${shipment.amount} physical forces to ${combatLocationName(g, shipment.destination)} at the native Guild rate of ${shipment.cost} spice. Karama removes only this faction discount, not Junction permission or another Homeworld benefit. The exact typed source and approved payment split must cover the full ${fullCost}; otherwise the declaration returns unused and a replacement this turn uses full price.` }; + log(g, 'Native Guild Homeworld shipment waits for its rate response before payment or physical departure.'); + return true; +} +function validateHomeworldGuildRateResponse(g: Game, response: ResponseWindow) { + const shipment = g.pendingHomeworldShipment; + const fullCost = shipment ? homeworldGuildRateFullCost(g, shipment) : null; + requireRule(response.kind === 'guildRate' && shipment && fullCost !== null && + response.owner === shipment.player && typeof shipment.guildRateEvent === 'string' && + shipment.guildRateEvent.length > 0 && response.guildRateEvent === shipment.guildRateEvent, + 'The saved native Homeworld rate lost its original declaration.'); + validateHomeworldShipment(g, shipment); + return { shipment, fullCost }; +} +function finishHomeworldGuildRateResponse(g: Game, response: ResponseWindow, canceled: boolean) { + const { shipment, fullCost } = validateHomeworldGuildRateResponse(g, response); + const p = getPlayer(g, shipment.player); + // The rate validator already authenticated this exact Cunning frame. + const cunning = shipment.guildNexusEvent + ? currentGuildCunning(g, p.id) as ReturnType + : null; + g.pendingHomeworldShipment = null; + delete shipment.guildRateEvent; + if (canceled) { + g.guildRateBlocked = { turn: g.turn, player: p.id }; + const bounds = shipmentPaymentBounds(fullCost, p.spice, aidFor(g, p)?.amount ?? 0); + if (shipment.allyPayment < bounds.minimum || shipment.allyPayment > bounds.maximum) { + if (cunning) { + delete cunning.shipment; + saveGuildCunning(g, cunning); + } + log(g, `Karama removed the native Guild discount. ${p.name}'s original Homeworld payment split cannot fund full price; no spice or typed forces moved. A replacement this turn uses full price.`); + return; + } + shipment.cost = fullCost; + } + if (cunning) { + cunning.shipment!.frame = JSON.stringify(shipment); + saveGuildCunning(g, cunning); + } + commitHomeworldShipment(g, shipment); + if (canceled && !g.pendingGuildBetrayal) { + delete g.guildRateBlocked; + log(g, `${p.name} paid the full ${fullCost} spice for the unchanged native Homeworld shipment.`); + } } function noFieldSkillProof(shipment: PendingShipment): string { return JSON.stringify({ ...shipment, noFieldSkillProof: undefined }); @@ -26327,7 +26417,8 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean { else validateLeaderSkillNoFieldShipment(g, shipment); const initial = responses.filter(r => r.kind === 'richeseNoField'); const guild = decisions.filter(d => d.kind === 'guildShipment'); - requireRule(initial.length + guild.length === 1 && + const rates = responses.filter(r => r.kind === 'guildRate'); + requireRule(initial.length + guild.length + rates.length === 1 && responses.every(r => r.noFieldSkillProof === undefined || r.kind === 'richeseNoField') && decisions.every(d => !('noFieldSkillProof' in d) || d.kind === 'guildShipment'), 'The saved No-Field shipment lost its unique cancellation response or Guild decision.'); @@ -26335,6 +26426,7 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean { response.noFieldSkillProof === shipment.noFieldSkillProof, 'The No-Field response changed its original declaration.'); for (const decision of guild) validateGuildShipmentDecision(g, decision); + for (const rate of rates) validateGuildRateResponse(g, rate); return true; } function validatePhysicalShipment(g: Game, shipment: PendingShipment) { @@ -26484,24 +26576,20 @@ const CLASSIC_FACTIONS: Partial> = { }; /** The independent Karama rate is a card benefit, not Guild's cancelable rate. */ -function guildRateModeSupported(g: Game): boolean { - return !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && - !g.techTokens && !g.strongholdCards; -} function ordinaryGuildRateFullCost(g: Game, shipment: PendingShipment): number | null { const guild = byFaction(g, 'guild'); const p = getPlayer(g, shipment.player); if (!guild || (p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id)) || - !guildRateModeSupported(g) || g.karamaShipping?.player === p.id || (g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id) || - shipment.source || shipment.noField || shipment.alliedNoField || - shipment.smuggler || shipment.smugglerCompanion || shipment.guildSecretEvent || + shipment.source || shipment.alliedNoField || shipment.guildSecretEvent || shipment.nexusEvent || shipment.richesePair) return null; const fullCost = reserveShipmentCost({ faction: p.faction, halfRate: false }, - territory(shipment.territory).type, shipment.amount); - return shipment.cost < fullCost ? fullCost : null; + territory(shipment.territory).type, shipment.amount - (shipment.smuggler ? 1 : 0)); + const halfCost = reserveShipmentCost({ faction: p.faction, halfRate: true }, + territory(shipment.territory).type, shipment.amount - (shipment.smuggler ? 1 : 0)); + return shipment.cost === halfCost && shipment.cost < fullCost ? fullCost : null; } function openGuildRate(g: Game, shipment: PendingShipment): boolean { const fullCost = ordinaryGuildRateFullCost(g, shipment); @@ -26513,6 +26601,8 @@ function openGuildRate(g: Game, shipment: PendingShipment): boolean { shipment.turn ??= g.turn; const event = crypto.randomUUID(); shipment.guildRateEvent = event; + if (shipment.noFieldSkillProof !== undefined) + shipment.noFieldSkillProof = noFieldSkillProof(shipment); if (cunning) { cunning.shipment!.frame = JSON.stringify(shipment); saveGuildCunning(g, cunning); @@ -26523,7 +26613,7 @@ function openGuildRate(g: Game, shipment: PendingShipment): boolean { owner: byFaction(g, 'guild')!.id, passed: [], guildRateEvent: event, - intent: `${getPlayer(g, shipment.player).name} declared ${shipment.amount} reserve forces to ${territory(shipment.territory).name} at the Guild rate of ${shipment.cost} spice. Karama can remove this discount before any payment or arrival. The original forces would cost ${fullCost} at full price; if the original authorized split cannot pay that amount, the declaration returns unused and a replacement this turn uses full price.`, + intent: `${getPlayer(g, shipment.player).name} declared ${shipment.noField ? 'one concealed No-Field' : `${shipment.amount} reserve forces`}${shipment.smugglerCompanion ? ' with one free Smuggler companion' : shipment.smuggler ? ' including one free Smuggler companion' : ''} to ${territory(shipment.territory).name} at the Guild rate of ${shipment.cost} spice. Karama can remove this discount before any payment or arrival, but does not remove the independent free companion. The same paid units would cost ${fullCost} at full price; if the original authorized split cannot pay that amount, the declaration returns unused and a replacement this turn uses full price.`, }; log(g, `${getPlayer(g, shipment.player).name} declared a Guild-rate reserve shipment; payment and arrival wait for the rate response.`); return true; @@ -26540,18 +26630,19 @@ function validateGuildRateResponse(g: Game, response: ResponseWindow) { response.guildRateEvent === shipment.guildRateEvent && shipment.turn === g.turn && fullCost !== null && shipment.cost === reserveShipmentCost({ faction: p.faction, halfRate: true }, - territory(shipment.territory).type, shipment.amount), + territory(shipment.territory).type, shipment.amount - (shipment.smuggler ? 1 : 0)), 'The saved Guild-rate response lost its original physical shipment.'); return { shipment, fullCost }; } function guildRateIntegrity(g: Game) { const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildRate'); const expected = Number(!!g.pendingShipment?.guildRateEvent) + - Number(!!g.pendingGuildTransport); + Number(!!g.pendingGuildTransport) + Number(!!g.pendingHomeworldShipment?.guildRateEvent); requireRule(expected <= 1 && responses.length === expected, 'The saved Guild-rate shipment lost its single Karama response.'); if (responses.length) { if (g.pendingGuildTransport) validateGuildTransportRateResponse(g, responses[0]); + else if (g.pendingHomeworldShipment?.guildRateEvent) validateHomeworldGuildRateResponse(g, responses[0]); else validateGuildRateResponse(g, responses[0]); } const blocked = g.guildRateBlocked; @@ -26582,12 +26673,14 @@ function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: bo shipment.cost = fullCost; checkShipmentIncomeFunding(g, shipper, fullCost, shipment.allyPayment); } + if (shipment.noFieldSkillProof !== undefined) + shipment.noFieldSkillProof = noFieldSkillProof(shipment); if (cunning) { cunning.shipment!.frame = JSON.stringify(shipment); saveGuildCunning(g, cunning); } commitShipment(g, shipment); - if (canceled) { + if (canceled && !g.pendingGuildBetrayal) { delete g.guildRateBlocked; log(g, `Karama removed Guild's half-price rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged shipment.`); } @@ -26599,7 +26692,7 @@ function guildTransportSignature(intent: PendingGuildTransport): string { } function ordinaryGuildTransportFullCost(g: Game, p: Player, frame: GuildTransportOrder): number | null { const guild = byFaction(g, 'guild'); - if (!guild || !guildRateModeSupported(g) || + if (!guild || (p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id)) || g.karamaShipping?.player === p.id || (g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id)) @@ -26796,7 +26889,7 @@ function finishGuildTransportRateResponse(g: Game, response: ResponseWindow, can return; } commitGuildTransport(g, shipper, frame, intent.fromReserves); - if (canceled) { + if (canceled && !g.pendingGuildBetrayal) { delete g.guildRateBlocked; log(g, `Karama removed Guild's half-price transport rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged physical transport.`); } @@ -30835,7 +30928,7 @@ function applyActionInner( decision.event === shipment.event && decision.shipper === shipment.player && decision.destination === shipment.destination && decision.amount === shipment.amount, 'The Guild decision no longer matches this Homeworld shipment.'); - commitHomeworldShipment(g, shipment); + if (!openHomeworldGuildRate(g, shipment)) commitHomeworldShipment(g, shipment); } else if (decision.kind === 'guildShipment') { requireRule( action.allow === true, diff --git a/game/guild-homeworld-shipment-options.ts b/game/guild-homeworld-shipment-options.ts index d1cf25a3..44659e32 100644 --- a/game/guild-homeworld-shipment-options.ts +++ b/game/guild-homeworld-shipment-options.ts @@ -68,6 +68,7 @@ export function guildHomeworldShipmentChoice( const quote = quoteGuildHomeworldShipment( { advanced: view.advanced, + nativeGuildRateCanceled: view.guildRateCanceled, storm: view.storm, mobileStronghold: view.mobileStronghold?.location ?? null, discoveries: view.discoveries, @@ -225,7 +226,7 @@ export function guildHomeworldShipmentActions( ); const amount = Math.min( total - retain, - budget * 2, + budget * (view.guildRateCanceled ? 1 : 2), [3, 5, 7, 9][difficulty], desired, ); diff --git a/game/guild-homeworld-shipment.ts b/game/guild-homeworld-shipment.ts index 4de5e096..9aee3b9c 100644 --- a/game/guild-homeworld-shipment.ts +++ b/game/guild-homeworld-shipment.ts @@ -58,7 +58,8 @@ function printedSector(key: string) { /** Guild's own special shipment from one Arrakis territory to a Homeworld. * The caller owns timing, payment, alliance funding, responses and arrival - * effects. No private card, concealed-force value or transport usage is read. + * effects. Native rate cancellation changes only the half tariff to full price. + * No private card, concealed-force value or transport usage is read. * The mobile stronghold's interior remains protected when its pointer is in * storm. A Homeworld destination never becomes a fabricated Arrakis sector. */ export function quoteGuildHomeworldShipment( @@ -206,7 +207,7 @@ export function quoteGuildHomeworldShipment( ...transferred, amount, elite: 0, - cost: Math.ceil(amount / 2), + cost: context.nativeGuildRateCanceled ? amount : Math.ceil(amount / 2), origin: origin!, boardSources, boardForces, diff --git a/game/homeworld-shipment-options.ts b/game/homeworld-shipment-options.ts index 81ab259c..ad8a9edc 100644 --- a/game/homeworld-shipment-options.ts +++ b/game/homeworld-shipment-options.ts @@ -67,6 +67,7 @@ export function homeworldShipmentChoice( } const context = { advanced: view.advanced, + nativeGuildRateCanceled: view.guildRateCanceled, players: view.players.map((player) => ({ id: player.id, faction: player.faction, @@ -231,7 +232,10 @@ export function homeworldShipmentActions( const amount = Math.min( pools.reduce((sum, pool) => sum + pool.capacity, 0), [3, 5, 7, 9][difficulty], - budget * (me.faction === 'guild' || useNexus ? 2 : 1), + budget * + ((me.faction === 'guild' && !view.guildRateCanceled) || useNexus + ? 2 + : 1), Math.max([1, 3, 4, 5][difficulty], required), ); if (amount <= 0) continue; diff --git a/game/homeworld-shipment.ts b/game/homeworld-shipment.ts index 6ddb3f77..7160f096 100644 --- a/game/homeworld-shipment.ts +++ b/game/homeworld-shipment.ts @@ -13,6 +13,8 @@ export type HomeworldShipmentContext = Omit< 'players' > & { players: (HomeworldReserveSeat & { ally: string | null })[]; + /** Only the native Guild faction rate; independent transport grants survive. */ + nativeGuildRateCanceled?: boolean; }; export type HomeworldShipmentIntent = { player: string; @@ -34,7 +36,9 @@ function requireShipment( * Guild may also return a foreign garrison to its own world; other own-world * exceptions need separately established permission. The caller owns timing, usage, * funding, bank payment, responses and arrival effects. Guild's E3 half rate - * applies to its own shipment; an alliance alone does not grant that rate. */ + * applies to its own shipment unless its native rate is canceled; an alliance + * alone does not grant that rate. Independent transport prices belong to their + * own grant, not this native cancellation flag. */ export function quoteHomeworldShipment( context: HomeworldShipmentContext, custody: HomeworldCustody, @@ -128,7 +132,10 @@ export function quoteHomeworldShipment( ...transferred, amount, elite, - cost: player.faction === 'guild' ? Math.ceil(amount / 2) : amount, + cost: + player.faction === 'guild' && !context.nativeGuildRateCanceled + ? Math.ceil(amount / 2) + : amount, sources: transferred.receipts.filter((r) => r.homeworld !== destination.id), }; } diff --git a/game/karama-context.ts b/game/karama-context.ts index 05e58381..fa4ed050 100644 --- a/game/karama-context.ts +++ b/game/karama-context.ts @@ -137,6 +137,7 @@ const cancellationSources = { guildRate: (g) => ({ pending: g.pendingShipment ?? null, ...(g.pendingGuildTransport ? { transport: g.pendingGuildTransport } : {}), + ...(g.pendingHomeworldShipment ? { homeworld: g.pendingHomeworldShipment } : {}), active: g.active, discountedRateBlocked: g.guildRateBlocked ?? null, purchasedRate: g.karamaShipping ?? null, diff --git a/game/reference.ts b/game/reference.ts index 833ed9c6..afdf0e3f 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -142,7 +142,7 @@ const phaseDetails = [ [ 'At the start of this phase, resolve the Atreides spice foresight response. If allowed, Atreides privately inspects the next card, including when using double spice blow.', 'Select a destination and sector. Ship from reserves before making a force move.', - 'Native Guild or its reciprocal ally’s discounted ordinary reserve shipment or eligible cross-planet transport waits for a Karama response before paying, including expansion/Nexus tables without Homeworlds, Skills, Discovery, Tech or Strongholds. Guild’s own return waits too. The same physical group and approved split must cover full price after cancellation; otherwise the declaration returns unused and a replacement this turn uses full price. Cunning retains its separate second allowance and original shipped history. Full-price return and unaffordable withdrawal remain provisional policies; income, independent purchased rates and transport permission are distinct.', + 'A native Guild or reciprocal ally’s discounted reserve/cross shipment waits before payment, including original module tables. Native Guild typed Homeworld transport and return retain their own source pools. Canceling native price preserves a free Smuggler companion; an own concealed No-Field prices one marker, never its hidden value. The same group and approved funding must pay full price or return uncommitted for a full-price replacement. Cunning retains its second allowance and original history. Junction sponsorship, independent Nexus/card prices and permission cancellation are distinct; affordability/return policies remain provisional.', 'For movement, select the source forces. Enable Combine sectors to include several groups from the same territory.', 'A Fremen move using the two-territory advantage has a Karama response. Cancellation leaves the forces in place and limits this move to one territory; city ornithopters and a later extra move keep their normal rules.', 'Guild transport moves one exact physical group across the planet or returns it to reserves. Native half-price cancellation is separate from transport permission, including supported expansion/Nexus tables and Cunning’s second shipment. Ordinary movement still checks range, storm and stronghold occupancy.', @@ -1431,7 +1431,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Documentation', status: 'Partial', detail: - 'Source-bound effects, copies and Duke guides remain. Native own-arrival continuation explicitly records child-first/live-token order as provisional, preserving actual controls and unsupported paired/module guards. Separate native rate cancellation now admits expansion/Nexus reserve/cross/return and Cunning receipts; Homeworld/Skills/Discovery/Tech/Stronghold rate compositions remain unfinished.', + 'Source-bound effects, copies and Duke guides remain. Native own-arrival child-first/live-token order stays provisional. Native rate price-source composition now retains original modules, independent Smuggler/own-marker pricing, typed Homeworlds and Cunning receipts; immune Junction and borrowed prices remain distinct. Allied-marker/special receipts, permission cancellation and full combinations remain open.', evidence: ['docs/ECAZ_AMBASSADORS_RULES.md', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'docs/BOT_ARRIVAL_PREFLIGHT.md'], }, { @@ -2556,7 +2556,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'If eligible to bid, use the card to take the current auction card without paying spice. A full hand cannot bypass the bidding limit.', 'Holding Karama also permits bids beyond your available spice. A winner can choose to spend it; an outbid player keeps it.', 'Use Cancel with Karama during a power response. Supported responses include Voice, prescience, Atreides auction and spice peeks, spiritual advisors, Emperor gifts and revival, faction income, Harkonnen bonus cards and allied traitors, plus Fremen storm and worm powers, elite combat strength and Fremen free spice support.', - 'Native Guild half-price cancellation covers ordinary reserve shipment, eligible cross-planet transport and Guild return before payment or arrival, including expansion/Nexus tables without Homeworlds, Skills, Discovery, Tech or Strongholds. Cunning’s second reserve/cross shipment retains its original allowance and history. Full price applies to the same group and approved split if affordable; otherwise the uncommitted attempt returns and its replacement is full price this turn. The return tariff and unaffordable outcome remain documented table policies. Income, transport permission and independent Karama prices are separate.', + 'Cancel a native Guild discount before payment or transfer on supported reserve/cross/return and typed Homeworld sources. Original module selection does not immunize the faction rate. Independent Smuggler counters remain free; an own No-Field bills one concealed marker. Cunning keeps its original second allowance. Full price uses the same approved split or releases the uncommitted attempt for a full-price replacement. Junction Homeworld sponsorship and independent card/Nexus prices stay separate. Income, transport permission and remaining special receipts are distinct; the no-debt/return policy remains provisional.', 'During shipment, use Guild rates for the active player before they ship. They choose and pay for their shipment at the rounded half rate, paid to the bank. You may provide this benefit for another player; it expires if they move or finish without shipping.', 'Advanced Bene Gesserit may spend Worthless cards as Karama. Each conversion opens a separate cancellation response; the Worthless card is discarded even if the power is canceled. Normal Karama cards resolve without that conversion response.', 'A pending Worthless purchase keeps space for its original auction card. Gifts and hand exchanges must leave enough room for every committed incoming card. Refreshing restores the same auction, unused shipment opportunity or original canceled power.', @@ -4653,7 +4653,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Native Richese Cunning uses the original signed two-No-Field shipment in supported single/paired E2 or selected mixed Ix+CHOAM native Nexus tables with classics, optional Skills/Homeworlds/Discovery/Tech/Advanced Strongholds. Select two distinct unused physical tokens: one reveals immediately into reserve-capped forces, one remains concealed; pay one marker invoice and spend the Nexus. The previous shipment token cannot be either choice, and the concealed one becomes last-used. Native/Guild response and actual payer/counter sources remain; a nested destination must be revealed. Prevention keeps physical tokens and unpaid spice, after any printed card cost. Sandtrout, arbitrary companions, shared allocation and other pending combinations retain their guards.', 'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.', 'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable. The separate guild-betrayal profile admits only original native shipment producers and explicitly blocks this second-shipment offer before Nexus spending; Cunning outside that profile is unchanged.', - 'In supported native expansion/Nexus tables without other physical modules, Cunning’s second reserve/cross shipment has the normal prepayment rate-Karama window. Its original shipped flag stays true. Allowance preserves rounded half price; cancellation pays full price once or returns the uncommitted second attempt without losing that allowance. A replacement stays full price. The first shipment and ordinary movement never reopen.', + 'Supported Cunning reserve/cross/typed Homeworld shipments retain native rate cancellation without clearing the original shipped history. Original module/source, event, paid group and approved split survive allow/cancel; unaffordable release preserves only the second attempt at full price. The first shipment and ordinary movement never reopen. Junction sponsorship and independent borrowed prices are not canceled by this native flag.', 'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to the holder’s own reserves, including the native-Homeworld return when Homeworlds are enabled, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Arbitrary foreign-world transport and concealed No-Fields are not granted.', 'Borrowed Guild/Richese tariffs do not change native faction or physical reserve origin. Non-Guild/non-Fremen off-planet arrivals can accrue Heighliners once for collection at the original phase end. A paid borrowed Guild route from Fremen southern reserves is still on-planet and does not trigger it; stopped and native-Guild-only shipments likewise remain excluded. The existing Emperor bank-auction purchase retains the actual buyer wallet and creates no industry activity.', 'Guild Betrayal has a separate fresh classic/native-Guild development preview. Before a fully funded positive shipment settles, an unallied holder may spend the physical Guild Nexus to take the full original fee, including their own pre-funded payment, instead of Guild, bank or occupied Junction income. Original donor escrow is consumed once and original physical delivery is unchanged. Neutral public acknowledgements reveal no fee/source; only private eligibility exposes Use. Zero/stopped shipments create no award or Nexus cost. See the dedicated guide; new verification remains Planned and ordinary Homeworld occupation/rounding questions stay local.', @@ -4662,11 +4662,11 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'], checklist: [ - {area:"Implementation",status:'Partial',detail:"Physical Traitor roles, permanent markers, optional-zone receipts and legal recycling remain integrated. Native expansion/Nexus rate cancellation now covers reserve/cross/return. Cunning reserve receipts bind the temporary rate event and settled price; unaffordable release retains only the second allowance. Both validators reuse source-aware shipment availability instead of clearing original shipped history. Remaining physical-module rate compositions and full acceptance stay open.",evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts"]}, - {area:"Player controls",status:'Partial',detail:"Earlier physical Hero and historical Traitor-return/recycling controls retain source-bound proof. Three current390px journeys exercise native full-price cancellation, unaffordable withdrawal/full-price replacement and Cunning reserve repricing through Finish Guild turn. Original groups, paid first shipment and no ordinary extra movement remain; no overflow/browser errors. The Cunning scene uses the explicitly controlled existing fixture, not the action-only native producer.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx"]}, - {area:"AI",status:'Partial',detail:"All four actual owner policies legally pass the new native expansion/Nexus rate response, pay rounded two for three and reach the original Ambassador offer; none cancels in that position. Existing Cunning bots and earlier Hero/Traitor-return participation remain covered. Human cancellation/retained-fighter branches are distinct proof. Strategy and calibration remain deferred.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']}, - {area:"Documentation",status:'Partial',detail:"Physical Hero, permanent victims and historical references stay distinct. Native rate admission and Cunning receipt transitions are documented separately from income and transport permission. Existing full-price return and unaffordable replacement policies remain provisional; no new publisher priority or numerical ruling. Homeworlds, Skills, Discovery, Tech and Stronghold rate combinations and public gates remain separate.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md"]}, - {area:"Verification",status:'Partial',detail:"Current affected nine-file101/101 and five action-only original API/policy paths pass. Six controlled Cunning reserve/cross cases cover allow/cancel/unaffordable replacement without reopening the first turn. Three actual390px journeys prove full price, no-debt withdrawal/replacement and Cunning turn completion. Earlier134/134 Hero/Traitor lifecycle evidence remains historical. No saved/deployed or full-module assurance claim.",evidence:['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/native-guild-rate-karama.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts", "tests/fixture-nexus-emperor-bank.ts", "tests/nexus-emperor-bank-profile.test.ts", "tests/nexus-emperor-bank-runtime.test.ts", "tests/duke-captured-custody.test.ts", "tests/nexus-ecaz-duke-captured.test.ts", "tests/fixture-nexus-duke-capture.ts", "tests/nexus-duke-capture-runtime.test.ts", "tests/nexus-duke-timing-runtime.test.ts"]}, + {area:"Implementation",status:'Partial',detail:"Earlier physical Traitor roles and receipts remain integrated. Native rate now follows original price/source rather than blanket module flags: independent Smuggler/own-marker billing, typed Homeworlds and Cunning world frames retain exact source/payment/allowance. Manual/automatic interception, Skills and No-Field integrity recognize the original rate stage. Deferred Betrayal retains full-price authority until actual settlement. Immune Junction and borrowed sources stay distinct; allied markers, permission cancellation and full acceptance remain open.",evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts"]}, + {area:"Player controls",status:'Partial',detail:"Four current390px journeys exercise composed original Smuggler shipping and ground movement, typed native return with approved donor1, poor world release/full-price replacement, and explicitly controlled own No-Field cancellation/reveal. Native instructions and prices agree after cancellation; one marker never exposes its hidden value in billing. Three original scenes, one controlled; no overflow/browser errors. Earlier Hero/Traitor/Cunning surfaces remain historical evidence, not full acceptance.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx"]}, + {area:"AI",status:'Partial',detail:"Both typed Homeworld owner quotes and bot budgets honor the actual canceled native tariff without repricing independent Nexus/Junction sources. All four policies legally pass the original native Homeworld rate in this position; human cancellation is separate evidence. Earlier Cunning/Hero/Traitor participation remains covered; no strategic tuning or calibration.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']}, + {area:"Documentation",status:'Partial',detail:"Native price/source composition supersedes blanket module exclusions without canceling immune Homeworld or independent card benefits. Original versus controlled proof, approved escrow, private marker billing, Cunning world receipts and deferred full invoices are explicit. Earlier no-debt/return policies remain provisional; no new numeric or publisher-priority ruling. Allied markers, special receipts, permission cancellation and public acceptance remain separate.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md"]}, + {area:"Verification",status:'Partial',detail:"Current affected18-file198/198 includes32 new source cases. Twenty-one runtime/policy paths pass:17 original-action and four controlled Cunning/No-Field paths. Four actual390px journeys prove paid/free separation, exact typed return, no-debt full-price replacement and actual concealed-marker reveal; original movement/turn resumes once. Six controlled Betrayal Use/Pass cases retain full fee until deferred payment. Earlier lifecycle evidence stays historical; no saved/deployed or complete-module assurance.",evidence:['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts", "tests/fixture-nexus-emperor-bank.ts", "tests/nexus-emperor-bank-profile.test.ts", "tests/nexus-emperor-bank-runtime.test.ts", "tests/duke-captured-custody.test.ts", "tests/nexus-ecaz-duke-captured.test.ts", "tests/fixture-nexus-duke-capture.ts", "tests/nexus-duke-capture-runtime.test.ts", "tests/nexus-duke-timing-runtime.test.ts"]}, ], }, { diff --git a/tests/fixture-guild-ambassador-income-arrival.ts b/tests/fixture-guild-ambassador-income-arrival.ts index fea2650a..ca20564a 100644 --- a/tests/fixture-guild-ambassador-income-arrival.ts +++ b/tests/fixture-guild-ambassador-income-arrival.ts @@ -5,13 +5,13 @@ import { advancePairedE3Nexus, createPairedE3NexusSetup, nextPairedE3NexusStep, pairedE3NexusClean, pairedE3NexusInventory, pairedE3NexusPlayer, pairedE3NexusReload, quietPairedE3NexusChoice, stepPairedE3Nexus, - type PairedE3NexusStep, type PairedE3NexusTransition, + type PairedE3NexusOptions, 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. +/** Original Ecaz/Moritani/Guild, with explicit original setup module choices. * 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 @@ -21,10 +21,11 @@ import { quietPlan } from './fixture-nexus-duke-capture'; * A one-force stronghold arrival has no rounded native-rate discount to * cancel (half/full both cost one). Larger native shipments retain the * separate prepayment rate window before this fixture's income path. */ -export function createGuildAmbassadorIncomeArrival() { +export function createGuildAmbassadorIncomeArrival(options: Pick = {}) { const setup = createPairedE3NexusSetup({ roster: ['ecaz', 'moritani', 'guild'], advanced: true, skills: false, homeworlds: false, discovery: false, - tech: false, strongholds: false }); + tech: false, strongholds: false, ...options }); const actions = setup.actions; const ecaz = setup.ecaz, entrant = setup.moritani; const guild = setup.afterSetup.players.find(p => p.faction === 'guild')!.id; diff --git a/tests/fixture-native-guild-homeworld-rate.ts b/tests/fixture-native-guild-homeworld-rate.ts new file mode 100644 index 00000000..1efb46a4 --- /dev/null +++ b/tests/fixture-native-guild-homeworld-rate.ts @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { homeworldShipmentChoice, type HomeworldShipmentSources } from '../game/homeworld-shipment-options'; +import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options'; +import { junctionTransportChoice } from '../game/junction-transport-options'; +import { createGuildAmbassadorIncomeArrival } from './fixture-guild-ambassador-income-arrival'; +import { + advancePairedE3Nexus, nextPairedE3NexusStep, pairedE3NexusClean, + pairedE3NexusPlayer, pairedE3NexusReload, quietPairedE3NexusChoice, + stepPairedE3Nexus, type PairedE3NexusStep, +} from './fixture-paired-e3-nexus-modules'; +import { + nexusGuildCunningFixture, originalGuildCunningTurn, nexusGuildCunningRequest, + nexusGuildCunningAllow, holdGuildCunningCard, +} from './fixture-nexus-guild-cunning'; + +export type NativeGuildHomeworldRoute = 'world' | 'arrakisForeign' | 'return'; + +export interface NativeGuildHomeworldRateFixture { + route: NativeGuildHomeworldRoute; + source: string; + destination: string; + sources: HomeworldShipmentSources; + guild: string; + entrant: string; + ecaz: string; + karama: string; + beforeAction: Game; + action: Action; +} +/** Original printed setup, original purchased Karama, and actual progression + * from Moritani's real turn to Guild's real turn. The shared fixture discloses + * its conserved undealt entropy ordering. No played custody, funding, control, + * phase or decision is assigned here. A return is Arrakis to native Junction. */ +export function createNativeGuildHomeworldRate(route: NativeGuildHomeworldRoute = 'world', + options: { unaffordable?: boolean } = {}) { + const base = createGuildAmbassadorIncomeArrival({ homeworlds: true }); + const actions = [...base.actions]; + let game = advancePairedE3Nexus(base.beforeAction, + g => g.phase === 5 && g.active === base.guild && pairedE3NexusClean(g), + actions, quietPairedE3NexusChoice); + const beforeFunding = pairedE3NexusReload(game); + if (options.unaffordable) { + const excess = pairedE3NexusPlayer(game, base.guild).spice - 1; + assert.ok(excess >= 0); + if (excess) game = stepPairedE3Nexus(game, { actor: base.guild, + action: { type: 'bribe', target: base.ecaz, amount: excess } }, actions); + } + game = stepPairedE3Nexus(game, { actor: base.entrant, + action: { type: 'pledgeAid', amount: 1 } }, actions); + const p = pairedE3NexusPlayer(game, base.guild); + const source = route === 'world' ? 'homeworld:guild' : Object.keys(p.forces) + .find(key => p.forces[key] >= 3 && Number(key.split(':')[1]) !== game.storm); + assert.ok(source, 'The original Guild retains three physical counters outside storm.'); + const destination = route === 'return' ? 'homeworld:guild' : 'homeworld:ecaz'; + const sources: HomeworldShipmentSources = { [source]: { normal: 3, elite: 0 } }; + const quote = route === 'world' + ? homeworldShipmentChoice(viewGame(game, base.guild), destination, sources, 1) + : guildHomeworldShipmentChoice(viewGame(game, base.guild), destination, sources, 1); + assert.equal(quote.blocked, null); + assert.equal(quote.cost, 2); + assert.equal(quote.ownPayment, 1); + assert.ok(quote.action); + return { ...base, actions, route, source, destination, sources, + beforeFunding, beforeAction: pairedE3NexusReload(game), action: quote.action, + contributions: [{ payer: base.guild, amount: 1 }, { payer: base.entrant, amount: 1 }], + provenance: [...base.provenance, + 'Actual quiet phase progression to the native Guild turn; original on-board and Junction pools retained.', + 'Moritani pledges one actual spice; optional Guild bribe reduces only its real available wallet.', + 'The owner-view canonical quote declares three normal counters and an explicit one-spice allied share.'] }; +} + +export function nativeGuildHomeworldAction(f: NativeGuildHomeworldRateFixture, + game: Game, amount = 3, allyPayment = 1): Action { + const sources = { [f.source]: { normal: amount, elite: 0 } }; + const view = viewGame(game, f.guild); + const quote = f.route === 'world' + ? homeworldShipmentChoice(view, f.destination, sources, allyPayment) + : guildHomeworldShipmentChoice(view, f.destination, sources, allyPayment); + assert.equal(quote.blocked, null); + assert.ok(quote.action); + return quote.action; +} + +/** Retain the original Guild special-interception decision if physically + * offered. It is distinct from the native rate's subsequent prepayment queue. */ +export function declareNativeGuildHomeworldRate(f: NativeGuildHomeworldRateFixture) { + const actions: PairedE3NexusStep[] = []; + let game = stepPairedE3Nexus(f.beforeAction, { actor: f.guild, action: f.action }, actions); + if (game.decision?.kind === 'homeworldShipmentGuild') { + game = stepPairedE3Nexus(game, { actor: f.guild, + action: { type: 'decision', event: game.decision.event, allow: true } }, actions); + } + assert.equal(game.response?.kind, 'guildRate'); + assert.ok(viewGame(game, f.ecaz).responseControls?.cancelCards.includes(f.karama)); + return { game, actions }; +} + +/** Only pass the actual requested kind; never clear a queue or fabricate a seat. */ +export function passNativeGuildHomeworldResponses(state: Game, kind: 'guildRate' | 'guildIncome') { + const actions: PairedE3NexusStep[] = []; + let game = pairedE3NexusReload(state); + for (let n = 0; game.response?.kind === kind && n < 20; n++) { + const step = nextPairedE3NexusStep(game); + assert.ok(step); + assert.equal(step.action.type, 'passResponse'); + game = stepPairedE3Nexus(game, step, actions); + } + assert.notEqual(game.response?.kind, kind); + return { game, actions }; +} + +/** Export a real pending prepayment scene suitable for API/UI loading. */ +export function createNativeGuildHomeworldRateScene(route: NativeGuildHomeworldRoute = 'world', + options: { unaffordable?: boolean } = {}) { + const fixture = createNativeGuildHomeworldRate(route, options); + const offered = declareNativeGuildHomeworldRate(fixture); + return { ...fixture, game: offered.game, declarationActions: offered.actions, + cancel: { actor: fixture.ecaz, action: { type: 'card', mode: 'cancel', card: fixture.karama } as Action } }; +} + +/** Original immune Junction offer, not a staged immunity flag: Moritani first + * loses its ordinary native alliance rate on a genuinely unaffordable six-force + * cross-transport. Its untouched original army then accepts Junction's own + * Homeworld return tariff while that cancellation marker remains active. */ +export function createNativeImmuneJunctionRate(rate: 'half' | 'full' = 'half') { + const base = createGuildAmbassadorIncomeArrival({ homeworlds: true }); + const actions = [...base.actions]; + const excess = pairedE3NexusPlayer(base.beforeAction, base.entrant).spice - 3; + assert.ok(excess >= 0); + let game = pairedE3NexusReload(base.beforeAction); + if (excess) game = stepPairedE3Nexus(game, { actor: base.entrant, + action: { type: 'bribe', target: base.ecaz, amount: excess } }, actions); + game = stepPairedE3Nexus(game, { actor: base.entrant, + action: { ...base.action, amount: 6 } }, actions); + assert.equal(game.response?.kind, 'guildRate'); + game = stepPairedE3Nexus(game, { actor: base.ecaz, + action: { type: 'card', mode: 'cancel', card: base.karama } }, actions); + assert.equal(viewGame(game, base.entrant).guildRateCanceled, true); + assert.equal(pairedE3NexusPlayer(game, base.entrant).shipped, false); + const released = pairedE3NexusReload(game); + const sponsor = viewGame(game, base.guild).junctionTransport; + assert.ok(sponsor?.canOffer); + game = stepPairedE3Nexus(game, { actor: base.guild, + action: { type: 'offerJunctionTransport', event: sponsor.offerEvent, rate } }, actions); + const sources = { [base.source]: { normal: 3, elite: 0 } }; + const destination = 'homeworld:moritani'; + const quote = junctionTransportChoice(viewGame(game, base.entrant), destination, sources, 0); + assert.equal(quote.blocked, null); + assert.equal(quote.cost, rate === 'half' ? 2 : 3); + assert.ok(quote.action); + return { ...base, actions, rate, sources, destination, released, + beforeAction: pairedE3NexusReload(game), action: quote.action, + provenance: [...base.provenance, + 'Actual unaffordable six-force alliance-rate cancellation leaves the original Moritani pool untouched.', + 'Original high-population Guild offers an independent printed Junction half/full tariff.'] }; +} + +/** Explicitly controlled older fixture: its wallet/phase/army and conserved + * Nexus identity swap are not original action-only provenance. First combined + * turn, Cunning grant, optional bribe, second declaration and all rate choices + * are actual actions. The observer's conserved Karama is dealt by the existing + * controlled helper, not claimed as an original auction purchase. */ +export function createControlledGuildCunningHomeworldRateScene(unaffordable = false) { + const base = nexusGuildCunningFixture({ homeworlds: true }); + let game = originalGuildCunningTurn(base); + const karama = holdGuildCunningCard(game, base.observer, 'karama').id; + game = nexusGuildCunningAllow(stepPairedE3Nexus(game, + { actor: base.owner, action: nexusGuildCunningRequest(base, game) })); + const actions: PairedE3NexusStep[] = []; + if (unaffordable) { + const amount = pairedE3NexusPlayer(game, base.owner).spice - 2; + assert.ok(amount >= 0); + if (amount) game = stepPairedE3Nexus(game, { actor: base.owner, + action: { type: 'bribe', target: base.observer, amount } }, actions); + } + const beforeAction = pairedE3NexusReload(game); + const sources = { 'homeworld:guild': { normal: 3, elite: 0 } }; + const destination = 'homeworld:harkonnen'; + const choice = homeworldShipmentChoice(viewGame(game, base.owner), destination, sources, 0); + assert.equal(choice.blocked, null); + assert.ok(choice.action); + game = stepPairedE3Nexus(game, { actor: base.owner, action: choice.action }, actions); + if (game.decision?.kind === 'homeworldShipmentGuild') + game = stepPairedE3Nexus(game, { actor: base.owner, + action: { type: 'decision', event: game.decision.event, allow: true } }, actions); + assert.equal(game.response?.kind, 'guildRate'); + return { ...base, game, beforeAction, sources, destination, actions, karama, + action: choice.action, + cancel: { actor: base.observer, action: { type: 'card', mode: 'cancel', card: karama } as Action }, + provenance: ['Controlled prior Nexus Guild fixture; physical hand acquisition is controlled, not auctioned.', + 'Actual completed first combined turn, actual Cunning, and actual second typed Homeworld declaration.'] }; +} diff --git a/tests/fixture-native-guild-rate-modules.ts b/tests/fixture-native-guild-rate-modules.ts new file mode 100644 index 00000000..25deef2b --- /dev/null +++ b/tests/fixture-native-guild-rate-modules.ts @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { distance, TERRITORIES } from '../game/board'; +import { presenceAt } from '../game/force-presence'; +import { quoteSmugglerShipment } from '../game/smuggler-shipment'; +import { createGuildAmbassadorIncomeArrival } from './fixture-guild-ambassador-income-arrival'; +import { + nextPairedE3NexusStep, pairedE3NexusClean, pairedE3NexusPlayer, + pairedE3NexusReload, quietPairedE3NexusChoice, stepPairedE3Nexus, + type PairedE3NexusStep, +} from './fixture-paired-e3-nexus-modules'; +import { smugglerNoFieldAction, smugglerNoFieldGame } from './smuggler-no-field-fixture'; + +/** Deliberately selected standalone modules and one composition, not a matrix. */ +export const NATIVE_GUILD_RATE_MODULE_SELECTIONS = { + skills: { skills: true }, + discovery: { discovery: true }, + tech: { tech: true }, + strongholds: { strongholds: true }, + composed: { skills: true, discovery: true, tech: true, strongholds: true }, +} as const; +export type NativeGuildRateModuleSelection = keyof typeof NATIVE_GUILD_RATE_MODULE_SELECTIONS; +export type NativeGuildRateRoute = 'reserve' | 'transport'; + +/** Original setup choices flow through the shared lobby/setup; modules are never + * grafted onto a played game. Original undealt entropy, the paid Karama auction, + * the real Nexus alliance and paid Ambassador retain the shared provenance. */ +export function createNativeGuildRateModules( + selection: NativeGuildRateModuleSelection = 'composed', + route: NativeGuildRateRoute = 'reserve', + amount = 3, +) { + const original = createGuildAmbassadorIncomeArrival(NATIVE_GUILD_RATE_MODULE_SELECTIONS[selection]); + const action: Action = route === 'transport' ? { ...original.action, amount } : { + type: 'ship', territory: original.destination, sector: original.sector, + amount, elite: 0, allyPayment: 0, + }; + return { ...original, selection, route, amount, action, + provenance: [...original.provenance, + `Original setup selection: ${selection}; ${route} declaration is an actual owner action.`] }; +} + +/** Stop before paying or moving anything. Advanced reserve permission is a + * separate real Guild choice, never a synthesized or cleared decision. */ +export function declareNativeGuildRateModules(fixture: { + beforeAction: Game; entrant: string; guild: string; action: Action; +}) { + const before = pairedE3NexusReload(fixture.beforeAction), actions: PairedE3NexusStep[] = []; + let game = stepPairedE3Nexus(before, { actor: fixture.entrant, action: fixture.action }, actions); + if (game.decision?.kind === 'guildShipment') + game = stepPairedE3Nexus(game, { actor: fixture.guild, + action: { type: 'decision', allow: true } }, actions); + assert.equal(game.response?.kind, 'guildRate'); + return { before, actions, game }; +} + +/** Pass only this rate window; the independent income response stays visible. */ +export function allowNativeGuildRateModules(state: Game) { + let game = pairedE3NexusReload(state); + const actions: PairedE3NexusStep[] = []; + for (let n = 0; game.response?.kind === 'guildRate' && n < 20; n++) { + const next = nextPairedE3NexusStep(game); assert.ok(next); + assert.equal(next.action.type, 'passResponse'); + game = stepPairedE3Nexus(game, next, actions); + } + assert.notEqual(game.response?.kind, 'guildRate'); + return { game, actions }; +} +export function cancelNativeGuildRateModules(state: Game, actor: string, card: string) { + assert.equal(state.response?.kind, 'guildRate'); + assert.ok(viewGame(state, actor).responseControls?.cancelCards.includes(card)); + const step: PairedE3NexusStep = { actor, action: { type: 'card', mode: 'cancel', card } }; + return { game: stepPairedE3Nexus(state, step), actions: [step] }; +} + +/** Drain the actual payment/arrival suffix, declining optional entry effects. + * No second shipment, move, phase advancement or physical repair is issued. */ +export function settleNativeGuildRateModules(state: Game) { + let game = pairedE3NexusReload(state); + const actions: PairedE3NexusStep[] = []; + for (let n = 0; n < 100; n++) { + if (pairedE3NexusClean(game) && !game.pendingShipment && !game.pendingGuildTransport) + return { game, actions }; + const next = nextPairedE3NexusStep(game) ?? quietPairedE3NexusChoice(game); + assert.ok(next, 'The original payment/arrival owner must have a real continuation.'); + game = stepPairedE3Nexus(game, next, actions); + } + throw Error('Original native Guild module arrival did not settle.'); +} + +/** A real one-force ordinary ground move after shipping proves that neither + * rate choice consumes or replays the native movement allowance. */ +export function moveAfterNativeGuildRateModules(fixture: { + entrant: string; source: string; destination: string; +}, state: Game) { + assert.ok(pairedE3NexusClean(state)); + const player = pairedE3NexusPlayer(state, fixture.entrant); + assert.ok(player.forces[fixture.source] > 0); + const destination = TERRITORIES.filter(t => t.type !== 'stronghold' && + t.id !== fixture.source.split(':')[0] && t.id !== fixture.destination && + state.players.every(p => presenceAt(p, t.id) === 0)) + .flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector }))) + .find(to => to.sector !== state.storm && + distance(fixture.source, `${to.territory}:${to.sector}`, + key => Number(key.split(':')[1]) === state.storm) <= 1); + assert.ok(destination, 'The original Polar Sink source must have an empty adjacent terrain.'); + const step: PairedE3NexusStep = { actor: fixture.entrant, + action: { type: 'move', from: fixture.source, amount: 1, elite: 0, ...destination } }; + const before = pairedE3NexusReload(state); + const afterMove = stepPairedE3Nexus(before, step); + const settled = settleNativeGuildRateModules(afterMove); + return { before, step, game: settled.game, actions: [step, ...settled.actions], + target: `${destination.territory}:${destination.sector}` }; +} + +/** Original Moritani actually accepts an originally offered Smuggler disc. + * Empty ordinary terrain keeps the printed free counter independent of Guild's + * cancelable rate. The entire group is physical; amount - 1 is priced. */ +export function createNativeGuildRateSmugglerShipment(composed = false, amount = 3) { + const original = createGuildAmbassadorIncomeArrival({ + ...(composed ? NATIVE_GUILD_RATE_MODULE_SELECTIONS.composed : {}), + skills: true, training: { moritani: 'smuggler' }, + }); + const game = original.beforeAction; + const destination = TERRITORIES.find(t => t.type === 'sand' && + t.sectors.some(sector => sector !== game.storm) && + game.players.every(p => presenceAt(p, t.id) === 0)); + assert.ok(destination, 'The original board must retain empty ordinary desert.'); + const sector = destination.sectors.find(s => s !== game.storm)!; + assert.ok(quoteSmugglerShipment(game, original.entrant, destination.id, amount)); + const action: Action = { type: 'ship', territory: destination.id, sector, + amount, elite: 0, allyPayment: 0, smuggler: true }; + return { ...original, destination: destination.id, sector, + target: `${destination.id}:${sector}`, action, amount, route: 'reserve' as const, + provenance: [...original.provenance, + 'Original undealt training order offers Smuggler; Moritani accepts through leaderSkill.', + 'Ordinary reserve shipment into actual empty desert; one printed free physical counter.'] }; +} + +export interface ControlledGuildRateNoFieldCompanion { + beforeAction: Game; entrant: string; guild: string; canceler: string; + karama: string; action: Action; destination: string; sector: number; + target: string; value: 0 | 3 | 5; provenance: string[]; +} + +/** Explicitly CONTROLLED regression, NOT an original action-only producer. + * Existing source performs genuine Richese/Skills training, then conserves and + * stages turn/armies/wallets. Here mutual alliance is also staged and one + * original deck Karama is conserved into Harkonnen custody. The original E3 + * producer cannot include Richese: its original roster/expansion is Ecaz-only. + * No new training, card identity or force is invented. */ +export function createControlledGuildRateNoFieldCompanion( + value: 0 | 3 | 5 = 5, +): ControlledGuildRateNoFieldCompanion { + const game = smugglerNoFieldGame(false); + const entrant = 'r', guild = 'g', canceler = 'h'; + pairedE3NexusPlayer(game, entrant).ally = guild; + pairedE3NexusPlayer(game, guild).ally = entrant; + const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); + const card = game.deck.splice(index, 1)[0]; + pairedE3NexusPlayer(game, canceler).hand.push(card); + const destination = 'wind_pass', sector = 14; + const action: Action = { ...smugglerNoFieldAction(game, value), territory: destination, sector }; + return { beforeAction: pairedE3NexusReload(game), entrant, guild, canceler, + karama: card.id, action, destination, sector, target: `${destination}:${sector}`, value, + provenance: [ + 'CONTROLLED existing smugglerNoFieldGame: genuine training, staged shipment turn/forces/wallets.', + 'CONTROLLED mutual Richese/Guild alliance; physical deck Karama transferred to Harkonnen.', + 'Actual No-Field declaration, response choices, native rate choice and reveal thereafter.', + ] }; +} + +export function declareControlledGuildRateNoFieldCompanion( + fixture: ControlledGuildRateNoFieldCompanion, +) { + const actions: PairedE3NexusStep[] = []; + let game = stepPairedE3Nexus(fixture.beforeAction, + { actor: fixture.entrant, action: fixture.action }, actions); + for (let n = 0; game.response?.kind === 'richeseNoField' && n < 20; n++) { + const next = nextPairedE3NexusStep(game); assert.ok(next); + assert.equal(next.action.type, 'passResponse'); + game = stepPairedE3Nexus(game, next, actions); + } + assert.equal(game.response?.kind, 'guildRate'); + return { game, actions }; +} diff --git a/tests/fixture-paired-e3-nexus-modules.ts b/tests/fixture-paired-e3-nexus-modules.ts index 37a327b7..72dd7b4b 100644 --- a/tests/fixture-paired-e3-nexus-modules.ts +++ b/tests/fixture-paired-e3-nexus-modules.ts @@ -33,6 +33,7 @@ export interface PairedE3NexusOptions { roster?: readonly FactionId[]; advanced?: boolean; skills?: boolean; + training?: Partial>; homeworlds?: boolean; discovery?: boolean; tech?: boolean; @@ -98,7 +99,9 @@ export function advancePairedE3Nexus(g: Game, until: (g: Game) => boolean, * available. Battle choices are not supplied by this traversal. */ export function quietPairedE3NexusChoice(g: Game): PairedE3NexusStep | null { const d = g.decision; if (!d || g.response || g.phaseOpening) return null; - if (['moritaniPlacement', 'ecazPlacement', 'ecazAmbassador', 'moritaniAssassinate', + if (d.kind === 'ecazAmbassador') return { actor: d.player, + action: { type: 'decision', event: g.pendingAmbassador!.event, decline: true } }; + if (['moritaniPlacement', 'ecazPlacement', 'moritaniAssassinate', 'homeworldRevivalDeployment'].includes(d.kind)) return { actor: d.player, action: { type: 'decision', decline: true, ...('event' in d ? { event: d.event } : {}) } }; @@ -135,11 +138,11 @@ function lottery(rolls: readonly number[], operation: () => T): T { else Reflect.deleteProperty(crypto, 'getRandomValues'); } } -function selectedSkills(g: Game): LeaderSkillId[] { +function selectedSkills(g: Game, training?: PairedE3NexusOptions['training']): LeaderSkillId[] { const preferred: Partial> = { ecaz: 'warmaster', moritani: 'master-of-assassins', guild: 'suk-graduate', emperor: 'swordmaster-of-ginaz', atreides: 'bureaucrat', fremen: 'killer-medic', beneGesserit: 'prana-bindu-adept', harkonnen: 'smuggler' }; - return g.players.map(p => preferred[p.faction]!); + return g.players.map(p => training?.[p.faction] ?? preferred[p.faction]!); } function traitorRolls(g: Game, faction: 'guild' | 'ecaz' = 'guild'): number[] { const original = traitorDeck(g.players), loyal = g.advanced ? chooseEcazLoyalty(original, 0xffffffff / 0x100000000) : null; @@ -182,7 +185,7 @@ export function createPairedE3NexusSetup(options: PairedE3NexusOptions = {}): Pa for (const p of g.players) if (!p.ready) g = stepPairedE3Nexus(g, { actor: p.id, action: { type: 'ready' } }, actions); if (options.initial) g = skills ? initializeLeaderSkillsGameForAudit(g) : initializePairedNexusGameForAudit(g); else { - const sourceSkills = LEADER_SKILL_CARDS.map(c => c.id), selected = selectedSkills(g); + const sourceSkills = LEADER_SKILL_CARDS.map(c => c.id), selected = selectedSkills(g, options.training); const rest = sourceSkills.filter(c => !selected.includes(c)); const skillOrder = selected.flatMap(c => [c, rest.shift()!]).concat(rest); const cards = treacheryDeck(['ecaz']), remaining = [...cards]; @@ -203,7 +206,7 @@ export function createPairedE3NexusSetup(options: PairedE3NexusOptions = {}): Pa assert.equal(!!g.leaderSkills, skills); assert.equal(!!g.discoveries, discovery); for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled); assert.ok(g.nexusCards?.cards); - const offered = pairedE3NexusReload(g), selected = selectedSkills(g); + const offered = pairedE3NexusReload(g), selected = selectedSkills(g, options.training); for (let n = 0; g.status === 'setup' && n < 300; n++) { let next: PairedE3NexusStep; if (g.setupStage === 'leaderSkills') { diff --git a/tests/native-guild-homeworld-rate.test.ts b/tests/native-guild-homeworld-rate.test.ts new file mode 100644 index 00000000..c1dad03b --- /dev/null +++ b/tests/native-guild-homeworld-rate.test.ts @@ -0,0 +1,212 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game, type GameView } from '../game/engine'; +import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; +import { homeworldForceGroups } from '../game/homeworld-custody'; +import { quoteHomeworldShipment } from '../game/homeworld-shipment'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options'; +import { junctionTransportChoice } from '../game/junction-transport-options'; +import { + createNativeGuildHomeworldRate, createNativeGuildHomeworldRateScene, + createNativeImmuneJunctionRate, nativeGuildHomeworldAction, + passNativeGuildHomeworldResponses, type NativeGuildHomeworldRateFixture, + createControlledGuildCunningHomeworldRateScene, +} from './fixture-native-guild-homeworld-rate'; +import { pairedE3NexusPlayer as player, stepPairedE3Nexus } from './fixture-paired-e3-nexus-modules'; + +const act = (g: Game, actor: string, action: Action) => applyAction(structuredClone(g), actor, action); +function context(g: Game, nativeGuildRateCanceled?: boolean) { + return { ...homeworldContext(g), nativeGuildRateCanceled, + players: homeworldContext(g).players.map(p => ({ ...p, ally: player(g, p.id).ally })), + storm: g.storm, mobileStronghold: g.mobileStronghold?.location ?? null, + discoveries: g.discoveries, + board: Object.fromEntries(g.players.map(p => [p.id, { forces: p.forces, eliteForces: p.elites?.forces ?? {} }])), + }; +} +function assertUnpaid(f: NativeGuildHomeworldRateFixture, state: Game) { + const before = f.beforeAction; + assert.deepEqual(state.homeworlds, before.homeworlds); + assert.deepEqual(state.aid, before.aid); + for (const id of [f.guild, f.entrant]) { + assert.equal(player(state, id).spice, player(before, id).spice); + assert.equal(player(state, id).reserves, player(before, id).reserves); + assert.deepEqual(player(state, id).forces, player(before, id).forces); + assert.equal(player(state, id).shipped, player(before, id).shipped); + } + assert.ok(!state.pendingArrivalReaction); + assert.ok(!state.pendingAmbassador); +} +function assertPaid(f: NativeGuildHomeworldRateFixture, paid: Game, amount: number, cost: number) { + const before = f.beforeAction; + const old = player(before, f.guild), guild = player(paid, f.guild); + // A Guild shipper pays its own share to the bank. The approved Moritani + // share independently earns the Guild its existing FAQ contributor income. + const settled = passNativeGuildHomeworldResponses(paid, 'guildIncome').game; + assert.equal(player(settled, f.guild).spice, old.spice - cost + 2); + // Pledging already debited the donor wallet before this declaration. + assert.equal(player(settled, f.entrant).spice, player(before, f.entrant).spice); + assert.equal(settled.aid[f.entrant].amount, 0); + assert.equal(guild.shipped, true); + assert.equal(guild.reserves, old.reserves + (f.route === 'return' ? amount : f.route === 'world' ? -amount : 0)); + if (f.route !== 'world') + assert.equal(guild.forces[f.source] ?? 0, old.forces[f.source] - amount); + else assert.deepEqual(guild.forces, old.forces); + if (f.route !== 'return') { + assert.deepEqual(settled.homeworlds!.custody!.visitors[f.destination][f.guild], { normal: amount, elite: 0 }); + } else assert.equal(settled.homeworlds!.custody!.visitors[f.destination]?.[f.guild], undefined); + assert.ok(!settled.pendingHomeworldShipment); + assert.ok(!settled.pendingArrivalReaction); + assert.ok(!settled.pendingAmbassador); + homeworldGameIntegrity(settled); + const worlds = homeworldForceGroups(context(settled), settled.homeworlds!.custody!); + const physical = Object.values(player(settled, f.guild).forces).reduce((n, count) => n + count, 0) + + worlds.reduce((n, w) => n + (w.forces[f.guild]?.normal ?? 0) + (w.forces[f.guild]?.elite ?? 0), 0); + assert.equal(physical, 20); + return settled; +} + +for (const route of ['world', 'arrakisForeign', 'return'] as const) { + + void test(`original native Homeworld ${route}: actual auctioned Ecaz Karama precedes every debit and physical transfer`, () => { + const scene = createNativeGuildHomeworldRateScene(route); + assertUnpaid(scene, scene.game); + assert.equal(scene.game.pendingHomeworldShipment?.cost, 2); + assert.equal(scene.game.pendingHomeworldShipment?.allyPayment, 1); + assert.deepEqual(scene.game.pendingHomeworldShipment?.sources, scene.sources); + const stored = structuredClone(scene.game); + assert.throws(() => act(stored, scene.entrant, scene.cancel.action)); + assert.deepEqual(stored, scene.game); + const canceled = stepPairedE3Nexus(scene.game, scene.cancel); + const settled = assertPaid(scene, canceled, 3, 3); + assert.equal(settled.discard.filter(c => c.id === scene.karama).length, 1); + assert.throws(() => act(settled, scene.guild, scene.action)); + const next = act(settled, scene.guild, { type: 'endMovement' }); + assert.notEqual(next.active, scene.guild); + assert.deepEqual(player(next, scene.guild).forces, player(settled, scene.guild).forces); + }); + + void test(`original native Homeworld ${route}: passing preserves the rounded half tariff and original approved split`, () => { + const scene = createNativeGuildHomeworldRateScene(route); + assertUnpaid(scene, scene.game); + const allowed = passNativeGuildHomeworldResponses(scene.game, 'guildRate').game; + const settled = assertPaid(scene, allowed, 3, 2); + assert.ok(player(settled, scene.ecaz).hand.some(c => c.id === scene.karama)); + }); + + void test(`original native Homeworld ${route}: unaffordable cancellation releases unchanged typed custody and supports a full-price replacement`, () => { + const scene = createNativeGuildHomeworldRateScene(route, { unaffordable: true }); + assert.equal(player(scene.beforeAction, scene.guild).spice, 1); + assertUnpaid(scene, scene.game); + const released = stepPairedE3Nexus(scene.game, scene.cancel); + assertUnpaid(scene, released); + assert.ok(!released.pendingHomeworldShipment); + assert.equal(released.response, null); + assert.equal(released.decision, null); + assert.equal(viewGame(released, scene.guild).guildRateCanceled, true); + assert.equal(viewGame(released, scene.ecaz).guildRateCanceled, false); + const view = viewGame(released, scene.guild); + const consumer = route === 'world' ? homeworldShipmentChoice : guildHomeworldShipmentChoice; + const unchanged = consumer(view, scene.destination, scene.sources, 1); + assert.equal(unchanged.cost, 3); + assert.equal(unchanged.ownPayment, 2); + assert.ok(unchanged.blocked); + const action = nativeGuildHomeworldAction(scene, released, 2, 1); + assert.deepEqual(action.sources, { [scene.source]: { normal: 2, elite: 0 } }); + let replacement = act(released, scene.guild, action); + if (replacement.decision?.kind === 'homeworldShipmentGuild') + replacement = act(replacement, scene.guild, { type: 'decision', event: replacement.decision.event, allow: true }); + assert.notEqual(replacement.response?.kind, 'guildRate'); + const settled = assertPaid(scene, replacement, 2, 2); + assert.equal(settled.discard.filter(c => c.id === scene.karama).length, 1); + }); +} + +void test('non-Guild world-to-world remains full price; controlled Secret Ally owner quote stays independent of native cancellation', () => { + const f = createNativeGuildHomeworldRate('world'), g = f.beforeAction; + const intent = { player: f.ecaz, destination: 'homeworld:moritani', + sources: { 'homeworld:ecaz': { normal: 3, elite: 0 } } }; + assert.equal(quoteHomeworldShipment(context(g, true), g.homeworlds!.custody!, intent).cost, 3); + // Narrow controlled presentation fixture, not an original action producer: + // Secret Ally legally requires Guild absent, no alliance, and an owned card. + const original = viewGame(g, f.ecaz); + const event = JSON.stringify(['nexusGuildSecretAlly', original.turn, f.ecaz]); + const view = { ...original, active: f.ecaz, guildRateCanceled: true, + players: original.players.filter(p => p.id !== f.guild).map(p => ({ ...p, ally: null, shipped: false })), + homeworlds: { ...original.homeworlds!, worlds: original.homeworlds!.worlds!.filter(w => w.native !== f.guild) }, + homeworldShipment: { event: 'controlled-secret-world-quote', blocked: null }, + nexusCards: { ...original.nexusCards!, card: 'guild', waiting: [] }, + nexusGuildSecretAlly: { event, blocked: null }, + } as GameView; + const ordinary = homeworldShipmentChoice(view, intent.destination, intent.sources); + const secret = homeworldShipmentChoice(view, intent.destination, intent.sources, 0, event); + assert.equal(ordinary.cost, 3); + assert.equal(secret.cost, 2); + assert.equal(secret.blocked, null); +}); + +for (const rate of ['half', 'full'] as const) { + void test(`original immune Junction ${rate} offer after actual native cancellation keeps its printed tariff and no second rate queue`, () => { + const f = createNativeImmuneJunctionRate(rate), before = f.beforeAction; + const cost = rate === 'half' ? 2 : 3; + assert.equal(viewGame(before, f.entrant).guildRateCanceled, true); + const view = viewGame(before, f.entrant); + assert.equal(junctionTransportChoice(view, f.destination, f.sources).cost, cost); + const done = act(before, f.entrant, f.action); + assert.notEqual(done.response?.kind, 'guildRate'); + assert.equal(done.decision, null); + assert.ok(!done.pendingHomeworldShipment); + assert.equal(player(done, f.entrant).spice, player(before, f.entrant).spice - cost); + assert.equal(player(done, f.guild).spice, player(before, f.guild).spice); + assert.equal(player(done, f.entrant).reserves, player(before, f.entrant).reserves + 3); + assert.equal(player(done, f.entrant).forces[f.source], player(before, f.entrant).forces[f.source] - 3); + assert.equal(player(done, f.entrant).shipped, true); + assert.equal(done.discard.filter(c => c.id === f.karama).length, 1); + homeworldGameIntegrity(done); + }); +} + +for (const outcome of ['allow', 'cancel', 'unaffordable'] as const) { + void test(`controlled Cunning Homeworld ${outcome}: the native rate retains one original second-shipment allowance`, () => { + const f = createControlledGuildCunningHomeworldRateScene(outcome === 'unaffordable'); + const before = f.beforeAction, record = before.nexusGuildCunningHistory!.at(-1)!; + assert.equal(record.stage, 'secondShipment'); + assert.equal(record.shipment, undefined); + assert.deepEqual(f.game.homeworlds, before.homeworlds); + assert.equal(player(f.game, f.owner).spice, player(before, f.owner).spice); + assert.equal(player(f.game, f.owner).reserves, player(before, f.owner).reserves); + assert.equal(f.game.nexusGuildCunningHistory!.at(-1)!.shipment?.stage, 'pending'); + assert.equal(f.game.pendingHomeworldShipment?.guildNexusEvent, record.receipt.event); + let done = outcome === 'allow' + ? passNativeGuildHomeworldResponses(f.game, 'guildRate').game + : stepPairedE3Nexus(f.game, f.cancel); + if (outcome === 'unaffordable') { + assert.equal(player(done, f.owner).spice, 2); + assert.deepEqual(done.homeworlds, before.homeworlds); + assert.equal(player(done, f.owner).reserves, player(before, f.owner).reserves); + assert.equal(done.nexusGuildCunningHistory!.at(-1)!.stage, 'secondShipment'); + assert.equal(done.nexusGuildCunningHistory!.at(-1)!.shipment, undefined); + assert.ok(!done.pendingHomeworldShipment); + const choice = homeworldShipmentChoice(viewGame(done, f.owner), f.destination, + { 'homeworld:guild': { normal: 2, elite: 0 } }, 0); + assert.equal(choice.cost, 2); + assert.ok(choice.action); + done = act(done, f.owner, choice.action); + assert.notEqual(done.response?.kind, 'guildRate'); + } + const amount = outcome === 'unaffordable' ? 2 : 3; + const cost = outcome === 'cancel' ? 3 : 2; + assert.equal(player(done, f.owner).spice, player(before, f.owner).spice - cost); + assert.equal(player(done, f.owner).reserves, player(before, f.owner).reserves - amount); + assert.deepEqual(done.homeworlds!.custody!.visitors[f.destination][f.owner], { normal: amount, elite: 0 }); + assert.equal(done.nexusGuildCunningHistory!.at(-1)!.shipment?.stage, 'complete'); + assert.equal(done.nexusGuildCunningHistory!.at(-1)!.receipt.event, record.receipt.event); + assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1); + assert.equal(done.discard.filter(card => card.id === f.karama).length, outcome === 'allow' ? 0 : 1); + const another = homeworldShipmentChoice(viewGame(done, f.owner), f.destination, + { 'homeworld:guild': { normal: 1, elite: 0 } }, 0); + assert.equal(another.action, null); + assert.ok(another.blocked); + homeworldGameIntegrity(done); + }); +} diff --git a/tests/native-guild-rate-betrayal.test.ts b/tests/native-guild-rate-betrayal.test.ts new file mode 100644 index 00000000..1cc32c36 --- /dev/null +++ b/tests/native-guild-rate-betrayal.test.ts @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, type Game } from '../game/engine'; +import { createGuildBetrayalFixture, settleGuildBetrayalFixture } from './fixture-nexus-guild-betrayal'; +const player = (g: Game, id: string) => g.players.find(p => p.id === id)!; + +for (const source of ['reserve', 'guildTransport', 'homeworld'] as const) { + for (const use of [false, true]) { + void test(`native ${source} full-price cancellation survives deferred Guild Betrayal ${use ? 'Use' : 'Pass'} until physical settlement`, () => { + const f = createGuildBetrayalFixture({ source, payer: 'guild', homeworlds: source === 'homeworld' }); + const before = structuredClone(f.before); + // Explicit controlled extension of the existing native source fixture: + // conserve a real undealt Karama into the future invoice holder's hand. + const index = before.deck.findIndex(card => card.effect === 'karama'); + assert.ok(index >= 0); + const card = before.deck.splice(index, 1)[0]; + player(before, f.holder).hand.push(card); + let g = applyAction(structuredClone(before), f.guild, f.declaration); + if (g.decision?.kind === 'guildShipment' || g.decision?.kind === 'homeworldShipmentGuild') + g = applyAction(g, f.guild, { type: 'decision', allow: true, + ...(g.decision.kind === 'homeworldShipmentGuild' ? { event: g.decision.event } : {}) }); + assert.equal(g.response?.kind, 'guildRate'); + const amount = g.pendingShipment?.amount ?? g.pendingGuildTransport?.amount ?? g.pendingHomeworldShipment!.amount; + assert.ok(amount > 1); + assert.ok(player(before, f.guild).spice >= amount); + assert.equal(player(g, f.guild).spice, player(before, f.guild).spice); + g = applyAction(g, f.holder, { type: 'card', mode: 'cancel', card: card.id }); + const frame = g.pendingGuildBetrayal!; + assert.ok(frame); + assert.equal(frame.invoice.price, amount); + assert.equal(player(g, f.guild).spice, player(before, f.guild).spice); + assert.deepEqual(player(g, f.guild).forces, player(before, f.guild).forces); + assert.equal(player(g, f.guild).reserves, player(before, f.guild).reserves); + assert.equal(player(g, f.guild).shipped, false); + assert.deepEqual(g.homeworlds, before.homeworlds); + if (use) g = applyAction(g, f.holder, { type: 'guildBetrayalUse', event: frame.invoice.event }); + g = settleGuildBetrayalFixture(g); + assert.equal(player(g, f.guild).spice, player(before, f.guild).spice - amount); + assert.equal(player(g, f.holder).spice, player(before, f.holder).spice + (use ? amount : 0)); + assert.equal(player(g, f.guild).shipped, true); + assert.deepEqual(player(g, f.guild).forces, player(f.original, f.guild).forces); + assert.equal(player(g, f.guild).reserves, player(f.original, f.guild).reserves); + assert.deepEqual(g.homeworlds, f.original.homeworlds); + assert.equal(g.guildBetrayal?.status, 'completed'); + assert.ok(!g.pendingGuildBetrayal); + assert.equal(g.discard.filter(c => c.id === card.id).length, 1); + assert.throws(() => applyAction(structuredClone(g), f.holder, + { type: 'guildBetrayalUse', event: frame.invoice.event })); + }); + } +} diff --git a/tests/native-guild-rate-modules.test.ts b/tests/native-guild-rate-modules.test.ts new file mode 100644 index 00000000..dfbaac8a --- /dev/null +++ b/tests/native-guild-rate-modules.test.ts @@ -0,0 +1,192 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { presenceAt } from '../game/force-presence'; +import { quoteSmugglerShipment } from '../game/smuggler-shipment'; +import { conserveNoFieldForces } from './smuggler-no-field-fixture'; +import { + pairedE3NexusInventory, pairedE3NexusPlayer as player, +} from './fixture-paired-e3-nexus-modules'; +import { + allowNativeGuildRateModules, cancelNativeGuildRateModules, + createControlledGuildRateNoFieldCompanion, createNativeGuildRateModules, + createNativeGuildRateSmugglerShipment, declareControlledGuildRateNoFieldCompanion, + declareNativeGuildRateModules, moveAfterNativeGuildRateModules, + settleNativeGuildRateModules, + type NativeGuildRateModuleSelection, type NativeGuildRateRoute, +} from './fixture-native-guild-rate-modules'; + +const act = (game: Game, actor: string, action: Action) => + applyAction(structuredClone(game), actor, action); +function reject(game: Game, actor: string, action: Action) { + const copy = structuredClone(game), before = structuredClone(copy); + assert.throws(() => applyAction(copy, actor, action)); + assert.deepEqual(copy, before); +} +function assertUnpaid(before: Game, offered: Game, entrant: string, guild: string) { + for (const actor of [entrant, guild]) { + const old = player(before, actor), current = player(offered, actor); + assert.equal(current.spice, old.spice); + assert.equal(current.reserves, old.reserves); + assert.deepEqual(current.forces, old.forces); + assert.equal(current.shipped, old.shipped); + assert.equal(current.moved, old.moved); + } +} +function assertPhysical(fixture: { + entrant: string; guild: string; source: string; target: string; + route: NativeGuildRateRoute; amount: number; +}, before: Game, done: Game, cost: number) { + const old = player(before, fixture.entrant), current = player(done, fixture.entrant); + assert.equal(current.spice, old.spice - cost); + assert.equal(player(done, fixture.guild).spice, player(before, fixture.guild).spice + cost); + assert.equal(current.reserves, old.reserves - (fixture.route === 'reserve' ? fixture.amount : 0)); + assert.equal(current.forces[fixture.source], + old.forces[fixture.source] - (fixture.route === 'transport' ? fixture.amount : 0)); + assert.equal(current.forces[fixture.target], (old.forces[fixture.target] ?? 0) + fixture.amount); + assert.equal(current.shipped, true); + assert.equal(current.moved, old.moved); + assert.equal(current.reserves + current.tanks + Object.values(current.forces).reduce((n, amount) => n + amount, 0), + old.reserves + old.tanks + Object.values(old.forces).reduce((n, amount) => n + amount, 0)); +} + +// Each standalone selection contributes one real route/outcome. The two +// composed examples cover both routes without claiming an option matrix. +const cases: { selection: NativeGuildRateModuleSelection; route: NativeGuildRateRoute; cancel: boolean }[] = [ + { selection: 'skills', route: 'reserve', cancel: true }, + { selection: 'discovery', route: 'transport', cancel: false }, + { selection: 'tech', route: 'reserve', cancel: false }, + { selection: 'strongholds', route: 'transport', cancel: true }, + { selection: 'composed', route: 'reserve', cancel: false }, + { selection: 'composed', route: 'transport', cancel: true }, +]; +for (const { selection, route, cancel } of cases) { + void test(`original ${selection} ${route}: ${cancel ? 'cancel' : 'allow'} rate, pay and transport once, then native movement`, () => { + const f = createNativeGuildRateModules(selection, route), before = f.beforeAction; + assert.equal(before.turn, 2); + assert.equal(before.phase, 5); + assert.equal(before.active, f.entrant); + assert.equal(player(before, f.entrant).ally, f.guild); + if (selection === 'skills' || selection === 'composed') { + assert.ok(before.leaderSkills?.assignments.length); + assert.ok(f.actions.some(step => step.action.type === 'leaderSkill')); + } + if (selection === 'discovery' || selection === 'composed') assert.ok(before.discoveries?.tokens.length); + if (selection === 'tech' || selection === 'composed') assert.ok(before.techTokens); + if (selection === 'strongholds' || selection === 'composed') assert.ok(before.strongholdCards); + const offered = declareNativeGuildRateModules(f).game; + assertUnpaid(before, offered, f.entrant, f.guild); + assert.ok(viewGame(offered, f.ecaz).responseControls?.cancelCards.includes(f.karama)); + reject(offered, f.entrant, f.action); + const priced = cancel + ? cancelNativeGuildRateModules(offered, f.ecaz, f.karama).game + : allowNativeGuildRateModules(offered).game; + const cost = cancel ? 3 : 2; + // A still-held Karama preserves the separate income response. After the + // canceling card is spent, an unanswerable income window may auto-resolve. + if (!cancel) assert.equal(priced.response?.kind, 'guildIncome'); + assert.equal(player(priced, f.entrant).spice, player(before, f.entrant).spice - cost); + assert.equal(player(priced, f.guild).spice, player(before, f.guild).spice + + (priced.response?.kind === 'guildIncome' ? 0 : cost)); + const done = settleNativeGuildRateModules(priced).game; + assertPhysical(f, before, done, cost); + assert.equal(done.discard.filter(card => card.id === f.karama).length, cancel ? 1 : 0); + assert.equal(player(done, f.ecaz).hand.some(card => card.id === f.karama), !cancel); + reject(done, f.entrant, f.action); + const moved = moveAfterNativeGuildRateModules(f, done); + assert.equal(player(moved.game, f.entrant).moved, player(done, f.entrant).moved + 1); + assert.equal(player(moved.game, f.entrant).forces[f.source], player(done, f.entrant).forces[f.source] - 1); + assert.equal(player(moved.game, f.entrant).forces[moved.target], 1); + assert.equal(player(moved.game, f.entrant).spice, player(done, f.entrant).spice); + assert.equal(player(moved.game, f.guild).spice, player(done, f.guild).spice); + assert.equal(player(moved.game, f.entrant).reserves, player(done, f.entrant).reserves); + assert.equal(player(moved.game, f.entrant).shipped, true); + reject(moved.game, f.entrant, moved.step.action); + const next = act(moved.game, f.entrant, { type: 'endMovement' }); + assert.notEqual(next.active, f.entrant); + assert.equal(player(next, f.entrant).spice, player(moved.game, f.entrant).spice); + assert.deepEqual(player(next, f.entrant).forces, player(moved.game, f.entrant).forces); + pairedE3NexusInventory(moved.game); + }); +} + +for (const cancel of [false, true]) { + void test(`original ${cancel ? 'composed' : 'Skills'} Smuggler reserve: ${cancel ? 'canceling' : 'allowing'} Guild rate prices two paid counters and transports all three`, () => { + const f = createNativeGuildRateSmugglerShipment(cancel), before = f.beforeAction; + assert.ok(before.players.every(p => presenceAt(p, f.destination) === 0)); + const trainer = before.leaderSkills!.assignments.find(a => a.owner === f.entrant && a.skill === 'smuggler'); + assert.ok(trainer); + assert.ok(f.actions.some(step => step.actor === f.entrant && step.action.type === 'leaderSkill' && + step.action.skill === 'smuggler' && step.action.leader === trainer.leader)); + assert.deepEqual(quoteSmugglerShipment(before, f.entrant, f.destination, 3), { leader: trainer.leader, amount: 3 }); + const offered = declareNativeGuildRateModules(f).game; + assertUnpaid(before, offered, f.entrant, f.guild); + const priced = cancel + ? cancelNativeGuildRateModules(offered, f.ecaz, f.karama).game + : allowNativeGuildRateModules(offered).game; + const done = settleNativeGuildRateModules(priced).game; + // Desert ordinary full price is two per PAID counter: (3 - 1) * 2. + // Charging all three would incorrectly cost six after cancellation. + assertPhysical(f, before, done, cancel ? 4 : 2); + assert.equal(done.discard.filter(card => card.id === f.karama).length, cancel ? 1 : 0); + reject(done, f.entrant, f.action); + pairedE3NexusInventory(done); + }); +} + +void test('original Smuggler canceled rate retains the free counter on an affordable full-price replacement, with no debt or replay', () => { + const f = createNativeGuildRateSmugglerShipment(true); + const before = act(f.beforeAction, f.entrant, { type: 'bribe', target: f.ecaz, + amount: player(f.beforeAction, f.entrant).spice - 3 }); + assert.equal(player(before, f.entrant).spice, 3); + const offered = declareNativeGuildRateModules({ ...f, beforeAction: before }).game; + const released = cancelNativeGuildRateModules(offered, f.ecaz, f.karama).game; + assertUnpaid(before, released, f.entrant, f.guild); + assert.equal(player(released, f.entrant).shipped, false); + assert.equal(viewGame(released, f.entrant).guildRateCanceled, true); + assert.equal(released.discard.filter(card => card.id === f.karama).length, 1); + reject(released, f.entrant, f.action); + const replacement: Action = { ...f.action, amount: 2 }; + let paid = act(released, f.entrant, replacement); + if (paid.decision?.kind === 'guildShipment') paid = act(paid, f.guild, { type: 'decision', allow: true }); + assert.notEqual(paid.response?.kind, 'guildRate'); + const done = settleNativeGuildRateModules(paid).game; + assertPhysical({ ...f, amount: 2 }, before, done, 2); + assert.equal(player(done, f.entrant).spice, 1); + assert.equal(done.discard.filter(card => card.id === f.karama).length, 1); + reject(done, f.entrant, replacement); +}); + +// Bounded existing controlled Richese source, deliberately separate from the +// original-action producers above. Hidden values are not a pricing parameter. +for (const value of [0, 5] as const) { + void test(`CONTROLLED genuine-trained No-Field ${value}: rate cancellation charges one marker and leaves its one physical Smuggler companion free`, () => { + const f = createControlledGuildRateNoFieldCompanion(value), before = f.beforeAction; + const offered = declareControlledGuildRateNoFieldCompanion(f).game; + assertUnpaid(before, offered, f.entrant, f.guild); + const priced = cancelNativeGuildRateModules(offered, f.canceler, f.karama).game; + const done = settleNativeGuildRateModules(priced).game; + assert.equal(player(done, f.entrant).spice, player(before, f.entrant).spice - 2); + assert.equal(player(done, f.guild).spice, player(before, f.guild).spice + 2); + assert.equal(player(done, f.entrant).reserves, player(before, f.entrant).reserves - 1); + assert.equal(player(done, f.entrant).forces[f.target], 1); + assert.equal(presenceAt(player(done, f.entrant), f.destination), 2); + assert.equal(player(done, f.entrant).noField!.deployed!.tokenId, f.action.noField); + assert.equal(player(done, f.entrant).shipped, true); + assert.equal(done.discard.filter(card => card.id === f.karama).length, 1); + // Consumer-visible privacy: opponents still see only the concealed marker, + // not its physical token identity or declaration proof. + const otherView = JSON.stringify(viewGame(done, f.canceler)); + assert.equal(otherView.includes(String(f.action.noField)), false); + assert.equal(otherView.includes('noFieldSkillProof'), false); + reject(done, f.entrant, f.action); + const revealed = act(done, f.entrant, { type: 'revealNoField', + event: player(done, f.entrant).noFieldEvent, token: f.action.noField }); + assert.equal(player(revealed, f.entrant).forces[f.target], value + 1); + assert.equal(player(revealed, f.entrant).reserves, player(before, f.entrant).reserves - value - 1); + assert.equal(player(revealed, f.entrant).spice, player(done, f.entrant).spice); + assert.equal(player(revealed, f.guild).spice, player(done, f.guild).spice); + conserveNoFieldForces(revealed); + reject(revealed, f.entrant, { type: 'card', mode: 'cancel', card: f.karama }); + }); +}