From 7e3d7458076162e40de063372171409c5f2f8d07 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Thu, 29 Oct 2026 04:55:44 +1100
Subject: [PATCH] Prototype native Homeworld No-Field return
---
components/homeworld-shipment.tsx | 11 +-
docs/CURRENT_STATUS.md | 9 +
docs/HOMEWORLD_RULES.md | 4 +-
docs/RICHESE_NO_FIELD_RULES.md | 28 ++-
docs/RULE_DECISIONS.md | 22 +-
game/engine.ts | 7 +-
game/homeworld-alliance.ts | 5 +-
game/homeworld-occupation-history.ts | 5 +-
game/reference.ts | 7 +-
game/richese-homeworld-marker-combat.ts | 4 +-
game/richese-homeworld-marker-shipment.ts | 7 +-
tests/native-world-no-field-controls.test.tsx | 122 +++++++++
tests/native-world-no-field-helpers.test.ts | 98 ++++++++
tests/native-world-no-field-runtime.test.ts | 231 ++++++++++++++++++
tests/richese-homeworld-marker-combat.test.ts | 3 +-
...omeworld-marker-transfer-controls.test.tsx | 5 +-
...se-homeworld-marker-transfer-quote.test.ts | 4 +-
17 files changed, 540 insertions(+), 32 deletions(-)
create mode 100644 tests/native-world-no-field-controls.test.tsx
create mode 100644 tests/native-world-no-field-helpers.test.ts
create mode 100644 tests/native-world-no-field-runtime.test.ts
diff --git a/components/homeworld-shipment.tsx b/components/homeworld-shipment.tsx
index 0ab8ecd9..e9e5d02f 100644
--- a/components/homeworld-shipment.tsx
+++ b/components/homeworld-shipment.tsx
@@ -26,8 +26,10 @@ export function HomeworldNoFieldShipment({ game, act, busy }: {
const deployed = info.private.deployed;
const source = deployed && 'homeworld' in deployed.location ? deployed.location.homeworld : undefined;
const targets = (game.homeworlds?.worlds ?? []).filter(world =>
- world.native !== me.id && world.native !== me.ally && world.id !== source);
- const destination = targets.find(world => world.id === selectedDestination) ?? targets[0];
+ (world.native !== me.id || !!source && game.advanced) &&
+ world.native !== me.ally && world.id !== source);
+ const destination = targets.find(world => world.id === selectedDestination) ??
+ targets.find(world => world.native !== me.id) ?? targets[0];
const tokens = info.private.tokens.filter(token => source ? token.id === deployed?.tokenId : token.id !== info.private!.lastShipped);
const token = tokens.find(candidate => candidate.id === selectedToken) ?? tokens[0];
const pool = source ? game.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id] :
@@ -39,8 +41,9 @@ export function HomeworldNoFieldShipment({ game, act, busy }: {
Ship a concealed No-Field
Provisional Homeworld rule: ship one concealed marker {source ? `from ${homeworldName(game, source)}` : 'from your supply'} to
another non-allied Homeworld. Its value stays private and its represented forces
- remain in your reserves until the marker is revealed. This uses your normal shipment.
- {' Physical forces from the same source may accompany it.'}
+ remain in your reserves until the marker is revealed. On your own Homeworld,
+ the represented forces are already in those reserves and are not added again.
+ This uses your normal shipment. Physical forces from the same source may accompany it.
Destination Homeworld for No-Field
setSelectedDestination(event.target.value)}>
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 99ba7f9b..c1fdf3a0 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -17,6 +17,15 @@ open mode gates.
## Current checkpoint and work
+**Advanced native Homeworld No-Field — 29 October. Prototyped / Partial.**
+A deployed marker may ship between a foreign World and native Richese, remain
+concealed and later leave or reveal. Native physical reserves are not duplicated
+in battle or on reveal; a zero marker still supplies public presence. Forty-one
+related cases, quick type/lint checks and the production build pass, including
+actual actions, controls and all four legal bot profiles. Native population and this Homeworld composition are provisional;
+Basic retains its guard, and full rules and deployed acceptance remain open.
+[Scope](RICHESE_NO_FIELD_RULES.md#advanced-native-homeworld-marker--29-october-2026).
+
**Advanced native Homeworld return — 29 October. Prototyped / Partial.**
Physical forces may ship from another Homeworld back to their own under the
user-authorized Advanced rulebook; Basic retains its original guard. Guild uses
diff --git a/docs/HOMEWORLD_RULES.md b/docs/HOMEWORLD_RULES.md
index 01b339c9..51d7c46e 100644
--- a/docs/HOMEWORLD_RULES.md
+++ b/docs/HOMEWORLD_RULES.md
@@ -375,8 +375,8 @@ World-to-World return is this general route rather than its cancelable native
cross/return privilege; the Guild rate and its own cancellation remain separate.
Previously opened native permission windows retain their saved continuation.
Basic retains its existing E3 own-World restriction and Guild exception. This
-cutover does not permit an ordinary Arrakis-to-own-World shipment, allied World
-entry or concealed No-Field return.
+cutover does not permit an ordinary Arrakis-to-own-World shipment or allied World
+entry. The later provisional [No-Field return](RICHESE_NO_FIELD_RULES.md#advanced-native-homeworld-marker--29-october-2026) has a separate physical interpretation.
The [real-action tests](../tests/homeworld-return-native-shipment.test.ts) stage
only the shipment opportunity after native setup and create the foreign garrison
diff --git a/docs/RICHESE_NO_FIELD_RULES.md b/docs/RICHESE_NO_FIELD_RULES.md
index 4b1dc2e1..079e4360 100644
--- a/docs/RICHESE_NO_FIELD_RULES.md
+++ b/docs/RICHESE_NO_FIELD_RULES.md
@@ -66,6 +66,30 @@ The authenticated three-human Basic transfer case passed in four fresh
isolated rooms. Broader combinations, browser and deployed acceptance remain
open.
+## Advanced native Homeworld marker — 29 October 2026
+
+**Prototyped / Partial.** An already deployed Richese No-Field may use a paid
+Advanced World-to-World shipment to return from a foreign Homeworld to native
+Richese, or later leave for another eligible World. The marker remains face
+down and counts as public presence; optional accompanying physical forces and
+the marker are priced together. Basic retains the foreign-only restriction.
+A new marker cannot be shipped from the screen straight to its own World.
+
+The supplied Advanced rules permit World-to-World shipments (physical p. 22,
+printed p. 20) and treat a No-Field as one force until revealed (physical p. 36,
+printed p. 34). They do not resolve a marker on its own reserve Homeworld. In
+this provisional interpretation the native population and battle army count
+the existing physical reserves without adding a duplicate token or its hidden
+value. Revelation removes the marker but leaves those physical reserves in
+place. A marker alone supplies public presence until revealed, including zero;
+exact population threshold and cross-module interactions remain provisional,
+not publisher rulings. The [actual-action journeys](../tests/native-world-no-field-runtime.test.ts)
+stage turn/phase opportunities, then exercise shipment, reveal and native battle.
+[Controls](../tests/native-world-no-field-controls.test.tsx) and
+[helper cases](../tests/native-world-no-field-helpers.test.ts) cover private
+values, Basic guards and physical custody. Full rules and deployed acceptance
+remain open.
+
## Foreign Homeworld marker — 28 October 2026
**Prototyped / Partial.** In supported native Homeworld games without optional
@@ -86,8 +110,8 @@ cancellation and the Advanced Guild stop. The owned source setup selects an
undealt Karama but does not stage resources, phases or forces. An isolated
authenticated Basic room also shipped the marker and confirmed the
other player's view contains the location but not the private token IDs.
-Own-Homeworld
-placement, multiple markers, optional-module combinations, complete mode,
+At that checkpoint own-Homeworld placement remained open; its later Advanced
+prototype is described above. Multiple markers, optional-module combinations, complete mode,
browser and deployed acceptance remain open.
The supplied Advanced revision distinguishes Homeworlds from territories
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 89f2643a..9a76ebec 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,17 @@
# Rule decision index
+## Advanced native Homeworld No-Field — 29 October 2026
+
+The user-authorized revised rules treat a concealed No-Field as one force
+(physical p. 36, printed p. 34) and permit World-to-World shipments (physical
+p. 22, printed p. 20), but do not define a marker on its own reserve World.
+As a provisional composition, an already deployed marker may return there or
+later leave, remains public presence with its value private, and does not add
+another physical counter to the existing native reserve army or population.
+Revealing it leaves that physical pool unchanged. Basic keeps the foreign-World
+restriction. This is an online interpretation, not a publisher ruling.
+[Scope](RICHESE_NO_FIELD_RULES.md#advanced-native-homeworld-marker--29-october-2026).
+
## Advanced native Homeworld return — 29 October 2026
The user-authorized revised rulebook, physical p. 22 (printed p. 20), permits
@@ -8,7 +20,7 @@ this includes return from a foreign World to the faction's own; it is a general
route even if native Guild transport is canceled. Basic retains the existing
printed E3 own-World restriction and Guild exception. Separately priced rates,
physical custody, allied restrictions and already-open Guild permission windows
-remain. Concealed No-Field return remains outside this physical-force slice.
+remain. The later native No-Field composition is recorded above.
[Scope](HOMEWORLD_RULES.md#advanced-native-homeworld-return--29-october-2026).
## Great Maker reserve ride through Aftermath — 29 October 2026
@@ -3192,8 +3204,8 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| No-Field / current Truthtrance fact | Broad game-related yes/no-fact authority supports the distinct current concealed printed-value predicate0/3/5. No marker/revealed history is No even for0; capped/exhausted5 remains printed5. Read the respondent's own model, never an ally or planned shipment. Publish only aggregate Yes/No after the private expected answer; information disclosure is not physical revelation or future-use obligation. This is explicit composition, not a dedicated No-Field FAQ. Existing physical-counter exclusion, native disposal/parent and mode gates remain. | [Source and current-fact scope](RICHESE_NO_FIELD_RULES.md#current-concealed-value-truthtrance--16-october-2026) |
| Harass / classic Homeworlds | Fresh classic2–6 Basic/Advanced independent36-card/Homeworld composition admits Arrakis and foreign-World returns, never an own World. Return exact typed board/visitor counters through native custody: Basic primary; Advanced Emperor normal→Kaitain/elite→Salusa. This destination/timing composition is explicit, not a new card FAQ or separate Nexus ruling. Original sealed commitment/support remains; subsequent population observations cannot rescore it. Homeworld loss receipts retain fully-committed Harass complements; native-only Traitors, late defense/explosion and after-use discard stay. Recruits and Reinforcements have their separate native compositions below; other faction/overlay/public gates remain. | [Source and native routing](HARASS_WITHDRAW_RUNTIME.md#classic-homeworld-reserve-routing--16-october-2026) |
| Recruits / classic Homeworlds | Publisher E3 pp11/16 gives low Fremen4 doubled but capped7. Under choose-recommended, compose original separate-request native population observations: current rate at acceptance, retained accepted group price/free classification, then later requests use changed population and recorded free usage. Bulk7 versus split1+5 is provisional timing, not a new FAQ/user answer. Fremen still cannot buy a seventh without Tleilaxu; typed native deposits and original special-force caps remain. Prior paid/ready returns retain payment and opportunity while phase4 stays open. Clean classic2–6 Basic/Advanced only; Reinforcements uses its separate native fee contract and other roster/overlay/public gates stay. | [Current-rate scope and proof](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026) |
-| Reinforcements / classic Homeworlds | Under standing choose-recommended, seal exact native normal-first sources for the existing three-reserve/all-outcome fee. Ambiguous omission rejects; a unique source may derive. Native reserves on an own World are also its army, so that fee cannot fund the same physical dial/casualties; an off-World fee leaves the pool. Keep original pre-fee traits/printed face, including explosion loss choices across a threshold, then typed Tanks, Harass return and original casualties. This is explicit provisional physical-pool composition, not a new FAQ or own-World ban. Source-aware full/complying plan reviews and named independent-card conditions use canonical names and actual combat locations. Other rosters/overlays/public starts remain gated. | [Native sources and exact proof](REINFORCEMENTS_RUNTIME.md#classic-homeworld-reserve-fees--16-october-2026) |
-| Reinforcements / clean selected Ix and CHOAM | Admit original independent cards with clean selected native/base Basic/Advanced rosters and no optional modules/overlays. Reuse existing provisional normal-first/all-outcome payment, original No-Field materialization before the fee, then successful Harass return; physical Stone ignores +2. Native cyborg substitution uses its own casualty origins, not a duplicate reserve fee. This closes admission consistency, not a new numerical ruling or public expansion gate. | [Profile and bounded proof](REINFORCEMENTS_RUNTIME.md#clean-selected-ixchoam-families--16-october-2026) |
+| Reinforcements / classic Homeworlds | Under standing choose-recommended, seal exact chosen native sources for the existing three-reserve/all-outcome fee; older plans without a choice retain normal-first. Ambiguous omission rejects; a unique source may derive. Native reserves on an own World are also its army, so that fee cannot fund the same physical dial/casualties; an off-World fee leaves the pool. Keep original pre-fee traits/printed face, including explosion loss choices across a threshold, then typed Tanks, Harass return and original casualties. This is explicit provisional physical-pool composition, not a new FAQ or own-World ban. Source-aware full/complying plan reviews and named independent-card conditions use canonical names and actual combat locations. Other rosters/overlays/public starts remain gated. | [Native sources and exact proof](REINFORCEMENTS_RUNTIME.md#classic-homeworld-reserve-fees--16-october-2026) |
+| Reinforcements / clean selected Ix and CHOAM | Admit original independent cards with clean selected native/base Basic/Advanced rosters and no optional modules/overlays. Reuse the provisional all-outcome payment and chosen typed reserve mix, original No-Field materialization before the fee, then successful Harass return; physical Stone ignores +2. Native cyborg substitution uses its own casualty origins, not a duplicate reserve fee. This closes admission consistency, not a new numerical ruling or public expansion gate. | [Profile and bounded proof](REINFORCEMENTS_RUNTIME.md#clean-selected-ixchoam-families--16-october-2026) |
| Independent Treachery / all14 Skills | Printed variant independence composes registered actual selected-family all14 Basic/Advanced2–6, including mixed E3/E1/E2, unused decks, original Worlds and Tech3+/Advanced Strongholds2+. No Nexus/exact E3 family/card dependency is invented. Original36/38/50, separate cache and native setup/custody remain. Bare mixed E3 World/phase cards, original Nexus/Discovery/Semuta, Advanced role, shared Duke and effect-specific limits stay separate. Fees/H/committed-only Suk, own-World bans and separate Banker/Tech remain. No new numeric rule/save retrofit. | [Current registered native scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026), [physical sources](ECAZ_TREACHERY_RULES.md) |
| Independent Treachery / Discovery / optional Nexus | Existing printed E3 variant independence admits fresh registered classic/selected0–3-family independent36/38/50 plus Discovery7/8, optionally Nexus12/World/all14/phase. Native setup and physical card rules remain original; transient encounters do not remove unrelated capabilities. Actual Cunning nested arrival, Sandtrout suppression, paid R/Harass and earned Recruits have bounded programmes. No new numerical/timing ruling; trained actual-E3/Harkonnen, unrelated previews, full modes and broader interactions remain guarded. | [Source-bound proof and limits](NEXUS_CARD_RULES.md#independent-cards-with-discovery--18-october-2026) |
| Classic independent Treachery / Nexus | Printed independent-variant authority composes fresh classic2–6 Basic/Advanced independent36/Nexus12, optionally all14, through one public configuration predicate and original setup. Recruits changes ordinary rates/total7, not fixed Tleilaxu group price/free classification. Cunning keeps temporary ordinary counters, separate Reinforcements reserve fees, actual Harass returns and original cancellation. No new numeric rule or played-save conversion. Native selected families, World/phase/Discovery/Semuta, reaction-preview and other effect-specific bounds remain separate; inherited provisional timings are not promoted to publisher rulings. | [Source and exact native proof](NEXUS_CARD_RULES.md#classic-independent-treachery-composition--18-october-2026) |
@@ -3206,10 +3218,10 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Original World Nexus/Discovery occupation | Shared fresh World setup supplies mandatory original occupation state in already admitted Nexus and/or Discovery envelopes, optionally Skills and original phase modules. Original entry predicates, native flags and phase limits remain authoritative; existing occupation resources/history cannot be overwritten. Printed qualification, Collection, percentage/Tupile/Ecaz benefits compose with original nested entry, Nexus custody and deferred Banker without a new numeric policy or played-game stamp. | [Source and current boundary](HOMEWORLD_RULES.md#original-world-nexus-and-discovery-occupation--26-october-2026) |
| Registered World Skills / occupation benefits | Existing registered clean World/all14 admission now initializes original occupation benefits alongside ordinary Skill/Banker state, with optional independent cards and original phase modules. No new roster/role permission or numeric policy. Real other-payer Bank payment earns deferred Banker; occupied Collection, ordinary Collection, token payout and actual Mentat settlement stay distinct. Preserve original native flags, physical offers/leaders and private choices; Nexus/Discovery/Semuta and played-game contexts remain separate. | [Current source and contract](HOMEWORLD_RULES.md#registered-world-skills-occupation--26-october-2026) |
| Clean Homeworlds / original phase modules | Audited printed World/occupied faces and supplied Advanced variant independence compose registered classic/no-deck and selected-family paired/mixed/all-three rosters with optional independent cards, Tech3–6 and Advanced Strongholds2–6. Fresh setup includes original occupation bank/percentage ledgers, native CHOAM leases and Ecaz World victory; module locations alone are insufficient. Native qualification/income/auction/Duke/tariff and existing Advanced cutovers remain. No new numeric policy, played-game stamp/retrofit or Skills/Nexus/Discovery/Semuta expansion. | [Current source and scope](HOMEWORLD_RULES.md#clean-homeworlds-and-phase-modules--26-october-2026) |
-| Selected independent cards / optional phase modules | Printed E3 variant independence and supplied Advanced physical21/printed19 compose clean registered selected-deck paired/mixed/all-three families and classic-only unused decks. Basic mixed Ecaz admits bare cards without incidental Tech; optional original Tech3–6 Basic/Advanced and Strongholds2–6 Advanced retain real setup/owners/claims. Existing Recruits current rates, separate Tleilaxu bank income, delayed Axlotl, normal-first reserve fee, owner-only coalition Harass and selected-winner Tech remain. Advanced native Assassinate uses the existing captured-leader contract; no new numerical ruling, bare mixed World/Skills/Nexus/Discovery/Semuta permission or played-game conversion. | [Current source and scope](ECAZ_TREACHERY_RULES.md#selected-independent-cards-and-phase-modules--26-october-2026) |
+| Selected independent cards / optional phase modules | Printed E3 variant independence and supplied Advanced physical21/printed19 compose clean registered selected-deck paired/mixed/all-three families and classic-only unused decks. Basic mixed Ecaz admits bare cards without incidental Tech; optional original Tech3–6 Basic/Advanced and Strongholds2–6 Advanced retain real setup/owners/claims. Existing Recruits current rates, separate Tleilaxu bank income, delayed Axlotl, chosen typed reserve fee, owner-only coalition Harass and selected-winner Tech remain. Advanced native Assassinate uses the existing captured-leader contract; no new numerical ruling, bare mixed World/Skills/Nexus/Discovery/Semuta permission or played-game conversion. | [Current source and scope](ECAZ_TREACHERY_RULES.md#selected-independent-cards-and-phase-modules--26-october-2026) |
| Independent Treachery / original phase modules | Printed independence retains original Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with classic/E1/E2 and original standalone/paired E3 bare cards; all14 also composes registered mixed selected families and Worlds. Real first-Storm/end-Mentat owners, separate delayed income and physical funding remain. Fees/H are not revival/shipment and +2 is not supported force. Native coalition/assassination and original queue stay source-labelled. Bare mixed E3 World/phase, original Nexus/Discovery/Semuta and role/effect guards remain. | [Original phase contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026), [registered scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026) |
| Classic World independent cards / phase modules | Compose original unused Tech3+ and/or Advanced Strongholds2+ with the existing classic36-card Homeworld entry. Preserve native typed custody, current separate-request rates/paid ledgers, exact source-bearing R fees, original face/support, fee/dial disjointness and fee-before-Harass. Keep every own-World Harass ban; native returns are not shipment/free Revival. Original delayed income and earned cards add no personal subsidy or supported +2. This inherits existing provisional timing, not a new publisher ruling or native expansion/public gate. | [Source-bound contract](ECAZ_TREACHERY_RULES.md#classic-world-phase-module-composition--16-october-2026) |
-| Selected E1/E2 Worlds / independent cards | Use original selected38/50 native/base Basic/Advanced setup, optionally all14 Skills and original Tech/Advanced Strongholds. Retain current-rate/unlimited revival, accepted invoices versus separate native income, native-only World declarations and every own-World H ban. R normal-first sources retain pre-fee face/traits and fee/dial disjointness. Richese validates3 AFTER projected materialization before a first seal, never from later H returns or skill strength. Only committed battle losses feed Suk and actual board Cyborg losses feed substitution; fee sources are never duplicated. Existing timing remains provisional, not a new FAQ. | [Original native funding](ECAZ_TREACHERY_RULES.md#native-selected-family-world-composition--17-october-2026), [current Skills composition](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026) |
+| Selected E1/E2 Worlds / independent cards | Use original selected38/50 native/base Basic/Advanced setup, optionally all14 Skills and original Tech/Advanced Strongholds. Retain current-rate/unlimited revival, accepted invoices versus separate native income, native-only World declarations and every own-World H ban. R chosen typed sources retain pre-fee face/traits and fee/dial disjointness. Richese validates3 AFTER projected materialization before a first seal, never from later H returns or skill strength. Only committed battle losses feed Suk and actual board Cyborg losses feed substitution; fee sources are never duplicated. Existing timing remains provisional, not a new FAQ. | [Original native funding](ECAZ_TREACHERY_RULES.md#native-selected-family-world-composition--17-october-2026), [current Skills composition](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026) |
| Clean Ix Stone / Tooth / Mirror | Reuse existing provisional original Stone-first/uncopied-Tooth order; the earlier priority question stays unanswered. Printed Mirror copy-first and user-selected physical retention remain independent. Selected Ix/CHOAM now retains original Tech3+ and Advanced Strongholds2+, original47/independent50 plus separate cache, with genuine earned custody. No numerical ruling changes. Typed winner ambiguity, mixed E3 with these phase modules and other overlays stay guarded; existing Ecaz/Moritani combinations remain Advanced-only. | [Source and profile distinction](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026), [phase-module proof](MIRROR_WEAPON_ENGINE_AUDIT.md#original-phase-module-composition--16-october-2026) |
| Expansion-faction setup | Selecting Ecaz/Moritani factions does not select their independent three-card variant. Ordinary decks contain 33/35/47 cards; Richese's ten-card cache stays separate. Ecaz allocates six total forces within Imperial Basin sectors 9–11, retaining fourteen reserves. Flexible sector allocation is the literal printed territory constraint; Fremen→Ecaz→Advanced BG order is an implementation inference, while Moritani-last is printed. | [Source and profile contract](EXPANSION_FACTIONS_PROTOTYPE.md) |
| Ixian Technology on Richese lots | Superseded 8 October by the authorized supplied Advanced physical p34, which expressly excludes Richese auctions from Ixian Technology. Richese cache/Black Market lots now open directly; the once-per-round Technology use remains for ordinary lots. The temporary per-lot decline scaffolding and its tests were deleted. | [Auction source boundary](RICHESE_AUCTION_RULES.md#13-september-saved-per-lot-technology-decline--superseded-8-october) |
diff --git a/game/engine.ts b/game/engine.ts
index 9013e0f0..faeb4b07 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -4820,7 +4820,7 @@ function revealPlayerNoField(g: Game, p: Player, cause: NoFieldRevealCause) {
p.noFieldEvent = crypto.randomUUID();
if (isHomeworldNoFieldLocation(result.location)) {
const destination = result.location.homeworld;
- if (result.forces) {
+ if (result.forces && destination !== 'homeworld:richese') {
const transfer = homeworldRule(() => quoteHomeworldCustody(homeworldContext(g), g.homeworlds!.custody!, [
{ homeworld: 'homeworld:richese', player: p.id, withdraw: { normal: result.forces, elite: 0 }, deposit: { normal: 0, elite: 0 } },
{ homeworld: destination, player: p.id, withdraw: { normal: 0, elite: 0 }, deposit: { normal: result.forces, elite: 0 } },
@@ -4839,7 +4839,7 @@ function revealPlayerNoField(g: Game, p: Player, cause: NoFieldRevealCause) {
observeOccupation(g);
log(
g,
- `${p.name} revealed the ${result.value} No-Field in ${isHomeworldNoFieldLocation(result.location) ? combatLocationName(g, result.location.homeworld) : territory(result.location.territory).name} (${cause === 'alliedSeparation' ? 'allied separation' : cause === 'discoveryBlow' ? 'Discovery spice blow; provisional destructive reveal' : cause}) and placed ${result.forces} physical forces from reserves.${result.forces < result.value ? ' Only the remaining reserves could be placed.' : ''}`,
+ `${p.name} revealed the ${result.value} No-Field in ${isHomeworldNoFieldLocation(result.location) ? combatLocationName(g, result.location.homeworld) : territory(result.location.territory).name} (${cause === 'alliedSeparation' ? 'allied separation' : cause === 'discoveryBlow' ? 'Discovery spice blow; provisional destructive reveal' : cause})${isHomeworldNoFieldLocation(result.location) && result.location.homeworld === 'homeworld:richese' ? `; ${result.forces} represented forces are already in the native reserve pool.` : ` and placed ${result.forces} physical forces from reserves.`}${result.forces < result.value ? ' Only the remaining reserves could be placed.' : ''}`,
{ faction: 'richese', name: 'No-Field revealed' },
);
return result;
@@ -10719,6 +10719,9 @@ function killTerritory(
* before requesting a pool. Actual reveal remains the native component action. */
function prospectiveNoFieldForces(p: Player, territory: string): number {
const marker = p.noField?.deployed;
+ // On Richese's own World these physical reserves already constitute its
+ // native army; revealing the marker does not create another copy of them.
+ if (territory === 'homeworld:richese' && marker && noFieldAtCombatLocation(p, territory)) return 0;
return marker && noFieldAtCombatLocation(p,territory)
? Math.min(p.reserves, p.noField!.tokens.find(token => token.id === marker.tokenId)!.value)
: 0;
diff --git a/game/homeworld-alliance.ts b/game/homeworld-alliance.ts
index 94fa0998..9859401f 100644
--- a/game/homeworld-alliance.ts
+++ b/game/homeworld-alliance.ts
@@ -16,8 +16,9 @@ export function homeworldAllianceBlock(
if (marker) {
const world = worlds.find(row => row.id === marker.homeworld);
const controller = context.players.find(row => row.id === marker.controller);
- if (!world || !controller || controller.faction !== 'richese' || world.native === controller.id)
- throw new HomeworldCustodyError('Homeworld alliance marker needs its seated Richese controller and an active foreign world.');
+ if (!world || !controller || controller.faction !== 'richese' ||
+ world.native === controller.id && !context.advanced)
+ throw new HomeworldCustodyError('Homeworld alliance marker needs its seated Richese controller and an active foreign world, or its own Homeworld in Advanced.');
if ((world.native === first && controller.id === second) ||
(world.native === second && controller.id === first))
return 'An alliance cannot form while either faction has forces on the other’s Homeworld.';
diff --git a/game/homeworld-occupation-history.ts b/game/homeworld-occupation-history.ts
index cb3c3ac1..3254ae61 100644
--- a/game/homeworld-occupation-history.ts
+++ b/game/homeworld-occupation-history.ts
@@ -98,8 +98,9 @@ function validateMarker(context: HomeworldCustodyContext, marker: HomeworldOccup
'a public No-Field marker needs its original world and controller.');
const home = homeworldLocations(context).find(row => row.id === marker.homeworld);
const player = context.players.find(row => row.id === marker.controller);
- requireHistory(home && player && player.faction === 'richese' && home.native !== player.id,
- 'a No-Field marker needs a seated Richese controller and an active foreign world.');
+ requireHistory(home && player && player.faction === 'richese' &&
+ (context.advanced || home.native !== player.id),
+ 'a No-Field marker needs a seated Richese controller and an active foreign world, or its own Homeworld in Advanced.');
}
/** Extract only the public off-planet marker location. No token ID or hidden
diff --git a/game/reference.ts b/game/reference.ts
index 507c09f7..0c0b5323 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -1127,7 +1127,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In Basic play, your own single concealed No-Field may provisionally ship into an ally’s territory if it can legally enter; move it and any paid accompanying forces out before your turn ends. Paid companions require the High Richese Homeworld power. If the marker remains, reveal it and send its newly materialized forces to Tanks under the provisional no-departure policy. A zero marker reveals but contributes no casualties. Existing physical forces and the ally are not part of that new-visitor loss. Paired markers keep their separate guards.',
'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.',
'Provisional Guild transport treats your owned concealed marker as one billable force alongside any same-origin physical counters. Native Guild allies may cross-ship it through the normal permission and rate responses. The actual absent-Guild Secret Ally may cross-ship or return it face down, spending its Nexus once and paying the Bank. Cross keeps concealment; return creates no denomination-sized reserve deposit and retains the same-token next-shipment ban. Ordinary Guild allies cannot return. Select the marker separately from physical counters; ordinary movement remains available.',
- 'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld, alone or with physical forces from those reserves. On a later turn, you may ship that same concealed marker between distinct eligible foreign Homeworlds, alone or with selected physical Richese counters from the marker’s current World. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal, when available reserves are placed there. Only one marker may be deployed globally. Own-Homeworld placement or return and optional-module combinations remain unavailable. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.',
+ 'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld, alone or with physical forces from those reserves. On a later turn, you may ship that same concealed marker between distinct eligible Homeworlds, alone or with selected physical Richese counters from the marker’s current World; its own Homeworld is an eligible endpoint only in Advanced. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal; available reserves are placed there unless it is already on its own World, where the physical pool stays unchanged. Only one marker may be deployed globally. Fresh placement from the screen to your own Homeworld and optional-module combinations remain unavailable; an Advanced native return uses the provisional physical-reserve interpretation described below. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.',
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Physical and Discovery flight use the provisional skill transport policy with original marker quotas; complete combinations remain unfinished.',
'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.',
@@ -1330,7 +1330,7 @@ export const RULE_TOPICS: RuleTopic[] = [
: definition.card.effect === 'ornithopter'
? 'Use the Ornithopter controls with one unused normal/Hajr movement and a selected group/destination. Two card groups stay distinct, while Hajr may reuse their forces afterward. A genuinely recovered Hajr may be legally replayed for another grant. Advanced needs a fresh immediate-after-movement opportunity. Kulon retains the fixed maximum three and extends both normal groups. Advisor/card and wider module combinations remain unfinished.'
: definition.card.effect === 'residualPoison'
- ? 'Use the Residual Poison panel before either combatant commits a leader. A shared preparation step provides an opportunity regardless of hand contents; advanced Harkonnen tables remain guarded pending the secret-captive ruling. In the explicit Semuta preview, including the supported Basic independent-card profile, a clean death pauses after public disposal for the neutral offer even if one combatant is already ready. The opponent’s readiness remains; the Poison player must ready again. Inspected battle commitments remain outside this reaction.'
+ ? 'Use the Residual Poison panel before either combatant commits a leader. A shared preparation step provides an opportunity regardless of hand contents. Advanced Harkonnen captives are included under the provisional public selected-death interpretation; unselected captives remain concealed. In the explicit Semuta preview, including the supported Basic independent-card profile, a clean death pauses after public disposal for the neutral offer even if one combatant is already ready. The opponent’s readiness remains; the Poison player must ready again. Inspected battle commitments remain outside this reaction.'
: definition.card.effect === 'stoneBurner'
? 'Use Stone Burner in the weapon selector and review public dial/support feasibility before sealing. Clean selected CHOAM/Ix now uses visibly provisional original Stone-first then uncopied Tooth order; copy-first and physical allocation guards remain.'
: definition.card.effect === 'mirrorWeapon'
@@ -5508,7 +5508,8 @@ export const RULE_TOPICS: RuleTopic[] = [
).join(' '),
steps: [
'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.',
- 'Advanced permits a paid physical shipment from a different Homeworld back to your own, including either Emperor World. Guild may use this common route even if native cross/return transport is canceled; the Guild rate is separate. Basic keeps its existing Guild exception and own-World restriction. Arrakis and concealed No-Field return remain separate.',
+ 'In the provisional Advanced native Richese preview, a deployed No-Field may ship from another Homeworld to its own and later leave. It stays concealed as public presence; native reserves remain one physical pool and are not duplicated when it reveals. Population and other interactions remain provisional; Basic retains the foreign-World restriction.',
+ 'Advanced permits a paid physical shipment from a different Homeworld back to your own, including either Emperor World. Guild may use this common route even if native cross/return transport is canceled; the Guild rate is separate. Basic keeps its existing Guild exception and own-World restriction. Arrakis return and other concealed No-Field interactions remain separate.',
'Clean World starts admit registered classic/no-deck and selected-family paired/mixed/all-three rosters with optional independent cards, Tech3–6 and Advanced Strongholds2–6. Skills or Nexus are not incidental prerequisites. Fresh setup includes original occupation qualification, bank/percentage benefits, native Tupile and Ecaz victory state alongside physical locations; existing played games are not retrofitted. Original module-specific profiles and complete-mode gates stay separate.',
'Fresh World all14 uses actual registered seats and selected ordinary decks in Basic/Advanced, including mixed Ecaz/Moritani with E1/E2 natives. Neither Nexus, independent cards, one exact family nor a seated native from every selected deck is required. Original Tech3+/Advanced Strongholds2+ may remain. Existing no-card Nexus/Discovery and Semuta keep their separate limits. Actual hands, Ix card-before-training and native five-disc assignment remain; Auditor/shared Duke never train. Native Suk saves stay at that home; external Emperor returns retain original typed destinations. Nested locations gain no ordinary Stronghold effect or invented saved history.',
'Fresh clean registered World/all14 starts also include original occupation benefit state beside normal Banker. Admission and native role limits remain. Real other-payer Bank payments can earn a deferred skill grant; occupied Collection and phase tokens do not become immediate Banker cash. Actual Mentat settles the original skill receipt once. Nexus/Discovery/Semuta contexts retain their own scope; played games are not retrofitted.',
diff --git a/game/richese-homeworld-marker-combat.ts b/game/richese-homeworld-marker-combat.ts
index 8c9837c0..330b7794 100644
--- a/game/richese-homeworld-marker-combat.ts
+++ b/game/richese-homeworld-marker-combat.ts
@@ -35,8 +35,8 @@ export function homeworldNoFieldPresence(
if (!owner || owner.faction !== 'richese')
throw new Error('A Homeworld No-Field needs its seated Richese controller.');
const home = homeworldCombatLocation(context, custody, marker.location.homeworld);
- if (home.native === owner.id)
- throw new Error('A concealed No-Field on its own Homeworld is not supported.');
+ if (home.native === owner.id && !context.advanced)
+ throw new Error('A concealed No-Field on its own Homeworld is not supported in Basic.');
return { homeworld: home.id, controller: owner.id, effectiveForces: 1 };
}
diff --git a/game/richese-homeworld-marker-shipment.ts b/game/richese-homeworld-marker-shipment.ts
index 97be7028..c41d66d6 100644
--- a/game/richese-homeworld-marker-shipment.ts
+++ b/game/richese-homeworld-marker-shipment.ts
@@ -93,6 +93,7 @@ export function quoteRicheseHomeworldMarkerShipment(
/** A deployed marker can be transported like one force between eligible
* Homeworlds, provisionally alongside physical forces from that same source.
+ * Advanced permits a native Homeworld endpoint; Basic remains foreign-only.
* Its denomination never enters the price or physical custody. */
export function quoteRicheseHomeworldMarkerTransfer(
context: HomeworldShipmentContext,
@@ -119,9 +120,9 @@ export function quoteRicheseHomeworldMarkerTransfer(
const player = context.players.find(seat => seat.id === intent.player);
const source = worlds.find(world => world.id === intent.source);
const destination = worlds.find(world => world.id === intent.destination);
- requireMarker(player?.faction === 'richese' && source && destination &&
- source.native !== player.id && destination.native !== player.id && source.id !== destination.id,
- 'Choose two different active foreign Homeworlds for native Richese.');
+ requireMarker(player?.faction === 'richese' && source && destination && source.id !== destination.id &&
+ (context.advanced || (source.native !== player.id && destination.native !== player.id)),
+ 'Choose two different active Homeworlds; Basic marker transfers require foreign Homeworlds.');
const native = context.players.find(seat => seat.id === destination.native)!;
requireMarker(!(player.ally === native.id && native.ally === player.id),
'You cannot ship to your ally’s Homeworld.');
diff --git a/tests/native-world-no-field-controls.test.tsx b/tests/native-world-no-field-controls.test.tsx
new file mode 100644
index 00000000..042ab1c6
--- /dev/null
+++ b/tests/native-world-no-field-controls.test.tsx
@@ -0,0 +1,122 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import { runInNewContext } from 'node:vm';
+import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import * as jsxRuntime from 'react/jsx-runtime';
+import ts from 'typescript';
+import type { Action, GameView } from '../game/engine';
+import { HomeworldNoFieldShipment } from '../components/homeworld-shipment';
+import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
+import { homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody';
+import { homeworldPopulations } from '../game/homeworld-population';
+import * as combat from '../game/combat';
+import * as options from '../game/homeworld-shipment-options';
+
+/** Personalized public Homeworld view with one real deployed marker. */
+function fixture(advanced: boolean): GameView {
+ const context = { advanced, players: [
+ { id: 'r', faction: 'richese' as const, reserves: 12, eliteReserves: 0, ally: null as string | null },
+ { id: 'a', faction: 'atreides' as const, reserves: 15, eliteReserves: 0, ally: null as string | null },
+ { id: 'g', faction: 'guild' as const, reserves: 20, eliteReserves: 0, ally: null as string | null },
+ ] };
+ const custody: HomeworldCustody = { salusa: null, visitors: {
+ 'homeworld:atreides': { r: { normal: 2, elite: 0 } },
+ } };
+ const populations = homeworldPopulations(context, custody);
+ const marker = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']),
+ { tokenId: 'five', controller: 'r', location: { homeworld: 'homeworld:atreides' } });
+ return {
+ me: 'r', active: 'r', advanced, status: 'playing', phase: 5,
+ homeworldShipment: { event: 'homeworld-event', blocked: null }, aid: { available: 0, pledged: 0 },
+ players: context.players.map(p => ({ ...p, name: p.faction, spice: p.id === 'r' ? 4 : undefined,
+ shipped: false, elites: { reserves: 0 } })),
+ homeworlds: { worlds: homeworldForceGroups(context, custody).map(world => ({ ...world,
+ ...populations.find(population => population.location === world.id)! })) },
+ richeseNoField: { owner: 'r', event: 'marker-event', private: marker,
+ canShip: false, shipBlock: 'Reveal the existing No-Field before shipping another.' },
+ } as unknown as GameView;
+}
+
+function markup(view: GameView): string {
+ return renderToStaticMarkup(createElement(HomeworldNoFieldShipment, { game: view, act() {}, busy: false }));
+}
+function destinations(html: string): string {
+ const match = /]*>([\s\S]*?)<\/select>/.exec(html);
+ assert.ok(match, 'The marker destination selector is present');
+ return match[1];
+}
+
+void test('Advanced deployed marker can select native Homeworld without exposing its value to another player', () => {
+ const view = fixture(true), before = structuredClone(view), html = markup(view);
+ assert.match(destinations(html), /value="homeworld:richese"/);
+ assert.doesNotMatch(destinations(html), /value="homeworld:atreides"/);
+ assert.match(html, /from Caladan/);
+ assert.match(html, /No-Field 5/);
+ assert.doesNotMatch(html, /No-Field 0|No-Field 3/);
+ assert.match(html, /already in those reserves and are not added again/);
+ const result = options.richeseHomeworldMarkerChoice(view, 'homeworld:richese', 'five', 0, 'homeworld:atreides');
+ assert.equal(result.cost, 1);
+ assert.deepEqual(result.action, { type: 'homeworldShip', event: 'homeworld-event', destination: 'homeworld:richese',
+ sources: {}, noField: 'five', noFieldEvent: 'marker-event', allyPayment: 0, noFieldFrom: 'homeworld:atreides' });
+ const observer = { ...view, me: 'a', richeseNoField: { ...view.richeseNoField!, private: null } } as GameView;
+ assert.equal(markup(observer), '');
+ assert.deepEqual(view, before);
+});
+
+void test('Basic return and fresh Advanced supply placement cannot target the native World', () => {
+ const basic = fixture(false);
+ assert.doesNotMatch(destinations(markup(basic)), /value="homeworld:richese"/);
+ assert.equal(options.richeseHomeworldMarkerChoice(basic, 'homeworld:richese', 'five', 0, 'homeworld:atreides').action, null);
+ const fresh = fixture(true);
+ fresh.richeseNoField!.private = createRicheseNoField(['zero', 'three', 'five']);
+ fresh.richeseNoField!.canShip = true;
+ assert.doesNotMatch(destinations(markup(fresh)), /value="homeworld:richese"/);
+ assert.equal(options.richeseHomeworldMarkerChoice(fresh, 'homeworld:richese', 'five', 0).action, null);
+});
+
+type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
+function elements(node: ReactNode): Element[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
+ return [node, ...elements(node.props.children)];
+}
+
+void test('the marker control sends the selected native return through the normal shipment action', () => {
+ const view = fixture(true), sent: Action[] = [], states: unknown[] = [];
+ let cursor = 0;
+ const exports: { HomeworldNoFieldShipment?: typeof HomeworldNoFieldShipment } = {};
+ runInNewContext(ts.transpileModule(readFileSync(new URL('../components/homeworld-shipment.tsx', import.meta.url), 'utf8'), {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
+ }).outputText, {
+ exports,
+ require(name: string) {
+ if (name === 'react/jsx-runtime') return jsxRuntime;
+ if (name === 'react') return { useState(initial: unknown) {
+ const index = cursor++; if (!(index in states)) states[index] = initial;
+ return [states[index], (next: unknown) => { states[index] = next; }];
+ } };
+ if (name === '@/game/combat') return combat;
+ if (name === '@/game/homeworld-shipment-options') return options;
+ if (name === './ui/button') return { Button: 'button' };
+ if (name === './ui/input') return { Input: 'input' };
+ throw Error(`Unexpected import ${name}`);
+ },
+ });
+ assert.ok(exports.HomeworldNoFieldShipment);
+ const render = () => {
+ cursor = 0;
+ return elements(exports.HomeworldNoFieldShipment!({ game: view, busy: false, act: action => sent.push(action) }));
+ };
+ const select = render().find(element => element.type === 'select' && element.props['aria-label'] === 'No-Field destination Homeworld');
+ assert.ok(select);
+ (select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: 'homeworld:richese' } });
+ const button = render().find(element => element.type === 'button');
+ assert.ok(button);
+ assert.equal(button.props.disabled, false);
+ (button.props.onClick as () => void)();
+ assert.deepEqual(JSON.parse(JSON.stringify(sent[0])), { type: 'homeworldShip', event: 'homeworld-event',
+ destination: 'homeworld:richese', sources: {}, noField: 'five', noFieldEvent: 'marker-event',
+ allyPayment: 0, noFieldFrom: 'homeworld:atreides' });
+});
diff --git a/tests/native-world-no-field-helpers.test.ts b/tests/native-world-no-field-helpers.test.ts
new file mode 100644
index 00000000..9701c893
--- /dev/null
+++ b/tests/native-world-no-field-helpers.test.ts
@@ -0,0 +1,98 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { type HomeworldCustody, HomeworldCustodyError } from '../game/homeworld-custody';
+import type { HomeworldShipmentContext } from '../game/homeworld-shipment';
+import { homeworldCombatArmy, homeworldCombatLocation, type HomeworldCombatContext } from '../game/homeworld-combat';
+import { currentHomeworldOccupationSnapshot, homeworldOccupationSnapshotPresence } from '../game/homeworld-occupation-history';
+import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
+import { quoteRicheseHomeworldMarkerShipment, quoteRicheseHomeworldMarkerTransfer } from '../game/richese-homeworld-marker-shipment';
+import { homeworldNoFieldPresence, quoteHomeworldBattlesWithNoField } from '../game/richese-homeworld-marker-combat';
+
+const native = 'homeworld:richese';
+const foreign = 'homeworld:atreides';
+const other = 'homeworld:guild';
+
+function fixture(tokenId = 'zero') {
+ const context: HomeworldShipmentContext = { advanced: true, players: [
+ { id: 'r', faction: 'richese', reserves: 7, eliteReserves: 0, ally: null },
+ { id: 'a', faction: 'atreides', reserves: 17, eliteReserves: 0, ally: null },
+ { id: 'g', faction: 'guild', reserves: 20, eliteReserves: 0, ally: null },
+ ] };
+ const custody: HomeworldCustody = { salusa: null, visitors: { [foreign]: { r: { normal: 2, elite: 0 } } } };
+ const noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']),
+ { tokenId, controller: 'r', location: { homeworld: foreign } });
+ return { context, custody, noField };
+}
+
+for (const tokenId of ['zero', 'three', 'five']) {
+ void test(`Advanced native marker return and later exit preserve ${tokenId} without materializing it`, () => {
+ const f = fixture(tokenId), before = structuredClone(f);
+ const returnQuote = quoteRicheseHomeworldMarkerTransfer(f.context, f.custody,
+ { player: 'r', tokenId, source: foreign, destination: native, forces: { normal: 2, elite: 0 } }, f.noField);
+ assert.equal(returnQuote.amount, 3);
+ assert.equal(returnQuote.cost, 3);
+ assert.equal(returnQuote.players!.find(player => player.id === 'r')!.reserves, 9);
+ assert.equal(returnQuote.state!.visitors[foreign]?.r, undefined);
+ assert.deepEqual(returnQuote.noField.deployed, { tokenId, controller: 'r', location: { homeworld: native } });
+ assert.equal(returnQuote.noField.lastShipped, tokenId);
+ assert.deepEqual(returnQuote.noField.tokens, f.noField.tokens);
+ assert.deepEqual(f, before);
+
+ const context: HomeworldShipmentContext = { ...f.context, players: f.context.players.map(seat => {
+ const next = returnQuote.players!.find(player => player.id === seat.id)!;
+ return { ...seat, reserves: next.reserves, eliteReserves: next.eliteReserves };
+ }) };
+ const exit = quoteRicheseHomeworldMarkerTransfer(context, returnQuote.state!,
+ { player: 'r', tokenId, source: native, destination: other, forces: { normal: 2, elite: 0 } }, returnQuote.noField);
+ assert.equal(exit.amount, 3);
+ assert.equal(exit.cost, 3);
+ assert.equal(exit.players!.find(player => player.id === 'r')!.reserves, 7);
+ assert.deepEqual(exit.state!.visitors[other].r, { normal: 2, elite: 0 });
+ assert.deepEqual(exit.noField.deployed, { tokenId, controller: 'r', location: { homeworld: other } });
+ assert.equal(exit.noField.lastShipped, tokenId);
+ assert.deepEqual(exit.noField.tokens, f.noField.tokens);
+ });
+}
+
+void test('Basic keeps foreign-only marker endpoints and fresh native placement remains unavailable', () => {
+ const f = fixture(), basic = { ...f.context, advanced: false };
+ const intent = { player: 'r', tokenId: 'zero', source: foreign, destination: native };
+ assert.throws(() => quoteRicheseHomeworldMarkerTransfer(basic, f.custody, intent, f.noField), HomeworldCustodyError);
+ const nativeMarker = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']),
+ { tokenId: 'zero', controller: 'r', location: { homeworld: native } });
+ assert.throws(() => quoteRicheseHomeworldMarkerTransfer(basic, f.custody,
+ { player: 'r', tokenId: 'zero', source: native, destination: other }, nativeMarker), HomeworldCustodyError);
+ assert.throws(() => quoteRicheseHomeworldMarkerShipment(f.context, f.custody,
+ { player: 'r', tokenId: 'three', destination: native }, createRicheseNoField(['zero', 'three', 'five'])), HomeworldCustodyError);
+ assert.throws(() => quoteRicheseHomeworldMarkerTransfer(f.context, f.custody,
+ { player: 'r', tokenId: 'zero', source: foreign, destination: foreign }, f.noField), HomeworldCustodyError);
+ const allied: HomeworldShipmentContext = { ...f.context, players: f.context.players.map(seat =>
+ seat.id === 'r' ? { ...seat, ally: 'g' } : seat.id === 'g' ? { ...seat, ally: 'r' } : seat) };
+ assert.throws(() => quoteRicheseHomeworldMarkerTransfer(allied, f.custody,
+ { player: 'r', tokenId: 'zero', source: foreign, destination: other }, f.noField), HomeworldCustodyError);
+});
+
+for (const reserves of [0, 7]) {
+ void test(`Advanced native marker preserves physical population ${reserves} and adds only presence`, () => {
+ const f = fixture();
+ f.context.players[0].reserves = reserves;
+ f.custody.visitors[native] = { a: { normal: 1, elite: 0 } };
+ const context: HomeworldCombatContext = { ...f.context, order: ['r', 'a', 'g'] };
+ const marker = { controller: 'r', location: { homeworld: native } };
+ const before = structuredClone({ context, custody: f.custody });
+ assert.deepEqual(homeworldNoFieldPresence(context, f.custody, marker),
+ { controller: 'r', homeworld: native, effectiveForces: 1 });
+ assert.equal(homeworldCombatLocation(context, f.custody, native).population, reserves);
+ assert.equal(homeworldCombatArmy(context, f.custody, native, 'r').normal, reserves);
+ const battles = quoteHomeworldBattlesWithNoField(context, f.custody, marker);
+ assert.equal(battles.filter(battle => battle.territory === native && battle.attacker === 'r' && battle.defender === 'a').length, 1);
+ const snapshot = currentHomeworldOccupationSnapshot(context, f.custody,
+ { homeworld: native, controller: 'r' });
+ assert.deepEqual(homeworldOccupationSnapshotPresence(snapshot, native), ['a', 'r']);
+ assert.deepEqual({ context, custody: f.custody }, before);
+ const basic = { ...context, advanced: false };
+ assert.throws(() => homeworldNoFieldPresence(basic, f.custody, marker), /own Homeworld/);
+ assert.throws(() => currentHomeworldOccupationSnapshot(basic, f.custody,
+ { homeworld: native, controller: 'r' }), /foreign world/);
+ });
+}
diff --git a/tests/native-world-no-field-runtime.test.ts b/tests/native-world-no-field-runtime.test.ts
new file mode 100644
index 00000000..a9ddc697
--- /dev/null
+++ b/tests/native-world-no-field-runtime.test.ts
@@ -0,0 +1,231 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import { homeworldShipmentActions, homeworldShipmentChoice, richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
+import { finishHarassPreparation } from './fixture-harass-withdraw';
+import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
+import { homeworldForceGroups } from '../game/homeworld-custody';
+import { currentHomeworldOccupationSnapshot, homeworldOccupationMarker,
+ homeworldOccupationSnapshotPresence } from '../game/homeworld-occupation-history';
+import {
+ createRicheseHomeworldShipmentFixture as fixture,
+ passRicheseHomeworldShipment as pass,
+ richeseHomeworldShipmentInventory as inventory,
+ richeseHomeworldShipmentPlayer as player,
+} from './fixture-richese-homeworld-shipment';
+
+const owner = 'richese', foreign = 'homeworld:emperor', native = 'homeworld:richese';
+function group(game: Game, location: string) {
+ const world = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
+ .find(item => item.id === location); assert.ok(world);
+ return world.forces[owner] ?? {normal:0, elite:0};
+}
+
+/** Start from real public Advanced setup and native phase actions. Only a later
+ * fresh shipment opportunity and wallet are staged after the real first entry;
+ * the intervening battle is intentionally skipped, so this is not a full game. */
+function stageNewTurn(game: Game) {
+ game.turn++;
+ Object.assign(game, { phase:5, phaseOpening:null, response:null, decision:null,
+ active:owner, order:game.players.map(p=>p.id),
+ movementRemaining:game.players.map(p=>p.id), ready:[] });
+ for (const p of game.players) { p.shipped=false; p.moved=0; }
+ player(game, owner).spice = 8;
+ homeworldGameIntegrity(game);
+}
+
+for (const value of [0,3,5] as const) void test(`Advanced concealed ${value} marker returns to its native World without materializing reserves`, () => {
+ const f = fixture({advanced:true, companions:2, value});
+ let game = f.game;
+ const initialInventory = inventory(game), original = structuredClone(game);
+ const view = viewGame(game,owner), token = view.richeseNoField!.private!.tokens.find(row=>row.value===value);
+ assert.ok(token);
+ const fresh = richeseHomeworldMarkerChoice(view, foreign, token.id, 0, undefined, {normal:2, elite:0});
+ assert.ok(fresh.action, fresh.blocked ?? 'The actual foreign entry must be available');
+ assert.equal(fresh.cost,3);
+ game = pass(applyAction(game,owner,fresh.action));
+ assert.ok(!game.pendingHomeworldShipment && player(game,owner).shipped);
+ assert.deepEqual(player(game,owner).noField!.deployed!.location,{homeworld:foreign});
+ assert.deepEqual(group(game,foreign),{normal:2,elite:0});
+ assert.equal(player(game,owner).reserves,player(original,owner).reserves-2);
+ assert.deepEqual(inventory(game),initialInventory);
+ stageNewTurn(game);
+ const before = structuredClone(game), beforeOwner = viewGame(game,owner);
+ const returning = richeseHomeworldMarkerChoice(beforeOwner,native,token.id,0,foreign,{normal:1,elite:0});
+ assert.ok(returning.action,returning.blocked ?? 'A distinct foreign-to-native return must be offered');
+ assert.equal(returning.amount,2);
+ assert.equal(returning.cost,2);
+ let returned = pass(applyAction(game,owner,returning.action));
+ assert.deepEqual(game,before);
+ assert.equal(player(returned,owner).spice,player(before,owner).spice-2);
+ assert.equal(player(returned,owner).reserves,player(before,owner).reserves+1);
+ assert.deepEqual(group(returned,foreign),{normal:1,elite:0});
+ assert.equal(player(returned,owner).noField!.deployed!.tokenId,token.id);
+ assert.deepEqual(player(returned,owner).noField!.deployed!.location,{homeworld:native});
+ assert.equal(player(returned,owner).noField!.lastShipped,token.id);
+ const observer = viewGame(returned,'emperor').richeseNoField!;
+ assert.equal(observer.private,null);
+ assert.deepEqual(observer.public.deployed!.location,{homeworld:native});
+ assert.equal(observer.public.deployed!.effectiveForces,1);
+ assert.equal(JSON.stringify(observer.public).includes(token.id),false);
+ assert.deepEqual(inventory(returned),initialInventory);
+ homeworldGameIntegrity(returned);
+ // A later ordinary shipment can move the same still-concealed marker back
+ // out of its native World with another physically paid companion.
+ let outgoing = structuredClone(returned);
+ stageNewTurn(outgoing);
+ const departure = richeseHomeworldMarkerChoice(viewGame(outgoing,owner),foreign,token.id,0,native,
+ {normal:1,elite:0});
+ assert.ok(departure.action, departure.blocked ?? 'The marker can depart its own World');
+ assert.equal(departure.cost,2);
+ const nativeBeforeDeparture = player(outgoing,owner).reserves;
+ outgoing = pass(applyAction(outgoing,owner,departure.action));
+ assert.deepEqual(player(outgoing,owner).noField!.deployed!.location,{homeworld:foreign});
+ assert.equal(player(outgoing,owner).reserves,nativeBeforeDeparture-1);
+ assert.deepEqual(group(outgoing,foreign),{normal:2,elite:0});
+ assert.deepEqual(inventory(outgoing),initialInventory);
+ homeworldGameIntegrity(outgoing);
+ const reserves = player(returned,owner).reserves, own = player(returned,owner);
+ returned = pass(applyAction(returned,owner,{type:'revealNoField',token:token.id,event:own.noFieldEvent}));
+ assert.equal(player(returned,owner).noField!.deployed,null);
+ assert.equal(player(returned,owner).reserves,reserves,
+ 'Revealing at home does not withdraw or duplicate the native reserve pool.');
+ assert.deepEqual(group(returned,foreign),{normal:1,elite:0});
+ assert.deepEqual(inventory(returned),initialInventory);
+ homeworldGameIntegrity(returned);
+});
+
+/** Stages the later phase opportunities, but uses real native shipments,
+ * battle selection, preparation and plan actions. The native reserves and the
+ * invading group remain the original physical counters. */
+for (const value of [0,3,5] as const) void test(`Advanced native battle counts a home No-Field ${value} without duplicating reserves`, () => {
+ const f = fixture({advanced:true, companions:2, value});
+ let game = f.game;
+ const originalInventory = inventory(game), own = viewGame(game,owner);
+ const token = own.richeseNoField!.private!.tokens.find(row=>row.value===value); assert.ok(token);
+ const first = richeseHomeworldMarkerChoice(own,foreign,token.id,0,undefined,{normal:2,elite:0});
+ assert.ok(first.action,first.blocked ?? 'The original marker deployment must be legal');
+ game = pass(applyAction(game,owner,first.action));
+ stageNewTurn(game);
+ const back = richeseHomeworldMarkerChoice(viewGame(game,owner),native,token.id,0,foreign,{normal:1,elite:0});
+ assert.ok(back.action,back.blocked ?? 'The native return must be legal');
+ game = pass(applyAction(game,owner,back.action));
+ const nativeReserves = player(game,owner).reserves;
+ const nativePhysical = group(game,native);
+ assert.equal(nativePhysical.normal,nativeReserves);
+ // Stage Emperor's next real World shipment from its original reserve pool.
+ game.turn++;
+ Object.assign(game,{phase:5,phaseOpening:null,response:null,decision:null,
+ active:'emperor',order:game.players.map(p=>p.id),
+ movementRemaining:game.players.map(p=>p.id),ready:[]});
+ for (const p of game.players) {p.shipped=false;p.moved=0;}
+ player(game,'emperor').spice = 10;
+ const invasion = homeworldShipmentChoice(viewGame(game,'emperor'),native,
+ {'homeworld:emperor':{normal:1,elite:0}});
+ assert.ok(invasion.action,invasion.blocked ?? 'An actual native World invasion must be legal');
+ game = pass(applyAction(game,'emperor',invasion.action));
+ assert.deepEqual(player(game,owner).noField!.deployed!.location,{homeworld:native});
+ homeworldGameIntegrity(game);
+ // The Battle phase is staged, then its ordinary chooser and simultaneous
+ // plans execute natively rather than constructing a saved battle object.
+ Object.assign(game,{phase:6,active:owner,ready:[],response:null,decision:null,phaseOpening:null});
+ const choice = viewGame(game,owner).battleChoices.find(row=>row.territory===native);
+ assert.ok(choice, 'The public home marker and invader create a native battle');
+ const target = choice.attacker === owner ? choice.defender : choice.attacker;
+ game = applyAction(game,owner,{type:'chooseBattle',territory:native,target});
+ const preview = viewGame(game,owner).battle!.ownForces!;
+ assert.equal(preview.normal,nativePhysical.normal,
+ 'The native army is its existing physical pool, regardless of hidden marker value.');
+ assert.equal(preview.elite,nativePhysical.elite);
+ game = finishHarassPreparation(game);
+ for (const actor of [owner,'emperor']) {
+ const leader = player(game,actor).leaders.find(row=>!row.dead && (!row.usedAt || row.usedAt===native));
+ assert.ok(leader);
+ game = applyAction(game,actor,{type:'battlePlan',dial:0,support:0,leader:leader.id,weapon:null,defense:null});
+ }
+ assert.equal(game.battle!.revealed,true);
+ assert.equal(player(game,owner).noField!.deployed,null);
+ assert.equal(player(game,owner).reserves,nativeReserves);
+ assert.deepEqual(group(game,native),nativePhysical);
+ assert.deepEqual(inventory(game),originalInventory);
+ homeworldGameIntegrity(game);
+});
+
+void test('a concealed native zero marker with no reserves creates public battle presence but no phantom force', () => {
+ const f = fixture({advanced:true, companions:2, value:0});
+ let game = f.game;
+ const originalInventory = inventory(game), own = viewGame(game,owner);
+ const token = own.richeseNoField!.private!.tokens.find(row=>row.value===0); assert.ok(token);
+ const first = richeseHomeworldMarkerChoice(own,foreign,token.id,0,undefined,{normal:2,elite:0});
+ assert.ok(first.action, first.blocked ?? 'The initial deployment must be legal');
+ game = pass(applyAction(game,owner,first.action));
+ stageNewTurn(game);
+ const back = richeseHomeworldMarkerChoice(viewGame(game,owner),native,token.id,0,foreign,{normal:1,elite:0});
+ assert.ok(back.action, back.blocked ?? 'The native return must be legal');
+ game = pass(applyAction(game,owner,back.action));
+ // Explicit physical staging: move all native reserves into the Tanks while
+ // preserving the real returned marker and the one foreign garrison counter.
+ const richese = player(game,owner);
+ richese.tanks += richese.reserves;
+ richese.reserves = 0;
+ assert.deepEqual(group(game,native),{normal:0,elite:0});
+ assert.deepEqual(inventory(game),originalInventory);
+ homeworldGameIntegrity(game);
+ game.turn++;
+ Object.assign(game,{phase:5,phaseOpening:null,response:null,decision:null,
+ active:'emperor',order:game.players.map(p=>p.id),
+ movementRemaining:game.players.map(p=>p.id),ready:[]});
+ for (const p of game.players) {p.shipped=false;p.moved=0;}
+ player(game,'emperor').spice = 10;
+ const invasion = homeworldShipmentChoice(viewGame(game,'emperor'),native,
+ {'homeworld:emperor':{normal:1,elite:0}});
+ assert.ok(invasion.action,invasion.blocked ?? 'The real invading force must be legal');
+ game = pass(applyAction(game,'emperor',invasion.action));
+ assert.deepEqual(group(game,native),{normal:0,elite:0});
+ assert.equal(viewGame(game,'emperor').richeseNoField!.public.deployed!.effectiveForces,1);
+ const withMarker = currentHomeworldOccupationSnapshot(homeworldContext(game), game.homeworlds!.custody!,
+ homeworldOccupationMarker(game.players));
+ assert.deepEqual(homeworldOccupationSnapshotPresence(withMarker,native),['emperor','richese'],
+ 'The native marker keeps the invading force from being alone before reveal.');
+ Object.assign(game,{phase:6,active:owner,ready:[],response:null,decision:null,phaseOpening:null});
+ const choice = viewGame(game,owner).battleChoices.find(row=>row.territory===native);
+ assert.ok(choice, 'The concealed marker remains a public battle participant even with zero native reserves');
+ const target = choice.attacker === owner ? choice.defender : choice.attacker;
+ game = applyAction(game,owner,{type:'chooseBattle',territory:native,target});
+ assert.equal(viewGame(game,owner).battle!.ownForces!.normal,0);
+ assert.equal(viewGame(game,owner).battle!.ownForces!.elite,0);
+ game = finishHarassPreparation(game);
+ for (const actor of [owner,'emperor']) {
+ const leader = player(game,actor).leaders.find(row=>!row.dead && (!row.usedAt || row.usedAt===native));
+ assert.ok(leader);
+ game = applyAction(game,actor,{type:'battlePlan',dial:0,support:0,leader:leader.id,weapon:null,defense:null});
+ }
+ assert.equal(game.battle!.revealed,true);
+ assert.equal(player(game,owner).noField!.deployed,null);
+ assert.equal(player(game,owner).reserves,0);
+ assert.deepEqual(group(game,native),{normal:0,elite:0});
+ const afterReveal = currentHomeworldOccupationSnapshot(homeworldContext(game), game.homeworlds!.custody!,
+ homeworldOccupationMarker(game.players));
+ assert.deepEqual(homeworldOccupationSnapshotPresence(afterReveal,native),['emperor'],
+ 'The removed zero marker creates no physical native presence after reveal.');
+ assert.deepEqual(inventory(game),originalInventory);
+ homeworldGameIntegrity(game);
+});
+
+void test('all four legal bot profiles offer a foreign-to-native concealed marker return', () => {
+ const f = fixture({advanced:true, companions:2, value:3});
+ let game = f.game;
+ const own = viewGame(game,owner), token = own.richeseNoField!.private!.tokens.find(row=>row.value===3);
+ assert.ok(token);
+ const first = richeseHomeworldMarkerChoice(own,foreign,token.id,0,undefined,{normal:2,elite:0});
+ assert.ok(first.action, first.blocked ?? 'The original marker deployment must be legal');
+ game = pass(applyAction(game,owner,first.action));
+ stageNewTurn(game);
+ for (let level=0; level<4; level++) {
+ const view = viewGame(game,owner);
+ const action = homeworldShipmentActions(view,level).find(candidate => candidate.type === 'homeworldShip' &&
+ candidate.destination === native && candidate.noFieldFrom === foreign && candidate.noField === token.id);
+ assert.ok(action, `Profile ${level} should offer the legal marker return`);
+ assert.doesNotThrow(() => applyAction(game,owner,action));
+ }
+});
diff --git a/tests/richese-homeworld-marker-combat.test.ts b/tests/richese-homeworld-marker-combat.test.ts
index 00b87852..62277c18 100644
--- a/tests/richese-homeworld-marker-combat.test.ts
+++ b/tests/richese-homeworld-marker-combat.test.ts
@@ -79,7 +79,8 @@ void test('a marker needs its seated Richese controller and a foreign active Hom
assert.deepEqual(quoteHomeworldBattlesWithNoField(context, custody, null), quoteHomeworldBattles(context, custody));
assert.throws(() => homeworldNoFieldPresence(context, custody, { ...marker, controller: 'e' }), /Richese/);
assert.throws(() => homeworldNoFieldPresence(context, custody, { ...marker, controller: 'missing' }), /Richese/);
- assert.throws(() => homeworldNoFieldPresence(context, custody,
+ const basic = { ...context, advanced: false };
+ assert.throws(() => homeworldNoFieldPresence(basic, createHomeworldCustody(basic),
{ ...marker, location: { homeworld: 'homeworld:richese' } }), /own Homeworld/);
assert.throws(() => homeworldNoFieldPresence(context, custody,
{ ...marker, location: { homeworld: 'homeworld:fremen' } }), /canonical Homeworld/);
diff --git a/tests/richese-homeworld-marker-transfer-controls.test.tsx b/tests/richese-homeworld-marker-transfer-controls.test.tsx
index 96c61c4c..33423024 100644
--- a/tests/richese-homeworld-marker-transfer-controls.test.tsx
+++ b/tests/richese-homeworld-marker-transfer-controls.test.tsx
@@ -48,7 +48,8 @@ void test('the owner receives the actual transfer packet although another deploy
void test('transfer quote enforces current custody, active timing, destination and funding', () => {
const view = fixture();
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'five', 0, 'homeworld:atreides').action, null);
- assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:richese', 'five', 0, 'homeworld:atreides').action, null);
+ assert.equal(options.richeseHomeworldMarkerChoice({ ...view, advanced: false }, 'homeworld:richese', 'five', 0, 'homeworld:atreides').action, null);
+ assert.ok(options.richeseHomeworldMarkerChoice(view, 'homeworld:richese', 'five', 0, 'homeworld:atreides').action);
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'three', 0, 'homeworld:atreides').action, null);
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:guild', 'five', 0, 'homeworld:unknown').action, null);
for (const patch of [
@@ -72,7 +73,7 @@ void test('owner sees the current marker and different destination; all minimal
const action = options.homeworldShipmentActions(view, level).find(candidate => candidate.noFieldFrom !== undefined);
assert.ok(action, `profile ${level} can transport the actual marker`);
assert.equal(action.noFieldFrom, 'homeworld:atreides'); assert.equal(action.noField, 'five');
- assert.equal(action.destination, 'homeworld:guild');
+ assert.ok(['homeworld:guild', 'homeworld:richese'].includes(String(action.destination)));
}
});
diff --git a/tests/richese-homeworld-marker-transfer-quote.test.ts b/tests/richese-homeworld-marker-transfer-quote.test.ts
index 0a5974a7..5fb424cf 100644
--- a/tests/richese-homeworld-marker-transfer-quote.test.ts
+++ b/tests/richese-homeworld-marker-transfer-quote.test.ts
@@ -33,12 +33,12 @@ void test('each physical marker transfers as one without materialization or ship
}
});
-void test('missing, stale, own-world, same-world, allied and foreign marker requests reject immutably', () => {
+void test('missing, stale, Basic own-world, same-world, allied and foreign marker requests reject immutably', () => {
for (const change of [
(f: ReturnType) => { f.intent.tokenId = 'three'; },
(f: ReturnType) => { f.intent.source = 'homeworld:guild'; },
(f: ReturnType) => { f.intent.destination = 'homeworld:atreides'; },
- (f: ReturnType) => { f.intent.destination = 'homeworld:richese'; },
+ (f: ReturnType) => { f.context.advanced = false; f.intent.destination = 'homeworld:richese'; },
(f: ReturnType) => { f.intent.destination = 'homeworld:unknown'; },
(f: ReturnType) => { f.intent.player = 'a'; },
(f: ReturnType) => { f.noField.deployed = null; },