diff --git a/components/richese-homeworld-forces.tsx b/components/richese-homeworld-forces.tsx
new file mode 100644
index 00000000..d0654f8e
--- /dev/null
+++ b/components/richese-homeworld-forces.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+export type RicheseHomeworldForcesProps = {
+ enabled: boolean;
+ amount: number;
+ max: number;
+ busy: boolean;
+ onChange: (amount: number) => void;
+};
+
+export function RicheseHomeworldForces({ enabled, amount, max, busy, onChange }: RicheseHomeworldForcesProps) {
+ const blocked = amount > 0 && !enabled
+ ? 'Paid forces alongside a No-Field require Richese at High population. Set the amount to 0 to continue without paid reserve forces.'
+ : amount > max ? `Only ${max} physical reserve ${max === 1 ? 'force is' : 'forces are'} available for this selection. Reduce the amount before shipping.` : null;
+ return (
+
+
+ Paid reserve forces with No-Field
+ {
+ const next = Number(event.target.value);
+ if (!busy && Number.isSafeInteger(next) && next >= 0 && next <= max && (enabled || next === 0)) onChange(next);
+ }}
+ />
+
+
+ Paid forces enter the destination now. The No-Field’s concealed forces
+ stay in reserves until revelation, which uses the reserves then available.
+ Each paid force adds to the marker’s shipment price.
+
+ {blocked ? {blocked}
+ : !enabled && Adding paid reserve forces requires native Richese at High population.
}
+
+ );
+}
diff --git a/components/richese-no-field.tsx b/components/richese-no-field.tsx
index 83fa4eae..13fdd49e 100644
--- a/components/richese-no-field.tsx
+++ b/components/richese-no-field.tsx
@@ -8,6 +8,8 @@ import { fighterCount } from '@/game/advisors';
import { reserveShipmentCost } from '@/game/shipment-price';
import type { RicheseNoFieldView } from '@/game/richese-no-field';
import { quoteSmugglerNoField } from '@/game/smuggler-no-field';
+import { quoteRicheseHomeworldForces } from '@/game/richese-homeworld-shipment';
+import { RicheseHomeworldForces } from './richese-homeworld-forces';
import { ShipmentQuote } from './shipment-quote';
import { Button } from './ui/button';
@@ -112,6 +114,7 @@ export function RicheseNoFieldControls({
const [pairConcealedSelection, setPairConcealedSelection] = useState('');
const [pairRevealedSelection, setPairRevealedSelection] = useState('');
const [useSmuggler, setUseSmuggler] = useState(false);
+ const [reserveForces, setReserveForces] = useState(0);
const info = game.richeseNoField;
const me = game.players.find((player) => player.id === game.me);
if (!info || info.owner !== game.me || !info.private || !me) return null;
@@ -136,22 +139,30 @@ export function RicheseNoFieldControls({
const rate = {
faction: me.faction,
halfRate:
- me.faction === 'guild' ||
+ (!game.guildRateCanceled && (me.faction === 'guild' ||
game.players.some(
(player) => player.faction === 'guild' && player.id === me.ally,
- ) ||
+ ))) ||
game.karamaShipping?.player === me.id,
};
- const cost = reserveShipmentCost(rate, selectedTerritory.type, 1);
+ const markerCost = reserveShipmentCost(rate, selectedTerritory.type, 1);
const smuggler = info.canShip
? quoteSmugglerNoField(game, me.id, destination)
: null;
+ let group: ReturnType | null = null;
+ let groupProblem: string | null = null;
+ try {
+ group = quoteRicheseHomeworldForces({ enabled: info.homeworldForcesEnabled,
+ reserves: me.reserves, amount: reserveForces, smuggler: useSmuggler && !!smuggler });
+ } catch (error) { groupProblem = error instanceof Error ? error.message : 'Choose available reserve forces.'; }
+ const cost = reserveShipmentCost(rate, selectedTerritory.type, group?.priced ?? 1);
const share =
!me.ally || allyPayment === ''
? Math.max(0, cost - (me.spice ?? 0))
: Number(allyPayment);
const validShare = Number.isSafeInteger(share) && share >= 0 && share <= cost;
const problems: string[] = [];
+ if (groupProblem) problems.push(groupProblem);
if (!info.canShip)
problems.push(info.shipBlock ?? 'No-Field shipment is not available now.');
if (!selected) problems.push('No different No-Field token is available.');
@@ -178,6 +189,7 @@ export function RicheseNoFieldControls({
problems.push('Your spice does not cover the chosen payment.');
}
const pairProblems = [...problems];
+ if (reserveForces > 0) pairProblems.push('Set paid reserve forces to 0 to use the separate two-token Cunning shipment.');
if (nexus?.blocked) pairProblems.push(nexus.blocked);
if (!pairConcealed || !pairRevealed)
pairProblems.push('Choose two different available No-Field tokens.');
@@ -270,8 +282,16 @@ export function RicheseNoFieldControls({
Destination: {selectedTerritory.name}, sector {sector}. Choose
another destination on the map or in the shipment controls. This
- replaces your normal shipment at the price of one force.
+ replaces your normal shipment. The marker is priced as one force;
+ selected paid reserve forces add to that invoice.
+ {(game.homeworlds || reserveForces > 0) && }
Ship concealed No-Field {selected?.value ?? ''} · {cost} spice
+ {reserveForces ? ` · + ${reserveForces} paid forces` : ''}
{useSmuggler && smuggler ? ' · + 1 real force' : ''}
{nexus && (
@@ -328,7 +350,7 @@ export function RicheseNoFieldControls({
concealed at {selectedTerritory.name}, sector {sector}. The
revealed token brings up to its printed value in forces from
your current reserves; the concealed marker still counts as
- one force. Both ship for the price of one marker ({cost} spice).
+ one force. Both ship for the price of one marker ({markerCost} spice).
This is separate from the ordinary shipment and Smuggler.
@@ -414,7 +436,7 @@ export function RicheseNoFieldControls({
}}
>
Reveal No-Field {pairRevealed?.value ?? ''} and ship concealed
- No-Field {pairConcealed?.value ?? ''} · {cost} spice
+ No-Field {pairConcealed?.value ?? ''} · {markerCost} spice
)}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 9a0eacd1..19f94f48 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,37 @@ open mode gates.
## Current checkpoint and work
+**High Richese mixed shipment — 25 October. Prototyped / Partial.**
+Native High Richese now selects paid physical reserve forces beside one
+concealed No-Field. The marker keeps its own token/event and amount 1; the
+additional group has an original signed count and one combined invoice.
+Actual companions enter immediately; the hidden value remains in reserves.
+The group may cross Low, after which normal Low movement rules apply, and later
+revelation uses the remaining stock. Original No-Field cancellation, native
+Guild interception and separate rate/payment continue. Smuggler retains its
+independent free counter. Persistent controls and four minimal policies are
+connected; combined tariff/rounding and grouped threshold timing are explicit
+provisional compositions, without strategy tuning.
+[Source, behavior and boundaries](HOMEWORLD_RULES.md#high-richese-mixed-shipment-prototype--25-october-2026).
+
+Disjoint helper/control and native-fixture lanes, bounded source/correctness
+review and root integration cover **15 files, 123 cases**. The original union
+passed 122; the sole failure was two stale orphaned-pair message assertions
+against an unchanged existing guard. Repairing them passes the full **6/6**
+file plus final types/lint/build, with the other 2,800 source files unchanged.
+Five native selector-callback/engine/GameTable SSR journeys pass: Basic 56 and
+Advanced 60 earn actual main 35/cache 10 setup, a real bribe of 7 completed at Mentat,
+then eleven paid arrivals beside a still-hidden marker. Capped-reveal 85 earns
+actual bribes of 10 and 2 and Charity before sixteen arrivals leave only four reserves;
+the printed 5 token then reveals only four. Native cancellation 56 spends original
+opposing Karama, leaves the whole declaration unused and permits normal
+replacement. Purchased-rate 27 spends that actual card to price marker+one
+paid force at one spice. All original physical zones remain; no played state
+is installed. A separate controlled rate/ally projection catches the repaired
+UI fee after native discount cancellation. No full-mode/final-assurance,
+hydration/browser/mobile/deployed acceptance or saved-room/D1/server/reset/
+restart/deployment operation is claimed.
+
**Ix High paid-Cyborg revival bonus — 25 October. Prototyped / Partial.**
An ordinary paid Cyborg return now automatically returns up to two additional
physical Suboids from Tanks. Selected free types, original invoice and native
@@ -43,9 +74,9 @@ is claimed; no saved-room/D1/server/reset/restart/deployment operation occurred.
A bounded ordinary-faction and Treachery-card lookup found no further wholly
absent native power; that is not complete source coverage.
-The remaining **High Richese No-Field plus reserve shipment** is a concrete
-Homeworld content gap, distinct from its existing Smuggler companion. Preserve
-the user's specific unfunded-Karama/Kull deferral while continuing other rules.
+That pass identified the High Richese content gap now prototyped above. The
+user's specific unfunded-Karama/Kull deferral still applies; source/timing and
+interaction refinement plus complete-mode/final assurance remain unfinished.
**Current Traitor-declaration Truthtrance — 25 October. Prototyped / Partial.**
A structured revealed-battle question now binds an actual unsubmitted voter to
diff --git a/docs/HOMEWORLD_RULES.md b/docs/HOMEWORLD_RULES.md
index b0e0cfdf..e8d47ef3 100644
--- a/docs/HOMEWORLD_RULES.md
+++ b/docs/HOMEWORLD_RULES.md
@@ -2,6 +2,69 @@
Source audit, 9 September 2026. No runtime activation. The primary module source is the [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), especially printed pp.4, 9–10 and the Homeworld FAQ on pp.15–16. The local publisher-text transcription is `/tmp/dune-rules/ecaz-audit.txt`; fresh official indexed pp.9, 10 and 15 were checked against it. Direct PDF retrieval currently returns HTTP 403. Physical card faces are separately audited in [Homeworld component audit](HOMEWORLD_COMPONENT_AUDIT.md); a rulebook statement and a card-face statement are not interchangeable evidence.
+## High Richese mixed shipment prototype — 25 October 2026
+
+**Prototyped / Partial.** Native Richese at High may select actual paid reserve
+forces alongside its ordinary concealed No-Field shipment. The marker keeps its
+original token/event and one-force identity; `noFieldReserves` names only the
+additional paid physical counters. No hidden 0/3/5 denomination is withdrawn or
+added to that invoice. The reserve group enters now, while a later reveal draws
+only the physical reserves still available then. No-field amount stays 1 for old
+and new actions; omitting the new field preserves ordinary marker-only behavior.
+
+The inspected High component grants accompaniment without specifying a free
+price or a special numerical cap. The user-authorized revised PDF's physical
+page 36 Richese B prices the marker as one force; physical page 22 supplies current
+population eligibility. Under the user's 14 October instruction to choose
+recommended interpretations, this prototype provisionally combines one marker
+plus paid counters into **one ordinary invoice**, with Guild rounding once on
+the whole priced group. Observe High before the accepted group, allow that
+group to cross Low, and apply the resulting Low movement penalty to subsequent
+actions. Combined pricing and grouped threshold timing are application
+composition, not a located printed worked example.
+
+Select at most actual native reserves. The independent Smuggler force, if
+eligible and selected, reserves one additional real counter and stays free;
+it never substitutes for a paid count or comes from the marker denomination.
+The private original declaration proof binds the new count through the ordinary
+No-Field cancellation, Advanced Guild shipment stop and separate Guild rate
+response. An allowed shipment withdraws the full actual companion group once,
+pays the original approved split and keeps the marker concealed. Preventing
+No-Field cancels the entire group without payment, force transfer or shipment
+use; the native token-use denial still applies and normal replacement remains.
+Native Guild interception retains its separate existing outcomes. No new
+Karama response can cancel only the High card's permission.
+
+Arrival/entry counts include one effective marker plus the actual companions.
+Physical shipment commitments count only the companions. The persistent private
+selector explains current stock, costs and immediate versus concealed forces.
+Existing minimal policies offer one affordable paid companion and retain the
+ordinary marker fallback; no strategic tuning is performed. The two-token
+Richese Nexus Cunning route retains its separate one-marker invoice and cannot
+silently inherit a selected ordinary paid group. Its combined High timing,
+other specialized routes/modules and comprehensive acceptance remain unfinished.
+
+Evidence paths: [shared quote](../game/richese-homeworld-shipment.ts),
+[selector](../components/richese-homeworld-forces.tsx),
+[native chronology](../tests/fixture-richese-homeworld-shipment.ts),
+[native rules](../tests/richese-homeworld-shipment-runtime.test.ts),
+[actual purchased-rate pricing](../tests/richese-homeworld-shipment-price.test.ts),
+[quote tests](../tests/richese-homeworld-shipment.test.ts) and
+[control callbacks](../tests/richese-homeworld-forces-controls.test.ts).
+Frozen evidence covers 15 related files, 123 cases: 122 originally passed, with
+only an old orphaned-pair assertion expecting obsolete text. Both message checks
+now match the unchanged guard; its full 6/6 file and final types/lint/build pass.
+Exact hashes preserve the other 2,800 source files. Five native runtime/SSR
+journeys pass: Basic 56, Advanced 60, actual capped-reveal 85, original opposing
+Karama cancellation 56 and actual purchased-rate 27 source actions. Funding uses
+real bribes/Mentat and Charity; no played resource, hand, phase or custody is
+installed. The selector callback, original responses and physical deposits are
+executed. A separate projected ally/rate UI case is explicitly controlled.
+Private source-bound proof/HTML is retained in
+`/home/quintine/.local/share/dune/checkpoints/20261025-richese-homeworld-shipment/`.
+This is a first usable card-effect implementation, not full Homeworld/module
+certification, saved-game assurance or deployed acceptance.
+
### Stable occupation runtime follow-up — 5 October 2026
Original fresh `homeworld-occupation` connects bank Collection/immediate sharing,
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 4c8d4e48..a42f8c3c 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,61 @@
# Dune implementation status
+## 25 October 2026 — High Richese ships paid reserves beside a concealed marker
+
+Native High Richese uses the original single-marker action plus positive
+`noFieldReserves`. The shared physical quote reserves actual paid counters and
+any independent free Smuggler counter, while pricing marker+paid group once.
+The private original marker proof binds quantities through No-Field Karama,
+Advanced Guild interception and separate rate repricing. Exact native withdrawal
+moves the combined physical companions once, preserves the hidden denomination
+and produces the correct public entry count. A completed group may cross Low;
+subsequent movement obeys Low and revelation draws only remaining reserves.
+Original omitted-field shipments retain their marker-only behavior. The new
+persistent selector and legal one-companion/fallback policies share the quote.
+The separate two-token Cunning route retains its existing contract.
+
+The card grants accompaniment; combined invoice/rounding and pre-group High
+eligibility are explicitly provisional source compositions. Bounded review
+caught a real UI omission: native Guild discount cancellation now restores the
+full replacement fee while a purchased Karama rate remains independent. Its
+regression uses an explicitly controlled projected alliance/rate, not a claimed
+native alliance. Actual original Karama separately proves one and two companions
+cost 1/2 under the combined rounded tariff. No mode gate is opened.
+
+Disjoint helper/control/native-fixture work and root integration cover **15
+files, 123 cases**. The original union passed 122; one old orphaned-pair case had
+two stale error-message assertions against the already unchanged funded-parent
+guard. Both now match its actual reason and its entire **6/6** file passes.
+Final types/lint/build pass; per-file hashes keep the other 2,800 source files,
+all native runtime code and all other passing tests unchanged. Initial lint
+feedback removed an unused fixture import. A private smoke assertion was also
+corrected to inspect the actual Low movement reason rather than a boolean;
+application behavior needed no repair for that assertion.
+
+Five native selector-callback/engine/GameTable SSR journeys pass. Basic 56 and
+Advanced 60 earn original main 35/cache 10, native ready/start/auctions, real 7-spice
+bribe/Mentat settlement and the next unused shipment. Eleven paid arrivals leave
+nine reserves and a concealed 5 marker, then reveal 5 from that remaining pool.
+Capped-reveal85 earns real 10/2 bribes and intervening Charity, then sixteen paid
+arrivals leave only four for the 5 reveal. Cancellation56 uses the original
+opposing Karama, preserves the unspent stock/payment/opportunity and completes
+an ordinary replacement. Purchased-rate 27 spends that original card and rounds
+marker+one companion to one spice. Physical components are never installed in
+the played state. All four minimal profiles produce/complete legal positive
+companion shipments; no strategic refinement.
+
+Private reports/HTML:
+`/home/quintine/.local/share/dune/checkpoints/20261025-richese-homeworld-shipment/`.
+`frozen-fixture-repair/report.json` binds tree
+`fe06ed932316df7e068563ba57da670dfed8df9b93dc6fcf3fbfbb38283132fc`,
+2801 files, base `052737d0f98564d50a39c1428ed90bb8efbbc3b5`.
+`frozen-remaining/report.json` retains the passing runtime and 122 cases;
+`fixture-repair-overlay.json` proves the sole changed test file.
+[Source/provisional contract](HOMEWORLD_RULES.md#high-richese-mixed-shipment-prototype--25-october-2026).
+Wider two-token/module interactions, complete modes and final assurance remain.
+SSR/callbacks are not browser hydration/mobile/deployed evidence. No saved-room/
+D1/server/reset/restart/deployment operation occurred.
+
## 25 October 2026 — Ix High adds physical Suboids to paid Cyborg revival
The original ordinary revival now snapshots the native High entitlement and
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 534b0efb..42887f21 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1253,6 +1253,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
| Ix High paid-Cyborg bonus | Provisional composition: observe High before the accepted ordinary group; add up to two actual remaining Suboid Tanks per paid Cyborg outside normal allowance/free quota, without Recruits multiplication or future debt. Preserve the paid invoice and original cancellation/stop outcomes. Prior La La La suppresses new grants; a delivered bonus is one existing free-return income event, not a second award. The card establishes the grant; quota, shortage and event classification are application policy, not a located Ix FAQ. | [Current contract and source boundaries](HOMEWORLD_BENEFITS_RULES.md#provisional-ix-high-paid-revival-bonus--25-october-2026) |
+| High Richese reserve accompaniment | The printed High card permits reserve forces beside a No-Field. Provisionally bill one marker plus the actual paid companions as one rounded ordinary group, observe High before that group and permit its completed shipment to cross Low. Hidden value stays in reserves; optional Smuggler reserves an additional real free counter. Original No-Field/Guild decisions and invoices remain, and the two-token Cunning route retains its separate contract. Combined tariff and grouped threshold timing are composition policy, not a printed worked example. | [Current prototype and boundaries](HOMEWORLD_RULES.md#high-richese-mixed-shipment-prototype--25-october-2026) |
| Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) |
| Duke Vidal revival custody | The user selected living set-aside custody after Ecaz revives Duke; a separate Ambassador/Nexus acquisition grants battle control. Only Ecaz may revive him, including Ghola. Native repeated returns now use the separately labelled provisional identity-cohort policy, not a new publisher example or user number. Captured scope and broad combinations remain separately bounded. | [Selected custody](ECAZ_REVIVAL_RULES.md#selected-product-interpretation-28-september-2026), [current cohort policy](ECAZ_REVIVAL_RULES.md#identity-tracked-repeated-cohorts--14-october-2026) |
| Mirror Weapon copied-card cleanup | The user selected ordinary winner retention for the physical Mirror even if its effective copy activates Tooth or Artillery. The original Tooth/Artillery cards retain their named mandatory-discard exceptions. This is a product interpretation, **not** a located publisher ruling. Independent copy-first Tooth/Stone choices and physical cleanup have a bounded CHOAM/Richese-deck runtime, including bare Basic native E3 and selected Ix with original fixed/typed coalition accounting. Basic E3 phase modules and other combined modules/full games remain gated. | [Printed Mirror face and source audit](MIRROR_WEAPON_ENGINE_AUDIT.md#user-selected-cleanup-interpretation-28-september-2026) |
diff --git a/game/bots.ts b/game/bots.ts
index b55e76dd..1cf55d2a 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -59,6 +59,7 @@ import {
paidForceRevivalCost,
} from './revival';
import { controlsLeader, nativeAvailable } from './leader-control';
+import { quoteRicheseHomeworldForces } from './richese-homeworld-shipment';
import { CHEAP_HERO_TRAITOR, matchingTraitor } from './traitors';
import {
treacheryDeck,
@@ -3431,23 +3432,22 @@ function policyActions(g: GameView): Action[] {
}
if (selected)
for (const to of markerTargets.slice(0, 24)) {
- if (Math.min(selected.value, me.reserves) === 0 && to.enemy > 0)
- continue;
- const cost = reserveShipmentCost(
- { faction: me.faction, halfRate },
- territory(to.t).type,
- 1,
- );
- if (cost > shipmentBudget) continue;
- actions.push({
- type: 'ship',
- noField: selected.id,
- event: noField.event,
- territory: to.t,
- sector: to.s,
- allyPayment: Math.max(0, cost - (me.spice ?? 0)),
- ...(quoteSmugglerNoField(g, me.id, to.t) ? { smuggler: true } : {}),
- });
+ const smuggler = !!quoteSmugglerNoField(g, me.id, to.t);
+ const companions = noField.homeworldForcesEnabled && me.reserves > (smuggler ? 1 : 0) ? [1, 0] : [0];
+ for (const amount of companions) {
+ const group = quoteRicheseHomeworldForces({ enabled: !!noField.homeworldForcesEnabled,
+ reserves: me.reserves, amount, smuggler });
+ if (Math.min(selected.value, me.reserves) + group.physical === 0 && to.enemy > 0) continue;
+ const cost = reserveShipmentCost({ faction: me.faction, halfRate }, territory(to.t).type, group.priced);
+ if (cost > shipmentBudget) continue;
+ actions.push({
+ type: 'ship', noField: selected.id, event: noField.event,
+ territory: to.t, sector: to.s,
+ allyPayment: Math.max(0, cost - (me.spice ?? 0)),
+ ...(smuggler ? { smuggler: true } : {}),
+ ...(amount ? { noFieldReserves: amount } : {}),
+ });
+ }
}
}
if (marker && noField && !g.homeworldMobility?.noFieldMovementBlocked && (me.moved ?? 0) < (me.movesAllowed ?? 1)) {
diff --git a/game/engine.ts b/game/engine.ts
index f577c49f..d8243609 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -570,6 +570,7 @@ import {
import type { MixedShipmentExpression } from './mixed-shipment-question';
import { controlsLeader, nativeAvailable } from './leader-control';
import { ixHomeworldRevivalEnabled, quoteIxSuboidBonus } from './ix-homeworld-revival';
+import { richeseHomeworldShipmentEnabled, quoteRicheseHomeworldForces, RicheseHomeworldShipmentError } from './richese-homeworld-shipment';
import type { EcazRevivalCohort } from './ecaz-revival-cohort';
import {
newRevivalRules,
@@ -1210,6 +1211,8 @@ type PendingShipment = {
/** Only new declarations use the bonus; legacy quoted shipments retain their original price. */
smuggler?: SmugglerShipment;
smugglerCompanion?: SmugglerNoFieldCompanion;
+ /** Actual paid ordinary reserves beside a marker; the marker remains amount1. */
+ noFieldReserves?: number;
noFieldSkillProof?: string;
guildSecretEvent?: string;
guildRateEvent?: string;
@@ -10632,7 +10635,7 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
Object.keys(shipment).every(key => ['turn', 'player', 'territory', 'sector', 'amount', 'elite',
'cost', 'allyPayment', 'advisors', 'homeworldSources', 'guildRateEvent',
'smuggler', 'smugglerCompanion', 'guildSecretEvent', 'guildNexusEvent', 'nexusEvent',
- 'richesePair', 'noField', 'alliedNoField', 'noFieldSkillProof'].includes(key)),
+ 'richesePair', 'noField', 'alliedNoField', 'noFieldSkillProof', 'noFieldReserves'].includes(key)),
'The saved reserve shipment contains foreign source fields.');
requireRule(!shipment.source && !shipment.guildSecretEvent &&
((!shipment.noField && !shipment.alliedNoField && !shipment.richesePair) || nexusCoreProfile(g)) &&
@@ -16547,7 +16550,7 @@ function quoteShipmentArrival(
const p = getPlayer(g, shipment.player);
const amount = shipment.alliedNoField
? Math.max(1, shipment.amount)
- : shipment.amount + (shipment.smugglerCompanion ? 1 : 0);
+ : shipment.amount + (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0);
const guild = byFaction(g, 'guild');
const currentDecision = !!(
g.decision &&
@@ -16602,6 +16605,7 @@ function recoverableUnsupportedShipment(shipment: PendingShipment) {
shipment.source === undefined &&
shipment.ambassadorEvent === undefined &&
shipment.noField === undefined &&
+ shipment.noFieldReserves === undefined &&
shipment.noFieldSkillProof === undefined &&
shipment.alliedNoField === undefined &&
shipment.smuggler === undefined &&
@@ -30168,12 +30172,22 @@ function finishHomeworldGuildRateResponse(g: Game, response: ResponseWindow, can
function noFieldSkillProof(shipment: PendingShipment): string {
return JSON.stringify({ ...shipment, noFieldSkillProof: undefined });
}
+function currentRicheseHomeworldForces(g: Game, p: Player, amount: number, smuggler = false) {
+ try {
+ return quoteRicheseHomeworldForces({ enabled: richeseHomeworldShipmentEnabled(g, p.id),
+ reserves: p.reserves, amount, smuggler });
+ } catch (error) {
+ if (error instanceof RicheseHomeworldShipmentError) throw new RuleError(error.message);
+ throw error;
+ }
+}
function validateLeaderSkillNoFieldShipment(g: Game, shipment: PendingShipment) {
- requireRule(g.leaderSkills && smugglerNoFieldModeSupported(g) && shipment.noField &&
+ const mixed = shipment.noFieldReserves !== undefined;
+ requireRule((mixed || g.leaderSkills && smugglerNoFieldModeSupported(g)) && shipment.noField &&
!shipment.alliedNoField && !shipment.smuggler && !shipment.source &&
!shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent &&
shipment.homeworldSources === undefined && shipment.ambassadorEvent === undefined,
- 'Smuggler No-Field shipping requires an own Richese marker without other shipment routes or modules.');
+ 'A paid Homeworld group or Smuggler shipment requires its own Richese marker without other shipment routes.');
requireRule(typeof shipment.noFieldSkillProof === 'string' &&
shipment.noFieldSkillProof === noFieldSkillProof(shipment),
'The saved No-Field shipment changed its original marker or optional Smuggler companion.');
@@ -30196,10 +30210,16 @@ function validateLeaderSkillNoFieldShipment(g: Game, shipment: PendingShipment)
shipment.smugglerCompanion !== null,
'The saved Smuggler No-Field companion lost its living trainer, reserve force or empty destination.',
);
+ if (mixed) integer(shipment.noFieldReserves, 1, p.reserves, 'Paid No-Field reserve forces');
+ const group = currentRicheseHomeworldForces(g, p, shipment.noFieldReserves ?? 0, !!shipment.smugglerCompanion);
+ if (g.homeworlds?.custody && group.physical) homeworldRule(() =>
+ quoteNativeReserveWithdrawal(homeworldContext(g), g.homeworlds!.custody!, p.id,
+ { normal: group.physical, elite: 0 }));
const cost = reserveShipmentCost({ faction: p.faction, halfRate: shipmentHalfRate(g, p) },
- territory(shipment.territory).type, 1);
+ territory(shipment.territory).type, group.priced);
requireRule(Number.isSafeInteger(shipment.cost) && shipment.cost === cost,
- 'A No-Field with an optional Smuggler force keeps the price of one marker.');
+ mixed ? 'The No-Field invoice must price one marker plus its paid reserve forces.'
+ : 'A No-Field with an optional Smuggler force keeps the price of one marker.');
integer(p.spice, 0, Number.MAX_SAFE_INTEGER, 'Current No-Field shipment spice');
integer(shipment.allyPayment, 0, cost, 'No-Field shipment ally payment');
const credit = aidFor(g, p);
@@ -30241,7 +30261,7 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean {
return true;
}
const pair = !!g.leaderSkills && !!shipment?.richesePair;
- if (!(pair || shipment?.smugglerCompanion !== undefined || shipment?.noFieldSkillProof !== undefined ||
+ if (!(pair || shipment?.noFieldReserves !== undefined || shipment?.smugglerCompanion !== undefined || shipment?.noFieldSkillProof !== undefined ||
(g.leaderSkills && shipment?.noField) || responses.some(r => r.noFieldSkillProof !== undefined) ||
decisions.some(d => 'noFieldSkillProof' in d))) return false;
requireRule(shipment, 'The saved No-Field response lost its original shipment.');
@@ -30262,10 +30282,12 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean {
return true;
}
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
+ requireRule(shipment.noFieldReserves === undefined || shipment.noField && !shipment.richesePair,
+ 'Paid No-Field reserves require a single native concealed marker.');
requireRule(!atomicsShipmentBlocked(g.moritaniAtomics, shipment.territory),
'Atomics Aftermath permanently blocks shipments into this territory.');
// The pair has its original signed two-token validator below; skill proofs bind single markers.
- if (!shipment.richesePair && (shipment.smugglerCompanion !== undefined || shipment.noFieldSkillProof !== undefined ||
+ if (!shipment.richesePair && (shipment.noFieldReserves !== undefined || shipment.smugglerCompanion !== undefined || shipment.noFieldSkillProof !== undefined ||
(g.leaderSkills && shipment.noField))) validateLeaderSkillNoFieldShipment(g, shipment);
if (shipment.smuggler) requireRule(
!shipment.source && !shipment.noField && !shipment.alliedNoField &&
@@ -30396,7 +30418,7 @@ function validateGuildShipmentDecision(
decision.territory === shipment.territory &&
decision.sector === shipment.sector &&
decision.noFieldSkillProof === shipment.noFieldSkillProof &&
- decision.amount === (shipment.alliedNoField ? 1 : shipment.amount),
+ decision.amount === (shipment.alliedNoField ? 1 : shipment.amount + (shipment.noFieldReserves ?? 0)),
'The Guild decision does not match the pending shipment.',
);
validatePhysicalShipment(g, shipment);
@@ -30411,7 +30433,8 @@ 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 || shipment.nexusEvent ? 1
+ const pricedAmount = shipment.noField ? 1 + (shipment.noFieldReserves ?? 0)
+ : shipment.alliedNoField || shipment.nexusEvent ? 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 ||
@@ -30837,7 +30860,7 @@ function offerShipment(g: Game, shipment: PendingShipment, matchingApproved = fa
return;
}
const { territory: to, sector: s } = shipment;
- const n = shipment.alliedNoField || shipment.ambassadorMarker ? 1 : shipment.amount;
+ const n = shipment.alliedNoField || shipment.ambassadorMarker ? 1 : shipment.amount + (shipment.noFieldReserves ?? 0);
const guild = byFaction(g, 'guild');
if (
g.advanced &&
@@ -30860,7 +30883,7 @@ function offerShipment(g: Game, shipment: PendingShipment, matchingApproved = fa
};
log(
g,
- `${p.name} declared a shipment of ${shipment.noField ? `one concealed No-Field${shipment.smugglerCompanion ? ' and one free Smuggler force' : ''}` : `${n} forces`} to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally is spent; the shipment is priced as one force (${shipment.cost} spice) if allowed.` : ''}`,
+ `${p.name} declared a shipment of ${shipment.noField ? `one concealed No-Field${shipment.noFieldReserves ? ` and ${shipment.noFieldReserves} paid reserve forces` : ''}${shipment.smugglerCompanion ? ' and one free Smuggler force' : ''}` : `${n} forces`} to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally is spent; the shipment is priced as one force (${shipment.cost} spice) if allowed.` : ''}`,
);
} else if (!openGuildRate(g, shipment)) commitShipment(g, shipment);
}
@@ -30882,7 +30905,9 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
finishShipmentPromises(
g,
getPlayer(g, shipment.player),
- shipment.noField || shipment.alliedNoField ? null : shipment,
+ shipment.noField ? (shipment.noFieldReserves || shipment.smugglerCompanion
+ ? { territory: shipment.territory, amount: (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0) }
+ : null) : shipment.alliedNoField ? null : shipment,
);
const {
territory: to,
@@ -30988,9 +31013,10 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
.join('; ');
if (n > 0) place(p, to, s, n, elite);
}
- if (shipment.smugglerCompanion) {
- withdrawNativeReserves(g, p, 1, 0);
- place(p, to, s, 1, 0);
+ const markerCompanions = (shipment.noFieldReserves ?? 0) + (shipment.smugglerCompanion ? 1 : 0);
+ if (markerCompanions) {
+ withdrawNativeReserves(g, p, markerCompanions, 0);
+ place(p, to, s, markerCompanions, 0);
}
if (advisors && n > 0) (p.advisors ??= {})[to] ??= {};
p.shipped = true;
@@ -31021,7 +31047,7 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
: shipment.noField
? pairRevealed
? `${p.name} shipped two No-Fields with Richese Nexus Cunning to ${territory(to).name}, sector ${s}, for ${cost} spice. The faceup ${pairRevealed.value} token placed ${pairRevealed.forces} physical forces from reserves${pairRevealed.forces < pairRevealed.value ? ' (only the available reserves)' : ''}; the other token remains concealed and counts as one force.`
- : `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. ${shipment.smugglerCompanion ? 'One free Smuggler force left reserves and arrived beside the marker; the concealed token remains unchanged until reveal.' : 'It counts as one force; physical reserves remain unchanged until reveal.'}`
+ : `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. ${shipment.noFieldReserves ? `${shipment.noFieldReserves} paid reserve forces${shipment.smugglerCompanion ? ' and one free Smuggler force' : ''} arrived beside the marker; the concealed token remains unchanged until reveal.` : shipment.smugglerCompanion ? 'One free Smuggler force left reserves and arrived beside the marker; the concealed token remains unchanged until reveal.' : 'It counts as one force; physical reserves remain unchanged until reveal.'}`
: `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${shipment.guildSecretEvent ? ` Guild Secret Ally is spent; Guild shipping prices cost ${cost} spice paid to the bank.` : ''}${shipment.nexusEvent ? ` Richese Secret Ally priced the ${n} physical forces as one: ${cost} spice.` : ''}${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`,
shipment.noField || shipment.alliedNoField
? { faction: 'richese', name: 'No-Field shipment' }
@@ -31052,7 +31078,7 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
p,
to,
s,
- shipment.alliedNoField ? Math.max(1, n) : n + (shipment.smugglerCompanion ? 1 : 0) + (pairRevealed?.forces ?? 0),
+ shipment.alliedNoField ? Math.max(1, n) : n + markerCompanions + (pairRevealed?.forces ?? 0),
elite,
'shipment',
);
@@ -36711,9 +36737,10 @@ function applyActionInner(
'Richese Secret Ally requires an ordinary physical reserve shipment.');
nexusEvent = offer.event;
}
- checkShipmentPromises(g, p, {
- territory: String(action.territory),
- amount: richesePair ? 1 : Number(action.amount),
+ requireRule(action.noFieldReserves === undefined || action.noField !== undefined && !richesePair,
+ 'Paid No-Field reserves accompany one native concealed marker.');
+ if (action.noField === undefined) checkShipmentPromises(g, p, {
+ territory: String(action.territory), amount: Number(action.amount),
});
const to = String(action.territory),
s = integer(action.sector, 0, 18, 'Sector');
@@ -36768,9 +36795,12 @@ function applyActionInner(
? quoteSmugglerNoField(g, id, to) : null;
requireRule(action.smuggler !== true || smuggler || smugglerCompanion,
'Smuggler needs a living face-up native trainer and an empty territory, with two ordinary shipment forces or one reserve companion beside your No-Field.');
+ const noFieldReserves = action.noFieldReserves === undefined ? 0
+ : integer(action.noFieldReserves, 1, p.reserves, 'Paid No-Field reserve forces');
+ const noFieldGroup = noField ? currentRicheseHomeworldForces(g, p, noFieldReserves, !!smugglerCompanion) : null;
const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(to).type,n)).cost : nexusEvent
? nexusRule(() => quoteNexusRicheseShipment(price,territory(to).type,n)).cost
- : reserveShipmentCost(price,territory(to).type,n - (smuggler ? 1 : 0));
+ : reserveShipmentCost(price,territory(to).type,noFieldGroup?.priced ?? n - (smuggler ? 1 : 0));
if (p.faction === 'fremen' && !guildSecretEvent) {
requireRule(
territory('the_great_flat').sectors.some((fs) =>
@@ -36790,6 +36820,7 @@ function applyActionInner(
const shipment: PendingShipment = {
...(smuggler ? { smuggler } : {}),
...(smugglerCompanion ? { smugglerCompanion } : {}),
+ ...(noFieldReserves ? { noFieldReserves } : {}),
...(currentGuildCunning(g,id) ? {guildNexusEvent:currentGuildCunning(g,id)!.receipt.event} : {}),
...(nexusEvent ? {nexusEvent} : {}),
...(guildSecretEvent ? {guildSecretEvent} : {}),
@@ -36811,7 +36842,9 @@ function applyActionInner(
}),
...(noField ? { noField } : {}),
};
- if (noField && g.leaderSkills && !richesePair) {
+ if (noField) checkShipmentPromises(g, p, noFieldGroup!.physical
+ ? { territory: to, amount: noFieldGroup!.physical } : null);
+ if (noField && (g.leaderSkills || noFieldReserves) && !richesePair) {
shipment.noFieldSkillProof = noFieldSkillProof(shipment);
validatePhysicalShipment(g, shipment);
}
@@ -36838,7 +36871,7 @@ function applyActionInner(
...(shipment.noFieldSkillProof ? { noFieldSkillProof: shipment.noFieldSkillProof } : {}) };
log(
g,
- `${p.name} declared a concealed No-Field shipment${richesePair ? ' with Richese Nexus Cunning and one faceup token' : ''}${smugglerCompanion ? ' with one free Smuggler force' : ''} to ${territory(to).name}, sector ${s}.${richesePair ? ' The Nexus card is spent; payment and both token effects await the responses.' : ''}`,
+ `${p.name} declared a concealed No-Field shipment${richesePair ? ' with Richese Nexus Cunning and one faceup token' : ''}${noFieldReserves ? ` with ${noFieldReserves} paid reserve forces` : ''}${smugglerCompanion ? ' with one free Smuggler force' : ''} to ${territory(to).name}, sector ${s}.${richesePair ? ' The Nexus card is spent; payment and both token effects await the responses.' : ''}`,
);
} else offerShipment(g, shipment);
return g;
@@ -38474,6 +38507,7 @@ export function viewGame(state: Game, id: string) {
? projectRicheseNoField(owner.noField, true).private
: null,
canShip: !shipBlock,
+ homeworldForcesEnabled: mine && richeseHomeworldShipmentEnabled(g, owner.id),
shipBlock,
canReveal: !revealBlock,
revealBlock,
diff --git a/game/reference.ts b/game/reference.ts
index adaae666..27f08961 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4602,6 +4602,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Caladan shares the admitted original Bidding inspection with the occupier, not its ally; native inspection can still be prevented independently. Ix gives the occupier the actual private drawn pool and selection, not unrelated setup or Technology. Richese sets its own method/direction before the occupier chooses only the physical cache card; seller, payment and other decisions stay native. Giedi gives one original purchase bonus to the occupier or its reciprocal ally with real receiver capacity, even when Harkonnen’s purchased card fills its hand.',
'The separately marked fresh Tupile entry grants one hand slot to the proved occupier and reciprocal ally. Every server-derived hand limit includes it, so Harkonnen can hold nine without receiving a tenth bonus. After a proved loss, each old holder privately selects exactly its excess original eligible cards to return to the normal limit; committed cards remain protected. Original phase opening and remaining movement resume without another shipment or payment. Basic unknown lifecycle blocks rather than guesses cleanup. Advanced departure can restore CHOAM’s low intelligence without clearing its previous answers or used factions.',
'The fresh native Ecaz high-card preview shows a separate prospective conjunction: native seven-plus population, at least one actual jointly held stronghold and proved current alliance holdings of two other native factions’ Homeworlds. This does not change the ordinary four-stronghold or three-joint Ecaz targets. Original Mentat decides the win before prediction/fallback; source uncertainty remains blocked. Duke authority and the Ecaz-only revival ruling are separate.',
+ 'At High, Richese may send paid physical reserve forces beside one concealed No-Field. The persistent selector names only actual counters; the marker stays priced as one and its hidden value stays in reserves until reveal. One rounded invoice for marker plus paid group and pre-group High timing are provisional. Actual departure can cross Low; later reveal uses remaining reserves. The optional Smuggler force stays separately free. Original No-Field/Guild responses stay; the two-token Cunning route remains separate.',
'Ix High adds up to two physical Suboids from Tanks per actually paid Cyborg in an ordinary revival. The persistent quote follows selected free types. Its additional allowance, partial shortage and free-event income are explicit provisional compositions; prior La La La suppresses new bonuses, while native cancellation and atomic request-stop outcomes remain. Recruits does not multiply the bonus. Old accepted requests are not retrofitted.',
'The card collection below is available for reference. Homeworld starts are enabled in supported fresh preview envelopes; the remaining occupation variants and combinations stay under verification.',
'Original classic World/Nexus uses base33 or selected independent36 with optional all14, Tech3+ and Advanced Strongholds2+ with real first-Storm token owners and end-Mentat card claims. Guild World shipments and Cunning visitor returns pass native permission/rate before the funded fee; borrowed Caladan fee1/fee5 settles to the distinct holder while typed custody waits. Temporary-Sardaukar own-Kaitain battles keep ordinary-only casualties and true Salusa custody; Giedi native-call cancellation follows original capture/execution/cleanup and one private replacement. Normal Smuggler companions may also supply the fee in core. On an own World, Reinforcements provisionally spends ordinary non-role counters first, reducing temporary Sardaukar roles only when converted counters pay. Kaitain7 paying local3 leaves four temporary roles on four normals; the original five-counter declaration and pre-fee traits remain. Other envelopes and complete-mode acceptance stay separate.',
@@ -4621,7 +4622,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
- "All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
+ "All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
evidence: [
'game/faction-module-profile.ts',
'game/reinforcements-homeworld.ts',
@@ -4629,6 +4630,13 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/nexus-world-independent-cards.test.ts',
'tests/nexus-world-independent-battles.test.ts',
'game/homeworld-cards.ts',
+ 'game/richese-homeworld-shipment.ts',
+ 'components/richese-homeworld-forces.tsx',
+ 'tests/fixture-richese-homeworld-shipment.ts',
+ 'tests/richese-homeworld-shipment-runtime.test.ts',
+ 'tests/richese-homeworld-shipment-price.test.ts',
+ 'tests/richese-homeworld-shipment.test.ts',
+ 'tests/richese-homeworld-forces-controls.test.ts',
'game/ix-homeworld-revival.ts',
'components/ix-homeworld-revival.tsx',
'tests/fixture-ix-homeworld-revival-bonus.ts',
diff --git a/game/richese-homeworld-shipment.ts b/game/richese-homeworld-shipment.ts
new file mode 100644
index 00000000..18a4d564
--- /dev/null
+++ b/game/richese-homeworld-shipment.ts
@@ -0,0 +1,45 @@
+import type { Game } from './engine';
+import { homeworldContext } from './homeworld-game';
+import { homeworldPopulations } from './homeworld-population';
+
+type RicheseHomeworldContext = Pick;
+export type RicheseHomeworldForcesInput = {
+ enabled: boolean;
+ reserves: number;
+ amount: number;
+ smuggler?: boolean;
+};
+export type RicheseHomeworldForcesQuote = { paid: number; physical: number; priced: number };
+
+export class RicheseHomeworldShipmentError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'RicheseHomeworldShipmentError';
+ }
+}
+
+/** The printed High Richese modifier applies in either rules band, using only
+ * native counters currently at Richese rather than foreign visitors. */
+export function richeseHomeworldShipmentEnabled(context: RicheseHomeworldContext, playerId: string): boolean {
+ if (!context.homeworlds?.custody ||
+ !context.players.some(player => player.id === playerId && player.faction === 'richese')) return false;
+ return homeworldPopulations(homeworldContext(context), context.homeworlds.custody)
+ .some(home => home.native === playerId && home.card === 'richese' && home.side === 'high');
+}
+
+const count = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= 20;
+
+/** One priced No-Field marker plus the selected paid physical reserve forces.
+ * The concealed value never supplies counters or changes this invoice. */
+export function quoteRicheseHomeworldForces(input: RicheseHomeworldForcesInput): RicheseHomeworldForcesQuote {
+ const { enabled, reserves, amount, smuggler } = input;
+ if (typeof enabled !== 'boolean' || !count(reserves) || !count(amount) ||
+ (smuggler !== undefined && typeof smuggler !== 'boolean'))
+ throw new RicheseHomeworldShipmentError('Choose zero to twenty physical reserve forces and a valid shipment source.');
+ if (amount > 0 && !enabled)
+ throw new RicheseHomeworldShipmentError('Paid forces alongside a No-Field require native Richese at High population.');
+ const physical = amount + (smuggler ? 1 : 0);
+ if (physical > reserves)
+ throw new RicheseHomeworldShipmentError('The paid forces and any Smuggler force must fit the available physical reserves.');
+ return { paid: amount, physical, priced: 1 + amount };
+}
diff --git a/tests/fixture-richese-homeworld-shipment.ts b/tests/fixture-richese-homeworld-shipment.ts
new file mode 100644
index 00000000..3e0098d6
--- /dev/null
+++ b/tests/fixture-richese-homeworld-shipment.ts
@@ -0,0 +1,200 @@
+import assert from 'node:assert/strict';
+import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
+import type { NoFieldValue } from '../game/richese-no-field';
+import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
+import { homeworldPopulations } from '../game/homeworld-population';
+import { actTruthMovement, type TruthMovementStep } from './fixture-truth-movement';
+import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
+
+export type RicheseHomeworldShipmentStep = TruthMovementStep;
+export type RicheseHomeworldShipmentTransition = { before: Game; steps: RicheseHomeworldShipmentStep[]; after: Game };
+export const actRicheseHomeworldShipment = actTruthMovement;
+export const richeseHomeworldShipmentPlayer = (game: Game, actor = 'richese') => {
+ const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
+};
+export const richeseHomeworldShipmentClean = (game: Game) => !game.response && !game.phaseOpening &&
+ !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
+export function richeseHomeworldShipmentInventory(game: Game) {
+ homeworldGameIntegrity(game);
+ const sum = (counts: Record) => Object.values(counts).reduce((total, count) => total + count, 0);
+ for (const player of game.players) {
+ const visitors = Object.values(game.homeworlds!.custody!.visitors)
+ .map(groups => groups[player.id] ?? { normal: 0, elite: 0 });
+ assert.equal(player.reserves + player.tanks + sum(player.forces) +
+ visitors.reduce((total, group) => total + group.normal + group.elite, 0), 20);
+ if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces) +
+ visitors.reduce((total, group) => total + group.elite, 0), 5);
+ }
+ const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
+ ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? []),
+ ...game.players.flatMap(player => player.hand)];
+ const ids = cards.map(card => card.id).sort();
+ assert.equal(new Set(ids).size, ids.length, 'Every original main/cache card retains one physical slot');
+ return ids;
+}
+export const richeseHomeworldShipmentPopulation = (game: Game, owner = 'richese') => {
+ const home = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!)
+ .find(home => home.native === owner && home.card === 'richese'); assert.ok(home); return home;
+};
+
+/** Native pass/unbid controls only. Cache selection always uses an actual
+ * remaining card, so a prior retired lot is never requested again. Emperor
+ * really takes ordinary Charity when eligible; no balance is installed. */
+export function nextRicheseHomeworldShipment(game: Game): RicheseHomeworldShipmentStep | null {
+ if (game.truthtrance) return null;
+ if (richeseHomeworldShipmentClean(game)) {
+ if (game.status === 'playing' && game.phase === 2) {
+ const emperor = richeseHomeworldShipmentPlayer(game, 'emperor');
+ if (emperor.spice < 2 && emperor.charityTurn !== game.turn)
+ return { actor: emperor.id, action: { type: 'charity' } };
+ }
+ if (game.status === 'playing' && game.phase === 5 && game.active === 'emperor')
+ return { actor: 'emperor', action: { type: 'endMovement' } };
+ }
+ return nextTruthMovementNativeEarlyStep(game, {
+ position: 'first', method: 'onceAround', direction: 'clockwise', keepUnbid: false,
+ card: game.richeseCache?.[0]?.id,
+ });
+}
+export const nextRicheseHomeworldShipmentNativeStep = nextRicheseHomeworldShipment;
+export function advanceRicheseHomeworldShipment(game: Game, until: (state: Game) => boolean,
+ actions: RicheseHomeworldShipmentStep[] = []): Game {
+ for (let count = 0; count < 1800; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The original Richese programme ended before its requested control');
+ const next = nextRicheseHomeworldShipment(game);
+ assert.ok(next, `Supply the original owned control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
+ game = actRicheseHomeworldShipment(game, next.actor, next.action, actions);
+ }
+ throw Error('The genuine Richese Homeworld shipment source did not reach its finite boundary');
+}
+export function passRicheseHomeworldShipment(game: Game, actions: RicheseHomeworldShipmentStep[] = []): Game {
+ for (let count = 0; game.response && count < 60; count++) {
+ const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
+ game = actRicheseHomeworldShipment(game, actor.id, { type: 'passResponse' }, actions);
+ }
+ assert.ok(!game.response); return game;
+}
+export function finishRicheseHomeworldShipment(game: Game, actions: RicheseHomeworldShipmentStep[] = []): Game {
+ return advanceRicheseHomeworldShipment(game, richeseHomeworldShipmentClean, actions);
+}
+
+/** Actual public Basic/Advanced Richese/Emperor with Homeworlds, original35+10
+ * cards and undeployed original No-Fields. Native deferred bribes fund one
+ * combined invoice without changing either physical army. Stop BEFORE ship. */
+export function createRicheseHomeworldShipmentFixture(options: {
+ advanced?: boolean; companions?: number; value?: NoFieldValue;
+} = {}) {
+ const advanced = options.advanced ?? false, companions = options.companions ?? 11, value = options.value ?? 5;
+ assert.ok(Number.isSafeInteger(companions) && companions >= 1 && companions <= 16,
+ 'The bounded native funding programme supports one through sixteen companions');
+ const owner = 'richese', holder = 'emperor', territory = 'arrakeen', sector = 10, location = 'arrakeen:10';
+ const cost = 1 + companions;
+ let game = createGame('RICHESEHOMEWORLDSHIPMENT', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']);
+ joinGame(game, newPlayer(holder, 'Emperor', 'emperor'));
+ const initial = structuredClone(game), actions: RicheseHomeworldShipmentStep[] = [];
+ const act = (actor: string, action: Action) => { game = actRicheseHomeworldShipment(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceRicheseHomeworldShipment(game, until, actions); };
+ const transition = (run: () => void): RicheseHomeworldShipmentTransition => {
+ const before = structuredClone(game), start = actions.length; run();
+ return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
+ };
+ const enableHomeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); });
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
+ const setup = structuredClone(game);
+ assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['choam']);
+ assert.ok(game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
+ !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
+ assert.ok(game.players.every(player => !player.hand.length));
+ const originalMainDeck = game.deck.map(card => card.id), originalCache = game.richeseCache!.map(card => card.id);
+ assert.equal(originalMainDeck.length, 35); assert.equal(originalCache.length, 10);
+ const ordinary = game.deck.find(card => card.kind === 'projectile');
+ const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(ordinary && karama);
+ const deal = [ordinary.id, karama.id];
+ const dealOrder = deal.map((id, position) => {
+ const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
+ [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
+ return { position, from, card: id };
+ });
+ const beforeStartingDeal = structuredClone(game);
+ reach(state => state.status === 'playing');
+ const afterSetup = structuredClone(game), originalNoField = structuredClone(richeseHomeworldShipmentPlayer(game, owner).noField);
+ assert.ok(originalNoField);
+ assert.deepEqual(originalNoField.tokens.map(token => token.value).sort((a, b) => a - b), [0, 3, 5]);
+ assert.equal(originalNoField.deployed, null); assert.equal(originalNoField.lastShipped, null);
+ assert.deepEqual(richeseHomeworldShipmentPlayer(game, owner).hand.map(card => card.id), [ordinary.id]);
+ assert.deepEqual(richeseHomeworldShipmentPlayer(game, holder).hand.map(card => card.id), [karama.id]);
+ assert.equal(richeseHomeworldShipmentPlayer(game, owner).reserves, 20);
+ assert.deepEqual(richeseHomeworldShipmentPlayer(game, owner).forces, {});
+ const originalInventory = richeseHomeworldShipmentInventory(game); assert.equal(originalInventory.length, 45);
+ const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
+ const spiceOrder = [0, 1, 2, 3, 4, 5].map(position => {
+ const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
+ assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
+ game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
+ });
+ assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
+ reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && richeseHomeworldShipmentClean(state));
+ const beforeFunding = structuredClone(game), funding: RicheseHomeworldShipmentTransition[] = [];
+ for (let count = 0; richeseHomeworldShipmentPlayer(game, owner).spice < cost && count < 3; count++) {
+ const donor = richeseHomeworldShipmentPlayer(game, holder);
+ const amount = Math.min(cost - richeseHomeworldShipmentPlayer(game, owner).spice, donor.spice);
+ assert.ok(amount > 0, 'Actual Emperor income must fund the next deferred bribe');
+ funding.push(transition(() => { act(holder, { type: 'bribe', target: owner, amount }); }));
+ const turn = game.turn;
+ reach(state => state.turn === turn + 1 && state.phase === 5 && state.active === owner && richeseHomeworldShipmentClean(state));
+ }
+ assert.ok(richeseHomeworldShipmentPlayer(game, owner).spice >= cost);
+ const player = richeseHomeworldShipmentPlayer(game, owner);
+ assert.equal(player.reserves, 20); assert.deepEqual(player.forces, {}); assert.equal(player.tanks, 0);
+ assert.equal(player.bribes, 0); assert.equal(player.shipped, false); assert.equal(player.moved, 0);
+ assert.equal(richeseHomeworldShipmentPopulation(game, owner).side, 'high');
+ assert.ok(richeseHomeworldShipmentPlayer(game, holder).hand.some(card => card.id === karama.id));
+ assert.deepEqual(richeseHomeworldShipmentInventory(game), originalInventory);
+ const token = player.noField!.tokens.find(token => token.value === value); assert.ok(token);
+ const event = player.noFieldEvent; assert.ok(event);
+ const action: Action = { type: 'ship', noField: token.id, event, noFieldReserves: companions,
+ territory, sector, allyPayment: 0 };
+ const cancel: Action = { type: 'card', card: karama.id, mode: 'cancel' };
+ const replacement: Action = { type: 'ship', amount: 1, elite: 0, territory, sector, allyPayment: 0,
+ homeworldSources: { 'homeworld:richese': { normal: 1, elite: 0 } } };
+ return { game, source: structuredClone(game), beforeShipment: structuredClone(game), initial, enableHomeworlds,
+ lobby, setup, beforeStartingDeal, afterSetup, beforeFunding, funding,
+ advanced, owner, richese: owner, actor: owner, holder, emperor: holder,
+ companions, noFieldReserves: companions, amount: companions, physical: companions, priced: companions + 1,
+ cost, shipmentCost: cost, value, markerValue: value, token: token.id, tokenId: token.id, event, shipmentEvent: event,
+ territory, sector, location, action, shipmentAction: action,
+ shipmentStep: { actor: owner, action } satisfies RicheseHomeworldShipmentStep,
+ karama: karama.id, card: karama.id, cancel, cancelAction: cancel, replacement,
+ ordinary: ordinary.id, originalNoField, noField: structuredClone(player.noField),
+ originalInventory, originalCards: originalInventory, originalMainDeck, originalCache, originalSpice, deal, dealOrder, spiceOrder,
+ actions, steps: actions, sourceActionCount: actions.length,
+ staging: [
+ 'Actual public Basic/Advanced Richese/Emperor lobby enables only Homeworlds, then native ready/start with original main35/cache10 and all three native No-Field components. Only original scalar lotteries, two unopened Treachery positions and six unopened ordinary Spice positions are selected.',
+ 'Richese really receives an ordinary projectile and Emperor its printed original Karama. Every original Storm, Spice, Charity, native cache/normal auction, Black Market decline in Advanced, Revival, Movement ending and Mentat control is used. Real cache lots are passed and retired, never replaced or acquired by assigning a hand.',
+ 'The default eleven-companion stronghold invoice needs twelve spice. Emperor really pays Richese a seven-spice bribe during turn one; its pending recipient share becomes available only at the actual Mentat. Richese’s next unused turn retains all twenty original reserves and HIGH population before declaration.',
+ 'The sixteen-companion cap variant uses an actual ten-spice first bribe, next-turn Emperor Charity for two and a second two-spice bribe, both completed through real Mentat. Its third unused turn has seventeen spendable spice and the same untouched original twenty reserves. Smaller companion requests use only the native funding they need.',
+ 'Stop BEFORE the marker/physical shipment. The original token/event, opponent-held Karama, quiet unused shipment and exact funded combined invoice remain live caller inputs. No force, wallet, phase, hand, stance, token custody, canceled history or pending proof is installed.',
+ ],
+ };
+}
+export type RicheseHomeworldShipmentFixture = ReturnType;
+
+export function commitRicheseHomeworldShipment(source: Game, fixture: RicheseHomeworldShipmentFixture,
+ action: Action = fixture.action, actions: RicheseHomeworldShipmentStep[] = []) {
+ const before = structuredClone(source), start = actions.length;
+ let game = actRicheseHomeworldShipment(source, fixture.owner, action, actions);
+ const requested = structuredClone(game);
+ game = finishRicheseHomeworldShipment(game, actions);
+ return { game, before, requested, after: structuredClone(game), steps: structuredClone(actions.slice(start)) };
+}
+export function revealRicheseHomeworldShipment(source: Game, fixture: RicheseHomeworldShipmentFixture,
+ actions: RicheseHomeworldShipmentStep[] = []) {
+ const before = structuredClone(source), start = actions.length, player = richeseHomeworldShipmentPlayer(source, fixture.owner);
+ assert.ok(player.noField?.deployed && player.noFieldEvent);
+ const action: Action = { type: 'revealNoField', token: player.noField.deployed.tokenId, event: player.noFieldEvent };
+ let game = actRicheseHomeworldShipment(source, fixture.owner, action, actions);
+ game = finishRicheseHomeworldShipment(game, actions);
+ return { game, before, after: structuredClone(game), action, steps: structuredClone(actions.slice(start)) };
+}
diff --git a/tests/nexus-richese-cunning.test.ts b/tests/nexus-richese-cunning.test.ts
index 8387b050..f9075fbd 100644
--- a/tests/nexus-richese-cunning.test.ts
+++ b/tests/nexus-richese-cunning.test.ts
@@ -98,7 +98,7 @@ void test('restoration rejects an orphaned pair before it can lose its Karama or
assert.equal(declared.response?.kind, 'richeseNoField');
const missingResponse = JSON.parse(JSON.stringify(declared)) as Game;
missingResponse.response = null;
- assert.throws(() => normalizeAutomaticGame(missingResponse), /Karama response or Guild interception/);
+ assert.throws(() => normalizeAutomaticGame(missingResponse), /unique Karama, native Guild interception or funded invoice continuation/);
const advanced = nexusRicheseFixture({
ownerFaction: 'richese', opponentFaction: 'guild', advanced: true,
@@ -109,7 +109,7 @@ void test('restoration rejects an orphaned pair before it can lose its Karama or
assert.equal(pending.decision?.kind, 'guildShipment');
const missingDecision = JSON.parse(JSON.stringify(pending)) as Game;
missingDecision.decision = null;
- assert.throws(() => viewGame(missingDecision, advanced.owner), /Karama response or Guild interception/);
+ assert.throws(() => viewGame(missingDecision, advanced.owner), /unique Karama, native Guild interception or funded invoice continuation/);
});
void test('pair rejects duplicate, prior-used and stale tokens without changing the original game', () => {
diff --git a/tests/richese-homeworld-forces-controls.test.ts b/tests/richese-homeworld-forces-controls.test.ts
new file mode 100644
index 00000000..d2d1ddd0
--- /dev/null
+++ b/tests/richese-homeworld-forces-controls.test.ts
@@ -0,0 +1,73 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { Children, createElement, isValidElement, type ChangeEvent, type InputHTMLAttributes, type ReactElement, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { RicheseHomeworldForces, type RicheseHomeworldForcesProps } from '../components/richese-homeworld-forces';
+
+/** Direct controlled props and actual callbacks; no browser or native engine
+ * provenance is claimed by these component-only tests. */
+function props(patch: Partial = {}): RicheseHomeworldForcesProps {
+ return { enabled: true, amount: 3, max: 10, busy: false, onChange() {}, ...patch };
+}
+const markup = (value: RicheseHomeworldForcesProps) => renderToStaticMarkup(createElement(RicheseHomeworldForces, value));
+function input(value: RicheseHomeworldForcesProps): ReactElement> {
+ const queue: ReactNode[] = [RicheseHomeworldForces(value)];
+ while (queue.length) {
+ const node = queue.shift();
+ if (!isValidElement<{ children?: ReactNode }>(node)) continue;
+ if (node.type === 'input') return node as ReactElement>;
+ queue.push(...Children.toArray(node.props.children));
+ }
+ throw Error('The persistent paid-force input is missing');
+}
+function change(field: ReactElement>, value: string) {
+ assert.ok(field.props.onChange);
+ field.props.onChange({ target: { value } } as ChangeEvent);
+}
+
+void test('paid-force control is persistent, labelled and separates current forces from concealed revelation', () => {
+ const html = markup(props());
+ assert.match(html, /aria-label="Richese Homeworld shipment"/);
+ assert.match(html, /Paid reserve forces with No-Field/);
+ assert.match(html, /type="number"[^>]*min="0"[^>]*max="10"[^>]*step="1"/);
+ assert.match(html, /Paid forces enter the destination now/);
+ assert.match(html, /stay in reserves until revelation/);
+ assert.match(html, /reserves then available/);
+ assert.doesNotMatch(html, /timeout|countdown|automatically submit/i);
+});
+
+void test('actual numeric callback accepts bounded whole amounts including zero', () => {
+ const seen: number[] = [], field = input(props({ onChange: value => seen.push(value) }));
+ for (const value of ['4', '0', '10']) change(field, value);
+ for (const value of ['-1', '11', '1.5', 'not-a-number', 'Infinity']) change(field, value);
+ assert.deepEqual(seen, [4, 0, 10]);
+});
+
+void test('busy state disables the field and an already queued callback cannot change the selection', () => {
+ const seen: number[] = [], value = props({ busy: true, onChange: amount => seen.push(amount) });
+ const field = input(value);
+ assert.equal(field.props.disabled, true);
+ change(field, '0');
+ assert.deepEqual(seen, []);
+ assert.match(markup(value), /disabled=""/);
+});
+
+void test('lost High entitlement clearly blocks a positive selection while keeping zero selectable', () => {
+ const seen: number[] = [], value = props({ enabled: false, onChange: amount => seen.push(amount) });
+ const field = input(value);
+ assert.equal(field.props.disabled, false);
+ assert.equal(field.props['aria-invalid'], true);
+ change(field, '2'); change(field, '0');
+ assert.deepEqual(seen, [0]);
+ const html = markup(value);
+ assert.match(html, /require Richese at High population/);
+ assert.match(html, /Set the amount to 0/);
+ assert.match(markup(props({ enabled: false, amount: 0, max: 0 })), /type="number"/);
+});
+
+void test('a stale amount above the current physical maximum stays visible with a correction path', () => {
+ const seen: number[] = [], value = props({ amount: 3, max: 1, onChange: amount => seen.push(amount) });
+ assert.match(markup(value), /Only 1 physical reserve force is available/);
+ change(input(value), '1');
+ assert.deepEqual(seen, [1]);
+});
diff --git a/tests/richese-homeworld-shipment-price.test.ts b/tests/richese-homeworld-shipment-price.test.ts
new file mode 100644
index 00000000..25992bec
--- /dev/null
+++ b/tests/richese-homeworld-shipment-price.test.ts
@@ -0,0 +1,50 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { viewGame } from '../game/engine';
+import { RicheseNoFieldControls } from '../components/richese-no-field';
+import {
+ createRicheseHomeworldShipmentFixture as fixture,
+ actRicheseHomeworldShipment as act,
+ commitRicheseHomeworldShipment as commit,
+ richeseHomeworldShipmentInventory as inventory,
+ richeseHomeworldShipmentPlayer as player,
+} from './fixture-richese-homeworld-shipment';
+
+for (const companions of [1, 2]) void test(`original Karama prices marker plus ${companions} paid companions as one rounded group`, () => {
+ const f = fixture({ companions });
+ const rated = act(f.game, f.holder, { type: 'card', card: f.karama, mode: 'shipment', target: f.owner });
+ assert.equal(rated.karamaShipping?.player, f.owner);
+ assert.ok(rated.discard.some(card => card.id === f.karama));
+ const source = structuredClone(rated), original = player(rated);
+ const result = commit(rated, f), done = player(result.game);
+ const expected = companions === 1 ? 1 : 2;
+ // The only opposing Karama was just spent, so native automatic passes may
+ // finish the response during the declaration itself.
+ assert.equal(result.requested.pendingShipment ?? null, null);
+ assert.equal(original.spice - done.spice, expected);
+ assert.equal(original.reserves - done.reserves, companions);
+ assert.equal(done.forces[f.location], companions);
+ assert.equal(done.noField?.deployed?.tokenId, f.token);
+ assert.equal(result.game.karamaShipping, null);
+ assert.equal(viewGame(result.game, f.holder).richeseNoField?.private, null);
+ assert.deepEqual(rated, source);
+ assert.deepEqual(inventory(result.game), f.originalInventory);
+});
+
+void test('controlled ally/rate projection shows the full replacement fee after native discount cancellation', () => {
+ const f = fixture({ companions: 1 }), view = viewGame(f.game, f.owner);
+ // Projection-only UI regression, not an earned Guild alliance/cancellation.
+ const owner = view.players.find(p => p.id === f.owner)!, ally = view.players.find(p => p.id === f.holder)!;
+ owner.ally = ally.id; ally.ally = owner.id; ally.faction = 'guild';
+ const markup = () => renderToStaticMarkup(createElement(RicheseNoFieldControls,
+ { game: view, act() {}, busy: false, destination: 'hagga_basin', sector: 8, allyPayment: '' }));
+ view.guildRateCanceled = true;
+ assert.match(markup(), /Ship concealed No-Field [035] · 2 spice/);
+ view.guildRateCanceled = false;
+ assert.match(markup(), /Ship concealed No-Field [035] · 1 spice/);
+ view.guildRateCanceled = true;
+ view.karamaShipping = { player: f.owner, owner: f.holder };
+ assert.match(markup(), /Ship concealed No-Field [035] · 1 spice/);
+});
diff --git a/tests/richese-homeworld-shipment-runtime.test.ts b/tests/richese-homeworld-shipment-runtime.test.ts
new file mode 100644
index 00000000..7e3d2566
--- /dev/null
+++ b/tests/richese-homeworld-shipment-runtime.test.ts
@@ -0,0 +1,155 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { presenceAt } from '../game/force-presence';
+import { reserveShipmentCost } from '../game/shipment-price';
+import { territory } from '../game/board';
+import { quoteRicheseHomeworldForces } from '../game/richese-homeworld-shipment';
+import {
+ createRicheseHomeworldShipmentFixture as fixture,
+ actRicheseHomeworldShipment as act,
+ commitRicheseHomeworldShipment as commit,
+ revealRicheseHomeworldShipment as reveal,
+ finishRicheseHomeworldShipment as finish,
+ richeseHomeworldShipmentPlayer as player,
+ richeseHomeworldShipmentPopulation as population,
+ richeseHomeworldShipmentInventory as inventory,
+} from './fixture-richese-homeworld-shipment';
+
+function reject(game: Game, actor: string, action: Action, reason?: RegExp) {
+ const before = structuredClone(game);
+ if (reason) assert.throws(() => applyAction(game, actor, action), reason);
+ else assert.throws(() => applyAction(game, actor, action));
+ assert.deepEqual(game, before);
+}
+
+for (const advanced of [false, true]) {
+ void test(`${advanced ? 'Advanced' : 'Basic'} native HIGH Richese commits eleven paid reserves and one concealed marker, then may reveal after crossing LOW`, () => {
+ const f = fixture({ advanced }), before = structuredClone(player(f.game, f.owner));
+ assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.owner);
+ assert.equal(f.funding.length, 1);
+ assert.deepEqual(f.funding[0].steps, [{ actor: f.holder, action: { type: 'bribe', target: f.owner, amount: 7 } }]);
+ assert.equal(before.spice, 12); assert.equal(before.bribes, 0); assert.equal(before.reserves, 20);
+ assert.equal(before.shipped, false); assert.equal(before.moved, 0);
+ assert.equal(population(f.game, f.owner).side, 'high');
+ assert.equal(viewGame(f.game, f.owner).richeseNoField?.homeworldForcesEnabled, true);
+ assert.deepEqual(quoteRicheseHomeworldForces({ enabled: true, reserves: before.reserves, amount: f.companions }),
+ { paid: 11, physical: 11, priced: 12 });
+ const action: Action = advanced ? { ...f.action, amount: 1 } : f.action;
+ const result = commit(f.game, f, action), requested = player(result.requested, f.owner), after = player(result.game, f.owner);
+ assert.equal(result.requested.response?.kind, 'richeseNoField');
+ assert.equal(result.requested.pendingShipment?.noFieldReserves, 11);
+ assert.equal(result.requested.pendingShipment?.cost, 12);
+ assert.ok(result.requested.pendingShipment?.noFieldSkillProof);
+ assert.equal(requested.reserves, before.reserves); assert.equal(requested.spice, before.spice);
+ assert.equal(requested.shipped, false); assert.deepEqual(requested.forces, {});
+ assert.deepEqual(requested.noField, before.noField); assert.equal(requested.noFieldEvent, before.noFieldEvent);
+ assert.equal(after.reserves, 9); assert.deepEqual(after.forces, { [f.location]: 11 });
+ assert.equal(after.spice, 0); assert.equal(after.shipped, true); assert.equal(after.moved, 0);
+ assert.equal(after.noField?.deployed?.tokenId, f.token);
+ assert.deepEqual(after.noField?.deployed?.location, { territory: f.territory, sector: f.sector });
+ assert.equal(after.noField?.lastShipped, f.token);
+ assert.notEqual(after.noFieldEvent, before.noFieldEvent);
+ assert.equal(presenceAt(after, f.territory), 12, 'The marker counts once beside eleven physical counters');
+ assert.equal(population(result.game, f.owner).side, 'low');
+ assert.equal(viewGame(result.game, f.owner).richeseNoField?.homeworldForcesEnabled, false);
+ const opponent = viewGame(result.game, f.holder);
+ assert.equal(opponent.richeseNoField?.private, null);
+ assert.equal(opponent.richeseNoField?.public.deployed?.effectiveForces, 1);
+ assert.equal(opponent.richeseNoField?.public.lastUsed, null);
+ assert.ok(player(result.game, f.holder).hand.some(card => card.id === f.karama));
+ assert.deepEqual(inventory(result.game), f.originalInventory);
+ const revealed = reveal(result.game, f), done = player(revealed.game, f.owner);
+ assert.equal(done.noField?.deployed, null); assert.equal(done.noField?.lastShipped, f.token);
+ assert.equal(done.reserves, 4); assert.deepEqual(done.forces, { [f.location]: 16 });
+ assert.equal(done.spice, after.spice); assert.equal(done.moved, 0); assert.equal(done.shipped, true);
+ reject(revealed.game, f.owner, revealed.action, /No concealed/);
+ assert.deepEqual(inventory(revealed.game), f.originalInventory);
+ assert.equal(f.sourceActionCount, f.actions.length);
+ });
+}
+
+void test('the original single-marker omission still pays one without withdrawing HIGH physical reserves', () => {
+ const f = fixture(), before = structuredClone(player(f.game, f.owner));
+ const action: Action = { type: 'ship', noField: f.token, event: f.event,
+ territory: f.territory, sector: f.sector, allyPayment: 0 };
+ const result = commit(f.game, f, action), after = player(result.game, f.owner);
+ assert.equal(result.requested.pendingShipment?.noFieldReserves, undefined);
+ assert.equal(after.spice, before.spice - 1);
+ assert.equal(after.reserves, 20); assert.deepEqual(after.forces, {});
+ assert.equal(after.noField?.deployed?.tokenId, f.token);
+ assert.equal(presenceAt(after, f.territory), 1);
+ assert.equal(population(result.game, f.owner).side, 'high');
+ assert.deepEqual(inventory(result.game), f.originalInventory);
+});
+
+void test('sixteen genuine paid companions leave only four native reserves for a printed five marker to reveal', () => {
+ const f = fixture({ companions: 16 }), before = structuredClone(player(f.game, f.owner));
+ assert.equal(f.game.turn, 3);
+ assert.deepEqual(f.funding.map(record => record.steps[0].action.amount), [10, 2]);
+ assert.ok(f.actions.some(step => step.actor === f.holder && step.action.type === 'charity'));
+ assert.equal(before.spice, 17); assert.equal(before.reserves, 20);
+ const result = commit(f.game, f), after = player(result.game, f.owner);
+ assert.equal(after.reserves, 4); assert.deepEqual(after.forces, { [f.location]: 16 });
+ assert.equal(before.spice - after.spice, 17);
+ assert.equal(after.noField?.deployed?.tokenId, f.token);
+ const revealed = reveal(result.game, f), done = player(revealed.game, f.owner);
+ assert.equal(done.reserves, 0); assert.deepEqual(done.forces, { [f.location]: 20 });
+ assert.equal(done.tanks, 0); assert.equal(done.noField?.deployed, null);
+ assert.equal(done.noField?.lastShipped, f.token);
+ assert.deepEqual(inventory(revealed.game), f.originalInventory);
+});
+
+void test('the genuinely dealt opposing Karama prevents the entire unpaid mixed declaration and preserves ordinary replacement shipment', () => {
+ const f = fixture(), before = structuredClone(player(f.game, f.owner));
+ const pending = act(f.game, f.owner, f.action);
+ assert.equal(pending.response?.kind, 'richeseNoField');
+ assert.equal(pending.pendingShipment?.noFieldReserves, 11);
+ const prevented = finish(act(pending, f.holder, f.cancel)), after = player(prevented, f.owner);
+ assert.equal(after.reserves, before.reserves); assert.equal(after.spice, before.spice);
+ assert.deepEqual(after.forces, before.forces); assert.deepEqual(after.noField, before.noField);
+ assert.equal(after.noFieldEvent, before.noFieldEvent);
+ assert.equal(after.shipped, false); assert.equal(after.moved, 0);
+ assert.equal(prevented.pendingShipment ?? null, null);
+ assert.equal(prevented.discard.filter(card => card.id === f.karama).length, 1);
+ reject(prevented, f.owner, f.action, /prevented No-Field/);
+ const replaced = finish(act(prevented, f.owner, f.replacement)), replacement = player(replaced, f.owner);
+ assert.equal(replacement.reserves, before.reserves - 1);
+ assert.deepEqual(replacement.forces, { [f.location]: 1 });
+ assert.equal(replacement.spice, before.spice - 1);
+ assert.equal(replacement.shipped, true); assert.equal(replacement.moved, 0);
+ assert.deepEqual(replacement.noField, before.noField);
+ assert.deepEqual(inventory(replaced), f.originalInventory);
+});
+
+void test('mixed payloads preserve the one-marker amount and require positive available physical reserves', () => {
+ const f = fixture();
+ reject(f.game, f.owner, { ...f.action, amount: 2 }, /one No-Field/);
+ reject(f.game, f.owner, { ...f.action, noFieldReserves: 0 });
+ reject(f.game, f.owner, { ...f.action, noFieldReserves: 21 });
+ reject(f.game, f.owner, { ...f.replacement, noFieldReserves: 1 });
+ assert.deepEqual(inventory(f.game), f.originalInventory);
+});
+
+void test('all four existing profiles propose a legal positive companion shipment through the native response and payment', () => {
+ const f = fixture();
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(f.game, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty;
+ const action = botActions(view).find(action => action.type === 'ship' && action.noField &&
+ typeof action.noFieldReserves === 'number' && action.noFieldReserves > 0);
+ assert.ok(action, `${difficulty} retains a minimal positive HIGH companion path`);
+ const companions = Number(action.noFieldReserves);
+ const priced = 1 + companions;
+ const expectedCost = reserveShipmentCost({ faction: 'richese', halfRate: false },
+ territory(String(action.territory)).type, priced);
+ const result = commit(structuredClone(f.game), f, action), after = player(result.game, f.owner);
+ assert.equal(after.reserves, 20 - companions);
+ assert.equal(Object.values(after.forces).reduce((total, count) => total + count, 0), companions);
+ assert.equal(after.spice, player(f.game, f.owner).spice - expectedCost);
+ assert.equal(after.shipped, true); assert.equal(after.moved, 0);
+ assert.ok(after.noField?.deployed);
+ assert.deepEqual(inventory(result.game), f.originalInventory);
+ }
+});
diff --git a/tests/richese-homeworld-shipment.test.ts b/tests/richese-homeworld-shipment.test.ts
new file mode 100644
index 00000000..bbb268e8
--- /dev/null
+++ b/tests/richese-homeworld-shipment.test.ts
@@ -0,0 +1,77 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { newPlayer, type Game } from '../game/engine';
+import { createHomeworldCustody, quoteHomeworldCustody } from '../game/homeworld-custody';
+import { homeworldContext } from '../game/homeworld-game';
+import {
+ richeseHomeworldShipmentEnabled, quoteRicheseHomeworldForces, RicheseHomeworldShipmentError,
+ type RicheseHomeworldForcesInput,
+} from '../game/richese-homeworld-shipment';
+
+/** Controlled native population context, not an earned shipment chronology. */
+function populationContext(population = 10, advanced = false): Pick {
+ const players = [newPlayer('r', 'Richese', 'richese'), newPlayer('g', 'Guild', 'guild')];
+ players[0].reserves = population;
+ players[0].tanks = 20 - population;
+ return { advanced, players, homeworlds: { custody: createHomeworldCustody(homeworldContext({ advanced, players })) } };
+}
+
+void test('native Richese High enables mixed No-Field shipment at ten counters in both rules bands', () => {
+ for (const advanced of [false, true]) for (const population of [0, 9, 10, 11, 20]) {
+ const context = populationContext(population, advanced), before = structuredClone(context);
+ assert.equal(richeseHomeworldShipmentEnabled(context, 'r'), population >= 10);
+ assert.equal(richeseHomeworldShipmentEnabled(context, 'g'), false);
+ assert.equal(richeseHomeworldShipmentEnabled(context, 'missing'), false);
+ assert.deepEqual(context, before);
+ }
+});
+
+void test('missing custody and foreign visitors cannot supply the native Richese High entitlement', () => {
+ const context = populationContext(9);
+ assert.equal(richeseHomeworldShipmentEnabled({ ...context, homeworlds: undefined }, 'r'), false);
+ assert.equal(richeseHomeworldShipmentEnabled({ ...context, homeworlds: { custody: null } }, 'r'), false);
+ const visit = quoteHomeworldCustody(homeworldContext(context), context.homeworlds!.custody!, [
+ { homeworld: 'homeworld:guild', player: 'g', withdraw: { normal: 8, elite: 0 }, deposit: { normal: 0, elite: 0 } },
+ { homeworld: 'homeworld:richese', player: 'g', withdraw: { normal: 0, elite: 0 }, deposit: { normal: 8, elite: 0 } },
+ ]);
+ for (const next of visit.players) context.players.find(player => player.id === next.id)!.reserves = next.reserves;
+ context.homeworlds!.custody = visit.state;
+ assert.equal(richeseHomeworldShipmentEnabled(context, 'r'), false);
+});
+
+const shipment = (patch: Partial = {}): RicheseHomeworldForcesInput => ({
+ enabled: true, reserves: 10, amount: 3, ...patch,
+});
+
+void test('one marker and paid physical forces form one priced group independently of concealed value', () => {
+ const input = shipment(), before = structuredClone(input);
+ assert.deepEqual(quoteRicheseHomeworldForces(input), { paid: 3, physical: 3, priced: 4 });
+ assert.deepEqual(input, before);
+ assert.deepEqual(quoteRicheseHomeworldForces(shipment({ amount: 20, reserves: 20 })), { paid: 20, physical: 20, priced: 21 });
+ assert.deepEqual(quoteRicheseHomeworldForces(shipment({ amount: 0, reserves: 0 })), { paid: 0, physical: 0, priced: 1 });
+});
+
+void test('a Smuggler force consumes a real reserve counter without adding to the paid invoice', () => {
+ assert.deepEqual(quoteRicheseHomeworldForces(shipment({ smuggler: true })), { paid: 3, physical: 4, priced: 4 });
+ assert.deepEqual(quoteRicheseHomeworldForces(shipment({ amount: 19, reserves: 20, smuggler: true })), { paid: 19, physical: 20, priced: 20 });
+ assert.throws(() => quoteRicheseHomeworldForces(shipment({ reserves: 3, smuggler: true })), /physical reserves/);
+ assert.throws(() => quoteRicheseHomeworldForces(shipment({ amount: 20, reserves: 20, smuggler: true })), /physical reserves/);
+});
+
+void test('ordinary marker-only shipment remains available without the benefit but cannot manufacture forces', () => {
+ assert.deepEqual(quoteRicheseHomeworldForces(shipment({ enabled: false, amount: 0, reserves: 0 })), { paid: 0, physical: 0, priced: 1 });
+ assert.deepEqual(quoteRicheseHomeworldForces(shipment({ enabled: false, amount: 0, reserves: 1, smuggler: true })), { paid: 0, physical: 1, priced: 1 });
+ assert.throws(() => quoteRicheseHomeworldForces(shipment({ enabled: false })), /High population/);
+ assert.throws(() => quoteRicheseHomeworldForces(shipment({ reserves: 2 })), /physical reserves/);
+ assert.throws(() => quoteRicheseHomeworldForces(shipment({ amount: 0, reserves: 0, smuggler: true })), /physical reserves/);
+});
+
+void test('malformed physical counts and source flags reject before producing a quote', () => {
+ for (const field of ['reserves', 'amount'] as const) for (const value of [-1, 0.5, 21, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
+ const input = shipment({ [field]: value }), before = structuredClone(input);
+ assert.throws(() => quoteRicheseHomeworldForces(input), RicheseHomeworldShipmentError);
+ assert.deepEqual(input, before);
+ }
+ assert.throws(() => quoteRicheseHomeworldForces(shipment({ enabled: 'yes' as unknown as boolean })), RicheseHomeworldShipmentError);
+ assert.throws(() => quoteRicheseHomeworldForces(shipment({ smuggler: 1 as unknown as boolean })), RicheseHomeworldShipmentError);
+});