From 4d769af8c7a54ee7184b285c53c4c46bdef74dd7 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Wed, 28 Oct 2026 22:26:47 +1100
Subject: [PATCH] Expand Semuta and Richese Homeworld shipments
---
components/game-table.tsx | 5 +-
components/homeworld-shipment.tsx | 11 +-
docs/CURRENT_STATUS.md | 25 +++-
docs/RICHESE_NO_FIELD_RULES.md | 16 ++-
docs/RULE_DECISIONS.md | 8 ++
docs/SEMUTA_DISCARD_CONTINUATIONS.md | 15 +-
game/engine.ts | 17 ++-
game/homeworld-shipment-options.ts | 21 ++-
game/reference.ts | 4 +-
game/richese-homeworld-marker-shipment.ts | 29 +++-
tests/fixture-semuta-independent-ordinary.ts | 115 ++++++++++++++++
...world-marker-fresh-mixed-controls.test.tsx | 80 +++++++++++
...meworld-marker-fresh-mixed-runtime.test.ts | 129 ++++++++++++++++++
...chese-homeworld-marker-fresh-mixed.test.ts | 99 ++++++++++++++
tests/semuta-independent-ordinary.test.ts | 91 ++++++++++++
15 files changed, 626 insertions(+), 39 deletions(-)
create mode 100644 tests/fixture-semuta-independent-ordinary.ts
create mode 100644 tests/richese-homeworld-marker-fresh-mixed-controls.test.tsx
create mode 100644 tests/richese-homeworld-marker-fresh-mixed-runtime.test.ts
create mode 100644 tests/richese-homeworld-marker-fresh-mixed.test.ts
create mode 100644 tests/semuta-independent-ordinary.test.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index f535aef9..e2059311 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -1281,8 +1281,9 @@ export function GameTable({
{g.semutaRecruitsPreview && (
Semuta Drug preview: in this independent-card game, Semuta can react
- to another player discarding Recruits. Other Semuta discard
- opportunities in this combination are still being implemented.{' '}
+ to another player discarding Recruits, Hajr, Ghola, Harvester,
+ Weather Control or Family Atomics. Other discard opportunities are
+ still being implemented.{' '}
Recruits rules
)}
diff --git a/components/homeworld-shipment.tsx b/components/homeworld-shipment.tsx
index 32b4bd4d..ec0f95d8 100644
--- a/components/homeworld-shipment.tsx
+++ b/components/homeworld-shipment.tsx
@@ -30,16 +30,17 @@ export function HomeworldNoFieldShipment({ game, act, busy }: {
const destination = targets.find(world => world.id === selectedDestination) ?? targets[0];
const tokens = info.private.tokens.filter(token => source ? token.id === deployed?.tokenId : token.id !== info.private!.lastShipped);
const token = tokens.find(candidate => candidate.id === selectedToken) ?? tokens[0];
- const pool = source ? game.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id] : undefined;
+ const pool = source ? game.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id] :
+ { normal: me.reserves - (me.elites?.reserves ?? 0), elite: me.elites?.reserves ?? 0 };
const quote = richeseHomeworldMarkerChoice(game, destination?.id ?? '', token?.id ?? '', allyPayment, source,
- source ? forces : undefined);
+ forces);
return
Ship a concealed No-Field
Provisional Homeworld rule: ship one concealed marker {source ? `from ${homeworldName(game, source)}` : 'from your supply'} to
another non-allied Homeworld. Its value stays private and its represented forces
remain in your reserves until the marker is revealed. This uses your normal shipment.
- {source && ' Physical forces already on the same Homeworld may accompany it.'}
+ {' Physical forces from the same source may accompany it.'}
Destination Homeworld for No-Field
setSelectedDestination(event.target.value)}>
@@ -56,8 +57,8 @@ export function HomeworldNoFieldShipment({ game, act, busy }: {
{tokens.map(candidate => No-Field {candidate.value} )}
- {source && pool &&
- Accompanying forces from {homeworldName(game, source)}
+ {pool &&
+ Accompanying forces from {source ? homeworldName(game, source) : 'your reserves'}
{(['normal', 'elite'] as const).filter(kind => kind === 'normal' || pool.elite > 0).map(kind =>
{kind === 'normal' ? 'Normal forces' : specialForceName(me.faction)} accompanying No-Field · {pool[kind]} available
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 493c622c..ea59535e 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,11 +16,28 @@ open mode gates.
## Current checkpoint and work
+**Fresh No-Field with reserves to a foreign Homeworld — 28 October.
+Prototyped / Partial.** In the native Basic/Advanced Homeworld preview,
+Richese can send selected physical reserves with its fresh concealed marker;
+one invoice and the native No-Field response cover the group. A purchased
+Guild price rounds once, and physical forces use the existing typed custody.
+This provisional composition keeps own-world and optional-module boundaries.
+The related Semuta and Homeworld batch passes 107 cases, including genuine
+allow/cancel journeys and the owner control. [Scope](RICHESE_NO_FIELD_RULES.md#fresh-marker-with-native-reserves--28-october-2026).
+
+**Semuta after ordinary specials in Basic independent cards — 28 October.
+Prototyped / Partial.** Fresh Richese/classic Basic starts with the independent
+Ecaz cards can offer used Hajr, Ghola, Harvester, Weather Control and Family
+Atomics to Semuta. The original effect commits once before claim/pass and its
+native phase continues. Eleven original-action tests pass, including both
+answers for each card and legal AI responses. Other discard producers and
+roster/module combinations remain separately guarded.
+
**Semuta after Recruits in Basic independent cards — 28 October. Prototyped /
Partial.** Fresh Richese/classic Basic starts with the CHOAM deck and Ecaz
Treachery Cards offer the actual Recruits discard to Semuta, retaining one
-committed revival benefit and the original phase. Other Semuta discards in
-this independent-card combination, native expansion factions and optional
+committed revival benefit and the original phase. Other unsupported Semuta
+discards in this independent-card combination, native expansion factions and optional
modules remain outside this preview. [Scope](SEMUTA_DISCARD_CONTINUATIONS.md#recruits-in-the-basic-independent-card-preview--28-october-2026).
The related eleven-file union passes 118 cases and the application builds.
Two Advanced module fixtures were corrected to follow the real ordered Revival;
@@ -149,7 +166,9 @@ cases, restart/replacement and origin verification, and published both its
revision-specific image and `checkpoint`. Run 6 for `cea310b` succeeded and
published its revision tag and `checkpoint` with digest
`sha256:74340835931011be1d0be907c8db688719f05a1a1b949ba0633eff4c5fb55248`.
-Run 7 was canceled before starting; run 8 for `e529cc2` is running.
+Run 7 was canceled before starting; run 8 for `e529cc2` succeeded and
+published digest `sha256:9a188718e46adec7ec9e4edc29fe03126c1afe2a349bb426052339a5ee4dce55`.
+Run 9 was canceled before starting; run 10 for `1ba9421` is running.
Deployment remains unverified.
Existing NAS deployment and its persistent storage remain untouched.
diff --git a/docs/RICHESE_NO_FIELD_RULES.md b/docs/RICHESE_NO_FIELD_RULES.md
index 41cb068c..4b1dc2e1 100644
--- a/docs/RICHESE_NO_FIELD_RULES.md
+++ b/docs/RICHESE_NO_FIELD_RULES.md
@@ -2,6 +2,19 @@
Audit date: 2026-09-06. This document distinguishes the printed token lifecycle from engine integration and unresolved combinations. It does not certify Richese, Homeworlds, or any other gated game mode.
+## Fresh marker with native reserves — 28 October 2026
+
+**Prototyped / Partial.** In the existing native Basic and Advanced Homeworld
+preview, Richese may ship a fresh concealed marker with selected physical
+forces from its own reserves to an eligible foreign non-allied Homeworld.
+The entire shipment is priced as one marker plus the selected physical forces;
+a purchased Guild rate halves the total and rounds once. The No-Field response
+precedes payment and transfer, and physical forces move through the original
+typed Homeworld custody if the shipment proceeds. The marker remains concealed
+until its normal reveal. This composes the source rules cited below and is a
+provisional interpretation; own-world placement and optional-module overlays
+remain outside this scope.
+
## Existing marker with same-source Homeworld forces — 28 October 2026
**Prototyped / Partial.** In supported native Basic and Advanced Homeworld
@@ -17,8 +30,7 @@ Count the marker as one plus the selected physical counters for one shipment
price. A separately purchased Guild rate halves that total, rounding up once;
the marker is not a physical counter and does not consume the physical
twenty-counter limit. Normal payment and Advanced Guild interception remain.
-No forces can be picked up from a different source. Initial placement of a
-fresh marker with companions, own-world return and optional-module overlays
+No forces can be picked up from a different source. Own-world return and optional-module overlays
remain unsupported.
The supplied Advanced revision permits Homeworld-to-Homeworld shipment
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 57f7f0c6..47b20bcf 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,13 @@
# Rule decision index
+## Fresh No-Field with native Homeworld reserves — 28 October 2026
+
+The supplied Advanced revision permits World-to-World shipment and counts a
+concealed marker as one force, but does not specify an accompanying physical
+group. The native preview provisionally permits fresh marker placement with
+same-source reserves, priced together as one shipment and subject to its
+original response. [Scope](RICHESE_NO_FIELD_RULES.md#fresh-marker-with-native-reserves--28-october-2026).
+
## Optional short alliance victory — 28 October 2026
The printed optional two-member alliance target is selectable at three
diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md
index fd0cd1dd..3c051994 100644
--- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md
+++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md
@@ -1,5 +1,16 @@
# Semuta discard producers and continuation inventory
+## Ordinary specials in Basic independent cards — 28 October 2026
+
+**Prototyped / Partial.** The same fresh Basic Richese/classic independent-card
+preview now offers used Hajr, Ghola, Harvester, Weather Control and Family
+Atomics to Semuta. Each original effect is committed before the neutral
+claim/pass window, and the native Movement, Revival, Spice Blow or Storm
+continues afterward without replay. The ordinary source guards and physical
+card ownership remain in force. Other discard producers, native expansion
+factions and optional module combinations remain outside this preview. The
+digital reaction ordering is provisional, as in the existing Semuta contract.
+
## Recruits in the Basic independent-card preview — 28 October 2026
**Prototyped / Partial.** A fresh Basic public start with Richese and classic
@@ -8,8 +19,8 @@ physical Recruits discard to Semuta. Recruits commits its doubled free rates
and seven-force ordinary limit once before the neutral claim/pass window; the
same Revival resumes afterward. The recipient may hold the recovered card but
cannot replay the effect in that turn. Existing physical cards and free-use
-ledgers remain authoritative. This is a Recruits-only preview: other Semuta
-discard producers in the independent-card combination remain unfinished, and
+ledgers remain authoritative. Other Semuta discard producers in the
+independent-card combination remain unfinished, and
other factions, decks and optional modules retain their prior boundaries.
The printed discard opportunity follows the source audit below; the order of
the completed effect and online reaction is a provisional implementation.
diff --git a/game/engine.ts b/game/engine.ts
index f692c399..704ce3f4 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -2605,7 +2605,7 @@ export type Game = {
moritaniAssassinatePreview?: true;
/** Explicit development-only neutral Semuta reaction profile. */
semutaPreview?: true;
- /** Fresh Basic independent-card preview for Semuta after Recruits only. */
+ /** Fresh Basic independent-card Semuta preview; scoped per discard source. */
semutaRecruitsPreview?: true;
moritaniAssassinate?: MoritaniAssassinateState;
moritaniAssassinateCallEvents?: string[];
@@ -9520,7 +9520,10 @@ function semutaOfferSupported(
if (continuation.kind === 'thumperDiscard' &&
(g.summonedBeforeBlow || g.summonedWorm || g.wormRides.length > 0 ||
g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove)) return false;
- return g.semutaPreview === true && g.status === 'playing' &&
+ return (g.semutaPreview === true || g.semutaRecruitsPreview === true &&
+ basicIndependentSemutaProfile(g) && continuation.kind === 'ordinaryCardDiscard' &&
+ ['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect)) &&
+ g.status === 'playing' &&
g.players.some(p => p.faction === 'richese') &&
entries.length > 0 &&
(ambassadorDiscard || continuation.kind === 'kaitainDiscard' || continuation.kind === 'tupileCleanupDiscard' ||
@@ -37304,15 +37307,15 @@ function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {rout
'Homeworld No-Field shipment currently supports native Homeworld play without optional modules.');
requireRule(!intent.route && !intent.guildSecretEvent && intent.sources && typeof intent.sources === 'object' &&
!Array.isArray(intent.sources) &&
- (intent.noField.from === undefined ? Object.keys(intent.sources).length === 0 :
- Object.keys(intent.sources).every(source => source === intent.noField!.from)) &&
+ Object.keys(intent.sources).every(source =>
+ source === (intent.noField!.from ?? 'homeworld:richese')) &&
p.noField && p.noFieldEvent === intent.noField.event &&
(intent.noField.from !== undefined || p.noFieldBlockedTurn !== g.turn),
'The original concealed Homeworld shipment is no longer available.');
const marker = intent.noField.from === undefined
- ? {...homeworldRule(() => quoteRicheseHomeworldMarkerShipment(context, g.homeworlds!.custody!,
- {player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId},p.noField!)),
- state:g.homeworlds.custody, players:context.players, sources:[]}
+ ? homeworldRule(() => quoteRicheseHomeworldMarkerShipment(context, g.homeworlds!.custody!,
+ {player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId,
+ ...(Object.hasOwn(intent.sources, 'homeworld:richese') ? {forces:intent.sources['homeworld:richese']} : {})},p.noField!))
: homeworldRule(() => quoteRicheseHomeworldMarkerTransfer(context, g.homeworlds!.custody!,
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId,source:intent.noField!.from!,
...(Object.hasOwn(intent.sources, intent.noField!.from!) ? {forces:intent.sources[intent.noField!.from!]} : {})},p.noField!));
diff --git a/game/homeworld-shipment-options.ts b/game/homeworld-shipment-options.ts
index 7d579ec2..68d8b349 100644
--- a/game/homeworld-shipment-options.ts
+++ b/game/homeworld-shipment-options.ts
@@ -22,9 +22,8 @@ export function homeworldName(view: GameView, id: string): string {
);
}
-/** Owner-only marker quote. The face stays private; the amount and invoice are
- * one regardless of denomination. Authoritative timing and payment remain in
- * the normal Homeworld shipment action. */
+/** Owner-only marker quote. The face stays private; the marker counts as one
+ * regardless of denomination, plus any selected physical companions. */
export function richeseHomeworldMarkerChoice(
view: GameView,
destination: string,
@@ -71,11 +70,10 @@ export function richeseHomeworldMarkerChoice(
}
try {
if (source === undefined) {
- if (forces !== undefined) {
- result.blocked = 'Physical forces may accompany only a deployed Homeworld No-Field.';
- return result;
- }
- quoteRicheseHomeworldMarkerShipment(context, custody, { player: me.id, destination, tokenId }, info.private);
+ const quote = quoteRicheseHomeworldMarkerShipment(context, custody,
+ { player: me.id, destination, tokenId, ...(forces === undefined ? {} : { forces }) }, info.private);
+ result.amount = quote.amount;
+ result.cost = quote.cost;
} else {
const quote = quoteRicheseHomeworldMarkerTransfer(context, custody,
{ player: me.id, destination, tokenId, source, ...(forces === undefined ? {} : { forces }) }, info.private);
@@ -92,8 +90,8 @@ export function richeseHomeworldMarkerChoice(
result.blocked = 'Your spice and selected allied contribution cannot pay this shipment.';
return result;
}
- const sources = source !== undefined && forces && forces.normal + forces.elite > 0
- ? { [source]: forces } : {};
+ const sources = forces && forces.normal + forces.elite > 0
+ ? { [source ?? 'homeworld:richese']: forces } : {};
result.action = { type: 'homeworldShip', event: option.event, destination, sources,
noField: tokenId, noFieldEvent: info.event, allyPayment, ...(source === undefined ? {} : { noFieldFrom: source }) };
} catch (error) {
@@ -412,7 +410,8 @@ export function homeworldShipmentActions(
Math.max(0, 1 - (me.spice ?? 0)), source);
if (quote.action) {
actions.push(quote.action);
- const pool = source && view.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id];
+ const pool = source ? view.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id] :
+ { normal: me.reserves - (me.elites?.reserves ?? 0), elite: me.elites?.reserves ?? 0 };
if (pool && (pool.normal > 0 || pool.elite > 0)) {
const forces = { normal: pool.normal > 0 ? 1 : 0, elite: pool.normal > 0 ? 0 : 1 };
const initial = richeseHomeworldMarkerChoice(view, target.world.id, token.id, 0, source, forces);
diff --git a/game/reference.ts b/game/reference.ts
index 68a3526e..70355b01 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -1124,7 +1124,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Richese has three No-Fields, numbered zero, three and five. A concealed token counts as one force. Only one may be deployed on Arrakis, and consecutive own or allied shipments cannot use the same token.',
'A normal No-Field shipment pays the one-force price. The concealed token moves like a force. Before Battle, Richese may reveal it voluntarily; a token involved in battle is revealed with the Battle Plan. Revelation brings the indicated forces from reserves, limited by what remains there.',
'Provisional Guild transport treats your owned concealed marker as one billable force alongside any same-origin physical counters. Native Guild allies may cross-ship it through the normal permission and rate responses. The actual absent-Guild Secret Ally may cross-ship or return it face down, spending its Nexus once and paying the Bank. Cross keeps concealment; return creates no denomination-sized reserve deposit and retains the same-token next-shipment ban. Ordinary Guild allies cannot return. Select the marker separately from physical counters; ordinary movement remains available.',
- 'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld for the one-force price. On a later turn, you may ship that same concealed marker between distinct eligible foreign Homeworlds, alone or with selected physical Richese counters from the marker’s current World. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal, when available reserves are placed there. Only one marker may be deployed globally. Initial marker placement with companions, own-Homeworld placement or return, and optional-module combinations remain unavailable. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.',
+ 'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld, alone or with physical forces from those reserves. On a later turn, you may ship that same concealed marker between distinct eligible foreign Homeworlds, alone or with selected physical Richese counters from the marker’s current World. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal, when available reserves are placed there. Only one marker may be deployed globally. Own-Homeworld placement or return and optional-module combinations remain unavailable. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.',
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Physical and Discovery flight use the provisional skill transport policy with original marker quotas; complete combinations remain unfinished.',
'The revealed token stays face up until another token is placed. Revealing it does not clear the previous-shipment restriction. An allied No-Field shipment reveals immediately and uses the ally’s reserves; an existing token must be revealed before another shipment.',
'Before an eligible ally uses its shipment, the game holds an offer opportunity for Richese. Richese may pass or privately propose a token, destination and payment. The ally accepts the exact offer and chooses its permitted regular/elite mix, or declines without payment. Success consumes the ally’s shipment, not Richese’s shipment or either player’s movement.',
@@ -6606,7 +6606,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits, bounded Reinforcements and Harass & Withdraw controls are connected.',
'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.',
- 'In a fresh Basic game with Richese, classic factions, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits immediately after another player discards it. The Recruits benefit still lasts for the turn and cannot be played again. This preview covers the Recruits discard only; other Semuta discard opportunities in this combination remain unfinished.',
+ 'In a fresh Basic game with Richese, classic factions, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits immediately after another player discards it. The Recruits benefit still lasts for the turn and cannot be played again. Used Hajr, Ghola, Harvester, Weather Control and Family Atomics can also be claimed; other Semuta discard opportunities in this combination remain unfinished.',
'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.',
'Free prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Fremen may now grant after play under the same provisional current-rate policy, with its original Karama response settled before commitment. Missing/inconsistent ledgers, pending transactions and same-turn replay remain guarded.',
'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.',
diff --git a/game/richese-homeworld-marker-shipment.ts b/game/richese-homeworld-marker-shipment.ts
index 478e2c2c..97be7028 100644
--- a/game/richese-homeworld-marker-shipment.ts
+++ b/game/richese-homeworld-marker-shipment.ts
@@ -9,6 +9,8 @@ export type RicheseHomeworldMarkerShipmentIntent = {
player: string;
destination: string;
tokenId: string;
+ /** Optional physical companions withdrawn from native Richese reserves. */
+ forces?: HomeworldForces;
};
export type RicheseHomeworldMarkerTransferIntent = RicheseHomeworldMarkerShipmentIntent & {
source: string;
@@ -21,10 +23,9 @@ function requireMarker(condition: unknown, message: string): asserts condition {
if (!condition) throw new HomeworldCustodyError(message);
}
-/** Quote one concealed Richese marker as a single shipment, not a physical
- * counter. The engine owns timing, payment, response windows and commitment.
- * Nothing in the input state is mutated, and the marker's denomination never
- * changes its invoice or causes reserve counters to move. */
+/** A fresh concealed marker counts as one for the whole shipment invoice. Any
+ * physical companions are independently withdrawn from native Richese reserves;
+ * the marker's denomination never enters the price or physical custody. */
export function quoteRicheseHomeworldMarkerShipment(
context: HomeworldShipmentContext,
custody: HomeworldCustody,
@@ -33,9 +34,17 @@ export function quoteRicheseHomeworldMarkerShipment(
) {
// This validates all physical reserve and foreign garrison custody first.
const worlds = homeworldForceGroups(context, custody);
- requireMarker(record(intent) && Object.keys(intent).sort().join(',') === 'destination,player,tokenId' &&
+ requireMarker(record(intent) &&
+ (Object.keys(intent).sort().join(',') === 'destination,player,tokenId' ||
+ Object.keys(intent).sort().join(',') === 'destination,forces,player,tokenId') &&
typeof intent.player === 'string' && typeof intent.destination === 'string' && typeof intent.tokenId === 'string',
'Choose a Richese player, one active Homeworld and one physical No-Field.');
+ if ('forces' in intent) {
+ requireMarker(record(intent.forces) && Object.keys(intent.forces).sort().join(',') === 'elite,normal' &&
+ Number.isSafeInteger(intent.forces.normal) && Number.isSafeInteger(intent.forces.elite) &&
+ intent.forces.normal >= 0 && intent.forces.elite >= 0,
+ 'Choose a valid physical normal and special counter group.');
+ }
requireMarker(context.players.every(seat => seat.ally === null ||
typeof seat.ally === 'string' && seat.ally !== seat.id && context.players.some(other => other.id === seat.ally)),
'Homeworld shipment needs valid seated alliance identities.');
@@ -60,6 +69,16 @@ export function quoteRicheseHomeworldMarkerShipment(
} catch (error) {
throw new HomeworldCustodyError(error instanceof Error ? error.message : 'Invalid No-Field shipment.');
}
+ if (intent.forces && intent.forces.normal + intent.forces.elite > 0) {
+ const physical = quoteHomeworldShipment(context, custody, {
+ player: player.id, destination: destination.id, sources: { [source.id]: intent.forces },
+ });
+ const amount = 1 + physical.amount;
+ return { player: player.id, tokenId: intent.tokenId, source: source.id,
+ destination: destination.id, amount, elite: physical.elite,
+ cost: context.purchasedGuildRate ? Math.ceil(amount / 2) : amount, noField: deployed,
+ state: physical.state, players: physical.players, sources: physical.sources };
+ }
return {
player: player.id,
tokenId: intent.tokenId,
diff --git a/tests/fixture-semuta-independent-ordinary.ts b/tests/fixture-semuta-independent-ordinary.ts
new file mode 100644
index 00000000..d8d410b2
--- /dev/null
+++ b/tests/fixture-semuta-independent-ordinary.ts
@@ -0,0 +1,115 @@
+import assert from 'node:assert/strict';
+import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
+import { TERRITORIES } from '../game/board';
+import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
+import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
+import {
+ actBasicSemutaAmbassador, basicSemutaAmbassadorClean,
+ basicSemutaAmbassadorInventory, basicSemutaAmbassadorPlayer,
+ type BasicSemutaAmbassadorStep,
+} from './fixture-basic-semuta-ambassador';
+
+export const actIndependentOrdinary = actBasicSemutaAmbassador;
+export const independentOrdinaryInventory = basicSemutaAmbassadorInventory;
+export const independentOrdinaryPlayer = basicSemutaAmbassadorPlayer;
+export const independentOrdinaryClean = basicSemutaAmbassadorClean;
+
+function next(game: Game): BasicSemutaAmbassadorStep | null {
+ if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
+ const reaction = game.pendingTreacheryDiscard.reaction;
+ const actor = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(actor);
+ return { actor: actor.id, action: {type: 'semutaPass', event: game.pendingTreacheryDiscard.batch.event} };
+ }
+ return nextRicheseStrongholdsNativeStep(game);
+}
+
+/** Continue the original phase using native player actions, never a staged jump. */
+export function advanceIndependentOrdinary(game: Game, until: (state: Game) => boolean): Game {
+ for (let count = 0; count < 1600; count++) {
+ if (until(game)) return game;
+ const step = next(game); assert.ok(step, `Native continuation required at ${game.turn}/${game.phase}`);
+ game = actIndependentOrdinary(game, step.actor, step.action);
+ }
+ throw Error('The original Semuta phase did not reach its requested boundary.');
+}
+
+/** Genuine public setup and auctions: only unopened deal and Spice order are
+ * selected; Guild buys Semuta and Atreides buys the relevant physical card. */
+export function createIndependentOrdinaryFixture(effect: 'hajr' | 'ghola' | 'weather' | 'harvester' | 'atomics') {
+ let game = createGame('SEMUTARECRUITS', newPlayer('atreides', 'Atreides', 'atreides'), false, ['choam']);
+ for (const faction of ['emperor', 'richese', 'guild'] as const)
+ joinGame(game, newPlayer(faction, faction, faction));
+ const owner = 'atreides', holder = 'guild', actions: BasicSemutaAmbassadorStep[] = [];
+ const act = (actor: string, action: Action) => { game = actIndependentOrdinary(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => {
+ for (let count = 0; count < 1600; count++) {
+ if (until(game)) return;
+ assert.notEqual(game.status, 'finished');
+ const step = next(game); assert.ok(step, `Native action required at ${game.turn}/${game.phase}/${game.decision?.kind}`);
+ act(step.actor, step.action);
+ }
+ throw Error('The original Semuta source did not reach its requested boundary.');
+ };
+ act(owner, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ act(owner, { type: 'start' });
+ assert.equal(game.status, 'setup'); assert.equal(game.semutaRecruitsPreview, true);
+ assert.ok(game.ecazTreachery && !game.techTokens && !game.leaderSkills && !game.nexusCards && !game.homeworlds);
+ const originalInventory = independentOrdinaryInventory(game);
+ const starting = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind))
+ .slice(0, game.players.length).map(card => card.id);
+ assert.equal(starting.length, game.players.length);
+ const card = game.deck.find(item => item.effect === effect); assert.ok(card);
+ const selectedDeal = [...starting, card.id];
+ for (let position = 0; position < selectedDeal.length; position++) {
+ const from = game.deck.findIndex(card => card.id === selectedDeal[position]); assert.ok(from >= position);
+ [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
+ }
+ for (let count = 0; game.status === 'setup' && count < 100; count++) {
+ const step = next(game); assert.ok(step); act(step.actor, step.action);
+ }
+ assert.equal(game.status, 'playing');
+ for (let position = 0; position < 4; 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);
+ }
+ reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
+ act('richese', { type: 'decision', event: game.richeseBidding!.event, card: SEMUTA_DRUG_ID,
+ method: 'onceAround', direction: 'counterclockwise' });
+ reach(state => independentOrdinaryClean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
+ for (let count = 0; !independentOrdinaryPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) {
+ const lot = game.richeseAuction; assert.ok(lot && lot.active && !lot.outcome);
+ act(lot.active, { type: 'richeseBid', event: lot.event, amount: lot.active === holder ? 1 : null, allyPayment: 0 });
+ reach(independentOrdinaryClean);
+ }
+ assert.ok(independentOrdinaryPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
+ reach(state => state.phase === 3 && independentOrdinaryClean(state) && state.auction?.active === owner);
+ assert.equal(game.auction!.cards[game.auction!.index].id, card.id);
+ act(owner, { type: 'bid', amount: 1, allyPayment: 0 });
+ reach(state => independentOrdinaryClean(state) && independentOrdinaryPlayer(state, owner).hand.some(held => held.id === card.id));
+ reach(state => state.phase === 5 && state.active === owner && independentOrdinaryClean(state));
+ if (effect === 'hajr') return { game, owner, holder, card: card.id, originalInventory, actions,
+ play: {type: 'card', card: card.id} as Action };
+ const sector = effect === 'atomics' ? [8, 9].find(value => value !== game.storm)! : game.storm % 18 + 1;
+ const destination = TERRITORIES.find(place => (effect === 'atomics' ? place.id === 'shield_wall' : place.type === 'sand') &&
+ place.sectors.includes(sector) && game.players.every(player =>
+ !Object.keys(player.forces).some(key => key.startsWith(`${place.id}:`))));
+ assert.ok(destination, 'The next Storm path has an empty legal sand sector');
+ const casualtyLocation = `${destination.id}:${sector}`;
+ act(owner, { type: 'ship', territory: destination.id, sector, amount: 3, elite: 0, allyPayment: 0 });
+ if (effect === 'weather' || effect === 'atomics') {
+ reach(state => state.turn === 2 && state.phase === 0 && state.stormPending !== null && independentOrdinaryClean(state));
+ } else if (effect === 'harvester') {
+ reach(state => state.turn === 2 && state.phase === 1 && !!state.spiceWindow &&
+ !state.spiceWindow.harvested && independentOrdinaryClean(state));
+ } else {
+ reach(state => state.turn === 2 && state.phase === 4 && independentOrdinaryClean(state));
+ assert.equal(independentOrdinaryPlayer(game, owner).forces[casualtyLocation], undefined);
+ assert.equal(independentOrdinaryPlayer(game, owner).tanks, 3);
+ }
+ assert.deepEqual(independentOrdinaryInventory(game), originalInventory);
+ return { game, owner, holder, card: card.id, originalInventory, actions, casualtyLocation,
+ play: (effect === 'weather' ? {type: 'card', card: card.id, amount: 1}
+ : effect === 'ghola' ? {type: 'card', card: card.id, amount: 3, elite: 0}
+ : {type: 'card', card: card.id}) as Action };
+}
diff --git a/tests/richese-homeworld-marker-fresh-mixed-controls.test.tsx b/tests/richese-homeworld-marker-fresh-mixed-controls.test.tsx
new file mode 100644
index 00000000..5cf88cc5
--- /dev/null
+++ b/tests/richese-homeworld-marker-fresh-mixed-controls.test.tsx
@@ -0,0 +1,80 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { readFileSync } from 'node:fs';
+import { runInNewContext } from 'node:vm';
+import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import * as jsxRuntime from 'react/jsx-runtime';
+import ts from 'typescript';
+import type { Action, GameView } from '../game/engine';
+import { createRicheseNoField } from '../game/richese-no-field';
+import { homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody';
+import { homeworldPopulations } from '../game/homeworld-population';
+import * as combat from '../game/combat';
+import * as options from '../game/homeworld-shipment-options';
+import { HomeworldNoFieldShipment } from '../components/homeworld-shipment';
+
+function fixture(): GameView {
+ const context = {advanced: false, players: [
+ {id: 'r', faction: 'richese' as const, reserves: 12, eliteReserves: 0, ally: null as string | null},
+ {id: 'a', faction: 'atreides' as const, reserves: 15, eliteReserves: 0, ally: null as string | null},
+ {id: 'g', faction: 'guild' as const, reserves: 20, eliteReserves: 0, ally: null as string | null},
+ ]};
+ const custody: HomeworldCustody = {salusa: null, visitors: {}};
+ const populations = homeworldPopulations(context, custody);
+ return {me: 'r', active: 'r', advanced: false, status: 'playing', phase: 5,
+ homeworldShipment: {event: 'homeworld-event', blocked: null}, aid: {available: 0, pledged: 0},
+ players: context.players.map(p => ({...p, name: p.faction, spice: p.id === 'r' ? 5 : undefined,
+ shipped: false, elites: {reserves: 0}})),
+ homeworlds: {worlds: homeworldForceGroups(context, custody).map(world => ({...world,
+ ...populations.find(population => population.location === world.id)!}))},
+ richeseNoField: {owner: 'r', event: 'marker-event', private: createRicheseNoField(['zero', 'three', 'five']),
+ canShip: true, shipBlock: null},
+ } as unknown as GameView;
+}
+type Element = ReactElement<{children?: ReactNode; [key: string]: unknown}>;
+function elements(node: ReactNode): Element[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!isValidElement<{children?: ReactNode; [key: string]: unknown}>(node)) return [];
+ return [node, ...elements(node.props.children)];
+}
+
+void test('fresh marker controls expose native reserve quantity and submit one mixed shipment', () => {
+ const view = fixture(), sent: Action[] = [], states: unknown[] = [];
+ const markup = renderToStaticMarkup(createElement(HomeworldNoFieldShipment, {game: view, act() {}, busy: false}));
+ assert.match(markup, /your reserves/); assert.match(markup, /12.*available/);
+ let cursor = 0;
+ const exports: {HomeworldNoFieldShipment?: typeof HomeworldNoFieldShipment} = {};
+ runInNewContext(ts.transpileModule(readFileSync(new URL('../components/homeworld-shipment.tsx', import.meta.url), 'utf8'), {
+ compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX},
+ }).outputText, {exports, require(name: string) {
+ if (name === 'react/jsx-runtime') return jsxRuntime;
+ if (name === 'react') return {useState(initial: unknown) {
+ const index = cursor++; if (!(index in states)) states[index] = initial;
+ return [states[index], (next: unknown) => { states[index] = next; }];
+ }};
+ if (name === '@/game/combat') return combat;
+ if (name === '@/game/homeworld-shipment-options') return options;
+ if (name === './ui/button') return {Button: 'button'};
+ if (name === './ui/input') return {Input: 'input'};
+ throw Error(`Unexpected import ${name}`);
+ }});
+ assert.ok(exports.HomeworldNoFieldShipment);
+ const render = () => { cursor = 0; return elements(exports.HomeworldNoFieldShipment!({game: view, busy: false,
+ act: action => sent.push(action)})); };
+ for (const [label, value] of [['No-Field destination Homeworld', 'homeworld:atreides'],
+ ['Private No-Field token', 'zero']] as const) {
+ const select = render().find(element => element.type === 'select' && element.props['aria-label'] === label);
+ assert.ok(select);
+ (select.props.onChange as (event: unknown) => void)({target: {value}});
+ }
+ const input = render().find(element => element.type === 'input'); assert.ok(input);
+ assert.equal(input.props.max, 12);
+ (input.props.onChange as (event: unknown) => void)({target: {value: '2'}});
+ const button = render().find(element => element.type === 'button'); assert.ok(button);
+ assert.equal(button.props.disabled, false);
+ (button.props.onClick as () => void)();
+ assert.deepEqual(JSON.parse(JSON.stringify(sent[0])), {type: 'homeworldShip', event: 'homeworld-event',
+ destination: 'homeworld:atreides', sources: {'homeworld:richese': {normal: 2, elite: 0}},
+ noField: 'zero', noFieldEvent: 'marker-event', allyPayment: 0});
+});
diff --git a/tests/richese-homeworld-marker-fresh-mixed-runtime.test.ts b/tests/richese-homeworld-marker-fresh-mixed-runtime.test.ts
new file mode 100644
index 00000000..6be92555
--- /dev/null
+++ b/tests/richese-homeworld-marker-fresh-mixed-runtime.test.ts
@@ -0,0 +1,129 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
+import { homeworldContext } from '../game/homeworld-game';
+import { homeworldForceGroups } from '../game/homeworld-custody';
+import { homeworldShipmentActions, richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
+import { nextIndependentCardModulesStep } from './fixture-independent-card-modules';
+
+const owner = 'richese', opponent = 'atreides', guild = 'guild';
+const origin = 'homeworld:richese', destination = 'homeworld:atreides';
+function seat(game: Game, id: string) {
+ const player = game.players.find(candidate => candidate.id === id); assert.ok(player); return player;
+}
+const quiet = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
+ !game.truthtrance && !game.pendingTreacheryDiscard;
+
+/** Follow the actual native phase decisions and allow the real Advanced Guild
+ * interception. No saved phase, played card, reserve or marker is staged. */
+function advance(game: Game, until: (state: Game) => boolean, trace: string[] = []): Game {
+ let state = game;
+ for (let count = 0; count < 2000; count++) {
+ if (until(state)) return state;
+ assert.notEqual(state.status, 'finished');
+ if (state.response) trace.push(`response:${state.response.kind}`);
+ if (state.decision?.kind === 'homeworldShipmentGuild') {
+ const decision = state.decision; trace.push(`decision:${decision.kind}`);
+ state = applyAction(state, decision.player, {type: 'decision', event: decision.event, allow: true});
+ continue;
+ }
+ const next = nextIndependentCardModulesStep(state);
+ assert.ok(next, `Native continuation stopped at ${state.phase}/${state.decision?.kind ?? 'no decision'}.`);
+ state = applyAction(state, next.actor, next.action);
+ }
+ throw Error('The real mixed Homeworld shipment did not reach its boundary.');
+}
+
+/** Original public Homeworld lobby, selecting just one undealt Karama position
+ * so Guild can actually exercise the marker response and Advanced stop. */
+function setup(advanced: boolean): Game {
+ let game = createGame('RICHESENATIVEFRESHMIX', newPlayer(owner, owner, 'richese'), advanced, ['choam']);
+ joinGame(game, newPlayer(opponent, opponent, 'atreides'));
+ joinGame(game, newPlayer(guild, guild, 'guild'));
+ game = applyAction(game, owner, {type: 'homeworlds', enabled: true});
+ for (const player of game.players) game = applyAction(game, player.id, {type: 'ready'});
+ game = applyAction(game, owner, advanced ? {type: 'start', advancedPreview: true} : {type: 'start'});
+ const card = game.deck.findIndex(candidate => candidate.effect === 'karama');
+ assert.ok(card >= 0 && game.deck.length >= 3);
+ [game.deck[2], game.deck[card]] = [game.deck[card], game.deck[2]];
+ game = advance(game, state => state.status === 'playing');
+ assert.ok(seat(game, guild).hand.some(card => card.effect === 'karama'));
+ return advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
+ !!viewGame(state, owner).homeworldShipment && !!viewGame(state, owner).richeseNoField?.canShip);
+}
+
+function inventory(game: Game) {
+ const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
+ return Object.fromEntries(game.players.map(player => [player.id, player.reserves + player.tanks +
+ Object.values(player.forces).reduce((sum, value) => sum + value, 0) + homes
+ .filter(home => home.native !== player.id)
+ .reduce((sum, home) => sum + (home.forces[player.id]?.normal ?? 0) + (home.forces[player.id]?.elite ?? 0), 0)]));
+}
+function declaration(game: Game) {
+ const own = viewGame(game, owner), info = own.richeseNoField!;
+ const token = info.private!.tokens.find(candidate => candidate.value === 3); assert.ok(token);
+ const selected = richeseHomeworldMarkerChoice(own, destination, token.id, 0, undefined, {normal: 2, elite: 0});
+ assert.equal(selected.amount, 3); assert.equal(selected.cost, 3); assert.ok(selected.action);
+ return {action: selected.action, token: token.id};
+}
+
+for (const advanced of [false, true]) {
+ void test(`${advanced ? 'Advanced' : 'Basic'} fresh marker and reserve companions use one paid shipment`, () => {
+ let game = setup(advanced);
+ const before = structuredClone(game), originalInventory = inventory(game), {action, token} = declaration(game);
+ assert.ok(seat(before, owner).spice >= 3);
+ for (const stale of [{...action, noFieldEvent: 'stale-marker'}, {...action, event: 'stale-shipment'}]) {
+ assert.throws(() => applyAction(game, owner, stale)); assert.deepEqual(game, before);
+ }
+ game = applyAction(game, owner, action);
+ assert.equal(game.response?.kind, 'richeseNoField');
+ assert.equal(seat(game, owner).spice, seat(before, owner).spice);
+ assert.equal(seat(game, owner).reserves, seat(before, owner).reserves);
+ const trace: string[] = [];
+ game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
+ seat(state, owner).shipped && !state.pendingHomeworldShipment, trace);
+ assert.ok(trace.includes('response:richeseNoField'));
+ assert.equal(trace.includes('decision:homeworldShipmentGuild'), advanced);
+ assert.equal(seat(game, owner).spice, seat(before, owner).spice - 3);
+ assert.equal(seat(game, owner).reserves, seat(before, owner).reserves - 2);
+ assert.deepEqual(game.homeworlds!.custody!.visitors[destination]?.[owner], {normal: 2, elite: 0});
+ assert.deepEqual(seat(game, owner).noField!.deployed, {tokenId: token, controller: owner, location: {homeworld: destination}});
+ assert.equal(seat(game, owner).noField!.lastShipped, token);
+ assert.deepEqual(inventory(game), originalInventory);
+ const other = viewGame(game, opponent).richeseNoField!;
+ assert.equal(other.private, null); assert.equal(JSON.stringify(other.public).includes(token), false);
+ });
+
+ void test(`${advanced ? 'Advanced' : 'Basic'} Karama cancellation preserves the fresh mixed source and unused shipment`, () => {
+ let game = setup(advanced);
+ const before = structuredClone(game), {action} = declaration(game);
+ const karama = seat(game, guild).hand.find(card => card.effect === 'karama'); assert.ok(karama);
+ game = applyAction(game, owner, action);
+ assert.equal(game.response?.kind, 'richeseNoField');
+ game = applyAction(game, guild, {type: 'card', mode: 'cancel', card: karama.id});
+ game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) && !state.pendingHomeworldShipment);
+ assert.equal(seat(game, owner).spice, seat(before, owner).spice);
+ assert.equal(seat(game, owner).reserves, seat(before, owner).reserves);
+ assert.equal(seat(game, owner).shipped, false);
+ assert.deepEqual(seat(game, owner).noField, seat(before, owner).noField);
+ assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
+ assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
+ assert.deepEqual(inventory(game), inventory(before));
+ const option = viewGame(game, owner).homeworldShipment!;
+ game = applyAction(game, owner, {type: 'homeworldShip', event: option.event, destination,
+ sources: {[origin]: {normal: 1, elite: 0}}, allyPayment: 0});
+ game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) && seat(state, owner).shipped);
+ assert.equal(game.homeworlds!.custody!.visitors[destination][owner].normal, 1);
+ assert.equal(seat(game, owner).spice, seat(before, owner).spice - 1);
+ });
+}
+
+void test('all minimal bot profiles offer native legal marker candidates', () => {
+ const game = setup(false), view = viewGame(game, owner);
+ for (const difficulty of [0, 1, 2, 3]) {
+ const action = homeworldShipmentActions(view, difficulty).find(candidate => candidate.noField !== undefined &&
+ (candidate.sources as Record | undefined)?.['homeworld:richese']?.normal === 1);
+ assert.ok(action, `Profile ${difficulty} must offer a marker with legal reserve companions when eligible.`);
+ assert.doesNotThrow(() => applyAction(game, owner, action));
+ }
+});
diff --git a/tests/richese-homeworld-marker-fresh-mixed.test.ts b/tests/richese-homeworld-marker-fresh-mixed.test.ts
new file mode 100644
index 00000000..b5a71b11
--- /dev/null
+++ b/tests/richese-homeworld-marker-fresh-mixed.test.ts
@@ -0,0 +1,99 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { GameView } from '../game/engine';
+import { HomeworldCustodyError, homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody';
+import { homeworldPopulations } from '../game/homeworld-population';
+import { richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
+import type { HomeworldShipmentContext } from '../game/homeworld-shipment';
+import { createRicheseNoField } from '../game/richese-no-field';
+import { quoteRicheseHomeworldMarkerShipment as quote,
+ type RicheseHomeworldMarkerShipmentIntent } from '../game/richese-homeworld-marker-shipment';
+
+function fixture() {
+ const context: HomeworldShipmentContext = {advanced: false, players: [
+ {id: 'r', faction: 'richese', reserves: 12, eliteReserves: 0, ally: null},
+ {id: 'a', faction: 'atreides', reserves: 17, eliteReserves: 0, ally: null},
+ {id: 'g', faction: 'guild', reserves: 20, eliteReserves: 0, ally: null},
+ ]};
+ const custody: HomeworldCustody = {salusa: null, visitors: {
+ 'homeworld:atreides': {r: {normal: 2, elite: 0}},
+ }};
+ const noField = createRicheseNoField(['zero', 'three', 'five']);
+ const intent: RicheseHomeworldMarkerShipmentIntent = {player: 'r', destination: 'homeworld:atreides',
+ tokenId: 'zero', forces: {normal: 3, elite: 0}};
+ return {context, custody, noField, intent};
+}
+
+void test('fresh marker and paid physical companions form one detached shipment in both rulesets', () => {
+ for (const advanced of [false, true]) for (const tokenId of ['zero', 'three', 'five']) {
+ const f = fixture(); f.context.advanced = advanced; f.intent.tokenId = tokenId;
+ const before = structuredClone(f);
+ const result = quote(f.context, f.custody, f.intent, f.noField);
+ assert.equal(result.amount, 4); assert.equal(result.cost, 4); assert.equal(result.elite, 0);
+ assert.deepEqual(result.noField.deployed, {tokenId, controller: 'r', location: {homeworld: 'homeworld:atreides'}});
+ assert.equal(result.players?.find(player => player.id === 'r')?.reserves, 9);
+ assert.deepEqual(result.state?.visitors['homeworld:atreides'].r, {normal: 5, elite: 0});
+ assert.deepEqual(result.sources?.[0], {homeworld: 'homeworld:richese', player: 'r',
+ before: {normal: 12, elite: 0}, after: {normal: 9, elite: 0}});
+ assert.deepEqual(f, before);
+ result.state!.visitors['homeworld:atreides'].r.normal = 0;
+ assert.deepEqual(f, before);
+ }
+});
+
+void test('whole mixed invoice rounds once and zero companions keep the legacy quote', () => {
+ const f = fixture();
+ const legacy = {...f.intent}; delete legacy.forces;
+ assert.deepEqual(quote(f.context, f.custody, {...legacy, forces: {normal: 0, elite: 0}}, f.noField),
+ quote(f.context, f.custody, legacy, f.noField));
+ f.context.purchasedGuildRate = true;
+ for (const count of [1, 2, 3, 11, 12]) {
+ f.intent.forces = {normal: count, elite: 0};
+ const result = quote(f.context, f.custody, f.intent, f.noField);
+ assert.equal(result.amount, count + 1); assert.equal(result.cost, Math.ceil((count + 1) / 2));
+ }
+});
+
+void test('invalid companion quantities, unavailable reserves and extra sources reject without mutation', () => {
+ for (const forces of [
+ {normal: 13, elite: 0}, {normal: 1, elite: 1}, {normal: -1, elite: 0},
+ {normal: 0.5, elite: 0}, {normal: 1}, {normal: 1, elite: 0, extra: 0}, null,
+ ]) {
+ const f = fixture();
+ f.intent = {...f.intent, forces} as unknown as RicheseHomeworldMarkerShipmentIntent;
+ const before = structuredClone(f);
+ assert.throws(() => quote(f.context, f.custody, f.intent, f.noField), HomeworldCustodyError);
+ assert.deepEqual(f, before);
+ }
+ const f = fixture(); f.intent = {...f.intent, source: 'homeworld:guild'} as RicheseHomeworldMarkerShipmentIntent;
+ const before = structuredClone(f);
+ assert.throws(() => quote(f.context, f.custody, f.intent, f.noField), HomeworldCustodyError);
+ assert.deepEqual(f, before);
+});
+
+void test('owned controls quote fresh reserve companions, exact source, affordability and rate', () => {
+ const f = fixture(), populations = homeworldPopulations(f.context, f.custody);
+ const view = {
+ me: 'r', active: 'r', advanced: false, status: 'playing', phase: 5,
+ homeworldShipment: {event: 'shipment', blocked: null}, aid: {available: 0, pledged: 0},
+ players: f.context.players.map(player => ({...player, name: player.faction, spice: player.id === 'r' ? 6 : undefined,
+ shipped: false, elites: {reserves: 0}})),
+ homeworlds: {worlds: homeworldForceGroups(f.context, f.custody).map(world => ({...world,
+ ...populations.find(population => population.location === world.id)!}))},
+ richeseNoField: {owner: 'r', event: 'marker', private: f.noField, canShip: true},
+ } as unknown as GameView;
+ const before = structuredClone(view);
+ const chosen = richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three', 0, undefined, {normal: 3, elite: 0});
+ assert.equal(chosen.amount, 4); assert.equal(chosen.cost, 4);
+ assert.deepEqual(chosen.action, {type: 'homeworldShip', event: 'shipment', destination: 'homeworld:atreides',
+ sources: {'homeworld:richese': {normal: 3, elite: 0}}, noField: 'three', noFieldEvent: 'marker', allyPayment: 0});
+ assert.deepEqual(view, before);
+ const discount = {...view, karamaShipping: {player: 'r'}} as GameView;
+ assert.equal(richeseHomeworldMarkerChoice(discount, 'homeworld:atreides', 'three', 0, undefined,
+ {normal: 2, elite: 0}).cost, 2);
+ view.players[0].spice = 2;
+ assert.equal(richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three', 0, undefined,
+ {normal: 3, elite: 0}).action, null);
+ assert.equal(richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three', 0, undefined,
+ {normal: 13, elite: 0}).action, null);
+});
diff --git a/tests/semuta-independent-ordinary.test.ts b/tests/semuta-independent-ordinary.test.ts
new file mode 100644
index 00000000..9a87628c
--- /dev/null
+++ b/tests/semuta-independent-ordinary.test.ts
@@ -0,0 +1,91 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { type Game, viewGame } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
+import { stormSectorAfter } from '../game/disaster-rules';
+import { actIndependentOrdinary as act, createIndependentOrdinaryFixture as fixture,
+ advanceIndependentOrdinary as advance, independentOrdinaryInventory as inventory,
+ independentOrdinaryPlayer as player } from './fixture-semuta-independent-ordinary';
+
+function react(game: Game, holder: string, event: string, claim: boolean) {
+ if (claim) return act(game, holder, {type: 'semutaCommit', event});
+ for (const seat of game.players) game = act(game, seat.id, {type: 'semutaPass', event});
+ return game;
+}
+
+for (const effect of ['hajr', 'ghola', 'weather', 'harvester', 'atomics'] as const) for (const claim of [false, true]) {
+ void test(`Basic independent cards: ${effect} offers Semuta ${claim ? 'claim' : 'pass'} without replay`, () => {
+ const f = fixture(effect), before = structuredClone(f.game);
+ const oldPlayer = player(f.game, f.owner);
+ const pending = act(f.game, f.owner, f.play);
+ assert.deepEqual(f.game, before);
+ const frame = pending.pendingTreacheryDiscard; assert.ok(frame);
+ assert.equal(frame.reaction?.stage, 'offer');
+ assert.equal(frame.continuation.kind, 'ordinaryCardDiscard');
+ assert.deepEqual(frame.batch.entries.map(entry => entry.card.id), [f.card]);
+ assert.ok(viewGame(pending, f.holder).semutaReaction?.canCommit);
+ if (effect === 'hajr') assert.ok(pending.hajr.includes(f.owner));
+ if (effect === 'ghola') {
+ assert.equal(player(pending, f.owner).reserves, oldPlayer.reserves + 3);
+ assert.equal(player(pending, f.owner).tanks, oldPlayer.tanks - 3);
+ }
+ if (effect === 'weather') assert.equal(pending.stormPending, 1);
+ if (effect === 'atomics') {
+ assert.equal(pending.shieldWallDestroyed, true);
+ assert.equal(player(pending, f.owner).forces[f.casualtyLocation!], undefined);
+ assert.equal(player(pending, f.owner).tanks, oldPlayer.tanks + 3);
+ }
+ if (effect === 'harvester') {
+ assert.equal(pending.spiceWindow!.amount, f.game.spiceWindow!.amount * 2);
+ assert.equal(pending.spiceWindow!.harvested, true);
+ }
+ const committed = {phase: pending.phase, turn: pending.turn, storm: pending.storm,
+ stormPending: pending.stormPending, hajr: pending.hajr, spice: pending.spice,
+ spiceWindow: pending.spiceWindow, shieldWallDestroyed: pending.shieldWallDestroyed,
+ forces: player(pending, f.owner).forces,
+ reserves: player(pending, f.owner).reserves, tanks: player(pending, f.owner).tanks};
+ const done = react(pending, f.holder, frame.batch.event, claim);
+ assert.equal(done.pendingTreacheryDiscard, null);
+ assert.deepEqual({phase: done.phase, turn: done.turn, storm: done.storm,
+ stormPending: done.stormPending, hajr: done.hajr, spice: done.spice,
+ spiceWindow: done.spiceWindow, shieldWallDestroyed: done.shieldWallDestroyed,
+ forces: player(done, f.owner).forces,
+ reserves: player(done, f.owner).reserves, tanks: player(done, f.owner).tanks}, committed);
+ assert.equal(player(done, f.holder).hand.some(card => card.id === f.card), claim);
+ assert.equal(done.discard.some(card => card.id === f.card), !claim);
+ assert.equal(done.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
+ assert.deepEqual(inventory(done), f.originalInventory);
+ assert.throws(() => act(done, f.holder, {type: 'semutaCommit', event: frame.batch.event}));
+ if (effect === 'hajr') {
+ const next = act(done, f.owner, {type: 'endMovement'});
+ assert.ok(!next.movementRemaining?.includes(f.owner));
+ } else if (effect === 'weather' || effect === 'atomics') {
+ const next = advance(done, state => state.turn === done.turn && state.phase === 1);
+ assert.equal(next.storm, stormSectorAfter(done.storm, done.stormPending!));
+ if (effect === 'atomics') assert.equal(next.shieldWallDestroyed, true);
+ } else if (effect === 'harvester') {
+ const next = advance(done, state => state.turn === done.turn && state.phase === 2);
+ const blow = pending.spiceWindow!;
+ const key = `${blow.territory}:${blow.sector}`;
+ assert.equal(next.spice[key] ?? 0, committed.spice[key] ?? 0);
+ } else {
+ const next = advance(done, state => state.turn === done.turn && state.phase === 5);
+ assert.equal(player(next, f.owner).reserves, committed.reserves);
+ assert.equal(player(next, f.owner).tanks, committed.tanks);
+ }
+ });
+}
+
+void test('all AI profiles can legally respond to an independent-card Hajr discard', () => {
+ const f = fixture('hajr'), pending = act(f.game, f.owner, f.play);
+ for (const difficulty of DIFFICULTIES) {
+ const game = structuredClone(pending);
+ player(game, f.holder).bot = difficulty;
+ const response = botActions(viewGame(game, f.holder)).find(action =>
+ action.type === 'semutaCommit' || action.type === 'semutaPass');
+ assert.ok(response);
+ assert.doesNotThrow(() => act(game, f.holder, response));
+ }
+});