- {g.guildRateCanceled
- ? 'The native Guild rate is canceled: pay one spice per force.'
- : 'Pay one spice per two forces, rounded up.'}{' '}
+ {g.karamaShipping?.player === g.me
+ ? 'Purchased Karama price: one spice per two forces, rounded up, paid entirely to the bank even if the native Guild rate is canceled.'
+ : 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 && (
diff --git a/components/homeworld-shipment.tsx b/components/homeworld-shipment.tsx
index 0bd05063..2c8ea580 100644
--- a/components/homeworld-shipment.tsx
+++ b/components/homeworld-shipment.tsx
@@ -29,6 +29,7 @@ export function HomeworldShipment({
const targets = worlds.filter(
(world) =>
(world.native !== g.me || me.faction === 'guild') &&
+ !(g.guildTransportCanceled && me.faction === 'guild' && world.native === me.id) &&
world.native !== me.ally,
);
const [origin, setOrigin] = useState(available[0]?.id ?? '');
@@ -39,7 +40,9 @@ export function HomeworldShipment({
const [allyPayment, setAllyPayment] = useState(0);
const [nexusEvent, setNexusEvent] = useState('');
const useNexus =
- !!g.nexusGuildSecretAlly && nexusEvent === g.nexusGuildSecretAlly.event;
+ g.karamaShipping?.player !== g.me &&
+ !!g.nexusGuildSecretAlly &&
+ nexusEvent === g.nexusGuildSecretAlly.event;
const option = g.homeworldShipment;
if (!option) return null;
const imperial = g.advanced && me.faction === 'emperor';
@@ -71,18 +74,22 @@ export function HomeworldShipment({
Use your shipment to send forces from one Homeworld to another. Native
reserves and foreign garrisons have separate source pools.{' '}
{me.faction === 'guild'
- ? 'Guild may also return a foreign garrison to Junction.'
+ ? g.guildTransportCanceled
+ ? 'The native Guild return permission is canceled: Junction cannot be a destination, even with a purchased price.'
+ : 'Guild may also return a foreign garrison to Junction.'
: 'Your own Homeworld cannot be a destination.'}{' '}
Allied Homeworlds cannot be destinations.
{useNexus
? 'Selected Guild Nexus price: one spice per two forces, rounded up.'
- : me.faction === 'guild'
- ? 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.'}{' '}
+ : g.karamaShipping?.player === g.me
+ ? 'Purchased Karama price: one spice per two forces, rounded up, paid entirely to the bank even if the native Guild rate is canceled.'
+ : me.faction === 'guild'
+ ? 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.
{g.nexusGuildSecretAlly && (
@@ -91,7 +98,11 @@ export function HomeworldShipment({
setNexusEvent(
event.target.checked ? g.nexusGuildSecretAlly!.event : '',
@@ -101,7 +112,8 @@ export function HomeworldShipment({
Use Guild Nexus Secret Ally for Homeworld shipment
- Spend the Nexus to pay one spice per two forces, rounded up.
+ Spend the Nexus for its independent price of one spice per two
+ forces, rounded up; it does not stack with a purchased Karama price.
Existing source and destination restrictions still apply.
{g.nexusGuildSecretAlly.blocked && (
diff --git a/components/richese-allied-no-field.tsx b/components/richese-allied-no-field.tsx
index 2133c4c4..17c5d9f0 100644
--- a/components/richese-allied-no-field.tsx
+++ b/components/richese-allied-no-field.tsx
@@ -3,7 +3,6 @@
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { territory, MOBILE_STRONGHOLD } from '@/game/board';
-import { alliedNoFieldShipmentPricing } from '@/game/shipment-price';
import { Button } from './ui/button';
import { Input } from './ui/input';
@@ -28,7 +27,7 @@ export function RicheseAlliedNoFieldControls({
const info = game.richeseNoField;
const me = game.players.find((player) => player.id === game.me);
const ally = game.players.find((player) => player.id === me?.ally);
- if (!info || info.owner !== game.me || !info.private || !me || !ally)
+ if (!info || info.owner !== game.me || !info.private || !info.allyPricing || !me || !ally)
return null;
const offer = info.allyOffer;
if (offer?.blocked)
@@ -57,7 +56,7 @@ export function RicheseAlliedNoFieldControls({
const selected =
available.find((candidate) => candidate.id === token) ?? available[0];
const target = territory(destination);
- const pricing = alliedNoFieldShipmentPricing(game, ally);
+ const pricing = info.allyPricing;
const cost = target.type === 'stronghold' ? pricing.stronghold : pricing.other;
const selectedPayer =
payer === 'both' && cost === 2
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index c8e84c72..d3d64d66 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -29,6 +29,31 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
+**Separate transport permission, purchased typed price and allied marker — 15 October.**
+Native cross/return now has its own unpaid counter before price. Denial
+preserves legal alternatives and half price; the bounded original-opportunity
+scope is provisional and does not remove a signed Cunning second opportunity
+or immune Junction permission. Purchased ordinary Karama halves original
+typed routes without granting a route and sends the whole invoice to bank.
+Existing special-stop returns the original purchase card under its prior
+provisional policy. Allied marker cancellation never charges materialized
+zero/five forces or silently increases a supplier’s approved whole fee.
+
+Five actual390px journeys pass, including fresh full2 supplier reoffer/consent.
+That surface caught and fixed lender UI/AI quote drift using an authorized
+recipient-specific price descriptor, not private wallet data. Twenty main
+runtime/policy paths pass (15 original-action/five controlled), plus four
+controlled optional-offer/recipient continuations. No overflow/application
+exception; one initial Vite dependency fetch aborted. Only the owned
+memory-only QA engine reloaded, retaining its exact synthetic states.
+Purchased-price/Junction sponsorship remains a separately tracked source
+composition; full combinations, assurance, public starts and full goal remain open.
+[Current authority and explicit interpretation](GUILD_RATE_KARAMA_RULES.md#permission-purchase-and-allied-marker-composition--15-october-2026).
+Affected25-file263/263 passes across frozen237+26 selections; final saved/
+deployed assurance is deliberately not claimed by these source checks.
+Final types/lint/build and repeated20+4 source smoke pass; existing build
+warnings remain. Current links and both partial five-facet checklists resolve.
+
**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
diff --git a/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md b/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md
index fafc4991..4da09ac2 100644
--- a/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md
+++ b/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md
@@ -2,6 +2,32 @@
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.
+## Separate permission and purchased price — 15 October 2026
+
+Native Guild Arrakis-to-world and native own-world returns now have an
+unpaid transport-permission response before the separate rate counter.
+Denial preserves exact sources, funds and legal alternatives; a purchased
+price never restores the denied route. Common world-to-foreign-world
+permission remains independently available. Junction sponsorship keeps its
+immune Homeworld source rather than using native cross/return permission.
+
+The supplied authorized Advanced Karama section, PDF physical16, permits
+any player’s Force Shipment at half normal price, paid to bank. Both typed
+quotes, actual controls and minimal budgets now honor that independent
+price and do not stack it with explicit Guild Nexus. Whole donor/own payment
+goes to bank, with no ordinary Guild receipt. Original manual/automatic
+interception and exact typed custody remain. Special-stop returns the
+original purchase card under the existing provisional reserve-stop policy;
+actual completion consumes its grant and preserves movement.
+
+Actual390px original controls gift the auctioned Ecaz card to Moritani,
+ship three from Grumman to Ecaz for two bank spice, preserve Guild6 and
+finish the original turn. A separate original typed return allows native
+permission, then cancels rate: fee3, approved donor1, Guild7→6 after existing
+donor income, Junction15→18 and Tuek5→2. Controlled second-card special-stop
+instead returns the original gifted card without any departure/payment.
+[Current authority, policy distinction and remaining sponsored-price gate](GUILD_RATE_KARAMA_RULES.md#permission-purchase-and-allied-marker-composition--15-october-2026).
+
## Native rate cancellation runtime — 15 October 2026
Original native Guild world-to-world, Arrakis-to-foreign-world and Junction
diff --git a/docs/GUILD_RATE_KARAMA_RULES.md b/docs/GUILD_RATE_KARAMA_RULES.md
index 81a1d2d7..6a89d201 100644
--- a/docs/GUILD_RATE_KARAMA_RULES.md
+++ b/docs/GUILD_RATE_KARAMA_RULES.md
@@ -2,6 +2,69 @@
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.
+## Permission purchase and allied marker composition — 15 October 2026
+
+Three distinct sources now remain distinct end to end:
+
+- **Native cross/return permission:** a separate `guildTransportPermission`
+ response holds the exact original group before payment/departure. Denial
+ leaves a legal different shipment or movement and does not cancel native
+ half price. The conservative **provisional** interpretation prevents
+ immediate cross/return redeclaration for that original shipment opportunity;
+ a signed Cunning second opportunity is distinct. It is not a new publisher
+ example or a ban on an independent Junction Homeworld permission.
+- **Purchased ordinary Karama price:** the authorized supplied Advanced
+ rulebook, **PDF physical page16, Karama Cards**, says “Purchase any player’s
+ Force Shipment at Guild rates (½ normal), paid to the Spice Bank.”
+ Original typed world-to-world and independently eligible Guild Arrakis/
+ return routes now use that source without granting new transport rights.
+ The entire approved invoice goes to bank, including donor shares; native
+ rate cancellation cannot erase the independent card. Actual completion
+ consumes only its grant. Guild special-stop reuses the older provisional
+ policy: return the exact physical purchase card to its original owner,
+ clear that grant, use shipment, and preserve movement/resources.
+- **Allied Richese marker price:** charge one paid marker, never materialized
+ zero/five forces. A recipient-funded half1 can become full2; a supplier’s
+ original whole-price approval1 cannot silently become2, even when wealthy.
+ Return it unchanged and require a new explicit full-price offer/consent.
+ The existing Guild-recipient bank-only payee precedence remains provisional.
+
+Selected prepayment composition is original Guild special interception →
+native transport permission → native rate → original settlement. Common
+Guild world-to-foreign-world permission does not depend on cross/return and
+has no extra permission counter. This ordering is a bounded product choice,
+not a newly located comprehensive timing FAQ. Legacy rate-only receipts keep
+their absent-field signature and do not reopen an earlier permission stage.
+
+Mobile smoke caught stale lender UI/AI pricing: a lender’s own view could not
+infer its recipient’s canceled rate. Both now use the same authorized
+recipient-specific `richeseNoField.allyPricing` quote. Canonical pricing takes
+explicit source booleans rather than accepting an ambiguous whole Game/View;
+no private recipient wallet is added.
+
+Twenty main runtime/policy paths pass:15 original-action and five explicitly
+controlled. Four additional controlled planning probes permit optional
+non-reoffer and prove executable recipient continuations, not strategic tuning.
+Five actual styled390px journeys cover prevention plus immune Junction,
+actual gifted world price, fresh supplier full2 consent, controlled special
+stop/refund, and separate typed permission allowance then rate cancellation.
+No overflow/application exception; one initial Vite dependency request aborted.
+The owned memory-only QA reload retained exact synthetic states; no real room,
+database, human server, deployed revision or publication gate changed.
+
+An already-purchased price combined with a Junction-sponsored offer retains
+its prior separate-composition gate and is tracked next. Other specialized
+sources, complete combinations and final assurance remain unfinished.
+[Source authority](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026),
+[loan policy and consent](RICHESE_ALLIED_SHIPMENT_AUDIT.md).
+Affected25-file **263/263** passes across the frozen237-case related selection
+and26 additional module/typed producers. This is targeted rules evidence,
+not a full suite, recovery campaign or complete-mode certification.
+Final `npm run check:quick` and production build pass; the20 main runtime
+paths and four controlled continuation probes pass again on the final source.
+Existing module-register/chunk/Unknown-route warnings remain. Eleven current
+local links and both five-facet evidence sets resolve.
+
## Native price-source composition — 15 October 2026
Native rate eligibility is now determined by the original price/source,
diff --git a/docs/GUILD_SHIPMENT_PAYMENTS.md b/docs/GUILD_SHIPMENT_PAYMENTS.md
index 09ce0b10..1e84f21b 100644
--- a/docs/GUILD_SHIPMENT_PAYMENTS.md
+++ b/docs/GUILD_SHIPMENT_PAYMENTS.md
@@ -2,6 +2,16 @@
Integrated checkpoint, 2026-09-07 local date. This closes the contributor-routing defect and adds transport price controls; ordinary rate/permission Karama cancellation and nonbattle Truthtrance commitments remain incomplete.
+## Distinct permission and independent typed pricing — 15 October 2026
+
+Native cross/return permission now has its own unpaid counter before native
+rate. Ordinary purchased Karama price remains independent on original typed
+routes, with the whole approved donor/own invoice paid to bank and no ordinary
+Guild income. Original special-stop card-return policy is reused rather than
+leaving a spent grant active. Allied marker reapproval and authoritative fresh
+supplier/AI prices preserve each original approval without reading other wallets.
+[Current source contract and explicit remaining Junction/purchased-price gate](GUILD_RATE_KARAMA_RULES.md#permission-purchase-and-allied-marker-composition--15-october-2026).
+
## Native rate admission follow-up — 15 October 2026
The separate prepayment **rate** window now also admits expansion/Nexus native
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index e795ae5d..d9976888 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,67 @@
# Dune implementation status
+## 15 October 2026 — Separate native permission, independent typed price and loan approval
+
+Native cross/return now uses a distinct unpaid `guildTransportPermission`
+response before rate/payment, with original exact groups and typed world
+sources. The conservative provisional denial scope is that original shipment
+opportunity; signed Cunning second opportunity is distinct. Ordinary reserve/
+movement and common World→foreign World remain legal alternatives; immune
+Junction transport does not inherit the native ban. Existing rate-only saved
+receipts retain their original absent-field signature and do not replay a
+new earlier window. No recovery certification is implied.
+
+The authorized supplied Advanced PDF physical16 Karama section permits any
+player’s half-normal Force Shipment paid to bank. Native typed routes now
+honor the independent played price without granting a route or stacking
+explicit Nexus. Original own/donor invoice goes wholly to bank; actual
+completion consumes the grant. Shared existing provisional special-stop refund
+returns the exact physical purchase card to its owner and preserves resources/
+movement while using shipment. No unrelated grant is cleared.
+
+Allied Richese native price cancellation bills one marker, never zero/five
+materialized units. Recipient-funded full2 is distinct from supplier whole-fee1
+approval: even a wealthy supplier needs a new full-price offer and recipient
+consent. Controlled release preserves its prior three-marker and both wallets.
+Real reapproval then materializes old Richese3 and Guild5, spending only the
+new supplier2 under existing provisional bank-only precedence.
+
+Mobile smoke found fresh lender UI/AI still inferred its recipient’s price
+from its own view. Both now use authorized-pair `richeseNoField.allyPricing`;
+the shared pure helper takes explicit source booleans, not an ambiguous
+whole Game/View. No private wallet is added. Implementation/default tactical
+first-candidate and flag-cleanup pins were removed rather than re-pinned;
+optional reoffer was not forced or strategically tuned.
+
+Twenty main runtime/policy paths pass:15 original-action and five controlled,
+including exact source denial/alternatives, permission then rate cancellation,
+four original gifted routes, loan reapproval, refund and Cunning. Four additional
+controlled planning probes choose no optional reoffer here but execute legal
+recipient shipments. Actual minimum AI remains legal, not calibrated.
+
+Five styled390px GameTable journeys pass: original native prevention then immune
+Junction2 and ground move; original actual Ecaz gift→three-force world purchase2
+with Guild6 unchanged; controlled supplier1 release→fresh full2 offer/consent;
+controlled second-card special-stop/refund then movement; original typed return
+permission allowed then full3 rate cancellation with approved donor1 unchanged.
+No horizontal overflow/application exception; one initial Vite dependency fetch
+aborted. The owned in-memory QA reload retained exact captured synthetic states
+to exercise the corrected projection; no real room, D1, human server or deployed
+revision was restarted or changed.
+
+An already-purchased card with Junction sponsorship retains its prior separate
+composition gate and an explicit next task. Complete combinations, source
+refinement, comprehensive assurance and public starts remain open.
+[Canonical contract](GUILD_RATE_KARAMA_RULES.md#permission-purchase-and-allied-marker-composition--15-october-2026).
+Affected25-file **263/263** passes on the corrected quote bridge: the237-case
+related selection plus26 module/typed producer cases on the same frozen
+implementation. The earlier tactical/default assumptions were deleted, not
+re-pinned; no broad persistence/privacy campaign was added.
+Final `npm run check:quick`, production build and the20+4 runtime probes pass
+on the final source. Existing module-register deprecation, chunk-size and
+Unknown-route warnings remain. Eleven current links and both canonical
+five-facet evidence sets resolve; owned QA services/tab are stopped.
+
## 15 October 2026 — Native rate source composition and typed Homeworlds
Removed blanket physical-module rate exclusions in favor of actual native
diff --git a/docs/NEXUS_GUILD_CUNNING_RUNTIME.md b/docs/NEXUS_GUILD_CUNNING_RUNTIME.md
index cec747e2..6dadeb26 100644
--- a/docs/NEXUS_GUILD_CUNNING_RUNTIME.md
+++ b/docs/NEXUS_GUILD_CUNNING_RUNTIME.md
@@ -2,6 +2,19 @@
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.
+## Native permission opportunity scope — 15 October 2026
+
+Cross/return permission is now a separate prepayment counter before native
+price. Its conservative provisional denial scope is the original shipment
+opportunity, not every later turn or signed Cunning grant. A denied second
+opportunity retains original shipped history and first payment, permits a
+legal ordinary reserve replacement, and grants no ordinary extra movement.
+Conversely, an ordinary denial followed by an actual paid ordinary replacement
+does not revoke the newly earned Cunning opportunity.
+Two controlled source transitions and an actual controlled runtime prove
+these distinctions; they are not original action-only histories.
+[Current source contract](GUILD_RATE_KARAMA_RULES.md#permission-purchase-and-allied-marker-composition--15-october-2026).
+
## Typed Homeworld and module rate composition — 15 October 2026
The later native composition removes the blanket physical-module rate
diff --git a/docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md b/docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md
index 74413f6f..36da7fcc 100644
--- a/docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md
+++ b/docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md
@@ -2,6 +2,28 @@
Primary-source audit, 2026-09-06. This narrows the broad integration warnings in the earlier No-Field audits. Ordinary turn ownership, consent and typed force selection can be composed without inventing extra faction powers. The genuinely unresolved payment and interruption intersections below remain distinct. The ordinary contract is now implemented in development fixtures as recorded below; this document does not activate a mode or certify all combinations.
+## Native rate cancellation and fresh approval — 15 October 2026
+
+Guild-recipient loans now expose native discount cancellation before any
+payment or compulsory old-marker reveal. Desert half1 becomes full2 for one
+marker, not zero/five materialized forces. The standing provisional bank-only
+payee is retained. An independent played Karama price still takes precedence.
+
+Supplier whole-price approval1 cannot become2 automatically. Release keeps
+both wallets, recipient allowance and prior deployed three-marker unchanged;
+a new full-price offer and recipient consent authorize the replacement.
+Actual390px controls then spend Richese10→8, materialize its prior three
+at Red Chasm and five Guild forces at Imperial Basin10, while Guild wallet10
+stays unchanged. This scene is explicitly controlled, not original action-only.
+
+The actual surface found a missing quote bridge: fresh lender UI/AI offers
+previously used their own view and displayed1 after the recipient’s rate was
+canceled. They now consume the authoritative authorized-pair `allyPricing`
+descriptor, with full2 and bank-only policy; no recipient wallet is projected.
+The canonical price helper requires explicit independent/native source
+booleans, preventing accidental use of an incomplete GameView as server state.
+[Current permission/price/loan source contract and proof](GUILD_RATE_KARAMA_RULES.md#permission-purchase-and-allied-marker-composition--15-october-2026).
+
## Advanced discounted shipment follow-up — 8 October 2026
The Basic prototype below now also applies in Advanced. Supplied physical
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 48f0c6d3..bc237032 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -669,7 +669,9 @@ 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 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) |
+| Guild native rate Karama | Native prices follow original source rather than blanket module flags. Independent Smuggler counters stay free; own/allied No-Field bills one marker, never materialized value. A supplier’s whole-fee1 approval cannot silently become2: release and obtain a new full-price offer/consent. Typed native World/cross/return retains original sources and approved split; deferred Betrayal retains full-price authority until delivery. Earlier bank-only allied-loan, no-debt withdrawal and return policies remain provisional. | [Current composition and proof](GUILD_RATE_KARAMA_RULES.md#permission-purchase-and-allied-marker-composition--15-october-2026) |
+| Guild native transport permission | A distinct unpaid cross/return response precedes rate/payment. Under the standing choose-recommendation instruction, the provisional denied scope is that original shipment opportunity, preventing immediate redeclaration while preserving a legal different shipment/movement. Signed Cunning second opportunity is distinct; common World-to-foreign-World and immune Junction permissions are not canceled by analogy. Selected special-stop→permission→rate order is product composition, not a new publisher timing FAQ. | [Policy, source and genuine/controlled evidence](GUILD_RATE_KARAMA_RULES.md#permission-purchase-and-allied-marker-composition--15-october-2026) |
+| Purchased Karama typed price | Supplied authorized Advanced PDF physical16 says any player’s Force Shipment may be half normal, paid to bank. Original typed routes use that independent tariff without new route rights or Nexus stacking; the entire approved invoice is bank-only. Completion consumes only the original grant. Special-stop uses existing provisional card-return parity, refunding the exact original physical card to its owner. An already-purchased card combined with Junction sponsorship retains its separate unfinished composition gate. | [Connected typed routes and source distinction](GUILD_HOMEWORLD_TRANSPORT_RULES.md#separate-permission-and-purchased-price--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/admin-seat-ai-shape.ts b/game/admin-seat-ai-shape.ts
index cf6b54a3..20a693c0 100644
--- a/game/admin-seat-ai-shape.ts
+++ b/game/admin-seat-ai-shape.ts
@@ -137,6 +137,7 @@ const responses = {
harkonnenTraitor: true,
guildIncome: true,
guildRate: true,
+ guildTransportPermission: true,
emperorGift: true,
emperorRevival: true,
stormPeek: true,
diff --git a/game/bot-arrival.ts b/game/bot-arrival.ts
index 1608a985..91419eb8 100644
--- a/game/bot-arrival.ts
+++ b/game/bot-arrival.ts
@@ -22,6 +22,9 @@ import { quoteSmugglerShipment } from './smuggler-shipment';
* are never treated as rejected candidates.
*/
export function botArrivalBlock(g: GameView, action: Action): string | null {
+ if (g.status === 'playing' && g.phase === 5 && g.active === g.me &&
+ action.type === 'guildShip' && action.nexus === undefined && g.guildTransportCanceled)
+ return 'Karama prevents native Guild cross/return transport for this shipment opportunity.';
if (
g.status !== 'playing' ||
g.phase !== 5 ||
diff --git a/game/bots.ts b/game/bots.ts
index bd3002be..cfe3f352 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -81,7 +81,7 @@ import {
canUseAsKaramaRole,
shrineTruthtranceBotAction,
} from './shrine';
-import { reserveShipmentCost, alliedNoFieldShipmentPricing } from './shipment-price';
+import { reserveShipmentCost } from './shipment-price';
import { moritaniSpecialKaramaBotAction } from './moritani-special-karama-options';
import { quoteSmugglerShipment } from './smuggler-shipment';
import { quoteSmugglerNoField } from './smuggler-no-field';
@@ -160,10 +160,10 @@ function alliedNoFieldOffer(g: GameView): Action | null {
alliedNoField?.owner === me.id &&
alliedNoField.canOfferAlly &&
!alliedNoField.allyDeclined &&
- alliedNoField.private && ally
+ alliedNoField.private && alliedNoField.allyPricing && ally
) {
const inventory = alliedNoField.private;
- const pricing = alliedNoFieldShipmentPricing(g, ally);
+ const pricing = alliedNoField.allyPricing;
const oldMarker = inventory.deployed;
const oldValue =
inventory.tokens.find((token) => token.id === oldMarker?.tokenId)
diff --git a/game/engine.ts b/game/engine.ts
index 27ad3aeb..da72e4b2 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1024,6 +1024,7 @@ export type ResponseWindow = {
advisors?: boolean;
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
guildRateEvent?: string;
+ guildTransportEvent?: string;
advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry';
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
@@ -1055,6 +1056,7 @@ export type ResponseWindow = {
| 'harkonnenTraitor'
| 'guildIncome'
| 'guildRate'
+ | 'guildTransportPermission'
| 'emperorGift'
| 'emperorRevival'
| 'stormPeek'
@@ -1112,6 +1114,7 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
guildSecretEvent?: string;
guildNexusEvent?: string;
guildRateEvent?: string;
+ guildTransportEvent?: string;
/** Absent in older world-to-world declarations. */
route?: 'arrakis';
event: string;
@@ -1151,6 +1154,8 @@ type PendingShipment = {
type PendingGuildTransport = {
event: string;
signature: string;
+ /** Absent in original rate-only continuations. */
+ permission?: true;
turn: number;
player: string;
fromReserves: boolean;
@@ -1165,7 +1170,7 @@ type PendingGuildTransport = {
allyPayment: number;
advisors: boolean;
};
-type GuildTransportOrder = Omit;
+type GuildTransportOrder = Omit;
type JunctionTransportDeclaration = {
player: string;
turn: number;
@@ -2031,6 +2036,8 @@ export type Game = {
pendingGuildTransport?: PendingGuildTransport | null;
/** One canceled unaffordable declaration may be redeclared at full price this turn. */
guildRateBlocked?: { turn: number; player: string };
+ /** Denied cross/return privilege for this original shipment opportunity only. */
+ guildTransportBlocked?: { turn: number; player: string; opportunity: string };
pendingHomeworldShipment?: PendingHomeworldShipment | null;
junctionOffer?: JunctionOffer | null;
pendingExchange?: {
@@ -4183,6 +4190,13 @@ function noFieldAllyOfferBlock(
return 'Karama prevented No-Field use for this allied shipment opportunity.';
return null;
}
+function alliedNoFieldPricing(g: Game, recipient: Pick) {
+ return alliedNoFieldShipmentPricing({
+ purchasedGuildRate: g.karamaShipping?.player === recipient.id,
+ nativeGuildRateCanceled: g.guildRateBlocked?.turn === g.turn &&
+ g.guildRateBlocked.player === recipient.id,
+ }, recipient);
+}
function alliedNoFieldQuote(
g: Game,
offer: RicheseAllyOffer,
@@ -4233,7 +4247,7 @@ function alliedNoFieldQuote(
false,
advisors,
);
- const pricing = alliedNoFieldShipmentPricing(g, recipient);
+ const pricing = alliedNoFieldPricing(g, recipient);
const cost = territory(offer.territory).type === 'stronghold' ? pricing.stronghold : pricing.other;
requireRule(
offer.payer !== 'both' || cost === 2,
@@ -14452,7 +14466,7 @@ function finishAutomaticDecision(g: Game): boolean {
if (homeworldShipmentAutomatic(g)) {
const shipment = g.pendingHomeworldShipment!;
g.decision = null;
- if (!openHomeworldGuildRate(g, shipment)) commitHomeworldShipment(g, shipment);
+ continueHomeworldShipment(g, shipment);
return true;
}
const market = g.choamMarket;
@@ -19267,6 +19281,7 @@ function completePhase(g: Game) {
g.turn++;
g.recruits = null;
delete g.guildRateBlocked;
+ delete g.guildTransportBlocked;
g.phase = 0;
observeOccupation(g, 'turnStart');
}
@@ -24819,6 +24834,10 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
if (response.kind === 'nexusFremenCunning') currentFremenCunningResponse(g, response);
if (response.kind === 'nexusEcazBetrayal') currentEcazBetrayalResponse(g, response);
if (response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,response);
+ if (response.kind === 'guildTransportPermission') {
+ if (g.pendingGuildTransport?.permission) validateGuildTransportPermissionResponse(g, response);
+ else validateHomeworldTransportPermission(g, response);
+ }
if (response.kind === 'guildRate') {
if (g.pendingGuildTransport) validateGuildTransportRateResponse(g, response);
else if (g.pendingHomeworldShipment?.guildRateEvent) validateHomeworldGuildRateResponse(g, response);
@@ -24894,6 +24913,11 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
log(g, canceled ? 'Karama canceled the combined dial: the selected lead now dials only their own forces; the other allied army contributes zero.' : 'Ecaz Occupy proceeds with its mandatory free contribution and the ally’s physical force pool.', { faction: 'ecaz', name: 'Occupy' });
return;
}
+ if (response.kind === 'guildTransportPermission') {
+ if (g.pendingGuildTransport?.permission) finishGuildTransportPermission(g, response, canceled);
+ else finishHomeworldTransportPermission(g, response, canceled);
+ return;
+ }
if (response.kind === 'guildRate') {
if (g.pendingGuildTransport) finishGuildTransportRateResponse(g, response, canceled);
else if (g.pendingHomeworldShipment?.guildRateEvent) finishHomeworldGuildRateResponse(g, response, canceled);
@@ -25999,7 +26023,8 @@ function homeworldShipmentEvent(g: Game, p: Player, route?: 'arrakis'): string {
return JSON.stringify([g.turn, g.phase, g.active, p.id, p.shipped,
homeworldContext(g), g.homeworlds?.custody,
g.players.map((seat) => [seat.id, seat.ally]),
- ...(currentGuildCunning(g,p.id) ? [currentGuildCunning(g,p.id)!.receipt.event] : [])]);
+ ...(currentGuildCunning(g,p.id) ? [currentGuildCunning(g,p.id)!.receipt.event] : []),
+ ...(g.karamaShipping?.player === p.id ? [g.karamaShipping] : [])]);
}
function homeworldShipmentBlock(g: Game, p: Player, checkPromises = true): string | null {
if (!g.homeworlds?.custody) return 'Homeworld shipment requires the Homeworld module.';
@@ -26009,8 +26034,6 @@ function homeworldShipmentBlock(g: Game, p: Player, checkPromises = true): strin
g.pendingNullentropy || g.pendingTreacheryDiscard || g.pendingShipment ||
g.pendingHomeworldShipment || g.pendingExchange || g.pendingRicheseGift || g.battle)
return 'Finish the current interaction before another Homeworld shipment.';
- if (g.karamaShipping?.player === p.id)
- return 'Homeworld shipment using a purchased Karama rate awaits its card-scope integration.';
const richese = byFaction(g, 'richese');
if (richese && !noFieldAllyOfferBlock(g, richese) &&
!(g.richeseAllyOpportunity?.turn === g.turn && g.richeseAllyOpportunity.recipient === p.id))
@@ -26021,6 +26044,8 @@ function homeworldShipmentBlock(g: Game, p: Player, checkPromises = true): strin
}
function guildHomeworldShipmentBlock(g: Game, p: Player): string | null {
if (p.faction !== 'guild') return 'Only Guild has this Arrakis-to-Homeworld shipment permission.';
+ if (guildTransportCanceled(g, p.id))
+ return 'Karama prevents native Guild cross/return transport for this shipment opportunity.';
return homeworldShipmentBlock(g, p);
}
function junctionTransportWindow(g: Game) {
@@ -26029,7 +26054,9 @@ function junctionTransportWindow(g: Game) {
if (!owner || !recipient || owner === recipient.id || g.phase !== 5 ||
g.status !== 'playing' || recipient.shipped) return null;
const offer = currentJunctionOffer(g);
- const blocked = homeworldShipmentBlock(g, recipient, false);
+ const blocked = g.karamaShipping?.player === recipient.id
+ ? 'Junction transport with a purchased Karama price awaits its separate source composition.'
+ : homeworldShipmentBlock(g, recipient, false);
return {owner, recipient: recipient.id, offer, blocked,
canOffer: !blocked, offerEvent: junctionOfferEvent(g),
event: JSON.stringify([homeworldShipmentEvent(g, recipient, 'arrakis'), offer])};
@@ -26149,10 +26176,15 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
}
function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}) {
requireRule(g.homeworlds?.custody, 'Homeworld shipment requires saved physical custody.');
+ requireRule(!intent.guildSecretEvent || g.karamaShipping?.player !== intent.player,
+ 'Choose the purchased Karama price or Guild Secret Ally, not both.');
+ requireRule(!homeworldUsesGuildTransport(g, intent) || !guildTransportCanceled(g, intent.player),
+ 'Karama prevents native Guild cross/return transport for this shipment opportunity.');
const context = {
...homeworldContext(g),
nativeGuildRateCanceled: g.guildRateBlocked?.turn === g.turn &&
g.guildRateBlocked.player === intent.player,
+ purchasedGuildRate: g.karamaShipping?.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};
@@ -26179,8 +26211,6 @@ function validateHomeworldShipment(g: Game, shipment: PendingHomeworldShipment)
shipment.turn === g.turn && (shipment.route === undefined || shipment.route === 'arrakis') &&
shipment.event === homeworldShipmentEvent(g, p, shipment.route),
'This Homeworld declaration no longer matches its unused shipment and physical custody.');
- requireRule(g.karamaShipping?.player !== p.id,
- 'Homeworld shipment using a purchased Karama rate awaits its card-scope integration.');
const quote = homeworldShipmentQuote(g, shipment);
requireRule(shipment.amount === quote.amount && shipment.elite === quote.elite &&
shipment.cost === quote.cost && JSON.stringify(shipment.pools) === JSON.stringify(quote.sources),
@@ -26196,6 +26226,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?.guildTransportEvent) {
+ const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildTransportPermission');
+ requireRule(!decisions.length && responses.length === 1 && !g.pendingShipment,
+ 'The saved Homeworld transport declaration lost its single permission response.');
+ validateHomeworldTransportPermission(g, responses[0]);
+ return;
+ }
if (shipment?.guildRateEvent) {
const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildRate');
requireRule(!decisions.length && responses.length === 1 && !g.pendingShipment,
@@ -26220,6 +26257,7 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'homeworld',shipment);
if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'homeworld',shipment);
const p = getPlayer(g, shipment.player);
+ const purchasedRate = g.karamaShipping?.player === p.id;
payWithAlly(g, p, quote.cost, shipment.allyPayment);
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += quote.cost;
g.homeworlds!.custody = quote.state;
@@ -26240,13 +26278,14 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
// Off-planet destinations do not create the Guild's ordinary onto-Dune income.
// November FAQ contributor routing remains independent of that trigger.
const guild = byFaction(g, 'guild');
- const income = guild && p.ally !== guild.id ? shipment.allyPayment : 0;
+ const income = !purchasedRate && guild && p.ally !== guild.id ? shipment.allyPayment : 0;
if (!settlement?.recipient && guild && income > 0)
g.response = guildPaymentResponse(g, guild.id, [income], undefined,
[{ payer: p.ally!, amount: income }]);
if (!settlement?.recipient) settleBankerShipmentIncome(g, p, quote.cost, shipment.allyPayment,
- { own: false, ally: !!guild && p.ally !== guild.id });
+ { own: false, ally: !purchasedRate && !!guild && p.ally !== guild.id });
finishShipmentPromises(g, p, null);
+ if (purchasedRate) g.karamaShipping = null;
log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`,
{faction: shipment.guildSecretEvent ? 'guild' : p.faction, name: shipment.guildSecretEvent ? 'Secret Ally Homeworld shipment' : 'Homeworld shipment'});
}
@@ -26262,7 +26301,68 @@ 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 if (!openHomeworldGuildRate(g, shipment)) commitHomeworldShipment(g, shipment);
+ } else continueHomeworldShipment(g, shipment);
+}
+function homeworldUsesGuildTransport(g: Game,
+ shipment: HomeworldShipmentIntent & {route?: 'arrakis'}): boolean {
+ return getPlayer(g, shipment.player).faction === 'guild' &&
+ (shipment.route === 'arrakis' || shipment.destination === 'homeworld:guild');
+}
+function continueHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
+ if (!openHomeworldTransportPermission(g, shipment) && !openHomeworldGuildRate(g, shipment))
+ commitHomeworldShipment(g, shipment);
+}
+function openHomeworldTransportPermission(g: Game, shipment: PendingHomeworldShipment): boolean {
+ if (!homeworldUsesGuildTransport(g, shipment)) return false;
+ validateHomeworldShipment(g, shipment);
+ // The existing validator has proved this signed pending source.
+ const cunning = shipment.guildNexusEvent
+ ? currentGuildCunning(g, shipment.player) as ReturnType
+ : null;
+ const event = crypto.randomUUID();
+ shipment.guildTransportEvent = event;
+ if (cunning) {
+ cunning.shipment!.frame = JSON.stringify(shipment);
+ saveGuildCunning(g, cunning);
+ }
+ g.pendingHomeworldShipment = shipment;
+ g.response = { kind: 'guildTransportPermission', owner: shipment.player, passed: [],
+ guildTransportEvent: event,
+ intent: `${getPlayer(g, shipment.player).name} declared native Guild cross/return transport to ${combatLocationName(g, shipment.destination)}. Karama may prevent only this faction transport privilege before payment or typed departure. The original shipment opportunity remains for a legal different route; an independent purchased price remains but grants no transport permission. Junction-sponsored transport is a separate immune Homeworld source.` };
+ return true;
+}
+function validateHomeworldTransportPermission(g: Game, response: ResponseWindow) {
+ const shipment = g.pendingHomeworldShipment;
+ requireRule(response.kind === 'guildTransportPermission' && shipment &&
+ shipment.guildRateEvent === undefined && homeworldUsesGuildTransport(g, shipment) &&
+ response.owner === shipment.player && typeof shipment.guildTransportEvent === 'string' &&
+ shipment.guildTransportEvent.length > 0 && response.guildTransportEvent === shipment.guildTransportEvent,
+ 'The saved Homeworld permission lost its original native transport.');
+ validateHomeworldShipment(g, shipment);
+ return shipment;
+}
+function finishHomeworldTransportPermission(g: Game, response: ResponseWindow, canceled: boolean) {
+ const shipment = validateHomeworldTransportPermission(g, response);
+ const cunning = shipment.guildNexusEvent
+ ? currentGuildCunning(g, shipment.player) as ReturnType
+ : null;
+ g.pendingHomeworldShipment = null;
+ delete shipment.guildTransportEvent;
+ if (canceled) {
+ g.guildTransportBlocked = { turn: g.turn, player: shipment.player,
+ opportunity: guildTransportOpportunity(g, shipment.player) };
+ if (cunning) {
+ delete cunning.shipment;
+ saveGuildCunning(g, cunning);
+ }
+ log(g, `Karama prevented the native Guild cross/return privilege. No typed forces or spice moved; ${getPlayer(g, shipment.player).name} retains a legal different shipment or movement.`);
+ return;
+ }
+ if (cunning) {
+ cunning.shipment!.frame = JSON.stringify(shipment);
+ saveGuildCunning(g, cunning);
+ }
+ if (!openHomeworldGuildRate(g, shipment)) commitHomeworldShipment(g, shipment);
}
function homeworldGuildRateFullCost(g: Game, shipment: PendingHomeworldShipment): number | null {
const p = getPlayer(g, shipment.player);
@@ -26401,11 +26501,13 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean {
'Allied No-Field skill composition retains its original ordinary shipment only.');
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,
'The allied No-Field must retain its original cancellation or Guild continuation.');
for (const response of initial) requireRule(noFieldCancellationQuote(g, response)?.kind === 'allied',
'The allied No-Field lost its original owner, recipient or token declaration.');
for (const decision of guild) validateGuildShipmentDecision(g, decision);
+ for (const rate of rates) validateGuildRateResponse(g, rate);
return true;
}
const pair = !!g.leaderSkills && !!shipment?.richesePair;
@@ -26579,16 +26681,18 @@ const CLASSIC_FACTIONS: Partial> = {
function ordinaryGuildRateFullCost(g: Game, shipment: PendingShipment): number | null {
const guild = byFaction(g, 'guild');
const p = getPlayer(g, shipment.player);
+ const pricedAmount = shipment.noField || shipment.alliedNoField ? 1
+ : shipment.amount - (shipment.smuggler ? 1 : 0);
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) ||
- shipment.source || shipment.alliedNoField || shipment.guildSecretEvent ||
+ shipment.source || shipment.guildSecretEvent ||
shipment.nexusEvent || shipment.richesePair)
return null;
const fullCost = reserveShipmentCost({ faction: p.faction, halfRate: false },
- territory(shipment.territory).type, shipment.amount - (shipment.smuggler ? 1 : 0));
+ territory(shipment.territory).type, pricedAmount);
const halfCost = reserveShipmentCost({ faction: p.faction, halfRate: true },
- territory(shipment.territory).type, shipment.amount - (shipment.smuggler ? 1 : 0));
+ territory(shipment.territory).type, pricedAmount);
return shipment.cost === halfCost && shipment.cost < fullCost ? fullCost : null;
}
function openGuildRate(g: Game, shipment: PendingShipment): boolean {
@@ -26613,7 +26717,7 @@ function openGuildRate(g: Game, shipment: PendingShipment): boolean {
owner: byFaction(g, 'guild')!.id,
passed: [],
guildRateEvent: event,
- 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.`,
+ intent: `${getPlayer(g, shipment.player).name} declared ${shipment.alliedNoField ? 'one allied No-Field shipment' : 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.${shipment.smuggler || shipment.smugglerCompanion ? ' The independent free companion remains free.' : ''} 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;
@@ -26628,16 +26732,35 @@ function validateGuildRateResponse(g: Game, response: ResponseWindow) {
response.owner === guild.id && typeof shipment.guildRateEvent === 'string' &&
shipment.guildRateEvent.length > 0 &&
response.guildRateEvent === shipment.guildRateEvent &&
- shipment.turn === g.turn && fullCost !== null &&
- shipment.cost === reserveShipmentCost({ faction: p.faction, halfRate: true },
- territory(shipment.territory).type, shipment.amount - (shipment.smuggler ? 1 : 0)),
+ shipment.turn === g.turn && fullCost !== null,
'The saved Guild-rate response lost its original physical shipment.');
return { shipment, fullCost };
}
+function guildTransportPermissionIntegrity(g: Game) {
+ const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildTransportPermission');
+ const transport = g.pendingGuildTransport?.permission === true;
+ const homeworld = !!g.pendingHomeworldShipment?.guildTransportEvent;
+ const expected = Number(transport) + Number(homeworld);
+ requireRule(expected <= 1 && responses.length === expected,
+ 'The native Guild transport lost its unique permission response.');
+ for (const response of responses) {
+ if (transport) validateGuildTransportPermissionResponse(g, response);
+ else validateHomeworldTransportPermission(g, response);
+ }
+ const blocked = g.guildTransportBlocked;
+ if (blocked) requireRule(blocked.turn === g.turn &&
+ g.players.some(player => player.id === blocked.player) &&
+ (blocked.opportunity === 'ordinary' ||
+ g.nexusGuildCunningHistory?.some(record => record.receipt.event === blocked.opportunity &&
+ record.receipt.turn === g.turn && record.receipt.owner === blocked.player)),
+ 'The denied Guild transport belongs to another shipment opportunity.');
+}
function guildRateIntegrity(g: Game) {
+ guildTransportPermissionIntegrity(g);
const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildRate');
const expected = Number(!!g.pendingShipment?.guildRateEvent) +
- Number(!!g.pendingGuildTransport) + Number(!!g.pendingHomeworldShipment?.guildRateEvent);
+ Number(!!g.pendingGuildTransport && !g.pendingGuildTransport.permission) +
+ Number(!!g.pendingHomeworldShipment?.guildRateEvent);
requireRule(expected <= 1 && responses.length === expected,
'The saved Guild-rate shipment lost its single Karama response.');
if (responses.length) {
@@ -26662,7 +26785,10 @@ function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: bo
g.guildRateBlocked = { turn: g.turn, player: shipper.id };
const bounds = shipmentPaymentBounds(fullCost, shipper.spice,
aidFor(g, shipper)?.amount ?? 0);
- if (shipment.allyPayment < bounds.minimum || shipment.allyPayment > bounds.maximum) {
+ // A loan supplier approved the original whole price, not an increased gift.
+ const supplierApprovalChanges = shipment.alliedNoField &&
+ shipment.alliedNoField.payer === shipment.alliedNoField.owner;
+ if (supplierApprovalChanges || shipment.allyPayment < bounds.minimum || shipment.allyPayment > bounds.maximum) {
if (cunning) {
delete cunning.shipment;
saveGuildCunning(g, cunning);
@@ -26680,15 +26806,17 @@ function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: bo
saveGuildCunning(g, cunning);
}
commitShipment(g, shipment);
- if (canceled && !g.pendingGuildBetrayal) {
+ if (canceled && shipper.shipped && !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.`);
}
}
function guildTransportSignature(intent: PendingGuildTransport): string {
- return JSON.stringify([intent.event, intent.turn, intent.player, intent.fromReserves,
+ const fields = [intent.event, intent.turn, intent.player, intent.fromReserves,
intent.group, intent.eliteGroup, intent.origin, intent.to, intent.sector,
- intent.amount, intent.elite, intent.cost, intent.allyPayment, intent.advisors]);
+ intent.amount, intent.elite, intent.cost, intent.allyPayment, intent.advisors];
+ if (intent.permission !== undefined) fields.push(intent.permission);
+ return JSON.stringify(fields);
}
function ordinaryGuildTransportFullCost(g: Game, p: Player, frame: GuildTransportOrder): number | null {
const guild = byFaction(g, 'guild');
@@ -26720,18 +26848,19 @@ function openGuildTransportRate(g: Game, p: Player, frame: GuildTransportOrder,
log(g, `${p.name} declared Guild-rate transport; payment and physical transfer wait for the Karama response.`);
return true;
}
-function validateGuildTransportRateResponse(g: Game, response: ResponseWindow) {
+function validateGuildTransportWindow(g: Game, response: ResponseWindow) {
const intent = g.pendingGuildTransport;
const guild = byFaction(g, 'guild');
const shipper = intent && g.players.find(p => p.id === intent.player);
- const fullCost = intent && shipper
- ? ordinaryGuildTransportFullCost(g, shipper, intent) : null;
- requireRule(response.kind === 'guildRate' && intent && guild && shipper &&
+ const permission = response.kind === 'guildTransportPermission';
+ requireRule(intent && guild && shipper &&
+ (permission ? intent.permission === true && response.guildTransportEvent === intent.event
+ : response.kind === 'guildRate' && intent.permission === undefined &&
+ response.guildRateEvent === intent.event) &&
g.status === 'playing' && g.phase === 5 && g.active === shipper.id &&
shipmentAvailable(g, shipper) && intent.turn === g.turn &&
typeof intent.event === 'string' && intent.event.length > 0 &&
- response.owner === guild.id && response.guildRateEvent === intent.event &&
- typeof intent.fromReserves === 'boolean' &&
+ response.owner === guild.id && typeof intent.fromReserves === 'boolean' &&
Array.isArray(intent.group) && intent.group.every(([key, n]) =>
typeof key === 'string' && key.length > 0 && Number.isSafeInteger(n) && n > 0) &&
intent.eliteGroup && typeof intent.eliteGroup === 'object' &&
@@ -26757,15 +26886,68 @@ function validateGuildTransportRateResponse(g: Game, response: ResponseWindow) {
intent.group.reduce((sum, [key]) => sum + intent.eliteGroup[key], 0) === intent.elite) &&
(intent.to !== 'reserves' || shipper.faction === 'guild') &&
typeof intent.signature === 'string' &&
- intent.signature === guildTransportSignature(intent) &&
- fullCost !== null &&
+ intent.signature === guildTransportSignature(intent),
+ 'The saved Guild transport lost its original physical response.');
+ return { intent, shipper };
+}
+function validateGuildTransportRateResponse(g: Game, response: ResponseWindow) {
+ const { intent, shipper } = validateGuildTransportWindow(g, response);
+ const fullCost = ordinaryGuildTransportFullCost(g, shipper, intent);
+ requireRule(response.kind === 'guildRate' && fullCost !== null &&
intent.cost === guildShipmentCost(
- intent.to === 'reserves' ? 'reserves' : territory(intent.to).type,
- intent.amount),
- 'The saved Guild transport lost its original physical rate response.');
+ intent.to === 'reserves' ? 'reserves' : territory(intent.to).type, intent.amount),
+ 'The saved Guild transport lost its original rate.');
return { intent, shipper, fullCost };
}
+function guildTransportOrder(intent: PendingGuildTransport, cost = intent.cost): GuildTransportOrder {
+ return {
+ turn: intent.turn, player: intent.player, origin: intent.origin,
+ group: intent.group, eliteGroup: intent.eliteGroup, to: intent.to,
+ sector: intent.sector, amount: intent.amount, elite: intent.elite,
+ cost, allyPayment: intent.allyPayment, advisors: intent.advisors,
+ };
+}
+function guildTransportOpportunity(g: Game, player: string): string {
+ return currentGuildCunning(g, player)?.receipt.event ?? 'ordinary';
+}
+function guildTransportCanceled(g: Game, player: string): boolean {
+ const blocked = g.guildTransportBlocked;
+ return !!blocked && blocked.turn === g.turn && blocked.player === player &&
+ blocked.opportunity === guildTransportOpportunity(g, player);
+}
+function openGuildTransportPermission(g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean) {
+ validateGuildTransportForCommit(g, p, frame, fromReserves);
+ const intent: PendingGuildTransport = {
+ ...frame, fromReserves, event: crypto.randomUUID(), signature: '', permission: true,
+ };
+ intent.signature = guildTransportSignature(intent);
+ g.pendingGuildTransport = intent;
+ g.response = { kind: 'guildTransportPermission', owner: byFaction(g, 'guild')!.id,
+ passed: [], guildTransportEvent: intent.event,
+ intent: `${p.name} declared native Guild ${frame.to === 'reserves' ? 'return' : 'cross-planet'} transport of ${frame.amount} physical forces. Karama may prevent this transport privilege, independently of its price. Denial moves no forces or spice and leaves this shipment opportunity available for a legal different route. Native cross/return stays unavailable for that opportunity; an independent purchased price is retained, not a new transport permission.` };
+}
+function validateGuildTransportPermissionResponse(g: Game, response: ResponseWindow) {
+ const result = validateGuildTransportWindow(g, response);
+ requireRule(response.kind === 'guildTransportPermission',
+ 'Use the original Guild transport permission response.');
+ validateGuildTransportForCommit(g, result.shipper, result.intent, result.intent.fromReserves);
+ return result;
+}
+function finishGuildTransportPermission(g: Game, response: ResponseWindow, canceled: boolean) {
+ const { intent, shipper } = validateGuildTransportPermissionResponse(g, response);
+ g.pendingGuildTransport = null;
+ if (canceled) {
+ g.guildTransportBlocked = { turn: g.turn, player: shipper.id,
+ opportunity: guildTransportOpportunity(g, shipper.id) };
+ log(g, `Karama prevented ${shipper.name}'s native Guild cross/return transport. No spice or forces moved; choose a legal different shipment route or continue movement.`);
+ return;
+ }
+ const frame = guildTransportOrder(intent);
+ if (!openGuildTransportRate(g, shipper, frame, intent.fromReserves))
+ commitGuildTransport(g, shipper, frame, intent.fromReserves);
+}
function nativeGuildTransportPermission(g: Game, p: Player): boolean {
+ if (guildTransportCanceled(g, p.id)) return false;
if (p.faction === 'guild') return true;
const guild = byFaction(g, 'guild');
return !!guild && p.ally === guild.id && guild.ally === p.id;
@@ -26874,13 +27056,7 @@ function finishGuildTransportRateResponse(g: Game, response: ResponseWindow, can
const { intent, shipper, fullCost } = validateGuildTransportRateResponse(g, response);
g.pendingGuildTransport = null;
if (canceled) g.guildRateBlocked = { turn: g.turn, player: shipper.id };
- const frame: GuildTransportOrder = {
- turn: intent.turn, player: intent.player, origin: intent.origin,
- group: intent.group, eliteGroup: intent.eliteGroup, to: intent.to,
- sector: intent.sector, amount: intent.amount, elite: intent.elite,
- cost: canceled ? fullCost : intent.cost,
- allyPayment: intent.allyPayment, advisors: intent.advisors,
- };
+ const frame = guildTransportOrder(intent, canceled ? fullCost : intent.cost);
try {
validateGuildTransportForCommit(g, shipper, frame, intent.fromReserves);
} catch (error) {
@@ -27064,7 +27240,7 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
cost,
allyPayment,
bankOnly: g.karamaShipping?.player === p.id ||
- !!(shipment.alliedNoField && alliedNoFieldShipmentPricing(g, p).bankOnly),
+ !!(shipment.alliedNoField && alliedNoFieldPricing(g, p).bankOnly),
});
if (!settlement?.recipient && guild && guildPayment > 0)
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),
@@ -28519,6 +28695,18 @@ function specialKaramaAction(intent: SpecialKaramaIntent): Action {
}
}
+/** Existing provisional special-stop policy: return only the original purchased card. */
+function refundStoppedShipmentKarama(g: Game, shipper: Player) {
+ const benefit = g.karamaShipping;
+ if (benefit?.player !== shipper.id) return;
+ if (benefit.card) {
+ const index = g.discard.findIndex(card => card.id === benefit.card);
+ if (index >= 0)
+ getPlayer(g, benefit.owner).hand.push(...g.discard.splice(index, 1));
+ }
+ g.karamaShipping = null;
+}
+
/** Revalidates exact custody and the original window before any mutation.
* A future paused-intent caller must restore its original decision/response first;
* this executor does not open, pass, or recreate a reaction window.
@@ -28741,6 +28929,7 @@ export function executeSpecialKaramaIntent(
p.specialKaramaUsed = true;
if (g.pendingHomeworldShipment?.guildNexusEvent) finishGuildCunningShipment(g,shipper.id,'homeworld',g.pendingHomeworldShipment,'stopped');
shipper.shipped = true;
+ refundStoppedShipmentKarama(g, shipper);
g.pendingHomeworldShipment = null;
g.decision = null;
finishShipmentPromises(g, shipper, null);
@@ -28764,15 +28953,7 @@ export function executeSpecialKaramaIntent(
if (g.pendingShipment) finishNexusRicheseShipment(g,g.pendingShipment,'stopped');
if (g.pendingShipment) finishRichesePairShipment(g, g.pendingShipment, 'stopped');
shipper.shipped = true;
- // Provisional canceled-shipment settlement pending a primary-source
- // clarification: retain the attempted shipment's spice and rate card.
- const benefit = g.karamaShipping;
- if (benefit?.player === shipper.id && benefit.card) {
- const index = g.discard.findIndex((c) => c.id === benefit.card);
- if (index >= 0)
- getPlayer(g, benefit.owner).hand.push(...g.discard.splice(index, 1));
- }
- g.karamaShipping = null;
+ refundStoppedShipmentKarama(g, shipper);
g.pendingShipment = null;
g.decision = null;
log(
@@ -30928,7 +31109,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.');
- if (!openHomeworldGuildRate(g, shipment)) commitHomeworldShipment(g, shipment);
+ continueHomeworldShipment(g, shipment);
} else if (decision.kind === 'guildShipment') {
requireRule(
action.allow === true,
@@ -32758,8 +32939,10 @@ function applyActionInner(
turn: g.turn, player: id, origin, group, eliteGroup, to, sector,
amount: n, elite, cost, allyPayment, advisors,
};
- if (!guildSecretEvent && openGuildTransportRate(g, p, frame, fromReserves))
+ if (!guildSecretEvent) {
+ openGuildTransportPermission(g, p, frame, fromReserves);
return g;
+ }
commitGuildTransport(g, p, frame, fromReserves, guildSecretEvent);
return g;
}
@@ -34031,6 +34214,7 @@ export function viewGame(state: Game, id: string) {
: null,
guildRateCanceled: g.guildRateBlocked?.turn === g.turn &&
g.guildRateBlocked.player === id,
+ ...(guildTransportCanceled(g, id) ? { guildTransportCanceled: true as const } : {}),
stormPending: g.stormPending ?? null,
advancedAllySeparation: advancedAllySeparationView(g, me),
basicAlliedShipment: basicAlliedShipmentView(g, me),
@@ -34386,6 +34570,7 @@ export function viewGame(state: Game, id: string) {
const owner = byFaction(g, 'richese');
if (!owner?.noField || !owner.noFieldEvent) return null;
const mine = owner.id === me.id;
+ const ally = owner.ally ? getPlayer(g, owner.ally) : null;
const shipBlock = mine
? noFieldShipBlock(g, owner)
: 'Only Richese controls this token inventory.';
@@ -34407,6 +34592,7 @@ export function viewGame(state: Game, id: string) {
allyOfferBlock: mine
? noFieldAllyOfferBlock(g, owner)
: 'Only Richese may offer these tokens.',
+ allyPricing: ally && (mine || ally.id === me.id) ? alliedNoFieldPricing(g, ally) : null,
allyDeclined:
g.richeseAllyDeclined?.turn === g.turn &&
g.richeseAllyDeclined.recipient === owner.ally,
@@ -34416,7 +34602,7 @@ export function viewGame(state: Game, id: string) {
return null;
const { event, owner, recipient, territory, sector, payer } = offer;
const recipientSeat = g.players.find(player => player.id === recipient);
- const pricingPolicy = recipientSeat ? alliedNoFieldShipmentPricing(g, recipientSeat).policy : null;
+ const pricingPolicy = recipientSeat ? alliedNoFieldPricing(g, recipientSeat).policy : null;
try {
const {
cost,
diff --git a/game/guild-homeworld-shipment-options.ts b/game/guild-homeworld-shipment-options.ts
index 44659e32..edc293f4 100644
--- a/game/guild-homeworld-shipment-options.ts
+++ b/game/guild-homeworld-shipment-options.ts
@@ -53,6 +53,11 @@ export function guildHomeworldShipmentChoice(
result.blocked = option.blocked;
return result;
}
+ if (view.guildTransportCanceled) {
+ result.blocked =
+ 'The native Guild transport permission is canceled; a purchased price cannot restore this route.';
+ return result;
+ }
const custody: HomeworldCustody = { salusa: null, visitors: {} };
for (const world of worlds) {
if (world.secondary) custody.salusa = { ...world.forces[world.native] };
@@ -69,6 +74,7 @@ export function guildHomeworldShipmentChoice(
{
advanced: view.advanced,
nativeGuildRateCanceled: view.guildRateCanceled,
+ purchasedGuildRate: view.karamaShipping?.player === view.me,
storm: view.storm,
mobileStronghold: view.mobileStronghold?.location ?? null,
discoveries: view.discoveries,
@@ -151,6 +157,7 @@ export function guildHomeworldShipmentActions(
me.faction !== 'guild' ||
!view.guildHomeworldShipment ||
view.guildHomeworldShipment.blocked ||
+ view.guildTransportCanceled ||
!nexusGuildShipmentAvailable(view) ||
view.phase !== 5 ||
view.active !== me.id
@@ -226,7 +233,10 @@ export function guildHomeworldShipmentActions(
);
const amount = Math.min(
total - retain,
- budget * (view.guildRateCanceled ? 1 : 2),
+ budget *
+ (view.karamaShipping?.player === view.me || !view.guildRateCanceled
+ ? 2
+ : 1),
[3, 5, 7, 9][difficulty],
desired,
);
diff --git a/game/guild-homeworld-shipment.ts b/game/guild-homeworld-shipment.ts
index 9aee3b9c..07d3dcf5 100644
--- a/game/guild-homeworld-shipment.ts
+++ b/game/guild-homeworld-shipment.ts
@@ -58,8 +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. Native rate cancellation changes only the half tariff to full price.
- * No private card, concealed-force value or transport usage is read.
+ * effects. An independently purchased ordinary Karama price survives native
+ * rate cancellation. No private card, concealed-force value or 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(
@@ -207,7 +207,10 @@ export function quoteGuildHomeworldShipment(
...transferred,
amount,
elite: 0,
- cost: context.nativeGuildRateCanceled ? amount : Math.ceil(amount / 2),
+ cost:
+ context.purchasedGuildRate || !context.nativeGuildRateCanceled
+ ? Math.ceil(amount / 2)
+ : amount,
origin: origin!,
boardSources,
boardForces,
diff --git a/game/homeworld-shipment-options.ts b/game/homeworld-shipment-options.ts
index ad8a9edc..9ee46c1b 100644
--- a/game/homeworld-shipment-options.ts
+++ b/game/homeworld-shipment-options.ts
@@ -65,9 +65,24 @@ export function homeworldShipmentChoice(
result.blocked = option.blocked;
return result;
}
+ if (
+ view.guildTransportCanceled &&
+ me.faction === 'guild' &&
+ destination === 'homeworld:guild'
+ ) {
+ result.blocked =
+ 'The native Guild return permission is canceled; a purchased price cannot restore this route.';
+ return result;
+ }
+ if (nexus !== undefined && view.karamaShipping?.player === view.me) {
+ result.blocked =
+ 'A purchased Karama price is already active; do not combine it with Guild Nexus Secret Ally.';
+ return result;
+ }
const context = {
advanced: view.advanced,
nativeGuildRateCanceled: view.guildRateCanceled,
+ purchasedGuildRate: view.karamaShipping?.player === view.me,
players: view.players.map((player) => ({
id: player.id,
faction: player.faction,
@@ -154,7 +169,9 @@ export function homeworldShipmentActions(
!nexusGuildShipmentAvailable(view)
)
return [];
- const useNexus = nexusGuildSecretAllyCanAct(view);
+ const useNexus =
+ view.karamaShipping?.player !== view.me &&
+ nexusGuildSecretAllyCanAct(view);
const difficulty = Math.max(0, Math.min(3, level));
const budget = Math.max(
0,
@@ -169,6 +186,11 @@ export function homeworldShipmentActions(
.filter(
(world) =>
(world.native !== me.id || me.faction === 'guild') &&
+ !(
+ view.guildTransportCanceled &&
+ me.faction === 'guild' &&
+ world.native === me.id
+ ) &&
world.native !== me.ally,
)
.map((world) => {
@@ -233,7 +255,9 @@ export function homeworldShipmentActions(
pools.reduce((sum, pool) => sum + pool.capacity, 0),
[3, 5, 7, 9][difficulty],
budget *
- ((me.faction === 'guild' && !view.guildRateCanceled) || useNexus
+ ((me.faction === 'guild' && !view.guildRateCanceled) ||
+ view.karamaShipping?.player === view.me ||
+ useNexus
? 2
: 1),
Math.max([1, 3, 4, 5][difficulty], required),
diff --git a/game/homeworld-shipment.ts b/game/homeworld-shipment.ts
index 7160f096..b56a772c 100644
--- a/game/homeworld-shipment.ts
+++ b/game/homeworld-shipment.ts
@@ -15,6 +15,8 @@ export type HomeworldShipmentContext = Omit<
players: (HomeworldReserveSeat & { ally: string | null })[];
/** Only the native Guild faction rate; independent transport grants survive. */
nativeGuildRateCanceled?: boolean;
+ /** Independently purchased ordinary Karama price; never a route permission. */
+ purchasedGuildRate?: boolean;
};
export type HomeworldShipmentIntent = {
player: string;
@@ -35,10 +37,10 @@ function requireShipment(
/** E3 world-to-world transport: use the actual native or foreign source pool.
* 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 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. */
+ * funding, bank payment, responses and arrival effects. A purchased ordinary
+ * Karama price takes precedence over the native Guild half rate or its
+ * cancellation; an alliance alone does not grant either price. Independent
+ * Nexus and Junction prices belong to their own grants. */
export function quoteHomeworldShipment(
context: HomeworldShipmentContext,
custody: HomeworldCustody,
@@ -133,7 +135,8 @@ export function quoteHomeworldShipment(
amount,
elite,
cost:
- player.faction === 'guild' && !context.nativeGuildRateCanceled
+ context.purchasedGuildRate ||
+ (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 fa4ed050..20bb936f 100644
--- a/game/karama-context.ts
+++ b/game/karama-context.ts
@@ -134,6 +134,13 @@ const cancellationSources = {
calls: g.battle?.traitorCalls ?? null,
}),
guildIncome: () => null,
+ guildTransportPermission: g => ({
+ ...(g.pendingGuildTransport ? { transport: g.pendingGuildTransport } : {}),
+ ...(g.pendingHomeworldShipment ? { homeworld: g.pendingHomeworldShipment } : {}),
+ active: g.active,
+ blocked: g.guildTransportBlocked ?? null,
+ purchasedRate: g.karamaShipping ?? null,
+ }),
guildRate: (g) => ({
pending: g.pendingShipment ?? null,
...(g.pendingGuildTransport ? { transport: g.pendingGuildTransport } : {}),
diff --git a/game/reference.ts b/game/reference.ts
index afdf0e3f..10557320 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -142,10 +142,10 @@ 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.',
- '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.',
+ 'Native Guild cross/return permission has a separate unpaid response before native price. Preventing it preserves legal ordinary shipment or movement and does not remove half price. The conservative provisional denied scope is that original shipment opportunity; signed Cunning second opportunity is distinct. Native rate cancellation keeps the same paid units and approved funding, or releases an unaffordable attempt for a full-price replacement. Free Smuggler counters stay free; own/allied No-Field bills one marker. Immune Junction and independent card prices remain separate sources.',
'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.',
+ 'Guild transport moves one exact physical group across the planet or returns it to reserves. Permission and price are separate counter opportunities; common world-to-foreign-world shipment does not require Guild cross/return privilege. Purchased half price grants no new route. Ordinary movement still checks range, storm and stronghold occupancy.',
'You may pass through an ally’s territory on a legal route that ends elsewhere. Ordinary physical reserve shipments in both modes may enter allied territory before movement, then depart during that turn. Storm and stronghold capacity remain binding. Basic visibly labels its provisional no-exit policy: excess new visitors still present go to Tanks. Advanced follows supplied p15: all the ending player’s shared forces go to Tanks, including old forces, subject to advisor/Polar/Ecaz exemptions. Neither grants an extra move. Concealed No-Fields and optional arrivals remain separate.',
'Ecaz and its reciprocal ally may share territories and count as one faction toward stronghold capacity, without extra movement or storm/Homeworld exceptions. Their three-joint-stronghold victory and desert allocation are connected. Fremen/Ecaz cooccupation permits the Fremen special win in Sietch Tabr in Basic; supplied Advanced p32 permits both Tabr and Habbanya Sietch. Other victory conditions and exceptional combined interactions remain separate.',
'A restored pending physical shipment is checked again before forces or spice are committed. If its saved turn, force allocation, destination, price or authorized contribution no longer matches the table, it is rejected without changing the saved declaration.',
@@ -1028,7 +1028,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A normal No-Field shipment pays the one-force price. The concealed token moves like a force. Before Battle, Richese may reveal it voluntarily; a token involved in battle is revealed with the Battle Plan. Revelation brings the indicated forces from reserves, limited by what remains there.',
'The revealed token stays face up until another token is placed. Revealing it does not clear the previous-shipment restriction. An allied No-Field shipment reveals immediately and uses the ally’s reserves; an existing token must be revealed before another shipment.',
'Before an eligible ally uses its shipment, the game holds an offer opportunity for Richese. Richese may pass or privately propose a token, destination and payment. The ally accepts the exact offer and chooses its permitted regular/elite mix, or declines without payment. Success consumes the ally’s shipment, not Richese’s shipment or either player’s movement.',
- 'Ordinary allied shipment costs one spice into a stronghold or two elsewhere, funded by either ally or one each at cost two. Guild recipients and already-active Karama rates use a visibly provisional one-spice price in both modes, paid entirely to bank even if Richese funds it. Prevention precedes payment and the old-marker/new-token reveals. Fremen reserves are on the planet and remain ineligible.',
+ 'Ordinary allied shipment costs one spice into a stronghold or two elsewhere. Guild recipients and active purchased prices use the existing provisional bank-only discount. Canceling a native Guild recipient’s rate charges one full-price marker, never zero/five materialized forces. A supplier’s original whole-fee1 approval cannot silently become2; release unchanged and obtain a new full-price offer/consent. Independent purchased price remains. Fremen on-planet reserves stay ineligible.',
'Storm and worm exposure reveal the token and destroy its forces. A zero token still had one-force presence before revelation. Trip to Gamont forces a reveal, then returns one force if present. Revealing a token already in a territory does not trigger Terror a second time.',
'Development tables support your own concealed shipment, movement of a marker alone or with a physical group, voluntary reveal and one-force board presence. Physical reserves remain unchanged until reveal. Karama can prevent the No-Field shipment, with Guild shipment prevention and payment handled separately. Price and chosen allied funding are shown before shipment.',
'The map shows a concealed marker without its denomination to everyone. Richese privately sees its token values, last-use restriction and reveal control. Movement selection keeps the marker separate from physical forces. No hidden denomination or physical token identity is included in another player’s projected board state.',
@@ -1050,31 +1050,35 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
- 'Own shipment, movement, reveal, effective presence, collection, hazards, Gamont and marker-only battles work in development fixtures. Basic and Advanced mixed battles have a provisional private pool and native support/aftermath. Allied consent, typed forces, full/split funding, prevention and immediate reveal are integrated; Guild/active-Karama offers in both modes have a shared provisional one-spice/bank quote. Combined mixed battles, whole-plan inspection and wider combinations remain separate.',
+ 'Existing own lifecycle, hazards and battle bounds remain. Native allied Guild rate cancellation bills one marker and preserves the existing provisional bank payee. Exact supplier approval cannot increase; release retains original token/army and a new full-price offer needs fresh consent. Engine, fresh lender controls and AI share authorized recipient pricing rather than inferring another player’s state from a personal view. Wider source combinations remain partial.',
+ evidence: ['game/shipment-price.ts', 'game/engine.ts'],
},
{
area: 'Player controls',
status: 'Partial',
detail:
- 'Private inventory, token selection, exact payment/funding feedback, reveal and marker movement controls are integrated with concealed map markers and private battle bounds. Browser shipment, marker-only movement, invitation recovery and voluntary reveal have been checked. The allied opportunity, private proposal, payment selection and recipient force/consent controls are integrated; owner proposal, restart recovery and recipient acceptance have been checked in synthetic browser fixtures. Mobile and full battle acceptance remain pending.',
+ 'Existing private inventory, movement, reveal and battle controls remain. Actual390px controlled supplier loan cancels half1 without spending or revealing its prior three-marker, shows authoritative full2, makes a new five-token offer, obtains Guild consent and original interception, then spends supplier2/materializes Richese3 plus Guild5 once. No recipient wallet is exposed. Earlier saved-room evidence remains historical, not new assurance.',
+ evidence: ['components/richese-allied-no-field.tsx', 'components/richese-no-field.tsx'],
},
{
area: 'AI',
status: 'Partial',
detail:
- 'All four profiles support own shipment, marker movement/reveal and marker-only battles using entitled projections. Mixed groups in both modes use the private authoritative battle pool; Basic and Advanced Easy planning/aftermath smokes pass. Allied offer/acceptance preserves human ownership and avoids repeated declined offers and forbidden dial prescience. Complete Richese-game strategy remains unverified.',
+ 'Own and allied legal participation remains. Fresh offer calculations consume the same authorized recipient quote as human controls; no inferred rival wallet or exact strategic first-candidate pin. Four controlled low-budget probes choose no optional reoffer here while the recipient executes a legal ordinary shipment. Full Richese strategy/calibration stays deferred.',
+ evidence: ['game/bots.ts'],
},
{
area: 'Documentation',
status: 'Partial',
detail:
- 'The lifecycle, allied consent/payment/typed forces, on-planet Fremen exclusion, current controls, privacy and five separate progress facets are recorded. Source interpretations and guarded combinations remain explicit; no combined-mode certification is claimed.',
+ 'Source distinctions, lifecycle, typed loan consent and current controls remain documented. Guild-recipient bank payee and one-marker price precedence stay provisional. Full2 supplier reapproval and the authoritative fresh-quote bridge are explicit; original versus controlled evidence and remaining sponsored/card combinations are not conflated.',
+ evidence: ['docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_RATE_KARAMA_RULES.md'],
},
{
area: 'Verification',
status: 'Partial',
detail:
- 'Thirteen pure-model, ten engine, eight independent review and nine AI scenarios pass, plus nineteen existing Gamont regressions. Tests include all hidden values, exact payments, private projections, zero battles, hazards, Gamont cancellation and JSON continuation. Four persisted-room tests also pass for private recovery, competing shipments, cancellation, stale movement and duplicate reveal. Allied shipment adds eleven engine, seven independent review, seven AI and three persisted scenarios, including declineable arrival preflight and AI fallback. The allied shipment checkpoint passed 1,207 rules/client and 90 persisted/API tests. Browser own shipment, movement and invitation recovery/reveal pass, and the allied owner proposal survived a server restart and recipient acceptance was checked in a synthetic fixture. Complete Richese-game acceptance remains pending.',
+ 'Current affected25-file263/263 passes across frozen237+26 selections. Seven controlled allied source cases cover zero/five, recipient full price, supplier release/reapproval, poverty and independent price. Actual390px complete supplier reoffer/consent fixes observed fresh-quote drift; API and four controlled continuation probes pass. Earlier own/battle/persisted evidence remains historical. No new saved/deployed or complete-mode acceptance.',
evidence: [
'tests/richese-no-field.test.ts',
'tests/richese-no-field-engine.test.ts',
@@ -1086,6 +1090,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/richese-allied-no-field-review.test.ts',
'tests/richese-allied-no-field-bots.test.ts',
'tests/richese-allied-no-field-recovery.test.ts',
+ 'tests/allied-marker-guild-rate.test.ts',
],
},
],
@@ -1431,7 +1436,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Documentation',
status: 'Partial',
detail:
- '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.',
+ 'Source-bound effects, copies and Duke guides remain. Native own-arrival child-first/live-token order stays provisional. Separate native transport permission, independent purchased typed prices and own/allied marker billing now preserve original sources and approvals. Cunning opportunity scope and special-stop card-return policy are explicit interpretations; sponsored/purchased composition and full combinations remain open.',
evidence: ['docs/ECAZ_AMBASSADORS_RULES.md', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'docs/BOT_ARRIVAL_PREFLIGHT.md'],
},
{
@@ -2551,13 +2556,13 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
summary:
- 'Buy an auction card or cancel a supported use of a faction power.',
+ 'Buy an auction card, purchase shipment pricing or cancel a supported faction power.',
steps: [
'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.',
- '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.',
+ 'Native Guild cross/return permission has its own unpaid counter before price. Preventing it keeps a legal different shipment/movement without removing native half price. The conservative provisional scope is that original opportunity, not a separately signed Cunning second opportunity; immune Junction and common world-to-foreign-world permission remain independent. Native rate cancellation bills the same paid units and original approved split. Free counters stay free and own/allied No-Field prices one marker. Supplier whole-fee1 requires a new offer/consent before becoming2.',
+ 'Use a held Karama to purchase the active player’s Force Shipment at half normal price, rounded up, paid entirely to bank. Original typed World routes now retain this independent source without new route permission or Nexus stacking. Completion consumes only its grant; original Guild special-stop provisionally returns the exact purchase card to its owner and preserves resources/movement while using shipment. An already purchased card with Junction sponsorship remains a separate unfinished composition.',
'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.',
'Atreides full-plan inspection, Emperor revival, Fremen’s summoned worm, Harkonnen hand exchange the Guild shipment stop and Tleilaxu revival prevention are implemented as once-per-game uses. Other cancellations and special Karamas are still being implemented.',
@@ -4653,7 +4658,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.',
- '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.',
+ 'Cunning native cross/return permission and rate use separate unpaid controls while original shipped history stays unchanged. A denial within its second opportunity allows a legal reserve replacement; denial of an earlier ordinary opportunity does not remove that new signed grant. Typed sources, original prices and approval stay bound; neither first payment nor ordinary movement reopens. Independent purchased price, immune Junction and other card sources remain distinct.',
'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 +4667,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:"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"]},
+ {area:"Implementation",status:'Partial',detail:"Earlier physical roles remain integrated. Native cross/return permission and native price now have distinct original unpaid stages; Cunning opportunity scope preserves its source. Independent purchased typed prices are bank-only, with exact completion/stop-card handling and no new route. Own/allied marker bills one unit; supplier approval cannot silently increase. Authorized recipient pricing now connects fresh human/AI offers. Legacy rate-only signatures remain; purchased Junction composition 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:"Five current390px journeys exercise original prevention then immune Junction/movement, actual gifted World3 for bank2, controlled supplier1 release and fresh full2 consent, controlled special-stop/refund then movement, and original typed permission allowance then rate cancellation. Fresh lender quote drift was observed and fixed with authoritative recipient pricing. Three original scenes/two controlled; no overflow/application exception, with one aborted initial Vite dependency request. Earlier surfaces remain historical.",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:"Native permission denial filters native routes but preserves legal ordinary alternatives; all four original policies execute them. Typed budgets retain independent purchased price without granting a permission. Fresh allied offer calculations use the authorized recipient quote, not a personal-view guess. Four controlled low-budget probes choose no optional reoffer and complete legal recipient shipments. No strategic first-candidate pin, 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:"Supplied authorized Advanced PDF physical16 supports any-player half-normal bank-paid shipment. Distinct native permission, independent typed price and one-marker loan approvals are documented. Original-opportunity denial scope and prepayment ordering are explicit conservative product choices, not new publisher timing rulings; older refund/bank-loan/return policies remain provisional. Original versus controlled evidence and the remaining purchased-Junction composition/public gates stay explicit.",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/RICHESE_ALLIED_SHIPMENT_AUDIT.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 affected25-file263/263 passes across frozen237+26 selections. Twenty main runtime/policy paths pass:15 original-action/five controlled, plus four explicitly controlled optional-offer/recipient probes. Five actual390px journeys prove distinct permission/price, bank-only gift, fresh supplier consent, exact refund and original movement. Controlled Fremen typed/free and BG conversion/counter cases retain original sources. 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/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.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/game/richese-cancellation.ts b/game/richese-cancellation.ts
index 385dade7..7cf63cc4 100644
--- a/game/richese-cancellation.ts
+++ b/game/richese-cancellation.ts
@@ -123,6 +123,7 @@ const RESPONSES = {
harkonnenTraitor: true,
guildIncome: true,
guildRate: true,
+ guildTransportPermission: true,
emperorGift: true,
emperorRevival: true,
stormPeek: true,
diff --git a/game/shipment-price.ts b/game/shipment-price.ts
index 3f6b6c33..2b9b16b3 100644
--- a/game/shipment-price.ts
+++ b/game/shipment-price.ts
@@ -32,13 +32,24 @@ const discountedAlliedNoFieldPricing = Object.freeze({
policy: 'Provisional pricing: a Guild recipient or already-active Karama rate uses the rounded one-force Guild fee (1 spice), paid entirely to the bank. This price/payee precedence is not an adjudicated publisher ruling.',
});
+const canceledGuildAlliedNoFieldPricing = Object.freeze({
+ stronghold: 1, other: 2, bankOnly: true,
+ policy: 'Provisional pricing: canceling a Guild recipient’s native rate charges the full one-force fee, still paid entirely to the bank. This price/payee precedence is not an adjudicated publisher ruling.',
+});
+
/** Shared first-version price/payee policy for Basic and Advanced. */
export function alliedNoFieldShipmentPricing(
- context: { advanced: boolean; karamaShipping?: { player: string } | null },
- recipient: { id: string; faction: FactionId },
+ context: {
+ purchasedGuildRate: boolean;
+ nativeGuildRateCanceled: boolean;
+ },
+ recipient: { faction: FactionId },
) {
- return recipient.faction === 'guild' || context.karamaShipping?.player === recipient.id
- ? discountedAlliedNoFieldPricing : ordinaryAlliedNoFieldPricing;
+ if (context.purchasedGuildRate)
+ return discountedAlliedNoFieldPricing;
+ if (recipient.faction !== 'guild') return ordinaryAlliedNoFieldPricing;
+ return context.nativeGuildRateCanceled
+ ? canceledGuildAlliedNoFieldPricing : discountedAlliedNoFieldPricing;
}
/** A pledge is already escrowed. Selecting a share never spends a donor's
diff --git a/game/transport-quote.ts b/game/transport-quote.ts
index 71df440d..62de759c 100644
--- a/game/transport-quote.ts
+++ b/game/transport-quote.ts
@@ -18,6 +18,8 @@ export function guildTransportQuote(g: GameView, action: Action) {
action.nexus === g.nexusGuildSecretAlly?.event;
if (nexus && !nexusAllowed)
unavailableReasons.push('Choose a current Guild Secret Ally offer.');
+ if (g.guildTransportCanceled && !nexusAllowed)
+ unavailableReasons.push('Karama prevents native Guild cross/return transport for this shipment opportunity.');
const fromReserves = action.from === 'reserves';
let total = 0;
let origin: string | undefined;
diff --git a/tests/allied-marker-guild-rate.test.ts b/tests/allied-marker-guild-rate.test.ts
new file mode 100644
index 00000000..3e54d477
--- /dev/null
+++ b/tests/allied-marker-guild-rate.test.ts
@@ -0,0 +1,164 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { viewGame, type Game } from '../game/engine';
+import {
+ alliedMarkerRatePlayer as player, createAlliedMarkerGuildRate, createAlliedMarkerGuildRateScene,
+ declareAlliedMarkerGuildRate, passAlliedMarkerRateResponses, stepAlliedMarkerRate,
+ type AlliedMarkerGuildRateFixture,
+} from './fixture-allied-marker-guild-rate';
+
+function conserve(g: Game) {
+ for (const p of g.players)
+ assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20);
+ const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)];
+ assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
+}
+function uncommitted(f: AlliedMarkerGuildRateFixture, g: Game) {
+ for (const id of [f.owner, f.recipient]) {
+ const p = player(g, id), before = player(f.beforeAction, id);
+ assert.equal(p.spice, before.spice);
+ assert.equal(p.reserves, before.reserves);
+ assert.deepEqual(p.forces, before.forces);
+ assert.equal(p.shipped, before.shipped);
+ assert.deepEqual(p.noField, before.noField);
+ assert.equal(p.noFieldEvent, before.noFieldEvent);
+ }
+ conserve(g);
+}
+function settled(f: AlliedMarkerGuildRateFixture, before: Game, after: Game, cost: number,
+ payer = f.payer) {
+ const owner = player(after, f.owner), recipient = player(after, f.recipient);
+ assert.equal(owner.spice, player(before, f.owner).spice - (payer === f.owner ? cost : 0));
+ assert.equal(recipient.spice, player(before, f.recipient).spice - (payer === f.recipient ? cost : 0));
+ assert.equal(owner.forces[f.priorSource], 3);
+ assert.equal(owner.reserves, 17);
+ assert.equal(owner.forces[f.target] ?? 0, 0);
+ assert.equal(recipient.forces[f.target] ?? 0, f.amount);
+ assert.equal(recipient.reserves, 20 - f.amount);
+ assert.equal(recipient.shipped, true);
+ assert.equal(owner.shipped, false);
+ assert.equal(owner.noField!.lastShipped, f.token);
+ assert.equal(owner.noField!.deployed, null);
+ assert.deepEqual(owner.noField!.tokens, player(before, f.owner).noField!.tokens);
+ assert.notEqual(owner.noFieldEvent, player(before, f.owner).noFieldEvent);
+ assert.equal(recipient.noField, player(before, f.recipient).noField);
+ assert.equal(after.pendingShipment, null);
+ assert.equal(after.richeseAllyOffer, null);
+ assert.equal(after.response, null);
+ assert.equal(after.players.reduce((sum, p) => sum + p.spice, 0),
+ before.players.reduce((sum, p) => sum + p.spice, 0) - cost,
+ 'The original Guild-recipient contract still pays the bank.');
+ conserve(after);
+}
+
+for (const token of ['zero', 'five'] as const) {
+ void test(`controlled allied ${token}: native cancellation charges one full-price marker, then materializes only Guild reserves`, () => {
+ const f = createAlliedMarkerGuildRateScene({ token });
+ assert.equal(f.accepted.response?.kind, 'richeseNoField');
+ uncommitted(f, f.game);
+ assert.deepEqual(player(f.game, f.owner).noField!.deployed, {
+ tokenId: 'three', controller: f.owner, location: { territory: 'red_chasm', sector: 7 },
+ });
+ const pending = f.game.pendingShipment!;
+ assert.equal(pending.player, f.recipient);
+ assert.equal(pending.amount, f.amount);
+ assert.equal(pending.cost, 1);
+ assert.equal(pending.alliedNoField!.owner, f.owner);
+ assert.equal(pending.alliedNoField!.recipient, f.recipient);
+ assert.equal(pending.alliedNoField!.tokenId, token);
+ assert.equal(pending.alliedNoField!.payer, f.recipient);
+ const done = stepAlliedMarkerRate(f.game, f.cancel);
+ settled(f, f.beforeAction, done, 2);
+ assert.equal(done.discard.filter(c => c.id === f.karama).length, 1);
+ assert.equal(player(done, f.observer).hand.some(c => c.id === f.karama), false);
+ const beforeReplay = structuredClone(done);
+ assert.throws(() => stepAlliedMarkerRate(done, f.cancel));
+ assert.deepEqual(done, beforeReplay);
+ });
+}
+
+void test('controlled Basic allied loan: allowing the native rate pays one bank spice and preserves actual five-force custody', () => {
+ const f = createAlliedMarkerGuildRateScene({ advanced: false });
+ const done = passAlliedMarkerRateResponses(f.game, 'guildRate');
+ settled(f, f.beforeAction, done, 1);
+ assert.ok(player(done, f.observer).hand.some(c => c.id === f.karama));
+});
+
+void test('controlled recipient poverty: cancellation releases token, wallets and army; ordinary full-price replacement remains legal', () => {
+ const f = createAlliedMarkerGuildRateScene({ unaffordable: true });
+ assert.equal(player(f.beforeAction, f.recipient).spice, 1);
+ const released = stepAlliedMarkerRate(f.game, f.cancel);
+ uncommitted(f, released);
+ assert.equal(released.pendingShipment, null);
+ assert.equal(released.response, null);
+ assert.equal(viewGame(released, f.recipient).guildRateCanceled, true);
+ assert.equal(released.discard.filter(c => c.id === f.karama).length, 1);
+ const beforeInvalid = structuredClone(released);
+ assert.throws(() => stepAlliedMarkerRate(released, { actor: f.recipient,
+ action: { type: 'ship', amount: 1, territory: f.territory, sector: f.sector } }));
+ assert.deepEqual(released, beforeInvalid);
+ let done = stepAlliedMarkerRate(released, { actor: f.recipient,
+ action: { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 } });
+ if (done.decision?.kind === 'guildShipment') done = stepAlliedMarkerRate(done,
+ { actor: f.recipient, action: { type: 'decision', allow: true } });
+ assert.equal(done.response, null);
+ assert.equal(done.pendingShipment, null);
+ assert.equal(player(done, f.recipient).spice, 0);
+ assert.equal(player(done, f.recipient).forces['arrakeen:10'], 1);
+ assert.equal(player(done, f.recipient).reserves, 19);
+ assert.equal(player(done, f.recipient).shipped, true);
+ assert.deepEqual(player(done, f.owner).noField, player(f.beforeAction, f.owner).noField);
+ assert.equal(player(done, f.owner).spice, 10);
+ conserve(done);
+});
+
+void test('controlled supplier-funded half-price loan cannot enlarge lender approval; a new full-price offer needs new recipient consent', () => {
+ const f = createAlliedMarkerGuildRateScene({ payer: 'supplier' });
+ assert.equal(player(f.game, f.owner).spice, 10, 'Release must not depend on supplier poverty.');
+ const released = stepAlliedMarkerRate(f.game, f.cancel);
+ uncommitted(f, released);
+ assert.equal(released.pendingShipment, null);
+ assert.equal(viewGame(released, f.recipient).guildRateCanceled, true);
+ assert.equal(released.discard.filter(c => c.id === f.karama).length, 1);
+ const newOffer = stepAlliedMarkerRate(released, { actor: f.owner, action: f.action });
+ assert.notEqual(newOffer.richeseAllyOffer!.event, f.offer.event);
+ assert.equal(newOffer.richeseAllyOffer!.payer, f.owner);
+ assert.equal(viewGame(newOffer, f.recipient).richeseNoField!.allyOffer!.cost, 2);
+ uncommitted(f, newOffer);
+ const declined = stepAlliedMarkerRate(newOffer, { actor: f.recipient,
+ action: { type: 'decision', event: newOffer.richeseAllyOffer!.event, decline: true } });
+ uncommitted(f, declined);
+ const replacement = declareAlliedMarkerGuildRate(f, declined);
+ assert.notEqual(replacement.game.response?.kind, 'guildRate');
+ settled(f, f.beforeAction, replacement.game, 2);
+ assert.equal(player(replacement.game, f.recipient).spice, 10, 'Never rewrite the original supplier payer.');
+});
+
+void test('controlled canceled native rate does not cancel a subsequently played independent Karama loan price', () => {
+ const f = createAlliedMarkerGuildRateScene({ unaffordable: true, independentCard: true });
+ const released = stepAlliedMarkerRate(f.game, f.cancel);
+ uncommitted(f, released);
+ assert.ok(f.independentKarama);
+ const purchased = stepAlliedMarkerRate(released, { actor: f.observer,
+ action: { type: 'card', mode: 'shipment', target: f.recipient, card: f.independentKarama } });
+ assert.equal(viewGame(purchased, f.recipient).guildRateCanceled, true);
+ assert.equal(purchased.karamaShipping?.player, f.recipient);
+ assert.equal(purchased.karamaShipping?.card, f.independentKarama);
+ assert.equal(purchased.discard.filter(c => c.id === f.independentKarama).length, 1);
+ const replacement = declareAlliedMarkerGuildRate(f, purchased);
+ assert.notEqual(replacement.game.response?.kind, 'guildRate');
+ settled(f, f.beforeAction, replacement.game, 1);
+ assert.equal(replacement.game.karamaShipping, null);
+ assert.equal(replacement.game.discard.filter(c => c.id === f.karama).length, 1);
+ assert.equal(replacement.game.discard.filter(c => c.id === f.independentKarama).length, 1);
+});
+
+void test('controlled already-active independent Karama price bypasses the native rate window without changing Guild-recipient bank payee', () => {
+ const f = createAlliedMarkerGuildRate();
+ const purchased = stepAlliedMarkerRate(f.beforeAction, { actor: f.observer,
+ action: { type: 'card', mode: 'shipment', target: f.recipient, card: f.karama } });
+ const done = declareAlliedMarkerGuildRate(f, purchased).game;
+ assert.notEqual(done.response?.kind, 'guildRate');
+ settled(f, f.beforeAction, done, 1);
+ assert.equal(done.discard.filter(c => c.id === f.karama).length, 1);
+});
diff --git a/tests/fixture-allied-marker-guild-rate.ts b/tests/fixture-allied-marker-guild-rate.ts
new file mode 100644
index 00000000..2443c910
--- /dev/null
+++ b/tests/fixture-allied-marker-guild-rate.ts
@@ -0,0 +1,148 @@
+import assert from 'node:assert/strict';
+import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { baseDeck } from '../game/cards';
+import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
+
+export interface AlliedMarkerRateStep { actor: string; action: Action }
+export interface AlliedMarkerGuildRateOptions {
+ token?: 'zero' | 'five';
+ payer?: 'supplier' | 'recipient';
+ unaffordable?: boolean;
+ independentCard?: boolean;
+ advanced?: boolean;
+}
+export interface AlliedMarkerGuildRateFixture {
+ initial: Game;
+ beforeAction: Game;
+ actions: AlliedMarkerRateStep[];
+ action: Action;
+ owner: string;
+ recipient: string;
+ observer: string;
+ token: 'zero' | 'five';
+ payer: string;
+ territory: string;
+ sector: number;
+ target: string;
+ priorSource: string;
+ karama: string;
+ independentKarama: string | null;
+ amount: number;
+ provenance: string[];
+}
+export const alliedMarkerRatePlayer = (g: Game, id: string) => {
+ const p = g.players.find(seat => seat.id === id); assert.ok(p); return p;
+};
+export const alliedMarkerRateReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
+export function stepAlliedMarkerRate(g: Game, step: AlliedMarkerRateStep, actions?: AlliedMarkerRateStep[]) {
+ const next = applyAction(alliedMarkerRateReload(g), step.actor, step.action);
+ actions?.push(structuredClone(step));
+ return next;
+}
+
+/** Bounded CONTROLLED source from richese-allied-no-field.test.ts, not an
+ * original setup/action-only chronology. Its turn, alliance, empty armies,
+ * ten-spice wallets and prior three-marker deployment are assigned here.
+ * Karama cards move out of the actual base deck, conserving physical identity;
+ * they are controlled acquisitions, not auction purchases. The original paired
+ * E3 producer excludes Richese from its supported roster. Every subsequent
+ * wallet reduction, offer, consent, response and settlement is an actual action.
+ */
+export function createAlliedMarkerGuildRate(options: AlliedMarkerGuildRateOptions = {}): AlliedMarkerGuildRateFixture {
+ const owner = 'r', recipient = 'g', observer = 'a';
+ let game = createGame('ALLIEDMARKERRATE', newPlayer(owner, 'Richese', 'richese'),
+ options.advanced ?? true, ['choam']);
+ game.players.push(newPlayer(recipient, 'Guild', 'guild'), newPlayer(observer, 'Atreides', 'atreides'));
+ game.status = 'playing'; game.phase = 5; game.turn = 2; game.active = recipient;
+ game.order = [owner, recipient, observer]; game.movementRemaining = [recipient, owner, observer];
+ game.storm = 18; game.deck = baseDeck();
+ for (const p of game.players) {
+ p.forces = {}; p.reserves = 20; p.tanks = 0; p.spice = 10; p.hand = [];
+ p.traitorChoices = []; p.traitors = [];
+ }
+ const supplier = alliedMarkerRatePlayer(game, owner);
+ supplier.ally = recipient; alliedMarkerRatePlayer(game, recipient).ally = owner;
+ supplier.noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']), {
+ tokenId: 'three', controller: owner, location: { territory: 'red_chasm', sector: 7 },
+ });
+ supplier.noFieldEvent = 'event-a';
+ const cards: string[] = [];
+ for (let n = 0; n < (options.independentCard ? 2 : 1); n++) {
+ const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
+ const [card] = game.deck.splice(index, 1);
+ alliedMarkerRatePlayer(game, observer).hand.push(card); cards.push(card.id);
+ }
+ const initial = alliedMarkerRateReload(game), actions: AlliedMarkerRateStep[] = [];
+ if (options.unaffordable) game = stepAlliedMarkerRate(game, { actor: recipient,
+ action: { type: 'bribe', target: observer, amount: 9 } }, actions);
+ const token = options.token ?? 'five', payer = options.payer === 'supplier' ? owner : recipient;
+ const territory = 'imperial_basin', sector = 10;
+ const action: Action = { type: 'offerRicheseNoField', token, event: supplier.noFieldEvent,
+ payer, territory, sector };
+ return { initial, beforeAction: alliedMarkerRateReload(game), actions, action,
+ owner, recipient, observer, token, payer, territory, sector, target: `${territory}:${sector}`,
+ priorSource: 'red_chasm:7', karama: cards[0], independentKarama: cards[1] ?? null,
+ amount: token === 'zero' ? 0 : 5,
+ provenance: [
+ 'CONTROLLED existing allied-loan setup: turn 2 Shipment, Richese/Guild alliance, 20 counters and 10 spice per seat, prior Richese-controlled three-marker in Red Chasm sector 7.',
+ 'One or two real Karama identities are removed from the undealt base deck into Atreides custody; no original auction purchase is claimed.',
+ 'Optional Guild poverty comes only from an actual nine-spice bribe. All loan offers, recipient consents, responses and physical settlement are actual actions from this bounded source.',
+ ] };
+}
+
+/** Only pass a current physical card holder's actual cancel opportunity. */
+export function passAlliedMarkerRateResponses(state: Game, kind: 'richeseNoField' | 'guildRate',
+ actions?: AlliedMarkerRateStep[]) {
+ let game = alliedMarkerRateReload(state);
+ for (let n = 0; game.response?.kind === kind && n < 20; n++) {
+ const actor = game.players.find(p => {
+ const controls = viewGame(game, p.id).responseControls;
+ return controls && !controls.hasPassed && controls.cancelCards.length > 0;
+ })?.id;
+ assert.ok(actor, 'The pending response needs a real eligible card holder.');
+ game = stepAlliedMarkerRate(game, { actor, action: { type: 'passResponse' } }, actions);
+ }
+ assert.notEqual(game.response?.kind, kind);
+ return game;
+}
+
+/** Includes original source fields before any price cancellation. */
+export function declareAlliedMarkerGuildRate(f: AlliedMarkerGuildRateFixture, state = f.beforeAction,
+ action = f.action) {
+ const actions: AlliedMarkerRateStep[] = [];
+ let game = stepAlliedMarkerRate(state, { actor: f.owner, action }, actions);
+ const offer = structuredClone(game.richeseAllyOffer); assert.ok(offer);
+ const beforeAcceptance = alliedMarkerRateReload(game);
+ game = stepAlliedMarkerRate(game, { actor: f.recipient,
+ action: { type: 'decision', event: offer.event, accept: true, elite: 0 } }, actions);
+ const accepted = alliedMarkerRateReload(game);
+ game = passAlliedMarkerRateResponses(game, 'richeseNoField', actions);
+ if (game.decision?.kind === 'guildShipment') {
+ assert.equal(game.decision.amount, 1);
+ game = stepAlliedMarkerRate(game, { actor: f.recipient,
+ action: { type: 'decision', allow: true } }, actions);
+ }
+ return { game, actions, beforeAcceptance, accepted, offer };
+}
+
+/** API/UI-loadable genuine pending rate scene; no response or decision is staged. */
+export function createAlliedMarkerGuildRateScene(options: AlliedMarkerGuildRateOptions = {}) {
+ const fixture = createAlliedMarkerGuildRate(options);
+ const declared = declareAlliedMarkerGuildRate(fixture);
+ assert.equal(declared.game.response?.kind, 'guildRate');
+ assert.equal(declared.game.pendingShipment?.cost, 1);
+ assert.ok(viewGame(declared.game, fixture.observer).responseControls?.cancelCards.includes(fixture.karama));
+ return { ...fixture, ...declared, declarationActions: declared.actions,
+ actions: [...fixture.actions, ...declared.actions],
+ original: {
+ offer: declared.offer, cost: 1, bankOnly: true,
+ source: { owner: fixture.owner, controller: fixture.recipient, tokenId: fixture.token,
+ tokenEvent: alliedMarkerRatePlayer(fixture.beforeAction, fixture.owner).noFieldEvent,
+ territory: fixture.territory, sector: fixture.sector },
+ supplierPayment: fixture.payer === fixture.owner ? 1 : 0,
+ recipientPayment: fixture.payer === fixture.recipient ? 1 : 0,
+ },
+ cancel: { actor: fixture.observer,
+ action: { type: 'card', mode: 'cancel', card: fixture.karama } as Action },
+ };
+}
diff --git a/tests/fixture-guild-ambassador-income-arrival.ts b/tests/fixture-guild-ambassador-income-arrival.ts
index ca20564a..a0a2627b 100644
--- a/tests/fixture-guild-ambassador-income-arrival.ts
+++ b/tests/fixture-guild-ambassador-income-arrival.ts
@@ -90,19 +90,32 @@ export function createGuildAmbassadorIncomeArrival(options: Pick g.phase === 5 && g.active === f.guild && pairedE3NexusClean(g), actions, quietPairedE3NexusChoice);
+ shipper = f.guild;
+ const selected = Object.keys(pairedE3NexusPlayer(beforeAction, shipper).forces)
+ .find(key => pairedE3NexusPlayer(beforeAction, shipper).forces[key] >= amount && Number(key.split(':')[1]) !== beforeAction.storm);
+ assert.ok(selected); source = selected;
+ action = { type: 'guildShip', from: source, territory: 'reserves', amount, elite: 0, allyPayment: 0 };
+ }
+ return { ...f, actions, beforeAction, route, shipper, source, action, cancelor: f.ecaz,
+ provenance: [...f.provenance, 'Original native cross/return permission is independent of its tariff.'] };
+}
+export function declareNativeGuildTransportPermission(f: {
+ beforeAction: Game; shipper: string; guild: string; cancelor: string; karama: string; action: Action;
+}) {
+ const actions: PairedE3NexusStep[] = [];
+ let game = stepPairedE3Nexus(f.beforeAction, { actor: f.shipper, 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, 'guildTransportPermission');
+ assert.ok(viewGame(game, f.cancelor).responseControls?.cancelCards.includes(f.karama));
+ return { game, actions };
+}
+export function createNativeGuildTransportPermissionScene(route: NativeGuildPermissionRoute = 'allyCross',
+ options: { amount?: number; homeworlds?: boolean } = {}) {
+ const fixture = createNativeGuildTransportPermission(route, options);
+ const declaration = declareNativeGuildTransportPermission(fixture);
+ return { ...fixture, game: declaration.game, declarationActions: declaration.actions,
+ cancel: { actor: fixture.cancelor, action: { type: 'card', mode: 'cancel', card: fixture.karama } as Action } };
+}
diff --git a/tests/fixture-purchased-homeworld-rate.ts b/tests/fixture-purchased-homeworld-rate.ts
new file mode 100644
index 00000000..66052798
--- /dev/null
+++ b/tests/fixture-purchased-homeworld-rate.ts
@@ -0,0 +1,121 @@
+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 { createGuildAmbassadorIncomeArrival, passNativeGuildTransportPermission } from './fixture-guild-ambassador-income-arrival';
+import {
+ createNativeGuildHomeworldRate, createNativeGuildHomeworldRateScene,
+ type NativeGuildHomeworldRoute,
+} from './fixture-native-guild-homeworld-rate';
+import { holdGuildCunningCard } from './fixture-nexus-guild-cunning';
+import {
+ pairedE3NexusReload, stepPairedE3Nexus, type PairedE3NexusStep,
+} from './fixture-paired-e3-nexus-modules';
+
+export type PurchasedHomeworldRoute = 'world' | 'guildWorld' | 'arrakisForeign' | 'return';
+export interface PurchasedHomeworldRateFixture {
+ route: PurchasedHomeworldRoute;
+ shipper: string;
+ ecaz: string;
+ guild: string;
+ entrant: string;
+ karama: string;
+ source: string;
+ destination: string;
+ sources: HomeworldShipmentSources;
+ allyPayment: number;
+ beforePurchase: Game;
+ beforeAction: Game;
+ action: Action;
+ actions: PairedE3NexusStep[];
+ provenance: string[];
+}
+
+function purchase(base: {
+ beforeAction: Game; ecaz: string; guild: string; entrant: string; karama: string;
+ actions: PairedE3NexusStep[]; provenance: string[];
+}, route: PurchasedHomeworldRoute, source: string, destination: string, allyPayment: number) {
+ const actions = [...base.actions];
+ const shipper = route === 'world' ? base.entrant : base.guild;
+ const beforePurchase = pairedE3NexusReload(base.beforeAction);
+ assert.equal(beforePurchase.active, shipper);
+ assert.ok(beforePurchase.players.find(p => p.id === base.ecaz)!.hand.some(c => c.id === base.karama));
+ const game = stepPairedE3Nexus(beforePurchase, { actor: base.ecaz,
+ action: { type: 'card', mode: 'shipment', card: base.karama, target: shipper } }, actions);
+ assert.deepEqual(game.karamaShipping, { player: shipper, owner: base.ecaz, card: base.karama });
+ const sources = { [source]: { normal: 3, elite: 0 } };
+ const view = viewGame(game, shipper);
+ const quote = route === 'world' || route === 'guildWorld'
+ ? homeworldShipmentChoice(view, destination, sources, allyPayment)
+ : guildHomeworldShipmentChoice(view, destination, sources, allyPayment);
+ assert.equal(quote.blocked, null);
+ assert.equal(quote.cost, 2);
+ assert.ok(quote.action);
+ return { ...base, route, shipper, source, destination, sources, allyPayment,
+ actions, beforePurchase, beforeAction: pairedE3NexusReload(game), action: quote.action,
+ provenance: [...base.provenance,
+ 'Ecaz plays its actual held Karama in shipment mode for the active beneficiary; no card identity is transferred.',
+ 'The original typed three-normal-counter route and explicit approved share are quoted after the independent purchase.'],
+ } satisfies PurchasedHomeworldRateFixture;
+}
+
+/** Original printed setup and auctioned Ecaz card from the native Homeworld
+ * factory. No played hand, wallet, army, phase, grant or decision is assigned.
+ * The shared factories disclose conserved undealt Treachery/Spice ordering. */
+export function createPurchasedHomeworldRate(route: PurchasedHomeworldRoute = 'world') {
+ if (route === 'world') {
+ const base = createGuildAmbassadorIncomeArrival({ homeworlds: true });
+ return purchase(base, route, 'homeworld:moritani', 'homeworld:ecaz', 0);
+ }
+ const nativeRoute: NativeGuildHomeworldRoute = route === 'guildWorld' ? 'world' : route;
+ const base = createNativeGuildHomeworldRate(nativeRoute);
+ return purchase(base, route, base.source, base.destination, 1);
+}
+
+/** Actual declaration; the original automatic stop policy may complete it immediately. */
+export function declarePurchasedHomeworldRate(f: PurchasedHomeworldRateFixture) {
+ const actions: PairedE3NexusStep[] = [];
+ const game = stepPairedE3Nexus(f.beforeAction, { actor: f.shipper, action: f.action }, actions);
+ assert.notEqual(game.response?.kind, 'guildRate');
+ return { game, actions };
+}
+
+export function createPurchasedHomeworldRateScene(route: PurchasedHomeworldRoute = 'world') {
+ const fixture = createPurchasedHomeworldRate(route);
+ const declared = declarePurchasedHomeworldRate(fixture);
+ return { ...fixture, game: declared.game, declarationActions: declared.actions,
+ allow: declared.game.decision?.kind === 'homeworldShipmentGuild'
+ ? { actor: fixture.guild, action: { type: 'decision',
+ event: declared.game.decision.event, allow: true } as Action } : null };
+}
+
+/** Continue only actual Guild interception/permission controls, never a native rate or income. */
+export function completePurchasedHomeworldRate(state: Game) {
+ const actions: PairedE3NexusStep[] = [];
+ let game = pairedE3NexusReload(state);
+ if (game.decision?.kind === 'homeworldShipmentGuild')
+ game = stepPairedE3Nexus(game, { actor: game.decision.player,
+ action: { type: 'decision', event: game.decision.event, allow: true } }, actions);
+ const permission = passNativeGuildTransportPermission(game);
+ return { game: permission.game, actions: [...actions, ...permission.actions] };
+}
+
+/** Explicitly controlled SECOND card acquisition only: the first auctioned
+ * Ecaz Karama really cancels an unaffordable native Guild return. The existing
+ * controlled physical-card helper supplies a second Karama to Ecaz; its later
+ * purchase and typed return are real actions. Not original action-only custody. */
+export function createControlledPurchasedHomeworldCanceledRate() {
+ const native = createNativeGuildHomeworldRateScene('return', { unaffordable: true });
+ const actions = [...native.actions, ...native.declarationActions];
+ let game = stepPairedE3Nexus(native.game, native.cancel, actions);
+ assert.equal(viewGame(game, native.guild).guildRateCanceled, true);
+ assert.ok(!game.pendingHomeworldShipment);
+ assert.equal(game.players.find(p => p.id === native.guild)!.shipped, false);
+ const karama = holdGuildCunningCard(game, native.ecaz, 'karama').id;
+ game = pairedE3NexusReload(game);
+ const fixture = purchase({ ...native, beforeAction: game, karama, actions,
+ provenance: [...native.provenance,
+ 'CONTROLLED second physical Karama acquisition via the existing held-card helper; no original-auction claim for this second card.'],
+ }, 'return', native.source, native.destination, 1);
+ return { ...fixture, canceledKarama: native.karama };
+}
diff --git a/tests/guild-ambassador-income-arrival.test.ts b/tests/guild-ambassador-income-arrival.test.ts
index 5a452224..d497e06c 100644
--- a/tests/guild-ambassador-income-arrival.test.ts
+++ b/tests/guild-ambassador-income-arrival.test.ts
@@ -7,7 +7,7 @@ import {
allowGuildAmbassadorIncomeArrival, cancelGuildAmbassadorIncomeArrival,
completeGuildAmbassadorIncomeArrival,
createGuildAmbassadorIncomeArrival, declareGuildAmbassadorIncomeArrival,
- guildAmbassadorIncomeChoice,
+ guildAmbassadorIncomeChoice, passNativeGuildTransportPermission,
} from './fixture-guild-ambassador-income-arrival';
import { pairedE3NexusPolicy, pairedE3NexusInventory as guildAmbassadorIncomeInventory,
pairedE3NexusPlayer as player, pairedE3NexusReload as reload } from './fixture-paired-e3-nexus-modules';
@@ -46,9 +46,9 @@ for (const trigger of [true, false]) {
assert.equal(botArrivalBlock(view, f.action), null,
'A future earned receipt is not an already-active arrival response');
- // This original engine path already commits; owner previews must agree.
+ // Permission is a distinct unpaid window; income follows actual allowance.
const declaration = declareGuildAmbassadorIncomeArrival(f);
- const paid = declaration.after;
+ const paid = passNativeGuildTransportPermission(declaration.after).game;
assert.ok(!paid.pendingGuildTransport);
assert.equal(paid.response?.kind, 'guildIncome');
assert.equal(paid.response?.amount, 1);
@@ -131,7 +131,7 @@ for (const trigger of [true, false]) {
void test('actual Ecaz owner-view policies complete the queued native Guild entry without replaying payment or counters', () => {
const f = createGuildAmbassadorIncomeArrival();
- const paid = declareGuildAmbassadorIncomeArrival(f).after;
+ const paid = passNativeGuildTransportPermission(declareGuildAmbassadorIncomeArrival(f).after).game;
const offered = allowGuildAmbassadorIncomeArrival(paid).game;
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const action = pairedE3NexusPolicy(reload(offered), f.ecaz, difficulty)[0];
diff --git a/tests/guild-transport-karama-engine.test.ts b/tests/guild-transport-karama-engine.test.ts
index 9becfaa4..bdec6eac 100644
--- a/tests/guild-transport-karama-engine.test.ts
+++ b/tests/guild-transport-karama-engine.test.ts
@@ -11,6 +11,8 @@ import {
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
+import { territory } from '../game/board';
+import { passNativeGuildTransportPermission } from './fixture-guild-ambassador-income-arrival';
const player = (g: Game, id: string) => g.players.find(seat => seat.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
@@ -52,9 +54,10 @@ function declare(g: Game, id: string, sources: Record | 'reserve
const selection = sources === 'reserves'
? { from: 'reserves', amount: 3 }
: { forces: sources, ...(eliteForces ? { eliteForces } : {}) };
- return applyAction(g, id, {
+ const permission = applyAction(g, id, {
type: 'guildShip', ...selection, territory: destination, sector, allyPayment,
});
+ return passNativeGuildTransportPermission(permission).game;
}
function canceled(g: Game, card: string): Game {
assert.equal(g.response?.kind, 'guildRate');
@@ -350,3 +353,59 @@ void test('all AI profiles pass pending transport and a blocked ally still selec
assert.equal(player(done, 'e').shipped, true);
assert.ok(player(done, 'e').spice < 2);
});
+
+void test('controlled southern-reserve permission denial preserves typed Fremen counters and their separate free reinforcement', () => {
+ const { g: initial, karama } = fixture('f', true);
+ player(initial, 'f').elites = { reserves: 3, tanks: 0, forces: {}, revived: 0 };
+ const offered = applyAction(reload(initial), 'f', { type: 'guildShip', from: 'reserves',
+ territory: 'carthag', sector: 11, amount: 3, elite: 1, allyPayment: 0 });
+ assert.equal(offered.response?.kind, 'guildTransportPermission');
+ const denied = applyAction(offered, 'a', { type: 'card', mode: 'cancel', card: karama.id });
+ assert.deepEqual(physical(denied, 'f'), physical(initial, 'f'));
+ assert.equal(viewGame(denied, 'f').guildTransportCanceled, true);
+ assert.equal(viewGame(denied, 'f').guildRateCanceled, false);
+ const sector = territory('the_great_flat').sectors.find(value => value !== denied.storm)!;
+ const done = applyAction(denied, 'f', { type: 'ship', territory: 'the_great_flat',
+ sector, amount: 3, elite: 1, allyPayment: 0 });
+ assert.equal(player(done, 'f').spice, player(initial, 'f').spice);
+ assert.equal(player(done, 'g').spice, player(initial, 'g').spice);
+ assert.equal(player(done, 'f').reserves, 17);
+ assert.equal(player(done, 'f').elites?.reserves, 2);
+ assert.equal(player(done, 'f').forces[`the_great_flat:${sector}`], 3);
+ assert.equal(player(done, 'f').elites?.forces[`the_great_flat:${sector}`], 1);
+ assert.equal(player(done, 'f').shipped, true);
+});
+
+for (const counterConversion of [false, true]) {
+ void test(`controlled BG conversion ${counterConversion ? 'countered' : 'allowed'} keeps the original native permission source and physical group`, () => {
+ const { g: initial, karama } = fixture('g', true);
+ stationed(initial, 'g', { 'pasty_mesa:5': 5 });
+ initial.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
+ initial.order.push('b'); initial.movementRemaining!.push('b');
+ const index = initial.deck.findIndex(card => card.kind === 'worthless');
+ assert.ok(index >= 0);
+ const worthless = initial.deck.splice(index, 1)[0];
+ player(initial, 'b').hand.push(worthless);
+ let g = applyAction(reload(initial), 'g', { type: 'guildShip', from: 'pasty_mesa:5',
+ territory: 'carthag', sector: 11, amount: 3, elite: 0, allyPayment: 0 });
+ assert.equal(g.response?.kind, 'guildTransportPermission');
+ g = applyAction(g, 'b', { type: 'card', mode: 'cancel', card: worthless.id });
+ assert.equal(g.response?.kind, 'worthlessKarama');
+ assert.deepEqual(physical(g, 'g'), physical(initial, 'g'));
+ g = applyAction(g, 'a', counterConversion
+ ? { type: 'card', mode: 'cancel', card: karama.id } : { type: 'passResponse' });
+ if (counterConversion) {
+ assert.equal(player(g, 'g').spice, 18);
+ assert.equal(player(g, 'g').forces['pasty_mesa:5'], 2);
+ assert.equal(player(g, 'g').forces['carthag:11'], 3);
+ assert.equal(player(g, 'g').shipped, true);
+ assert.ok(!viewGame(g, 'g').guildTransportCanceled);
+ } else {
+ assert.deepEqual(physical(g, 'g'), physical(initial, 'g'));
+ assert.equal(viewGame(g, 'g').guildTransportCanceled, true);
+ assert.ok(player(g, 'a').hand.some(card => card.id === karama.id));
+ }
+ assert.equal(g.discard.filter(card => card.id === worthless.id).length, 1);
+ assert.equal(g.discard.filter(card => card.id === karama.id).length, counterConversion ? 1 : 0);
+ });
+}
diff --git a/tests/native-guild-rate-betrayal.test.ts b/tests/native-guild-rate-betrayal.test.ts
index 1cc32c36..c5ccf0a5 100644
--- a/tests/native-guild-rate-betrayal.test.ts
+++ b/tests/native-guild-rate-betrayal.test.ts
@@ -2,6 +2,7 @@ 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';
+import { passNativeGuildTransportPermission } from './fixture-guild-ambassador-income-arrival';
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
for (const source of ['reserve', 'guildTransport', 'homeworld'] as const) {
@@ -19,6 +20,7 @@ for (const source of ['reserve', 'guildTransport', 'homeworld'] as const) {
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 } : {}) });
+ g = passNativeGuildTransportPermission(g).game;
assert.equal(g.response?.kind, 'guildRate');
const amount = g.pendingShipment?.amount ?? g.pendingGuildTransport?.amount ?? g.pendingHomeworldShipment!.amount;
assert.ok(amount > 1);
diff --git a/tests/native-guild-rate-karama.test.ts b/tests/native-guild-rate-karama.test.ts
index c598fd08..77056932 100644
--- a/tests/native-guild-rate-karama.test.ts
+++ b/tests/native-guild-rate-karama.test.ts
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import {
createGuildAmbassadorIncomeArrival, allowGuildAmbassadorIncomeArrival,
- completeGuildAmbassadorIncomeArrival,
+ completeGuildAmbassadorIncomeArrival, passNativeGuildTransportPermission,
} from './fixture-guild-ambassador-income-arrival';
import {
advancePairedE3Nexus, nextPairedE3NexusStep, pairedE3NexusClean,
@@ -23,7 +23,7 @@ function declare(f: NativeFixture, before: Game, route: 'reserve' | 'transport',
assert.deepEqual(player(g, f.entrant).forces, player(before, f.entrant).forces);
g = act(g, f.guild, { type: 'decision', allow: true });
}
- return g;
+ return passNativeGuildTransportPermission(g).game;
}
function allowRate(state: Game) {
let g = state;
@@ -117,8 +117,9 @@ void test('original expansion/Nexus Guild return has its own prepayment rate win
const p = player(before, f.guild);
const source = Object.keys(p.forces).find(key => p.forces[key] >= 3)!;
assert.ok(source);
- const offered = act(before, f.guild, { type: 'guildShip', from: source,
+ const permission = act(before, f.guild, { type: 'guildShip', from: source,
territory: 'reserves', amount: 3, elite: 0, allyPayment: 0 });
+ const offered = passNativeGuildTransportPermission(permission).game;
assert.equal(offered.response?.kind, 'guildRate');
assert.deepEqual(player(offered, f.guild).forces, p.forces);
const done = act(offered, f.ecaz, { type: 'card', mode: 'cancel', card: f.karama });
diff --git a/tests/native-guild-transport-permission.test.ts b/tests/native-guild-transport-permission.test.ts
new file mode 100644
index 00000000..e0c9f293
--- /dev/null
+++ b/tests/native-guild-transport-permission.test.ts
@@ -0,0 +1,187 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { guildTransportQuote } from '../game/transport-quote';
+import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
+import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
+import { junctionTransportChoice } from '../game/junction-transport-options';
+import { botActions } from '../game/bots';
+import { passNativeGuildTransportPermission } from './fixture-guild-ambassador-income-arrival';
+import { createNativeGuildTransportPermissionScene } from './fixture-native-guild-transport-permission';
+import { settleNativeGuildRateModules } from './fixture-native-guild-rate-modules';
+import { nexusGuildCunningFixture, originalGuildCunningTurn, nexusGuildCunningRequest,
+ nexusGuildCunningAllow } from './fixture-nexus-guild-cunning';
+import { pairedE3NexusPlayer as player, stepPairedE3Nexus } from './fixture-paired-e3-nexus-modules';
+const act = (g: Game, actor: string, action: Action) => applyAction(structuredClone(g), actor, action);
+const stock = (g: Game, id: string) => {
+ const p = player(g, id);
+ return { spice: p.spice, reserves: p.reserves, forces: p.forces, elites: p.elites,
+ shipped: p.shipped, moved: p.moved };
+};
+function reject(g: Game, actor: string, action: Action) {
+ const copy = structuredClone(g), before = structuredClone(copy);
+ assert.throws(() => applyAction(copy, actor, action));
+ assert.deepEqual(copy, before);
+}
+
+for (const route of ['allyCross', 'guildReturn', 'homeworldReturn'] as const) {
+ void test(`original ${route}: preventing native permission spends no fee or force, keeps ordinary shipment and does not cancel its price`, () => {
+ const f = createNativeGuildTransportPermissionScene(route), before = f.beforeAction;
+ assert.deepEqual(stock(f.game, f.shipper), stock(before, f.shipper));
+ if (before.homeworlds) assert.deepEqual(f.game.homeworlds, before.homeworlds);
+ assert.ok(!f.game.pendingArrivalReaction);
+ assert.ok(!f.game.pendingAmbassador);
+ reject(f.game, f.shipper, f.action);
+ const denied = stepPairedE3Nexus(f.game, f.cancel);
+ assert.deepEqual(stock(denied, f.shipper), stock(before, f.shipper));
+ if (before.homeworlds) assert.deepEqual(denied.homeworlds, before.homeworlds);
+ assert.equal(denied.discard.filter(c => c.id === f.karama).length, 1);
+ assert.equal(viewGame(denied, f.shipper).guildTransportCanceled, true);
+ assert.equal(viewGame(denied, f.shipper).guildRateCanceled, false);
+ reject(denied, f.shipper, f.action);
+ const view = viewGame(denied, f.shipper);
+ if (route === 'homeworldReturn') {
+ assert.ok(guildHomeworldShipmentChoice(view, f.destination,
+ { [f.source]: { normal: 3, elite: 0 } }, 1).blocked);
+ // Common world-to-world shipment does not use Guild's cross/return privilege.
+ const alternative = homeworldShipmentChoice(view, 'homeworld:ecaz',
+ { 'homeworld:guild': { normal: 3, elite: 0 } }, 1);
+ assert.ok(alternative.action);
+ assert.equal(alternative.cost, 2);
+ const done = settleNativeGuildRateModules(act(denied, f.shipper, alternative.action)).game;
+ assert.deepEqual(done.homeworlds!.custody!.visitors['homeworld:ecaz'][f.shipper], { normal: 3, elite: 0 });
+ assert.equal(player(done, f.shipper).reserves, player(before, f.shipper).reserves - 3);
+ assert.equal(player(done, f.shipper).forces[f.source], player(before, f.shipper).forces[f.source]);
+ } else {
+ assert.ok(guildTransportQuote(view, f.action).unavailableReasons.length);
+ const alternative: Action = { type: 'ship', territory: route === 'allyCross' ? f.destination : 'carthag',
+ sector: route === 'allyCross' ? f.sector : 11, amount: 3, elite: 0, allyPayment: 0 };
+ const done = settleNativeGuildRateModules(act(denied, f.shipper, alternative)).game;
+ assert.equal(player(done, f.shipper).spice, player(before, f.shipper).spice - 2);
+ assert.equal(player(done, f.shipper).reserves, player(before, f.shipper).reserves - 3);
+ assert.equal(player(done, f.shipper).forces[f.source], player(before, f.shipper).forces[f.source]);
+ assert.equal(player(done, f.shipper).shipped, true);
+ const next = act(done, f.shipper, { type: 'endMovement' });
+ assert.notEqual(next.active, f.shipper);
+ }
+ });
+
+ void test(`original ${route}: allowing permission still permits a separate full-price rate cancellation`, () => {
+ const f = createNativeGuildTransportPermissionScene(route), before = f.beforeAction;
+ const priced = passNativeGuildTransportPermission(f.game).game;
+ assert.equal(priced.response?.kind, 'guildRate');
+ assert.deepEqual(stock(priced, f.shipper), stock(before, f.shipper));
+ const done = settleNativeGuildRateModules(act(priced, f.cancelor,
+ { type: 'card', mode: 'cancel', card: f.karama })).game;
+ assert.equal(player(done, f.shipper).shipped, true);
+ assert.ok(!viewGame(done, f.shipper).guildTransportCanceled);
+ assert.equal(done.discard.filter(c => c.id === f.karama).length, 1);
+ assert.equal(player(done, f.shipper).spice, player(before, f.shipper).spice - (route === 'homeworldReturn' ? 1 : 3));
+ if (route === 'homeworldReturn') {
+ assert.equal(player(done, f.shipper).reserves, player(before, f.shipper).reserves + 3);
+ assert.equal(done.aid[f.entrant].amount, 0);
+ } else if (route === 'guildReturn') {
+ assert.equal(player(done, f.shipper).reserves, player(before, f.shipper).reserves + 3);
+ } else {
+ assert.equal(player(done, f.shipper).forces[f.target], 3);
+ assert.equal(player(done, f.shipper).reserves, player(before, f.shipper).reserves);
+ }
+ });
+}
+
+void test('original Junction sponsorship survives prevention of the same entrant’s native Guild cross/return privilege', () => {
+ const f = createNativeGuildTransportPermissionScene('allyCross', { homeworlds: true });
+ let g = stepPairedE3Nexus(f.game, f.cancel);
+ const before = structuredClone(g);
+ assert.equal(viewGame(g, f.shipper).guildTransportCanceled, true);
+ const offer = viewGame(g, f.guild).junctionTransport!;
+ assert.ok(offer.canOffer);
+ g = act(g, f.guild, { type: 'offerJunctionTransport', event: offer.offerEvent, rate: 'half' });
+ const choice = junctionTransportChoice(viewGame(g, f.shipper), 'homeworld:moritani',
+ { [f.source]: { normal: 3, elite: 0 } }, 0);
+ assert.ok(choice.action);
+ assert.equal(choice.cost, 2);
+ g = act(g, f.shipper, choice.action);
+ assert.notEqual(g.response?.kind, 'guildTransportPermission');
+ assert.equal(player(g, f.shipper).spice, player(before, f.shipper).spice - 2);
+ assert.equal(player(g, f.shipper).reserves, player(before, f.shipper).reserves + 3);
+ assert.equal(player(g, f.shipper).forces[f.source], player(before, f.shipper).forces[f.source] - 3);
+ assert.equal(player(g, f.guild).spice, player(before, f.guild).spice);
+});
+
+void test('controlled Cunning second opportunity denial retains the first shipment and can use a legal paid reserve replacement', () => {
+ const f = nexusGuildCunningFixture({ advanced: true, karama: true });
+ let g = originalGuildCunningTurn(f, false);
+ g = nexusGuildCunningAllow(act(g, f.owner, nexusGuildCunningRequest(f, g)));
+ const before = structuredClone(g);
+ g = act(g, f.owner, { type: 'guildShip', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 3 });
+ assert.equal(g.response?.kind, 'guildTransportPermission');
+ g = act(g, f.target, { type: 'card', mode: 'cancel', card: f.karama! });
+ assert.equal(g.nexusGuildCunningLast?.stage, 'secondShipment');
+ assert.equal(player(g, f.owner).shipped, true);
+ assert.deepEqual(stock(g, f.owner), stock(before, f.owner));
+ assert.ok(!g.nexusGuildCunningHistory!.at(-1)!.shipment);
+ const done = nexusGuildCunningAllow(act(g, f.owner,
+ { type: 'ship', territory: 'carthag', sector: 11, amount: 3 }));
+ // Advanced Guild's original interception still needs its own real allowance.
+ const paid = settleNativeGuildRateModules(done).game;
+ assert.equal(player(paid, f.owner).spice, player(before, f.owner).spice - 2);
+ assert.equal(player(paid, f.owner).reserves, player(before, f.owner).reserves - 3);
+ assert.equal(player(paid, f.owner).forces['arrakeen:10'], 5);
+ assert.equal(player(paid, f.owner).forces['carthag:11'], 3);
+ assert.equal(paid.nexusGuildCunningLast?.stage, 'extraMove');
+ reject(paid, f.owner, { type: 'move', from: 'arrakeen:10', territory: 'hagga_basin', sector: 12, amount: 1 });
+});
+
+void test('controlled actual denied ordinary privilege does not remove the separately earned Cunning opportunity', () => {
+ const f = nexusGuildCunningFixture({ advanced: true, karama: true });
+ // Explicit controlled initial stock extension, not action-only provenance.
+ // Preserve twenty counters; every subsequent denial/replacement/grant is real.
+ player(f.g, f.owner).reserves -= 5;
+ player(f.g, f.owner).forces['tueks_sietch:5'] = 5;
+ let g = act(f.g, f.owner,
+ { type: 'guildShip', from: 'tueks_sietch:5', territory: 'carthag', sector: 11, amount: 3 });
+ g = act(g, f.target, { type: 'card', mode: 'cancel', card: f.karama! });
+ assert.equal(viewGame(g, f.owner).guildTransportCanceled, true);
+ g = settleNativeGuildRateModules(act(g, f.owner,
+ { type: 'ship', territory: 'arrakeen', sector: 10, amount: 3 })).game;
+ g = nexusGuildCunningAllow(act(g, f.owner, nexusGuildCunningRequest(f, g)));
+ assert.ok(!viewGame(g, f.owner).guildTransportCanceled);
+ const before = structuredClone(g);
+ g = settleNativeGuildRateModules(act(g, f.owner,
+ { type: 'guildShip', from: 'tueks_sietch:5', territory: 'carthag', sector: 11, amount: 3 })).game;
+ assert.equal(player(g, f.owner).spice, player(before, f.owner).spice - 2);
+ assert.equal(player(g, f.owner).forces['tueks_sietch:5'], 2);
+ assert.equal(player(g, f.owner).forces['arrakeen:10'], 3);
+ assert.equal(player(g, f.owner).forces['carthag:11'], 3);
+ assert.equal(g.nexusGuildCunningLast?.stage, 'extraMove');
+});
+
+void test('all four original owner policies retain an executable alternative after native transport permission is prevented', () => {
+ const f = createNativeGuildTransportPermissionScene();
+ const denied = stepPairedE3Nexus(f.game, f.cancel);
+ for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const view = viewGame(denied, f.shipper); view.players.find(p => p.id === f.shipper)!.bot = level;
+ const actions = botActions(view); assert.ok(actions[0]);
+ assert.ok(actions.every(action => action.type !== 'guildShip'));
+ const action = actions[0];
+ const done = settleNativeGuildRateModules(act(denied, f.shipper, action)).game;
+ if (action.type === 'ship') {
+ assert.equal(player(done, f.shipper).shipped, true);
+ assert.equal(player(done, f.shipper).reserves, player(denied, f.shipper).reserves - Number(action.amount));
+ const destination = `${String(action.territory)}:${String(action.sector)}`;
+ assert.equal(player(done, f.shipper).forces[destination],
+ (player(denied, f.shipper).forces[destination] ?? 0) + Number(action.amount));
+ } else if (action.type === 'move') {
+ assert.equal(player(done, f.shipper).moved, player(denied, f.shipper).moved + 1);
+ assert.equal(player(done, f.shipper).forces[String(action.from)] ?? 0,
+ player(denied, f.shipper).forces[String(action.from)] - Number(action.amount));
+ assert.equal(player(done, f.shipper).spice, player(denied, f.shipper).spice);
+ } else {
+ assert.equal(action.type, 'endMovement');
+ assert.notEqual(done.active, f.shipper);
+ assert.deepEqual(player(done, f.shipper).forces, player(denied, f.shipper).forces);
+ assert.equal(player(done, f.shipper).spice, player(denied, f.shipper).spice);
+ }
+ }
+});
diff --git a/tests/nexus-guild-cunning-engine.test.ts b/tests/nexus-guild-cunning-engine.test.ts
index f40fc3da..56c37723 100644
--- a/tests/nexus-guild-cunning-engine.test.ts
+++ b/tests/nexus-guild-cunning-engine.test.ts
@@ -7,6 +7,7 @@ import {
type Game,
type Action,
} from '../game/engine';
+import { passNativeGuildTransportPermission } from './fixture-guild-ambassador-income-arrival';
import {
nexusGuildCunningFixture,
originalGuildCunningTurn,
@@ -503,6 +504,7 @@ for (const route of ['reserve', 'transport'] as const) {
assert.equal(g.decision?.kind, 'guildShipment');
g = applyAction(g, f.owner, { type: 'decision', allow: true });
}
+ g = passNativeGuildTransportPermission(g).game;
assert.equal(g.response?.kind, 'guildRate');
assert.equal(g.players[0].shipped, true);
assert.equal(g.nexusGuildCunningLast?.stage, 'secondShipment');
diff --git a/tests/purchased-homeworld-rate.test.ts b/tests/purchased-homeworld-rate.test.ts
new file mode 100644
index 00000000..da762427
--- /dev/null
+++ b/tests/purchased-homeworld-rate.test.ts
@@ -0,0 +1,205 @@
+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 { guildHomeworldShipmentActions, guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
+import {
+ createPurchasedHomeworldRate, createPurchasedHomeworldRateScene,
+ createControlledPurchasedHomeworldCanceledRate, declarePurchasedHomeworldRate,
+ completePurchasedHomeworldRate, type PurchasedHomeworldRateFixture,
+} from './fixture-purchased-homeworld-rate';
+import { holdGuildCunningCard } from './fixture-nexus-guild-cunning';
+import { pairedE3NexusPlayer as player } from './fixture-paired-e3-nexus-modules';
+
+const act = (g: Game, actor: string, action: Action) => applyAction(structuredClone(g), actor, action);
+function context(g: Game, purchasedGuildRate: boolean, nativeGuildRateCanceled = false) {
+ return { ...homeworldContext(g), purchasedGuildRate, nativeGuildRateCanceled,
+ players: homeworldContext(g).players.map(p => ({ ...p, ally: player(g, p.id).ally })),
+ storm: g.storm, discoveries: g.discoveries, mobileStronghold: g.mobileStronghold?.location ?? null,
+ board: Object.fromEntries(g.players.map(p => [p.id, { forces: p.forces, eliteForces: p.elites?.forces ?? {} }])),
+ };
+}
+function unpaid(f: PurchasedHomeworldRateFixture, g: Game) {
+ assert.deepEqual(g.homeworlds, f.beforeAction.homeworlds);
+ assert.deepEqual(g.aid, f.beforeAction.aid);
+ for (const id of [f.shipper, f.guild, f.entrant]) {
+ assert.equal(player(g, id).spice, player(f.beforeAction, id).spice);
+ assert.equal(player(g, id).reserves, player(f.beforeAction, id).reserves);
+ assert.equal(player(g, id).shipped, player(f.beforeAction, id).shipped);
+ assert.deepEqual(player(g, id).forces, player(f.beforeAction, id).forces);
+ }
+ assert.deepEqual(g.karamaShipping, f.beforeAction.karamaShipping);
+}
+function paid(f: PurchasedHomeworldRateFixture, g: Game) {
+ const before = f.beforeAction, old = player(before, f.shipper), p = player(g, f.shipper);
+ assert.equal(p.spice, old.spice - (2 - f.allyPayment));
+ if (f.shipper !== f.guild) assert.equal(player(g, f.guild).spice, player(before, f.guild).spice);
+ if (f.allyPayment) {
+ assert.equal(player(g, f.entrant).spice, player(before, f.entrant).spice);
+ assert.equal(g.aid[f.entrant].amount, before.aid[f.entrant].amount - f.allyPayment);
+ }
+ assert.notEqual(g.response?.kind, 'guildIncome', 'Every purchased-price contribution goes to the bank, including an allied donor share.');
+ assert.notEqual(g.response?.kind, 'guildRate', 'Native cancellation cannot target an independent card price.');
+ assert.equal(p.shipped, true);
+ assert.equal(p.moved, old.moved);
+ assert.equal(g.karamaShipping, null);
+ assert.ok(!g.pendingHomeworldShipment);
+ assert.equal(g.discard.filter(c => c.id === f.karama).length, 1);
+ assert.ok(!player(g, f.ecaz).hand.some(c => c.id === f.karama));
+ assert.equal(p.reserves, old.reserves + (f.route === 'return' ? 3 : f.route === 'world' || f.route === 'guildWorld' ? -3 : 0));
+ if (f.route === 'return' || f.route === 'arrakisForeign')
+ assert.equal(p.forces[f.source] ?? 0, old.forces[f.source] - 3);
+ else assert.deepEqual(p.forces, old.forces);
+ if (f.route !== 'return')
+ assert.deepEqual(g.homeworlds!.custody!.visitors[f.destination][f.shipper], { normal: 3, elite: 0 });
+ else assert.equal(g.homeworlds!.custody!.visitors[f.destination]?.[f.shipper], undefined);
+ homeworldGameIntegrity(g);
+ const worlds = homeworldForceGroups(homeworldContext(g), g.homeworlds!.custody!);
+ assert.equal(Object.values(p.forces).reduce((sum, n) => sum + n, 0) + p.tanks + worlds.reduce((sum, w) =>
+ sum + (w.forces[f.shipper]?.normal ?? 0) + (w.forces[f.shipper]?.elite ?? 0), 0), 20);
+ assert.throws(() => act(g, f.shipper, f.action));
+ const ended = act(g, f.shipper, { type: 'endMovement' });
+ assert.notEqual(ended.active, f.shipper);
+ assert.deepEqual(player(ended, f.shipper).forces, p.forces);
+}
+
+for (const route of ['world', 'guildWorld', 'arrakisForeign', 'return'] as const) {
+ void test(`original purchased Homeworld ${route}: actual auctioned gift pays the entire approved price to the bank only on completion`, () => {
+ const f = createPurchasedHomeworldRateScene(route);
+ const prior = viewGame(f.beforePurchase, f.shipper);
+ const oldQuote = route === 'world' || route === 'guildWorld'
+ ? homeworldShipmentChoice(prior, f.destination, f.sources, f.allyPayment)
+ : guildHomeworldShipmentChoice(prior, f.destination, f.sources, f.allyPayment);
+ assert.equal(oldQuote.cost, route === 'world' ? 3 : 2);
+ assert.ok(player(f.beforePurchase, f.ecaz).hand.some(c => c.id === f.karama));
+ assert.equal(player(f.beforeAction, f.ecaz).spice, player(f.beforePurchase, f.ecaz).spice);
+ if (f.game.pendingHomeworldShipment) unpaid(f, f.game);
+ const completed = completePurchasedHomeworldRate(f.game).game;
+ paid(f, completed);
+ });
+}
+
+void test('controlled second card after actual unaffordable native cancellation restores only the independent half price', () => {
+ const f = createControlledPurchasedHomeworldCanceledRate();
+ const before = viewGame(f.beforePurchase, f.guild);
+ const native = guildHomeworldShipmentChoice(before, f.destination, f.sources, 1);
+ assert.equal(native.cost, 3);
+ assert.ok(native.blocked);
+ assert.equal(viewGame(f.beforeAction, f.guild).guildRateCanceled, true);
+ assert.equal(guildHomeworldShipmentChoice(viewGame(f.beforeAction, f.guild), f.destination, f.sources, 1).cost, 2);
+ const declared = declarePurchasedHomeworldRate(f).game;
+ if (declared.pendingHomeworldShipment) unpaid(f, declared);
+ const done = completePurchasedHomeworldRate(declared).game;
+ paid(f, done);
+ assert.equal(done.discard.filter(c => c.id === f.canceledKarama).length, 1);
+});
+
+void test('purchased price changes neither typed physical identities nor original route permissions', () => {
+ const f = createPurchasedHomeworldRate('world'), g = f.beforeAction;
+ const ctx = context(g, true, true), custody = g.homeworlds!.custody!;
+ const intent = { player: f.shipper, destination: f.destination, sources: f.sources };
+ assert.equal(quoteHomeworldShipment(ctx, custody, intent).cost, 2);
+ assert.equal(quoteHomeworldShipment({ ...ctx, purchasedGuildRate: false }, custody, intent).cost, 3);
+ const own = homeworldShipmentChoice(viewGame(g, f.shipper), 'homeworld:moritani',
+ { 'homeworld:moritani': { normal: 3, elite: 0 } });
+ assert.equal(own.action, null);
+ assert.ok(own.blocked);
+ const invalid: Action[] = [
+ { ...f.action, destination: 'homeworld:guild' }, // Its actual allied Homeworld.
+ { ...f.action, sources: { [f.source]: { normal: 0, elite: 3 } } },
+ { ...f.action, type: 'guildHomeworldShip', sources: { [Object.keys(player(g, f.shipper).forces)[0]]: { normal: 3, elite: 0 } } },
+ ];
+ for (const action of invalid) {
+ const unchanged = structuredClone(g);
+ assert.throws(() => applyAction(unchanged, f.shipper, action));
+ assert.deepEqual(unchanged, g);
+ }
+ const otherOwner = { ...viewGame(g, f.shipper), karamaShipping: { player: f.ecaz, owner: f.ecaz, card: f.karama } };
+ assert.equal(homeworldShipmentChoice(otherOwner, f.destination, f.sources).cost, 3);
+});
+
+void test('controlled second physical Guild card stops the original gifted purchase and refunds its card to the original owner without payment or departure', () => {
+ const f = createPurchasedHomeworldRate('world');
+ // This second physical acquisition is controlled, not an auction provenance claim.
+ const stop = holdGuildCunningCard(f.beforeAction, f.guild, 'karama');
+ const scene = declarePurchasedHomeworldRate(f).game;
+ const done = act(scene, f.guild, { type: 'card', mode: 'special', card: stop.id });
+ assert.deepEqual(done.homeworlds, f.beforeAction.homeworlds);
+ assert.deepEqual(done.aid, f.beforeAction.aid);
+ assert.equal(player(done, f.shipper).spice, player(f.beforeAction, f.shipper).spice);
+ assert.deepEqual(player(done, f.shipper).forces, player(f.beforeAction, f.shipper).forces);
+ assert.equal(done.karamaShipping, null);
+ assert.ok(player(done, f.ecaz).hand.some(c => c.id === f.karama));
+ assert.ok(!player(done, f.shipper).hand.some(c => c.id === f.karama));
+ assert.equal(done.discard.filter(c => c.id === f.karama).length, 0);
+ assert.equal(player(done, f.shipper).shipped, true);
+ assert.equal(player(done, f.shipper).moved, player(f.beforeAction, f.shipper).moved);
+ assert.equal(player(done, f.guild).specialKaramaUsed, true);
+ assert.equal(done.discard.filter(c => c.id === stop.id).length, 1);
+ assert.ok(!done.pendingHomeworldShipment);
+ homeworldGameIntegrity(done);
+});
+
+
+
+void test('controlled second physical Karama cannot stack another grant on the original active beneficiary', () => {
+ const f = createPurchasedHomeworldRate('world');
+ // Only this second card's physical acquisition is controlled.
+ const second = holdGuildCunningCard(f.beforeAction, f.ecaz, 'karama');
+ const before = structuredClone(f.beforeAction);
+ assert.notEqual(second.id, f.karama);
+ assert.throws(() => applyAction(f.beforeAction, f.ecaz,
+ { type: 'card', mode: 'shipment', card: second.id, target: f.shipper }));
+ assert.deepEqual(f.beforeAction, before);
+ assert.ok(player(f.beforeAction, f.ecaz).hand.some(c => c.id === second.id));
+ const declared = declarePurchasedHomeworldRate(f).game;
+ const done = completePurchasedHomeworldRate(declared).game;
+ paid(f, done);
+ assert.ok(player(done, f.ecaz).hand.some(c => c.id === second.id));
+});
+
+void test('controlled owner-view permission denial blocks native Guild routes but leaves purchased ordinary world-to-world transport legal', () => {
+ // Narrow public permission-flag projections; original auction/action scenes above are unchanged.
+ const f = createPurchasedHomeworldRate('guildWorld');
+ const view = { ...viewGame(f.beforeAction, f.guild), guildTransportCanceled: true as const };
+ const foreign = homeworldShipmentChoice(view, f.destination, f.sources, 1);
+ assert.equal(foreign.cost, 2);
+ assert.ok(foreign.action);
+ const ownReturn = homeworldShipmentChoice(view, 'homeworld:guild',
+ { 'homeworld:ecaz': { normal: 3, elite: 0 } }, 1);
+ assert.equal(ownReturn.action, null);
+ assert.ok(ownReturn.blocked);
+ const r = createPurchasedHomeworldRate('return');
+ const denied = { ...viewGame(r.beforeAction, r.guild), guildTransportCanceled: true as const };
+ const arrakisReturn = guildHomeworldShipmentChoice(denied, r.destination, r.sources, 1);
+ assert.equal(arrakisReturn.action, null);
+ assert.ok(arrakisReturn.blocked);
+ assert.deepEqual(guildHomeworldShipmentActions(denied, 0), []);
+});
+
+void test('controlled Secret Ally projection keeps its explicit source separate from an already purchased card price', () => {
+ // Secret Ally needs Guild absent and no alliance; this is not a played-game chronology.
+ const f = createPurchasedHomeworldRate('world'), original = viewGame(f.beforeAction, f.shipper);
+ const event = JSON.stringify(['nexusGuildSecretAlly', original.turn, original.me]);
+ const view = { ...original, guildRateCanceled: true,
+ players: original.players.filter(p => p.id !== f.guild).map(p => ({ ...p, ally: null })),
+ homeworlds: { ...original.homeworlds!, worlds: original.homeworlds!.worlds!.filter(w => w.native !== f.guild) },
+ nexusCards: { ...original.nexusCards!, card: 'guild', waiting: [] },
+ nexusGuildSecretAlly: { event, blocked: null },
+ aid: { ...original.aid, available: 0 },
+ } as GameView;
+ assert.equal(homeworldShipmentChoice(view, f.destination, f.sources).cost, 2);
+ const stacked = homeworldShipmentChoice(view, f.destination, f.sources, 0, event);
+ assert.equal(stacked.action, null);
+ assert.ok(stacked.blocked);
+ const withoutPurchase = { ...view, karamaShipping: null };
+ assert.equal(homeworldShipmentChoice(withoutPurchase, f.destination, f.sources).cost, 3);
+ const nexus = homeworldShipmentChoice(withoutPurchase, f.destination, f.sources, 0, event);
+ assert.equal(nexus.cost, 2);
+ assert.ok(nexus.action);
+});