From a4f1cfc6504f8d98cfa1a439ee65c468f77deceb Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Mon, 12 Oct 2026 16:40:03 +1100
Subject: [PATCH] Compose native Homeworld Kaitain and Tupile discards with
Semuta
---
components/game-table.tsx | 16 ++
docs/CURRENT_STATUS.md | 20 ++
docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md | 24 +++
docs/IMPLEMENTATION_STATUS.md | 51 +++++
docs/SEMUTA_SOURCE_UPDATE_20260907.md | 29 +++
game/card-presentation.ts | 2 +-
game/engine.ts | 125 +++++++++--
game/reference.ts | 3 +
tests/fixture-semuta-kaitain.ts | 154 ++++++++++++++
tests/fixture-semuta-tupile.ts | 174 +++++++++++++++
tests/semuta-kaitain.test.ts | 200 ++++++++++++++++++
tests/semuta-tupile.test.ts | 163 ++++++++++++++
tools/prototype-room.ts | 5 +
tools/start-prototype.ts | 7 +-
14 files changed, 955 insertions(+), 18 deletions(-)
create mode 100644 tests/fixture-semuta-kaitain.ts
create mode 100644 tests/fixture-semuta-tupile.ts
create mode 100644 tests/semuta-kaitain.test.ts
create mode 100644 tests/semuta-tupile.test.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 20d64bd6..d2358984 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -1162,6 +1162,20 @@ export function GameTable({
)}
{g.semutaPreview && (
+ {g.homeworlds ? (
+ <>
+ Semuta Drug with Homeworlds development preview · original Richese,
+ CHOAM and classic occupation setup, with no other optional modules.
+ Only paid high-Kaitain closing-Bidding discards and occupied Tupile
+ lost-slot cleanup offer Semuta here. Private discarded faces remain
+ hidden until commitment; a simultaneous group requires one selection.
+ Claim or Continue keeps the paid price or lost slot and resumes the
+ same closing owner, next old-holder cleanup or phase opening once.
+ Other Homeworld producers and Basic unresolved Tupile loss remain
+ guarded. Not complete or certified Homeworld rules.{' '}
+ >
+ ) : (
+ <>
Semuta Drug development preview · bounded fresh-discard reactions,
not complete Richese or certified expansion rules. Original Tech Tokens
may remain in supported fresh 3–6-seat CHOAM/Ix profiles; the original
@@ -1185,6 +1199,8 @@ export function GameTable({
support is Basic only: clean shipment/movement Sabotage and Robbery
overflow plus native losing-ally card-retention cleanup; other arrival
continuations remain guarded.{' '}
+ >
+ )}
Preview rules and limits
)}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index ab018b8f..fb15687d 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -29,6 +29,26 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
+**Semuta / native Homeworld discard composition — 11 October.**
+The separate fresh `--profile semuta --homeworlds` route reuses original
+Richese/CHOAM/classic occupation setup, exact CHOAM, Basic/Advanced2–6 and
+no other optional modules. Only paid high-Kaitain closing disposal and
+proved Tupile lost-slot cleanup offer Semuta in this envelope.
+Four native Kaitain before cases and real two-holder Tupile cleanup had no
+Semuta opportunity; the original ready Homeworld lobby was rejected.
+Eleven post-cutover native programs and two real memory-only prototype
+dispatches pass. Kaitain keeps the2/card debit and original two closing
+owners; private groups require commitment before selection. Tupile keeps
+actual departure, lost limits and separate old-holder costs before original
+Movement. Twenty new consumer cases pass; the frozen five-file union is
+51/51, original Semuta private-entry case1/1, types/lint clean.
+Styled390px humans pay4/select Poison Tooth at4/4, retain Emperor6 spice and
+reach actual Revival only after both owners finish. Guild's Snooper claim,
+Atreides's separate Gom Jabbar disposal and four neutral acknowledgements
+restore both normal4 hands and usable original Guild Movement.
+Basic unresolved Tupile epochs, other Homeworld producers, mixed modules,
+public starts and full/deployed acceptance remain separate.
+
**Semuta / native income-cancellation costs — 11 October.**
Printed Karama canceling original `guildIncome`, non-Ambassador
`emperorIncome` or `richesePurchaseIncome` now offers its public discard before
diff --git a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md
index 572aeb2c..5c66f0b3 100644
--- a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md
+++ b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md
@@ -2,6 +2,30 @@
Runtime checkpoint, 10 September 2026. Native CHOAM can now request and retain a private Tupile intelligence observation in a game with proven setup history. This implements the bounded disclosure described in the [source contract](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md); occupation entitlement, expiry and occupied income remain unresolved. Advanced and unfinished expansion starts, publication and complete-rules release retain the gates in [README](../README.md).
+## Semuta / original lost-slot discard — 11 October 2026
+
+The separate fresh `semuta --homeworlds` entry reuses original occupation setup
+for native Richese/CHOAM and classics, exact CHOAM, with no other optional
+modules. It does not convert played profiles or widen the Basic epoch ruling.
+
+Actual selected original cleanup cards are privately discarded first. The
+typed receipt retains only that cleanup's event, original held-ID commitment
+and suspended opening; its lease row remains pending during the neutral
+Semuta response. Claim/pass then completes the original ledger, restores its
+opening and prepares the next old holder once. No slot, card draw, departure
+fee or force transfer is restored or repeated. Separate old holders remain
+separate fresh costs rather than a fabricated simultaneous pair.
+
+A genuine Advanced Guild/Atreides worm alliance, original turn-two Guild
+invasion and turn-three paid return establish and end the actual Tupile epoch.
+Conserved later CHOAM/hand positions isolate the source; neither history nor
+alliances are assigned. Both old holders go5→4, Richese exchanges at4, and the
+original unused Guild Movement remains usable. Three claim-first/claim-second/
+pass-both runtime programs and five consumer cases cover that continuation.
+Styled390px humans discard Guild's Snooper, claim it with Richese, discard
+Atreides's separate Gom Jabbar, acknowledge the neutral event and finish
+original Guild Movement. Printed source and underlying lease rules are unchanged.
+
## Occupied Tupile slots and original cleanup — 5 October 2026
The fresh original `homeworld-occupation` entry now separately enables Tupile
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index ef6f7d54..172cf174 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,56 @@
# Dune implementation status
+## 11 October 2026 — native Homeworld Semuta cost composition
+
+Separate `semuta --homeworlds` private entry reuses original Homeworld
+occupation setup for native Richese/CHOAM and classic factions, exact CHOAM,
+Basic/Advanced2–6, with no other optional modules. CLI selection and stored
+private prototype dispatch use existing undealt-lobby conventions. Runtime
+profile identity retains original custody/occupation/Tupile history. Only
+the following two producers offer Semuta in this envelope; other Homeworld
+producers and combinations remain coverage to finish, not printed bans.
+
+Original `kaitainDiscard` already paid2 per selected physical card and
+retained the closing event. Its private simultaneous batch now offers
+Semuta: singleton completes on commitment; a group exposes candidates only
+after commitment and requires one selection. Taking Karama or Poison Tooth
+does not play it, refund its price or finish Emperor/CHOAM. The original
+paid action clears previous readiness, requiring both owners to acknowledge.
+Original three-spice CHOAM duplicate sale still coexists in the same window.
+
+New `tupileCleanupDiscard` physically discards the owner's exact selected
+normal-limit excess and detaches only its original pending cleanup/opening.
+Its source lease row stays pending during Semuta. Retirement completes the
+same original ledger and restores opening/sync/next-holder once. It does not
+re-discard, restore the lost slot, replay departure or merge distinct holders.
+Unmarked original native cleanup uses the same extracted suffix unchanged.
+
+Four source-qualified native Kaitain before cases and real Guild/Atreides
+two-holder cleanup lacked the reaction; original ready HW entry rejected
+Semuta. Eleven post-cutover programs cover Basic/Advanced singleton/group
+claim/pass and Advanced Tupile claim-first/claim-second/pass-both. Original
+all45 Treachery identities and actual paid source mechanics remain.
+Two real memory-only prototype dispatches reach original setup/playing;
+the CLI flag parses without store access. No existing database was touched.
+
+Twenty new consumer cases, the related five-file unit union51/51 and original
+Semuta prototype-entry case1/1 pass; types/lint pass. Child readiness-shape
+assumptions were removed and replaced by actual required owner acknowledgements;
+CHOAM sale passes now follow the existing native policy until its real response
+closes. Redundant exported fixture aliases, duplicate/default/wiring checks and
+unused typed-force scaffolding were removed rather than made contracts.
+
+Actual styled390px GameTable humans pay4 for Karama/Poison Tooth, privately
+choose Tooth at4/4, observe Emperor6 and finish both owners into Revival.
+Original Guild/Atreides lost-holder controls each go5→4; Richese claims
+Guild's Snooper, the ally discards its distinct Gom Jabbar, all seats continue
+the second neutral event, and Guild finishes its original unused Movement.
+Screenshots show the authorized private group and original ally cleanup;
+observed root widths remain390. Four initial optimizer dependency requests
+were aborted; the exercised controls subsequently rendered and completed.
+Temporary scripts/service/tab were removed. No full-phone/error-free or
+deployed acceptance, new source ruling, public gate or played-save conversion.
+
## 11 October 2026 — native income-cancellation costs through Semuta
Five genuine native before scenarios discarded the printed Karama and
diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md
index abc2cb48..19e40946 100644
--- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md
+++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md
@@ -872,3 +872,32 @@ Movement and restored Richese declaration/cache choice are usable. Guild
rate cancellation, Homeworld/Ambassador payments and unsupported overlapping
parents remain distinct coverage; no printed prohibition or module gate is
invented to describe those limits.
+
+**11 October 2026 — separate original Homeworld composition.**
+`--profile semuta --homeworlds` reuses original fresh Homeworld occupation
+setup for Richese/CHOAM and classics, exact CHOAM, Basic/Advanced2–6, without
+Nexus, Skills, Tech, Strongholds, Discovery or Ix/E3 mixing. Only the two
+following source-qualified producers offer Semuta here; other Homeworld
+producers are not thereby certified or made illegal by printed rules.
+
+High Kaitain's original closing-Bidding discard pays two spice per selected
+physical card before its private fresh batch. A group requires commitment
+and one private selection; disposal never plays Karama or Poison Tooth.
+Claim/pass keeps the payment and original Emperor/CHOAM closing owners.
+The original paid action clears earlier readiness, so both owners acknowledge
+the changed window before Revival. Original CHOAM duplicate sale can coexist.
+
+Proved occupied Tupile loss privately discards each old holder's exact excess
+before its own fresh event. The original cleanup ledger and source opening
+finish once after Semuta; the next old holder remains queued. Taking the card
+does not restore the lost slot or repeat Guild's actual paid departure.
+Basic unresolved epochs retain their original guard.
+
+Four native Kaitain before cases and real two-holder Tupile cleanup lacked
+Semuta; the original ready Homeworld lobby was rejected. Eleven post-cutover
+native programs pass, plus two real memory-only prototype dispatches through
+original setup; the CLI flag parses without store access. Twenty consumer
+cases cover private choice, full hands, old-holder ordering, readiness and
+legal bots. Styled390px humans pay4, select Poison Tooth, retain Emperor6
+spice and finish both closing owners; separate Guild/Atreides costs restore
+normal4 limits and original Movement. No public gate or full-mode acceptance.
diff --git a/game/card-presentation.ts b/game/card-presentation.ts
index eb5088d3..00ceefa9 100644
--- a/game/card-presentation.ts
+++ b/game/card-presentation.ts
@@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
if (definition?.card.effect === 'distrans')
return 'Choose a recipient and card in the Distrans transfer panel.';
if (definition?.card.effect === 'semutaDrug')
- return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation, Bene Gesserit Charity or supported native battle powers, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Acquiring a printed cancellation cost never restores its canceled effect. Battle discards may pause before original Tech award, native Advanced capture, Face Dance, quoted CHOAM income or Auditor; original cleanup and the same opportunity resume once, using the current audited hand when relevant. Tech requires a supported fresh 3–6-seat CHOAM/optional-Ix profile. Native Ixian casualty substitution keeps its original before-Tooth order. Native Basic Moritani cleanup offers only unkept played cards after its original choice and Karama response. A Basic Richese/Moritani CHOAM/Ecaz profile also reacts to private Sabotage and Robbery overflow after clean shipment/movement arrivals. Clean reactive Baliset and La La La pause after cost without completing the stopped move or free revival. Proactive/final-Mentat Trip to Gamont preserves the exact ordinary/elite return or native No-Field reveal and capped recall, restoring its final owner window when applicable. The approved full-hand exchange spends Semuta without increasing the completed hand. Other pending rewards, producers and nested responses remain unfinished.';
+ return 'Use the neutral fresh-discard response when offered in the selected Semuta development profile. Its connected producers are listed in the table preview and rules guide. Commit before inspecting private discarded faces; choose exactly one when the fresh group has multiple eligible cards. Taking a card never refunds a paid cost or replays its original effect. The approved full-hand exchange spends Semuta without increasing the completed hand.';
return definition && definition.card.effect !== 'karama'
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
: null;
diff --git a/game/engine.ts b/game/engine.ts
index c9947150..3efc1eb9 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1267,6 +1267,12 @@ export type Game = {
| { kind: 'nexusChoamTrade'; event: string; owner: string; card: string; spiceAfter: number }
| { kind: 'nexusChoamBetrayal'; event: string; owner: string; target: string; card: string; handBefore: number }
| { kind: 'kaitainDiscard'; owner: string; event: string; cost: number; spiceAfter: number }
+ | {
+ kind: 'tupileCleanupDiscard';
+ cleanup: NonNullable;
+ cards: string[];
+ stateSignature: string;
+ }
| { kind: 'winnerMandatoryDiscard'; event: string; player: string; territory: string; optional: string[]; commitment: NonNullable }
| {
kind: 'nullentropyDiscard';
@@ -5653,10 +5659,40 @@ function semutaNexusProfile(g: Game): boolean {
mixedE1E2NexusLeaderSkillsProfile(g)
: pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g) || mixedE1E2NexusProfile(g));
}
+/** Original Richese/CHOAM Homeworld occupation only; other optional envelopes stay separate. */
+function semutaHomeworldRoster(g: Game): boolean {
+ return !!g.homeworlds && g.expansions.length === 1 && g.expansions[0] === 'choam' &&
+ g.players.length >= 2 && g.players.length <= 6 &&
+ g.players.some(p => p.faction === 'richese') &&
+ g.players.every(p => p.faction === 'richese' || p.faction === 'choam' ||
+ faction(p.faction).expansion === 'base') &&
+ !g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards &&
+ !g.discoveryEnabled && !g.discoveries && !g.discoveryStash &&
+ !g.greatMaker && !g.ecazTreachery;
+}
+type TupileDiscardContinuation = Extract<
+ NonNullable['continuation'],
+ { kind: 'tupileCleanupDiscard' }
+>;
+function tupileDiscardSignature(g: Game, c: TupileDiscardContinuation) {
+ return JSON.stringify({
+ cleanup: c.cleanup, cards: c.cards, turn: g.turn, phase: g.phase,
+ active: g.active, order: g.order, ready: g.ready, movementRemaining: g.movementRemaining,
+ response: g.response, decision: g.decision, opening: g.phaseOpening,
+ homeworlds: g.homeworlds, history: g.homeworldOccupationHistory,
+ tupile: g.homeworldTupileState,
+ players: g.players.map(p => ({
+ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id),
+ spice: p.spice, reserves: p.reserves, tanks: p.tanks, forces: p.forces,
+ elites: p.elites, shipped: p.shipped, moved: p.moved, slot: p.tupileHandSlot,
+ })),
+ });
+}
function treacheryDiscardIntegrity(g: Game) {
requireRule(g.semutaPreview === undefined || g.semutaPreview === true &&
g.expansions.includes('choam') && !!byFaction(g, 'richese') &&
- !g.homeworlds && (!g.nexusCards || semutaNexusProfile(g)) &&
+ (!g.homeworlds || g.homeworldOccupationPreview === true && semutaHomeworldRoster(g)) &&
+ (!g.nexusCards || semutaNexusProfile(g)) &&
(!g.leaderSkills || ordinaryLeaderSkillModeSupported(g)) && !g.discoveries &&
!g.discoveryEnabled && (!g.techTokens || (g.nexusCards
? semutaNexusProfile(g) : g.leaderSkills
@@ -6808,6 +6844,26 @@ function treacheryDiscardIntegrity(g: Game) {
batch.entries[0].discardedBy === c.target && !batch.entries[0].publicFace &&
batch.entries[0].card.id === c.card && g.nexusChoamBetrayals?.at(-1)?.event === c.event,
'The CHOAM Nexus Betrayal has changed its random physical discard.');
+ } else if (continuation?.kind === 'tupileCleanupDiscard') {
+ const c = continuation, source = c.cleanup;
+ const row = g.homeworldTupileState?.cleanups.find(cleanup => cleanup.event === source.event);
+ const player = getPlayer(g, source.player);
+ requireRule(semutaHomeworldRoster(g) && g.homeworldTupilePreview &&
+ !g.pendingHomeworldTupileCleanup && !g.decision && !g.response && !g.phaseOpening &&
+ row?.status === 'pending' && row.player === source.player &&
+ source.signature === JSON.stringify([source.event, source.player, source.hand,
+ source.opening, g.turn, g.phase]) &&
+ batch.cause === 'homeworld:tupileCleanup' && c.cards.length > 0 &&
+ batch.entries.length === c.cards.length &&
+ batch.entries.every((entry, index) => !entry.publicFace &&
+ entry.discardedBy === source.player && entry.card.id === c.cards[index]) &&
+ JSON.stringify(player.hand.map(card => card.id)) ===
+ JSON.stringify(source.hand.filter(id => !c.cards.includes(id))) &&
+ c.stateSignature === tupileDiscardSignature(g, c),
+ 'The private Tupile discard no longer matches its original cleanup.');
+ const quote = homeworldRule(() => quoteOccupiedTupileCleanup(g, g.homeworldTupileState!, source.event));
+ requireRule(!quote.blocked && quote.excess === 0,
+ 'The original normal-limit cleanup must be paid before its Semuta reaction.');
} else if (continuation?.kind === 'kaitainDiscard') {
const c = continuation;
requireRule(g.phase === 3 && g.biddingEnd?.event === c.event &&
@@ -6855,6 +6911,8 @@ function semutaOfferSupported(
continuation.kind !== 'karamaChoamWorthlessDiscard' &&
continuation.kind !== 'karamaEmperorShareDiscard' &&
continuation.kind !== 'karamaIncomeDiscard' &&
+ continuation.kind !== 'kaitainDiscard' &&
+ continuation.kind !== 'tupileCleanupDiscard' &&
continuation.kind !== 'karamaPurchaseDiscard' &&
continuation.kind !== 'karamaPaymentDiscard' &&
continuation.kind !== 'thumperDiscard' &&
@@ -6882,6 +6940,8 @@ function semutaOfferSupported(
(continuation.promise === null ||
continuation.promise.kind === 'shipment' ||
continuation.promise.kind === 'battle'))) return false;
+ if (g.homeworlds && continuation.kind !== 'kaitainDiscard' &&
+ continuation.kind !== 'tupileCleanupDiscard') return false;
if (continuation.kind === 'battleResolved' &&
!g.lastBattleContext?.mandatoryDiscard) return false;
if ((continuation.kind === 'battleCleanup' ||
@@ -6923,7 +6983,9 @@ function semutaOfferSupported(
return g.semutaPreview === true && g.status === 'playing' &&
g.players.some(p => p.faction === 'richese') &&
entries.length > 0 &&
- (continuation.kind === 'ixAllyCard' || continuation.kind === 'terrorDiscard' ||
+ (continuation.kind === 'kaitainDiscard' || continuation.kind === 'tupileCleanupDiscard'
+ ? entries.every(entry => !entry.publicFace)
+ : continuation.kind === 'ixAllyCard' || continuation.kind === 'terrorDiscard' ||
continuation.kind === 'nexusChoamBetrayal' || choamCunningDiscard(continuation)
? entries.length === 1 && !entries[0].publicFace
: continuation.kind === 'battleCleanup' ||
@@ -6954,6 +7016,8 @@ function semutaOfferSupported(
(continuation.kind === 'battleCleanup' ||
continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved' ||
+ continuation.kind === 'kaitainDiscard' ||
+ continuation.kind === 'tupileCleanupDiscard' ||
continuation.kind === 'ixAllyCard' ||
continuation.kind === 'terrorDiscard' ||
continuation.kind === 'nexusChoamTrade' ||
@@ -7024,7 +7088,9 @@ function semutaOfferSupported(
(continuation.kind === 'choamSaleDiscard'
? !!g.choamMarket && !g.choamMarket.sale &&
!g.choamMarket.trade && !g.biddingEnd && !g.currentAuctionSale
- : !g.choamMarket) &&
+ : continuation.kind === 'kaitainDiscard'
+ ? g.biddingEnd?.event === continuation.event && biddingEndError(g) === null
+ : !g.choamMarket) &&
!g.pendingChoamMarketGhola;
}
function semutaContext(g: Game, owner: Player): SemutaContext {
@@ -7150,6 +7216,10 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
return;
}
if (next.kind === 'kaitainDiscard') return;
+ if (next.kind === 'tupileCleanupDiscard') {
+ finishTupileCleanup(g, next.cleanup, next.cards.length);
+ return;
+ }
if (next.kind === 'ambassador') {
const p = getPlayer(g, next.entry.beneficiary!);
if (next.entry.effect === 'ixians') {
@@ -9775,8 +9845,8 @@ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game {
initializeMoritaniAssassinateState(g);
return g;
}
-/** Development-only Semuta with original native Tech/Advanced Strongholds/Nexus
- * and explicit native skills, or bounded Basic Moritani/E3. Public starts stay gated. */
+/** Development-only Semuta with original native modules/skills, separate bounded
+ * Homeworld occupation, or Basic Moritani/E3. Public starts stay gated. */
export function initializeSemutaGameForAudit(state: Game, skills = false): Game {
const nativeTerror = !state.advanced && state.expansions.length === 2 &&
state.expansions.includes('choam') && state.expansions.includes('ecaz') &&
@@ -9787,11 +9857,14 @@ export function initializeSemutaGameForAudit(state: Game, skills = false): Game
state.expansions.includes('choam') &&
(state.expansions.every(id => id === 'choam' || id === 'ix') || nativeTerror) &&
state.players.some(p => p.faction === 'richese'),
- 'Semuta needs a fresh Richese faction game: CHOAM with optional Ix, original Tech/Advanced Strongholds/Nexus, and explicit native Basic Richese or Advanced E1/E2 Leader Skills; separate Basic Moritani uses CHOAM/Ecaz and classics without modules. Other overlays remain excluded.');
+ 'Semuta needs a fresh Richese faction game: CHOAM with optional Ix and supported original modules/skills; separate exact-CHOAM Homeworld occupation has no other modules, while Basic Moritani uses CHOAM/Ecaz and classics without modules. Other overlays remain excluded.');
if (state.nexusCards) requireRule(!nativeTerror && !state.homeworlds &&
!state.discoveryEnabled && !state.discoveries,
'Semuta Nexus retains original E1/E2 rosters without Homeworlds, Discovery or E3.');
- const g = skills ? initializeLeaderSkillsGameForAudit(state)
+ if (state.homeworlds) requireRule(!skills && semutaHomeworldRoster(state),
+ 'Semuta Homeworlds require original Richese/CHOAM and classic factions, exact CHOAM, and no other modules.');
+ const g = state.homeworlds ? initializeHomeworldOccupationGameForAudit(state)
+ : skills ? initializeLeaderSkillsGameForAudit(state)
: state.nexusCards ? state.expansions.length === 1 && !!byFaction(state, 'choam')
? initializePairedNexusGameForAudit(state) : initializeNexusGameForAudit(state)
: state.strongholdCards ? initializeStrongholdFactionsGameForAudit(state)
@@ -19072,6 +19145,20 @@ function projectedTupileCleanup(g: Game, viewer: string) {
blocked: quote.blocked ?? (eligibleCards.length < quote.excess
? 'The original committed cards must settle before this hand cleanup can complete.' : null) };
}
+/** Resume only the original cleanup ledger and source controls; cards are already discarded. */
+function finishTupileCleanup(g: Game,
+ cleanup: NonNullable, discarded: number) {
+ const player = getPlayer(g, cleanup.player);
+ g.homeworldTupileState = homeworldRule(() => completeOccupiedTupileCleanup(g,
+ g.homeworldTupileState!, cleanup.event, player.id));
+ g.pendingHomeworldTupileCleanup = null;
+ g.decision = null;
+ g.phaseOpening = cleanup.opening;
+ log(g, `${player.name} discarded ${discarded} Treachery Card${discarded === 1 ? '' : 's'} to the normal hand limit after losing the original occupied Tupile slot.`,
+ { faction: player.faction, name: 'Tupile hand cleanup' });
+ syncTupileSlots(g);
+ prepareTupileCleanup(g);
+}
function decideTupileCleanup(g: Game, player: Player,
decision: Extract, action: Action) {
tupileSlotsIntegrity(g, decision);
@@ -19085,15 +19172,21 @@ function decideTupileCleanup(g: Game, player: Player,
requireRule(!quote.blocked && selected.length === quote.excess &&
selected.every(id => eligible.includes(id) && physical.filter(card => card.id === id).length === 1),
'Discard exactly the normal-limit excess without consuming a reserved card.');
- for (const id of selected) discard(g, player, id);
- g.homeworldTupileState = homeworldRule(() => completeOccupiedTupileCleanup(g, g.homeworldTupileState!, pending.event, player.id));
- g.pendingHomeworldTupileCleanup = null;
- g.decision = null;
- g.phaseOpening = pending.opening;
- log(g, `${player.name} discarded ${selected.length} Treachery Card${selected.length === 1 ? '' : 's'} to the normal hand limit after losing the original occupied Tupile slot.`,
- { faction: player.faction, name: 'Tupile hand cleanup' });
- syncTupileSlots(g);
- prepareTupileCleanup(g);
+ const entries = selected.map(id => ({ card: discard(g, player, id),
+ discardedBy: player.id, publicFace: false }));
+ if (g.semutaPreview && semutaHomeworldRoster(g) && entries.length > 0) {
+ const continuation: TupileDiscardContinuation = {
+ kind: 'tupileCleanupDiscard', cleanup: structuredClone(pending),
+ cards: [...selected], stateSignature: '',
+ };
+ g.pendingHomeworldTupileCleanup = null;
+ g.decision = null;
+ g.phaseOpening = null;
+ continuation.stateSignature = tupileDiscardSignature(g, continuation);
+ stageTreacheryDiscard(g, 'homeworld:tupileCleanup', entries, continuation);
+ return;
+ }
+ finishTupileCleanup(g, pending, selected.length);
}
function tupileAutomatic(g: Game): boolean {
if (!g.homeworldTupilePreview || g.pendingHomeworldTupileCleanup ||
diff --git a/game/reference.ts b/game/reference.ts
index 3d3c8aa2..5f314187 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -1186,6 +1186,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A distinct printed Karama canceling those five ordinary native CHOAM printed or Cunning powers also offers its public cost before the original denial completes. CHOAM retains the declared fuel; a Cunning Nexus remains spent. Claim or Continue restores the same saved response and one original movement, free-revival, storm or closing-Mentat suffix. Acquiring Karama cannot return the canceled force, grant range, stop the original request, protect the storm or restore Nexus. The retained CHOAM fuel is not a discard candidate; Kull and unsupported arrival/flight parents remain separate.',
'A printed Karama canceling the Emperor’s shared gift or extra allied revival also offers that exact public cost before the denial completes. The canceled share moves no spice, forces or revival; the Emperor’s ally and extra-revival ledger are unchanged. Claim or Continue restores the saved response and finishes the cancellation once. Only the actual Semuta holder sees the private claimant choice; other seats see the neutral offer.',
'A printed Karama canceling original paid Guild shipment income, non-Ambassador Emperor auction income or Richese special-purchase income also offers its exact public cost before the native denial. The price and physical transaction have already committed: claim or Continue cannot refund spice, re-ship forces, undo cache acquisition or replay a purchase. Original Movement, next-lot inspection or the Richese purchase’s suspended controls resume once. A winner may spend the very Karama it just bought; claiming that identity does not reverse its payment. Homeworld and Ambassador payment compositions remain separate.',
+ 'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Only paid high-Kaitain closing-Bidding disposal and occupied Tupile lost-slot cleanup offer Semuta in this envelope. Kaitain pays two spice per discarded card; multiple private cards become one fresh group, inspected only after commitment. Taking one cannot play it, refund the cost or finish either original closing owner. After a proved Tupile loss, each old holder discards its own exact normal-limit excess in a separate private event; claim or Continue completes that original cleanup before the next holder or saved phase opening. The lost slot is not restored. Basic unresolved Tupile epochs, other Homeworld producers, module mixtures and public starts stay guarded.',
'An ordinary Ghola played during battle also offers its consumed card after revival. Claim or Continue preserves the revived leader or forces and the opposing sealed plan, then normal planning resumes. It cannot revive again by resuming the receipt.',
'A completed paid Nullentropy Box search during battle offers only the used Box. Claim or Continue preserves the two-spice payment, private recovered card, already-shuffled pile and sealed opposing plan. The recovered card may be used in normal legal planning; the search is not repeated.',
'A clean Distrans transfer during battle also offers only the used Distrans. The private card has already reached its recipient; claim or Continue cannot repeat the gift or change a sealed plan. A recipient holding Semuta may claim the used Distrans and keep the already-transferred card.',
@@ -1256,6 +1257,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/semuta-emperor-share.test.ts',
'tests/semuta-guild-income.test.ts',
'tests/semuta-emperor-income.test.ts',
+ 'tests/semuta-kaitain.test.ts',
+ 'tests/semuta-tupile.test.ts',
],
},
],
diff --git a/tests/fixture-semuta-kaitain.ts b/tests/fixture-semuta-kaitain.ts
new file mode 100644
index 00000000..7ff0760c
--- /dev/null
+++ b/tests/fixture-semuta-kaitain.ts
@@ -0,0 +1,154 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction, createGame, handLimit, initializeHomeworldOccupationGameForAudit,
+ initializeSemutaGameForAudit, joinGame, newPlayer, viewGame,
+ type Action, type Game,
+} from '../game/engine';
+import { treacheryDeck, type Card } from '../game/cards';
+import { richeseCards } from '../game/richese-cards';
+import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
+import { highKaitainDiscardsAvailable } from '../game/homeworld-card-economy';
+import { biddingEndDiscardChoice } from '../game/bidding-end-options';
+import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
+import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
+import { moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
+
+export type SemutaKaitainOptions = {
+ initial?: Game;
+ advanced?: boolean;
+ count?: 1 | 2;
+ fullHolder?: boolean;
+ ownerReady?: boolean;
+ choamReady?: boolean;
+ /** Before-cutover proof uses the original native Homeworld entry, without Semuta. */
+ nativeOnly?: boolean;
+};
+export type SemutaKaitainFixture = {
+ game: Game;
+ paid: Game;
+ initial: Game;
+ setup: Game;
+ owner: string;
+ holder: string;
+ choam: string;
+ semuta: string;
+ cards: Card[];
+ cardIds: string[];
+ action: Action;
+ event: string;
+ actions: RicheseSkillsTechStep[];
+ staging: string[];
+ stock: string[];
+};
+
+
+/** Genuine original setup/first Storm/original auctions establish closing Bidding.
+ * Only later physical hand positions and unplayed original Spice order are staged.
+ * Kaitain population, wallets, ownership, readiness and every clock stay native. */
+export function createSemutaKaitainFixture(options: SemutaKaitainOptions = {}): SemutaKaitainFixture {
+ let game = options.initial ? structuredClone(options.initial)
+ : createGame('SEMUTAKAITAIN', newPlayer('r', 'Richese', 'richese'), options.advanced ?? false, ['choam']);
+ if (!options.initial) {
+ joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
+ joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
+ joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
+ }
+ assert.equal(game.status, 'lobby');
+ assert.deepEqual(game.expansions, ['choam']);
+ if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
+ const holder = game.players.find(p => p.faction === 'richese')!.id;
+ const owner = game.players.find(p => p.faction === 'emperor')!.id;
+ const choam = game.players.find(p => p.faction === 'choam')!.id;
+ const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
+ const act = (actor: string, action: Action) => {
+ actions.push({ actor, action });
+ game = applyAction(game, actor, action);
+ };
+ if (!game.homeworlds) act(game.host, { type: 'homeworlds', enabled: true });
+ for (const p of game.players) if (!p.ready) act(p.id, { type: 'ready' });
+ const initial = structuredClone(game);
+ game = options.nativeOnly ? initializeHomeworldOccupationGameForAudit(game)
+ : initializeSemutaGameForAudit(game);
+ const stock = biddingPhysicalIds(game);
+ assert.deepEqual(stock, [...treacheryDeck(['choam']), ...richeseCards()].map(card => card.id).sort(),
+ 'The original native initializer supplies the canonical stock exactly once');
+ const reach = (until: () => boolean) => {
+ for (let step = 0; !until() && step < 1200; step++) {
+ assert.notEqual(game.status, 'finished', 'Native setup and Bidding must reach Kaitain');
+ let next: RicheseSkillsTechStep;
+ const pending = game.pendingTreacheryDiscard;
+ const reaction = pending?.reaction;
+ if (reaction?.stage === 'offer' && pending) {
+ next = { actor: game.players.find(p => !reaction.passed.includes(p.id))!.id,
+ action: { type: 'semutaPass', event: pending.batch.event } };
+ } else if (game.decision?.kind === 'choamMarket') {
+ // Decline the real post-Storm market, preserving original cards and wallets.
+ next = { actor: game.decision.player, action: { type: 'decision', done: true } };
+ } else next = nextRicheseSkillsTechNativeStep(game);
+ act(next.actor, next.action);
+ }
+ assert.ok(until(), 'Original actions must reach the requested Kaitain boundary');
+ };
+ reach(() => game.status === 'playing');
+ const setup = structuredClone(game);
+ // Two original non-worm blows avoid an unrelated alliance opportunity.
+ const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 2);
+ assert.equal(blows.length, 2);
+ for (const [position, card] of blows.entries()) {
+ const at = game.spiceDeck.indexOf(card);
+ game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
+ }
+ staging.push('Reorder two still-unplayed original non-worm Spice Cards; no replacement deck.');
+ reach(() => !!game.biddingEnd && !game.decision && !game.response && !game.pendingTreacheryDiscard);
+ assert.equal(game.turn, 1);
+ assert.equal(game.phase, 3);
+ assert.equal(highKaitainDiscardsAvailable(game, owner), true, 'Original Emperor reserves qualify high Kaitain');
+ assert.equal(semutaEmperorPlayer(game, owner).spice, 10, 'No native auction bid was paid');
+ assert.deepEqual(biddingPhysicalIds(game), stock);
+
+ // Conserved later hands; no card is created or treated as played/acquired by an auction.
+ for (const id of [owner, holder]) game.deck.push(...semutaEmperorPlayer(game, id).hand.splice(0));
+ const semuta = moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, holder);
+ const first = moveSemutaEmperorCard(game, semutaEmperorCardId(game, card => card.effect === 'karama'), owner);
+ const cardIds = [first];
+ if ((options.count ?? 2) === 2) cardIds.push(moveSemutaEmperorCard(game,
+ semutaEmperorCardId(game, card => card.kind === 'poisonTooth'), owner));
+ // An unselected native card keeps the owner's optional finish genuinely available.
+ moveSemutaEmperorCard(game, semutaEmperorCardId(game,
+ card => card.kind === 'shield' && !cardIds.includes(card.id)), owner);
+ if (options.fullHolder) {
+ while (semutaEmperorPlayer(game, holder).hand.length < handLimit(semutaEmperorPlayer(game, holder))) {
+ const card = game.deck[0];
+ assert.ok(card);
+ moveSemutaEmperorCard(game, card.id, holder);
+ }
+ assert.equal(semutaEmperorPlayer(game, holder).hand.length, 4);
+ }
+ staging.push(`Conserved original ${semuta} to Richese; ${cardIds.join(', ')} and one retained Shield to Emperor${options.fullHolder ? '; three original undealt cards fill Richese to four' : ''}.`);
+ assert.deepEqual(biddingPhysicalIds(game), stock);
+ const event = game.biddingEnd!.event;
+ if (options.ownerReady) act(owner, { type: 'biddingEnd', event, mode: 'ready' });
+ if (options.choamReady) act(choam, { type: 'biddingEnd', event, mode: 'ready' });
+ const choice = biddingEndDiscardChoice(viewGame(game, owner), cardIds);
+ assert.equal(choice.blocked, null);
+ assert.ok(choice.action);
+ const cards = cardIds.map(id => structuredClone(semutaEmperorPlayer(game, owner).hand.find(card => card.id === id)!));
+ const before = structuredClone(game);
+ act(owner, choice.action);
+ return { game: before, paid: game, initial, setup, owner, holder, choam, semuta,
+ cards, cardIds, action: choice.action, event, actions, staging, stock };
+}
+
+/** The actual event requires commitment before private multi-card selection. */
+export function reactSemutaKaitain(state: Game, fixture: SemutaKaitainFixture, claim: boolean, selected = fixture.cardIds[0]): Game {
+ let game = state;
+ const event = game.pendingTreacheryDiscard!.batch.event;
+ if (!claim) {
+ for (const player of game.players) game = applyAction(game, player.id, { type: 'semutaPass', event });
+ return game;
+ }
+ game = applyAction(game, fixture.holder, { type: 'semutaCommit', event });
+ if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
+ game = applyAction(game, fixture.holder, { type: 'semutaSelect', event, card: selected });
+ return game;
+}
diff --git a/tests/fixture-semuta-tupile.ts b/tests/fixture-semuta-tupile.ts
new file mode 100644
index 00000000..404c65e7
--- /dev/null
+++ b/tests/fixture-semuta-tupile.ts
@@ -0,0 +1,174 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction, createGame, handLimit, initializeHomeworldOccupationGameForAudit,
+ initializeSemutaGameForAudit, joinGame, newPlayer, viewGame,
+ type Action, type Game,
+} from '../game/engine';
+import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
+import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
+import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
+import {
+ moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer as semutaTupilePlayer,
+} from './fixture-semuta-emperor-share';
+
+export const SEMUTA_TUPILE_WORLD = 'homeworld:choam';
+export type SemutaTupileStep = { actor: string; action: Action };
+export type SemutaTupileFixture = {
+ game: Game;
+ setup: Game;
+ occupied: Game;
+ beforeDeparture: Game;
+ owner: string;
+ holder: string;
+ occupier: string;
+ ally: string;
+ event: string;
+ action: Action;
+ selected: string[];
+ semuta: string;
+ sourceActions: SemutaTupileStep[];
+ actions: SemutaTupileStep[];
+ cards: string[];
+};
+
+export function semutaTupileCards(game: Game): string[] {
+ return [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
+ ...game.players.map(player => player.hand)].flat().map(card => card.id).sort();
+}
+
+/** Public physical totals include visitors, which are not native reserves. */
+export function semutaTupileForceTotals(game: Game) {
+ return game.players.map(player => ({ id: player.id, total: player.reserves + player.tanks +
+ Object.values(player.forces).reduce((sum, amount) => sum + amount, 0) +
+ Object.values(game.homeworlds!.custody!.visitors).reduce((sum, visitors) =>
+ sum + (visitors[player.id]?.normal ?? 0) + (visitors[player.id]?.elite ?? 0), 0) }));
+}
+
+export function nextSemutaTupileStep(game: Game): SemutaTupileStep {
+ const pending = game.pendingTreacheryDiscard;
+ const reaction = pending?.reaction;
+ if (reaction?.stage === 'offer' && pending) return {
+ actor: game.players.find(player => !reaction.passed.includes(player.id))!.id,
+ action: { type: 'semutaPass', event: pending.batch.event },
+ };
+ if (game.decision?.kind === 'homeworldShipmentGuild') return {
+ actor: game.decision.player,
+ action: { type: 'decision', event: game.decision.event, allow: true },
+ };
+ return nextRicheseSkillsTechNativeStep(game);
+}
+
+export function semutaTupileCleanupStep(game: Game): SemutaTupileStep {
+ assert.equal(game.decision?.kind, 'homeworldTupileCleanup');
+ const actor = game.decision!.player;
+ const cleanup = viewGame(game, actor).homeworldTupileCleanup!;
+ assert.ok(cleanup && !cleanup.blocked);
+ return { actor, action: { type: 'decision', event: cleanup.event,
+ cards: cleanup.eligibleCards.slice(0, cleanup.excess) } };
+}
+
+export function reactSemutaTupile(state: Game, holder: string, claim: boolean): Game {
+ const event = state.pendingTreacheryDiscard!.batch.event;
+ if (claim) return applyAction(state, holder, { type: 'semutaCommit', event });
+ let game = state;
+ for (const player of state.players)
+ game = applyAction(game, player.id, { type: 'semutaPass', event });
+ return game;
+}
+
+/** Original setup and phase actions establish the alliance, occupation epoch,
+ * lease and departure. Later conserved physical positions isolate the producer;
+ * they never assign its clocks, alliances, wallets, visitor custody or history. */
+export function createSemutaTupileFixture(options: { nativeOnly?: boolean } = {}): SemutaTupileFixture {
+ let game = createGame('SEMUTATUPILE', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
+ joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
+ joinGame(game, newPlayer('g', 'Guild', 'guild'));
+ joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
+ game = applyAction(game, 'r', { type: 'homeworlds', enabled: true });
+ for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
+ game = options.nativeOnly
+ ? initializeHomeworldOccupationGameForAudit(game)
+ : initializeSemutaGameForAudit(game);
+ const actions: SemutaTupileStep[] = [], sourceActions: SemutaTupileStep[] = [];
+ const act = (step: SemutaTupileStep, source = false) => {
+ actions.push(step);
+ if (source) sourceActions.push(step);
+ game = applyAction(game, step.actor, step.action);
+ };
+ const clean = () => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
+ const reach = (until: () => boolean) => {
+ for (let n = 0; !until() && n < 2400; n++) {
+ assert.notEqual(game.status, 'finished', 'Original native actions must reach Tupile');
+ act(nextSemutaTupileStep(game));
+ }
+ assert.ok(until(), 'Original native actions must reach the requested Tupile boundary');
+ };
+ const orderUnplayedSpice = (kinds: ('land' | 'worm')[]) => {
+ for (const [position, kind] of kinds.entries()) {
+ const index = game.spiceDeck.findIndex((card, i) => i >= position &&
+ (kind === 'land' ? 'territory' in card : 'worm' in card));
+ assert.ok(index >= position, 'An original unplayed Spice Card must remain');
+ game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
+ }
+ };
+ reach(() => game.status === 'playing');
+ const setup = structuredClone(game), cards = semutaTupileCards(game);
+ orderUnplayedSpice(['land', 'land']);
+ reach(() => game.phase === 5 && clean());
+ // Conserved later positions: remove all Arrakis encounters without changing
+ // native Homeworld custody, phase controls or original twenty-counter stocks.
+ for (const player of game.players) {
+ player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
+ player.forces = {};
+ }
+ reach(() => game.turn === 2 && game.phase === 1 && clean());
+ orderUnplayedSpice(['worm', 'land', 'land']);
+ reach(() => game.nexus && !game.spiceWindow && !game.spiceResolution && clean());
+ act({ actor: 'g', action: { type: 'alliance', target: 'a' } }, true);
+ act({ actor: 'a', action: { type: 'alliance', target: 'g' } }, true);
+ reach(() => game.phase === 5 && game.active === 'g' && clean());
+ // Conserved later CHOAM position: its original native counters are on Arrakis,
+ // not imaginary visitors. The ORIGINAL invasion must qualify the sole occupier.
+ const choam = semutaTupilePlayer(game, 'c');
+ choam.forces['polar_sink:0'] = (choam.forces['polar_sink:0'] ?? 0) + choam.reserves;
+ choam.reserves = 0;
+ const invade = homeworldShipmentChoice(viewGame(game, 'g'), SEMUTA_TUPILE_WORLD,
+ { 'homeworld:guild': { normal: 1, elite: 0 } });
+ assert.ok(invade.action, invade.blocked ?? undefined);
+ act({ actor: 'g', action: invade.action }, true);
+ reach(() => clean());
+ assert.equal(semutaTupilePlayer(game, 'g').tupileHandSlot, true);
+ assert.equal(semutaTupilePlayer(game, 'a').tupileHandSlot, true);
+ const occupied = structuredClone(game);
+ // No further worm is needed: the reciprocal alliance remains native.
+ orderUnplayedSpice(['land', 'land', 'land', 'land']);
+ reach(() => game.turn === 3 && game.phase === 5 && game.active === 'g' && clean());
+ // Conserved original physical cards only, staged after all native auctions.
+ for (const player of game.players) game.deck.push(...player.hand.splice(0));
+ const semuta = moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
+ for (const [id, size] of [['g', 5], ['a', 5], ['r', 4]] as const) {
+ const player = semutaTupilePlayer(game, id);
+ while (player.hand.length < size) {
+ const card = semutaEmperorCardId(game, candidate => game.deck.some(held => held.id === candidate.id) &&
+ candidate.id !== SEMUTA_DRUG_ID && candidate.effect !== 'karama');
+ moveSemutaEmperorCard(game, card, id);
+ }
+ assert.equal(player.hand.length, handLimit(player));
+ }
+ const beforeDeparture = structuredClone(game);
+ const depart = homeworldShipmentChoice(viewGame(game, 'g'), 'homeworld:guild',
+ { [SEMUTA_TUPILE_WORLD]: { normal: 1, elite: 0 } });
+ assert.ok(depart.action, depart.blocked ?? undefined);
+ act({ actor: 'g', action: depart.action }, true);
+ reach(() => game.decision?.kind === 'homeworldTupileCleanup');
+ assert.equal(game.homeworldTupileState!.current, null);
+ assert.equal(game.homeworldTupileState!.cleanups.filter(row => row.status === 'pending').length, 2);
+ const cleanup = semutaTupileCleanupStep(game);
+ const selected = cleanup.action.cards as string[];
+ assert.equal(selected.length, 1, 'Each ordinary granted-five holder owes its own original one-card cleanup');
+ assert.deepEqual(semutaTupileCards(game), cards);
+ assert.ok(semutaTupileForceTotals(game).every(row => row.total === 20));
+ return { game, setup, occupied, beforeDeparture, owner: cleanup.actor, holder: 'r',
+ occupier: 'g', ally: 'a', event: cleanup.action.event as string, action: cleanup.action,
+ selected, semuta, sourceActions, actions, cards };
+}
diff --git a/tests/semuta-kaitain.test.ts b/tests/semuta-kaitain.test.ts
new file mode 100644
index 00000000..052db82f
--- /dev/null
+++ b/tests/semuta-kaitain.test.ts
@@ -0,0 +1,200 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { biddingEndDiscardChoice } from '../game/bidding-end-options';
+import { homeworldCardEconomyPopulation } from '../game/homeworld-card-economy';
+import {
+ createSemutaKaitainFixture, reactSemutaKaitain, type SemutaKaitainFixture,
+} from './fixture-semuta-kaitain';
+import { moveSemutaEmperorCard, semutaEmperorPlayer as semutaKaitainPlayer } from './fixture-semuta-emperor-share';
+import { biddingPhysicalIds as semutaKaitainStock } from './fixture-homeworld-occupied-bidding';
+import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
+
+const wallets = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice }));
+const held = (game: Game, id: string) => semutaKaitainPlayer(game, id).hand.map(card => card.id);
+
+function assertPaidSource(f: SemutaKaitainFixture): void {
+ const population = homeworldCardEconomyPopulation(f.game, f.owner, 'kaitain');
+ assert.equal(population?.side, 'high');
+ assert.deepEqual(population, homeworldCardEconomyPopulation(f.setup, f.owner, 'kaitain'),
+ 'Kaitain uses its original setup reserve population, not manufactured forces');
+ assert.equal(semutaKaitainPlayer(f.game, f.owner).spice, 10);
+ const pending = f.paid.pendingTreacheryDiscard!;
+ assert.ok(pending);
+ assert.equal(pending.batch.cause, 'kaitain');
+ assert.deepEqual(pending.batch.entries, f.cards.map(card => ({ card, discardedBy: f.owner, publicFace: false })));
+ assert.equal(pending.reaction?.stage, 'offer');
+ assert.deepEqual(f.paid.deck, f.game.deck, 'Paid disposal neither auctions nor draws a card');
+ assert.deepEqual(f.paid.richeseCache, f.game.richeseCache);
+ assert.equal(f.paid.phase, 3);
+ assert.equal(f.paid.turn, 1);
+ assert.ok(!f.paid.response && !f.paid.pendingKarama && !f.paid.battle,
+ 'Discarded Karama/Poison Tooth are not played');
+ assert.deepEqual(held(f.paid, f.owner), held(f.game, f.owner).filter(id => !f.cardIds.includes(id)));
+ assert.deepEqual(wallets(f.paid), wallets(f.game).map(p => p.id === f.owner
+ ? { ...p, spice: p.spice - 2 * f.cardIds.length } : p));
+ assert.deepEqual(semutaKaitainStock(f.paid), f.stock);
+}
+
+function assertResumed(f: SemutaKaitainFixture, game: Game, selected?: string): void {
+ assert.equal(game.resolvedTreacheryDiscardSequence, f.paid.pendingTreacheryDiscard!.sequence);
+ assert.equal(game.turn, 1);
+ assert.equal(game.phase, 3);
+ assert.deepEqual(game.deck, f.game.deck);
+ assert.deepEqual(game.richeseCache, f.game.richeseCache);
+ assert.deepEqual(wallets(game), wallets(f.paid), 'Semuta never refunds or repeats the printed debit');
+ assert.deepEqual(held(game, f.owner), held(f.paid, f.owner));
+ assert.deepEqual(held(game, f.holder), selected
+ ? [...held(f.game, f.holder).filter(id => id !== f.semuta), selected]
+ : held(f.game, f.holder));
+ for (const id of f.cardIds) assert.equal(game.discard.filter(card => card.id === id).length, Number(id !== selected));
+ assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(!!selected));
+ assert.deepEqual(semutaKaitainStock(game), f.stock);
+}
+
+for (const advanced of [false, true]) {
+ const mode = advanced ? 'Advanced' : 'Basic';
+ for (const claim of [false, true]) void test(`${mode} original high Kaitain singleton ${claim ? 'claim' : 'pass'} keeps its paid owner finish`, () => {
+ const f = createSemutaKaitainFixture({ advanced, count: 1, choamReady: true });
+ assertPaidSource(f);
+ const event = f.paid.pendingTreacheryDiscard!.batch.event;
+ assert.equal(viewGame(f.paid, f.holder).semutaReaction?.canCommit, true);
+ assert.deepEqual(viewGame(f.paid, f.holder).semutaReaction?.candidates, []);
+ assert.throws(() => applyAction(f.paid, f.owner, { type: 'biddingEnd', event: f.event, mode: 'ready' }));
+ assert.throws(() => applyAction(f.paid, f.holder, { type: 'semutaSelect', event, card: f.cardIds[0] }));
+ const resumed = reactSemutaKaitain(f.paid, f, claim);
+ assertResumed(f, resumed, claim ? f.cardIds[0] : undefined);
+ assert.equal(resumed.biddingEnd!.ready.includes(f.owner), false);
+ assert.throws(() => applyAction(resumed, f.holder, { type: 'biddingEnd', event: f.event, mode: 'ready' }));
+ assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event }));
+ const emperorFinished = applyAction(resumed, f.owner, { type: 'biddingEnd', event: f.event, mode: 'ready' });
+ assert.equal(emperorFinished.phase, 3, 'The paid action requires CHOAM to acknowledge again');
+ const finished = applyAction(emperorFinished, f.choam, { type: 'biddingEnd', event: f.event, mode: 'ready' });
+ assert.equal(finished.phase, 4);
+ assert.deepEqual(finished.deck, f.game.deck);
+ assert.deepEqual(wallets(finished), wallets(f.paid));
+ });
+
+ for (const selectedIndex of [0, 1]) void test(`${mode} simultaneous private Kaitain group allows actual post-commit choice ${selectedIndex + 1} at full four-card capacity`, () => {
+ const f = createSemutaKaitainFixture({ advanced, count: 2, fullHolder: true });
+ assertPaidSource(f);
+ assert.equal(held(f.game, f.holder).length, 4);
+ const event = f.paid.pendingTreacheryDiscard!.batch.event;
+ const offered = viewGame(f.paid, f.holder).semutaReaction!;
+ assert.equal(offered.canCommit, true, 'Exchanging Semuta needs no fifth slot');
+ assert.deepEqual(offered.candidates, []);
+ assert.equal(viewGame(f.paid, f.owner).biddingEnd?.canAct, false);
+ const committed = applyAction(f.paid, f.holder, { type: 'semutaCommit', event });
+ assert.equal(committed.pendingTreacheryDiscard?.reaction?.stage, 'select');
+ assert.deepEqual(viewGame(committed, f.holder).semutaReaction?.candidates, f.cards);
+ assert.deepEqual(viewGame(committed, f.owner).semutaReaction?.candidates, []);
+ assert.deepEqual(held(committed, f.holder), held(f.game, f.holder), 'Commit alone has not chosen a physical card');
+ assert.deepEqual(wallets(committed), wallets(f.paid));
+ assert.throws(() => applyAction(committed, f.owner, { type: 'semutaSelect', event, card: f.cardIds[0] }));
+ assert.throws(() => applyAction(committed, f.holder, { type: 'semutaSelect', event, card: f.semuta }));
+ assert.throws(() => applyAction(committed, f.holder, { type: 'semutaPass', event }));
+ const selected = f.cardIds[selectedIndex];
+ const resumed = applyAction(committed, f.holder, { type: 'semutaSelect', event, card: selected });
+ assertResumed(f, resumed, selected);
+ assert.equal(held(resumed, f.holder).length, 4);
+ assert.equal(viewGame(resumed, f.owner).biddingEnd?.canAct, true);
+ // CHOAM shares this same native window; neither owner's finish substitutes for the other.
+ const emperorReady = applyAction(resumed, f.owner, { type: 'biddingEnd', event: f.event, mode: 'ready' });
+ assert.equal(emperorReady.phase, 3);
+ assert.equal(viewGame(emperorReady, f.choam).biddingEnd?.canAct, true);
+ const finished = applyAction(emperorReady, f.choam, { type: 'biddingEnd', event: f.event, mode: 'ready' });
+ assert.equal(finished.phase, 4);
+ assert.deepEqual(finished.deck, f.game.deck);
+ assert.deepEqual(wallets(finished), wallets(f.paid));
+ });
+
+ void test(`${mode} a new paid Kaitain action requires both original owners to acknowledge again`, () => {
+ const f = createSemutaKaitainFixture({ advanced, count: 2, ownerReady: true });
+ assertPaidSource(f);
+ const resumed = reactSemutaKaitain(f.paid, f, false);
+ assertResumed(f, resumed);
+ assert.equal(resumed.choamMarket?.owner, f.choam);
+ assert.equal(resumed.choamMarket?.resume, 'phase');
+ assert.equal(viewGame(resumed, f.choam).biddingEnd?.canAct, true);
+ const choamFinished = applyAction(resumed, f.choam, { type: 'biddingEnd', event: f.event, mode: 'ready' });
+ assert.equal(choamFinished.phase, 3, 'Earlier Emperor readiness cannot finish a changed closing window');
+ const finished = applyAction(choamFinished, f.owner, { type: 'biddingEnd', event: f.event, mode: 'ready' });
+ assert.equal(finished.phase, 4);
+ assert.deepEqual(wallets(finished), wallets(f.paid));
+ });
+
+ void test(`${mode} original CHOAM duplicate sale coexists with the paid Kaitain owner finish`, () => {
+ const f = createSemutaKaitainFixture({ advanced, count: 1 });
+ let game = reactSemutaKaitain(f.paid, f, true);
+ const spice = semutaKaitainPlayer(game, f.choam).spice;
+ // Explicit later conserved position: two original Shields enter CHOAM's hand,
+ // not an auction purchase, played card or replacement stock.
+ while (semutaKaitainPlayer(game, f.choam).hand.filter(card => card.name === 'Shield').length < 2) {
+ const card = [...game.deck, ...game.discard,
+ ...game.players.filter(p => p.id !== f.choam && p.id !== f.owner).flatMap(p => p.hand)]
+ .find(card => card.name === 'Shield');
+ assert.ok(card);
+ moveSemutaEmperorCard(game, card.id, f.choam);
+ }
+ const deck = structuredClone(game.deck);
+ const sale = viewGame(game, f.choam).choamMarket?.sales?.find(sale => sale.price === 3);
+ assert.ok(sale);
+ game = applyAction(game, f.choam, { type: 'biddingEnd', event: f.event,
+ mode: 'sell', card: sale.card, witness: sale.witness });
+ assert.equal(game.response?.kind, 'choamSale');
+ assert.throws(() => applyAction(game, f.owner, { type: 'biddingEnd', event: f.event, mode: 'ready' }));
+ while (game.response?.kind === 'choamSale') {
+ const next = nextRicheseSkillsTechNativeStep(game);
+ game = applyAction(game, next.actor, next.action);
+ }
+ assert.equal(semutaKaitainPlayer(game, f.choam).spice, spice + 3, 'Original exact-duplicate sale price');
+ assert.equal(semutaKaitainPlayer(game, f.owner).spice, 8, 'Independent Kaitain debit remains paid exactly once');
+ assert.ok(game.discard.some(card => card.id === sale.card));
+ assert.ok(held(game, f.choam).includes(sale.witness!));
+ assert.equal(game.biddingEnd!.event, f.event);
+ assert.equal(game.phase, 3);
+ assert.deepEqual(semutaKaitainStock(game), f.stock);
+ game = applyAction(game, f.choam, { type: 'biddingEnd', event: f.event, mode: 'ready' });
+ assert.equal(game.phase, 3);
+ game = applyAction(game, f.owner, { type: 'biddingEnd', event: f.event, mode: 'ready' });
+ assert.equal(game.phase, 4);
+ assert.deepEqual(game.deck, deck);
+ });
+
+ void test(`${mode} native closing controls reject nonowner, stale and duplicate paid selections`, () => {
+ const f = createSemutaKaitainFixture({ advanced });
+ assert.equal(biddingEndDiscardChoice(viewGame(f.game, f.owner), f.cardIds).cost, 4);
+ assert.equal(biddingEndDiscardChoice(viewGame(f.game, f.holder), f.cardIds).action, null);
+ assert.throws(() => applyAction(f.game, f.holder, f.action));
+ assert.throws(() => applyAction(f.game, f.choam, f.action));
+ assert.throws(() => applyAction(f.game, f.owner, { ...f.action, event: `${f.event}:stale` }));
+ assert.throws(() => applyAction(f.game, f.owner, { ...f.action, cards: [f.cardIds[0], f.cardIds[0]] }));
+ assert.throws(() => applyAction(f.game, f.owner, { ...f.action, cards: [f.semuta] }));
+ const resumed = reactSemutaKaitain(f.paid, f, false);
+ assert.throws(() => applyAction(resumed, f.owner, f.action), 'Original selected cards cannot be charged again');
+ assert.deepEqual(wallets(resumed), wallets(f.paid));
+ });
+}
+
+void test('every minimal legal Semuta bot resolves native paid Kaitain without replaying the debit', () => {
+ for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const f = createSemutaKaitainFixture({ count: 2, fullHolder: true });
+ let game = f.paid;
+ const own = viewGame(game, f.holder);
+ own.players.find(p => p.id === f.holder)!.bot = difficulty;
+ const commit = botActions(own)[0];
+ assert.ok(commit);
+ assert.equal(commit.type, 'semutaCommit');
+ game = applyAction(game, f.holder, commit);
+ assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'select');
+ const selectionView = viewGame(game, f.holder);
+ selectionView.players.find(p => p.id === f.holder)!.bot = difficulty;
+ const select = botActions(selectionView)[0];
+ assert.ok(select);
+ assert.equal(select.type, 'semutaSelect');
+ assert.ok(f.cardIds.includes(select.card as string));
+ game = applyAction(game, f.holder, select);
+ assertResumed(f, game, select.card as string);
+ }
+});
diff --git a/tests/semuta-tupile.test.ts b/tests/semuta-tupile.test.ts
new file mode 100644
index 00000000..ffc9d646
--- /dev/null
+++ b/tests/semuta-tupile.test.ts
@@ -0,0 +1,163 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, handLimit, viewGame, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
+import {
+ createSemutaTupileFixture, reactSemutaTupile, semutaTupileCards,
+ semutaTupileCleanupStep, semutaTupileForceTotals,
+ type SemutaTupileFixture,
+} from './fixture-semuta-tupile';
+import { semutaEmperorPlayer as semutaTupilePlayer } from './fixture-semuta-emperor-share';
+
+function assertOriginalDeparture(f: SemutaTupileFixture) {
+ for (const id of [f.occupier, f.ally]) {
+ const occupied = semutaTupilePlayer(f.beforeDeparture, id);
+ assert.equal(occupied.hand.length, 5);
+ assert.equal(handLimit(occupied), 5);
+ assert.equal(handLimit(semutaTupilePlayer(f.game, id)), 4);
+ }
+ assert.equal(semutaTupilePlayer(f.occupied, f.occupier).ally, f.ally);
+ assert.equal(semutaTupilePlayer(f.occupied, f.ally).ally, f.occupier);
+ const lease = f.occupied.homeworldTupileState!.leases[f.occupied.homeworldTupileState!.current!];
+ assert.deepEqual(lease.holders, [f.occupier, f.ally]);
+ const source = f.occupied.homeworldOccupationHistory!.sources.find(row => row.event === lease.source.qualification)!;
+ assert.ok(source);
+ assert.notEqual(source.cause, 'setup');
+ assert.equal(f.game.homeworldTupileState!.current, null);
+ assert.deepEqual(f.game.homeworldTupileState!.leases[lease.id].lost, [f.occupier, f.ally]);
+ assert.equal(semutaTupilePlayer(f.game, f.occupier).spice,
+ semutaTupilePlayer(f.beforeDeparture, f.occupier).spice - 1);
+ assert.equal(semutaTupilePlayer(f.game, f.occupier).reserves,
+ semutaTupilePlayer(f.beforeDeparture, f.occupier).reserves + 1);
+ assert.equal(semutaTupilePlayer(f.game, f.occupier).shipped, true);
+ assert.equal(f.game.active, f.occupier);
+ assert.equal(f.game.phase, 5);
+ assert.deepEqual(semutaTupileCards(f.game), f.cards);
+ assert.ok(semutaTupileForceTotals(f.game).every(row => row.total === 20));
+}
+
+function openOriginalCleanup(state: Game, actor: string, action: Parameters[2]): Game {
+ const selected = action.cards as string[], event = action.event as string;
+ const cards = selected.map(id => semutaTupilePlayer(state, actor).hand.find(card => card.id === id)!);
+ const pending = applyAction(state, actor, action);
+ const batch = pending.pendingTreacheryDiscard!;
+ assert.ok(batch, 'The selected original private cleanup cost must pause before its source suffix');
+ assert.deepEqual(batch.batch.entries, cards.map(card => ({ card, discardedBy: actor, publicFace: false })));
+ assert.equal(semutaTupilePlayer(pending, actor).hand.length, 4);
+ assert.equal(pending.homeworldTupileState!.cleanups.find(row => row.event === event)!.status, 'pending');
+ const holder = state.players.find(player => player.hand.some(card => card.id === SEMUTA_DRUG_ID))?.id;
+ for (const seat of state.players) {
+ const offer = viewGame(pending, seat.id).semutaReaction!;
+ assert.deepEqual(offer.candidates, [], 'No private cleanup faces before commitment');
+ assert.equal(offer.canCommit, seat.id === holder);
+ assert.throws(() => applyAction(pending, seat.id, {
+ type: 'semutaSelect', event: batch.batch.event, card: selected[0],
+ }));
+ }
+ assert.throws(() => applyAction(pending, actor, action));
+ assert.throws(() => applyAction(pending, state.active!, { type: 'endMovement' }));
+ return pending;
+}
+
+for (const claimAt of [null, 'first', 'second'] as const) void test(
+ `Advanced real Tupile departure: ${claimAt ? `claim ${claimAt}` : 'pass both'} original old-holder costs before native Movement`, () => {
+ const f = createSemutaTupileFixture();
+ assertOriginalDeparture(f);
+ const first = openOriginalCleanup(f.game, f.owner, f.action);
+ const firstEvent = first.pendingTreacheryDiscard!.batch.event;
+ const firstCard = f.selected[0];
+ assert.equal(viewGame(first, f.holder).semutaReaction!.canCommit, true);
+ assert.throws(() => applyAction(first, f.owner, { type: 'semutaCommit', event: firstEvent }));
+ let game = reactSemutaTupile(first, f.holder, claimAt === 'first');
+ assert.equal(game.homeworldTupileState!.cleanups.find(row => row.event === f.event)!.status, 'completed');
+ assert.equal(semutaTupilePlayer(game, f.holder).hand.some(card => card.id === firstCard), claimAt === 'first');
+ assert.equal(semutaTupilePlayer(game, f.owner).hand.length, 4);
+ assert.equal(handLimit(semutaTupilePlayer(game, f.owner)), 4);
+ assert.equal(game.decision?.kind, 'homeworldTupileCleanup');
+ const second = semutaTupileCleanupStep(game);
+ assert.notEqual(second.actor, f.owner);
+ assert.notEqual(second.action.event, f.event);
+ assert.equal(semutaTupilePlayer(game, second.actor).hand.length, 5, 'Separate old holder has not paid yet');
+ assert.throws(() => applyAction(game, f.owner, second.action));
+ assert.throws(() => applyAction(game, f.owner, f.action));
+ const secondCard = (second.action.cards as string[])[0];
+ const secondBefore = game;
+ if (claimAt === 'first') {
+ game = applyAction(game, second.actor, second.action);
+ // Semuta was spent on the first cost; the second ORIGINAL cleanup still
+ // discards its own selected card, without creating a fake pair offer.
+ if (game.pendingTreacheryDiscard) game = reactSemutaTupile(game, f.holder, false);
+ } else {
+ const secondPending = openOriginalCleanup(game, second.actor, second.action);
+ assert.notEqual(secondPending.pendingTreacheryDiscard!.batch.event, firstEvent);
+ assert.equal(secondPending.pendingTreacheryDiscard!.batch.entries.length, 1);
+ assert.equal(secondPending.pendingTreacheryDiscard!.batch.entries[0].card.id, secondCard);
+ game = reactSemutaTupile(secondPending, f.holder, claimAt === 'second');
+ }
+ assert.equal(semutaTupilePlayer(game, second.actor).hand.length, 4);
+ assert.equal(semutaTupilePlayer(game, f.holder).hand.length, 4, 'Full Richese hand exchanges Semuta rather than gaining capacity');
+ assert.equal(semutaTupilePlayer(game, f.holder).hand.some(card => card.id === secondCard), claimAt === 'second');
+ for (const row of game.homeworldTupileState!.cleanups) assert.equal(row.status, 'completed');
+ assert.equal(game.phase, 5);
+ assert.equal(game.active, f.occupier);
+ assert.deepEqual(game.movementRemaining, f.game.movementRemaining);
+ assert.deepEqual(game.players.map(player => player.spice), f.game.players.map(player => player.spice));
+ assert.deepEqual(semutaTupileForceTotals(game), semutaTupileForceTotals(f.game));
+ assert.deepEqual(semutaTupileCards(game), f.cards);
+ assert.equal(game.discard.filter(card => card.id === firstCard).length, Number(claimAt !== 'first'));
+ assert.equal(game.discard.filter(card => card.id === secondCard).length, Number(claimAt !== 'second'));
+ assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimAt !== null));
+ assert.throws(() => applyAction(game, second.actor, second.action));
+ assert.throws(() => applyAction(game, f.holder, { type: 'semutaCommit', event: firstEvent }));
+ // The actual unused Movement suffix remains legal exactly once: departure
+ // already paid and shipped, and neither cleanup can replay it.
+ const suffix = applyAction(game, f.occupier, { type: 'endMovement' });
+ assert.ok(!suffix.movementRemaining?.includes(f.occupier));
+ assert.notEqual(suffix.active, f.occupier);
+ assert.throws(() => applyAction(suffix, f.occupier, { type: 'endMovement' }));
+ assert.equal(semutaTupilePlayer(suffix, f.occupier).spice, semutaTupilePlayer(secondBefore, f.occupier).spice);
+ assert.deepEqual(semutaTupileCards(suffix), f.cards);
+ },
+);
+
+void test('neutral Tupile passes keep the next old holder closed until the last seat acknowledges', () => {
+ const f = createSemutaTupileFixture();
+ let game = openOriginalCleanup(f.game, f.owner, f.action);
+ const event = game.pendingTreacheryDiscard!.batch.event;
+ for (const seat of game.players.filter(player => player.id !== f.holder)) {
+ game = applyAction(game, seat.id, { type: 'semutaPass', event });
+ assert.ok(game.pendingTreacheryDiscard);
+ assert.equal(game.homeworldTupileState!.cleanups.find(row => row.event === f.event)!.status, 'pending');
+ assert.throws(() => applyAction(game, seat.id, { type: 'semutaPass', event }));
+ }
+ game = applyAction(game, f.holder, { type: 'semutaPass', event });
+ assert.equal(game.decision?.kind, 'homeworldTupileCleanup');
+ assert.notEqual(game.decision!.player, f.owner);
+});
+
+
+void test('minimal legal bot settles both native Tupile cleanup owners and the full-hand Semuta claim', () => {
+ const f = createSemutaTupileFixture();
+ let game = f.game;
+ for (let n = 0; (game.decision?.kind === 'homeworldTupileCleanup' || game.pendingTreacheryDiscard) && n < 24; n++) {
+ const pending = game.pendingTreacheryDiscard;
+ const actor = pending?.reaction?.stage === 'offer'
+ ? f.holder
+ : game.decision!.player;
+ const view = viewGame(game, actor);
+ view.players.find(player => player.id === actor)!.bot = 'Easy';
+ const action = botActions(view)[0];
+ assert.ok(action, 'Actual original cleanup / Semuta owner has a legal bot action');
+ game = applyAction(game, actor, action);
+ if (game.pendingTreacheryDiscard && !semutaTupilePlayer(game, f.holder).hand.some(card => card.id === f.semuta))
+ game = reactSemutaTupile(game, f.holder, false);
+ }
+ assert.equal(game.pendingTreacheryDiscard, null);
+ assert.equal(game.pendingHomeworldTupileCleanup, null);
+ assert.equal(game.decision, null);
+ assert.equal(game.active, f.occupier);
+ assert.ok(game.homeworldTupileState!.cleanups.every(row => row.status === 'completed'));
+ assert.equal(semutaTupilePlayer(game, f.holder).hand.length, 4);
+ assert.deepEqual(semutaTupileCards(game), f.cards);
+});
diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts
index 0a3b9f0a..b34724b3 100644
--- a/tools/prototype-room.ts
+++ b/tools/prototype-room.ts
@@ -57,6 +57,8 @@ export type PrototypeOptions = {
discoveries?: boolean;
/** Explicit original native skills only with the Semuta profile. */
leaderSkills?: boolean;
+ /** Original Richese/CHOAM and classic Homeworld occupation with Semuta only. */
+ homeworlds?: boolean;
};
export function isPrototypeProfile(value: string): value is PrototypeProfile {
return PROTOTYPE_PROFILES.some((profile) => profile === value);
@@ -104,10 +106,13 @@ export function startPrototypeRoom(
throw new Error('--discoveries requires the leader-skills or nexus profile.');
if (options.leaderSkills && profile !== 'semuta')
throw new Error('--leader-skills requires the semuta profile.');
+ if (options.homeworlds && profile !== 'semuta')
+ throw new Error('--homeworlds requires the semuta profile.');
if (options.ecazTreachery) initial.ecazTreachery = true;
if ((profile === 'nexus' || options.nexusCards) && !initial.nexusCards)
initial.nexusCards = { cards: null, phase: null };
if (options.discoveries) initial.discoveryEnabled = true;
+ if (options.homeworlds) initial.homeworlds ??= { custody: null };
const game =
profile === 'ix'
? initializeIxGameForAudit(initial)
diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts
index 286b6ba2..e21837c3 100644
--- a/tools/start-prototype.ts
+++ b/tools/start-prototype.ts
@@ -19,6 +19,7 @@ async function main() {
'nexus-cards': { type: 'boolean' },
discoveries: { type: 'boolean' },
'leader-skills': { type: 'boolean' },
+ homeworlds: { type: 'boolean' },
help: { type: 'boolean' },
},
});
@@ -29,6 +30,7 @@ async function main() {
'\nSemuta with CHOAM and optional Ix preserves original unused Tech Tokens in fresh 3–6-seat Basic/Advanced Richese lobbies under existing native prerequisites. Advanced additionally retains original unused Stronghold Cards at2–6; both modules require3–6. Native setup, first-Storm Tech and final-Mentat Stronghold assignment remain original. Mandatory/winner discards react before the original winner takes one loser-owned token, before Face Dance. Closing Gamont reacts before the original CHOAM finish and changed-board claim. Optional original skills require --leader-skills as described below; other modules, the separate CHOAM/Ecaz Moritani entry, retrofit and public starts remain excluded.',
'\n--profile semuta --leader-skills explicitly composes original all14 skills with fresh native Basic Richese/CHOAM and classics using exact CHOAM, or Advanced native E1/E2 with Richese and selected required CHOAM/optional Ix decks, at2–6 seats. Optional original Tech requires3+; Strongholds requires Advanced. Original private offers/training, Auditor exclusion, normal Mentat question, typed Suk rescue and Rihani inspection/exchange remain. Traitor/Face Dancer movement is not a Treachery discard. Basic Ix mixtures without their original Nexus envelope, E3/Moritani, Homeworld/Discovery/Banker overlays, public starts and played-game conversion are not opened.',
'\n--profile semuta --nexus-cards retains original all12 Nexus in fresh native Richese CHOAM/optional Ix lobbies, optionally --leader-skills, original Tech3+ and Advanced Strongholds2+. Single/paired CHOAM or both selected E1/E2 native families retain original prerequisites. No starting Nexus hand is added: a qualifying actual end-Spice draw follows a Nexus and settled alliance. CHOAM Secret Ally spends a Worthless for two bank spice only with CHOAM absent; CHOAM Betrayal spends its Nexus to discard one server-sampled native CHOAM hand card without compensation. Semuta claim/pass retires the original receipt once, after the committed fresh discard. No Homeworld, Discovery, E3, retrofit or public gate opens.',
+ '\n--profile semuta --homeworlds uses original fresh Basic/Advanced Richese with CHOAM and classic factions, exact CHOAM deck, at2–6 seats. Native Homeworld occupation setup and Tupile source history remain original. This bounded composition offers only paid high-Kaitain closing-Bidding discards and completed occupied-Tupile lost-slot cleanup; other Semuta/Homeworld producers remain guarded. No Nexus, Skills, Tech, Strongholds, Discovery, Ix/E3 mixing, retrofit or public gate opens.',
'\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Advanced uses distinct selected ecaz/ix/choam decks; Basic uses the exact ecaz deck with classic/optional Moritani opponents. Ordinary33/47/35 cards retain real original offers/HMS/Auditor/Face Dancers and Richese cache. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38 (Advanced only). Basic combined battles require an even Ecaz force count; the preserved publisher odd-force casualty-rounding conflict rejects before any lead choice. Native Richese ordinary and marker-only pools compose with selected lead plans; original own mixed No-Field dialing, whole-plan disclosure and optional overlays remain guarded.' +
'\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Normal Mentat questions use a uniform private card-show step in supported Leader Skills games. Banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.',
'\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards with optional unused Tech Tokens at 3–6 seats. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' +
@@ -64,6 +66,8 @@ async function main() {
throw new Error('--discoveries requires --profile leader-skills or nexus.');
if (values['leader-skills'] && values.profile !== 'semuta')
throw new Error('--leader-skills requires --profile semuta.');
+ if (values.homeworlds && values.profile !== 'semuta')
+ throw new Error('--homeworlds requires --profile semuta.');
if (
!isPrototypeProfile(values.profile) ||
!values.db ||
@@ -88,7 +92,7 @@ async function main() {
try {
const result = startPrototypeRoom(db, values.room, version, values.profile,
{ ecazTreachery: values['ecaz-treachery'], nexusCards: values['nexus-cards'], discoveries: values.discoveries,
- leaderSkills: values['leader-skills'] });
+ leaderSkills: values['leader-skills'], homeworlds: values.homeworlds });
writeFileSync(
resolve(values.out, 'prototype.json'),
JSON.stringify(
@@ -99,6 +103,7 @@ async function main() {
ecazTreachery: !!values['ecaz-treachery'],
nexusCards: !!values['nexus-cards'],
leaderSkills: !!values['leader-skills'],
+ homeworlds: !!values.homeworlds,
source,
beforeVersion: version,
...result,