From 235f763625ec44efaeefef6446e2c6894888c169 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:00:22 +1100 Subject: [PATCH] feat: connect Basic native Semuta Terror discard reactions --- components/game-table.tsx | 9 ++++ docs/CURRENT_STATUS.md | 17 +++++++ docs/IMPLEMENTATION_STATUS.md | 42 ++++++++++++++++ docs/RULE_DECISIONS.md | 1 + docs/SEMUTA_DISCARD_CONTINUATIONS.md | 9 ++++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 46 ++++++++++++++++-- docs/TERROR_DISCARD_CONTINUATIONS.md | 9 ++++ game/card-presentation.ts | 4 +- game/engine.ts | 24 +++++++-- game/reference.ts | 1 + tests/semuta-engine.test.ts | 70 +++++++++++++++++++++++++-- tools/start-prototype.ts | 1 + 12 files changed, 220 insertions(+), 13 deletions(-) diff --git a/components/game-table.tsx b/components/game-table.tsx index b7324192..ddd86b31 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1160,6 +1160,15 @@ export function GameTable({ {g.richeseBidding.offerBlocked} )} + {g.semutaPreview && ( +

+ Semuta Drug development preview · bounded fresh-discard reactions, + not complete Richese or certified expansion rules. Native Moritani + support is Basic only: clean shipment/movement Sabotage and Robbery + overflow; other arrival continuations remain guarded.{' '} + Preview rules and limits +

+ )} {g.nexusKullPreview ? (

CHOAM Nexus Cunning · Kull Wahad development preview. Any eligible diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 12ff69ef..16e99e08 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,23 @@ 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 Basic Terror — 11 October.** The local `semuta` entry now +admits fresh Basic native Richese/Moritani plus classics with CHOAM/Ecaz decks +and no optional modules. Private Sabotage and Robbery overflow after clean +ordinary shipment/movement arrivals offer the fresh card before the original +gift/arrival suffix; no victim, draw, payment or forces repeat. The persistent +table warning visibly qualifies the profile and Basic Moritani boundary. +Two native regressions fail before and pass after; types/lint and 80 affected +unit cases across eight files pass. CLI help prints the entry without store +access. Thirty-six runtime scenarios +cover both producers/arrival types, claim/pass, four legal AI profiles and +Sabotage gift accept/decline; four excluded-entry probes reject without mutation. +Real GameTable at 390px hides candidates, claims Stunner, gives Shield and ends +the original Atreides turn to Richese with Carthag2/reserves18 and no overflow. +Advanced Moritani, native Ecaz, Ix/E3 mixtures and other arrival parents remain +separate. Temporary QA scaffolds are removed; no saved store or deployed game was +targeted. See [source and entry scope](SEMUTA_SOURCE_UPDATE_20260907.md). + **Semuta / paid battle Box — 11 October.** A completed paid Nullentropy Box search now offers only the used Box during battle, preserving the payment, private acquired card, shuffled pile and opposing sealed plan. The regression diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index edca35e8..fb6b16ef 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,47 @@ # Dune implementation status +## 11 October 2026 — native Basic Semuta/Terror continuation + +The local `semuta` initializer now additionally admits Basic native +Richese/Moritani plus classics with exactly CHOAM/Ecaz decks and no optional +modules. The existing CLI profile delegates to the same initializer; help +documents the entry. Advanced Moritani, native Ecaz and Ix/E3 mixtures remain +excluded. Existing CHOAM/optional-Ix entries are unchanged. + +Private Sabotage and Robbery-overflow receipts after clean ordinary +shipment/movement arrivals now receive the existing neutral all-seat offer and +committed-only candidate access. Only admission changed: random victim/draw, +removed token, payment, physical entry and original gift/overflow suffix remain +native. Worm, Ambassador, other arrival causes, active movement cards and overlap +parents retain automatic continuation. The public projection now includes the +explicit `semutaPreview` configuration so GameTable persistently labels this +incomplete mode rather than implying certified expansion play. + +Two native setup/regression cases fail before and pass after, covering both +private producers, original optional gift or drawn-card custody, card +conservation, settled arrival resources and rejected retired/second-shipment +actions. Thirty-six isolated engine scenarios cover both producers and arrival +types, claim/pass, all four legal AI profiles and Sabotage gift accept/decline. +Each ends the original turn with exactly two Carthag counters and eighteen +reserves; movement entries keep their one completed move. Four fresh-lobby +probes reject Advanced, native Ecaz, missing Moritani and extra Ix without +mutating their inputs. + +Real production GameTable at 390px displays the profile warning, hides the +private face before commitment, claims Stunner, restores the original optional +Shield gift and finishes the Atreides turn to Richese. Arrival counters stay +2/reserves18; there is no horizontal overflow. The adapter only mounts the real +table and switches seats against memory-only state; no saved store was used. +Temporary scripts, isolated server and browser tab are removed/stopped. + +Types/lint and 80 affected unit cases across eight files pass. The actual +`start-prototype --help` command prints the documented entry without opening a +store. SQLite recovery/complete-game/deployed assurance is not claimed here. + +Contracts: [Semuta source/entry](SEMUTA_SOURCE_UPDATE_20260907.md), +[native Terror suffix](TERROR_DISCARD_CONTINUATIONS.md). Full Semuta/Moritani, +combinations and deployed acceptance remain open. + ## 11 October 2026 — Semuta after a paid battle Box search The completed `nullentropyDiscard` receipt already binds the acquired card, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 56057d29..f4a2e9b0 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -456,6 +456,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Mirror Weapon copied-card cleanup | The user selected ordinary winner retention for the physical Mirror even if its effective copy activates Tooth or Artillery. The original Tooth/Artillery cards retain their named mandatory-discard exceptions. This is a product interpretation, **not** a located publisher ruling. Independent copy-first Tooth/Stone choices and physical cleanup now have a bounded CHOAM/Richese-deck battle runtime; combined modules and full Richese games remain gated. | [Printed Mirror face and source audit](MIRROR_WEAPON_ENGINE_AUDIT.md#user-selected-cleanup-interpretation-28-september-2026) | | Kull Wahad preview timing | User selected a **distinct eligible counter** while the interrupted physical card stays reserved, and BG Worthless interception **before conversion/discard**. Pending declaration is not a ban; only success stamps turn/phase and retains original/special once-use. Decline or prevention resumes the original once. Old `kull` stays printed-only; fresh `nexus-kull` admits native CHOAM plus classic Basic/Advanced seats, CHOAM/Ix Treachery and Nexus alone, without physical Sandtrout. Nexus Use needs actual unallied CHOAM Nexus and canonical eligible own fuel: accepted declaration spends Nexus even if prevented, fuel stays held until success. Use requires source/event/card, with no old-source action shim or proactive Kull. Verified prior printed counter saves migrate only their selection binding; no new-choice retrofit. Reservations, promises and remaining winning-payment coverage constrain fuel/counters; ordinary retained Karama payment is not a canceled Cunning effect. Priority/custody/overbid are user policy; neutral timing/spend conventions are application policy. Bounded checks/CLI/runtime/phone cases pass; broader verification is Partial and no full/public/deployed gate opens. | [Selected preview policy](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026), [source/cost boundary](NEXUS_CHOAM_RULES.md#kull-source-cost-and-action-boundary), [runtime and remaining limits](CHOAM_KULL_DESIGN.md), [new evidence](NEXUS_CHOAM_RUNTIME.md#five-part-status-for-the-new-interaction) | | Semuta full-hand exchange | User selected **Allow exchange**: acquisition followed by Semuta disposal is an atomic net-zero hand exchange at the faction limit. Existing incoming reservations still consume capacity; this does not permit an already overfull hand or settle Box/Richese purchase timing. | [Selected capacity policy](SEMUTA_SOURCE_UPDATE_20260907.md#user-selected-full-hand-exchange--30-september-2026) | +| Semuta / native Basic Terror | Fresh local `semuta` additionally admits Basic Richese/Moritani plus classics, CHOAM/Ecaz decks and no optional modules. Private Sabotage/Robbery overflow after clean ordinary shipment/movement arrivals uses the existing neutral all-seat response and committed-only face access. Original victim/draw, removed token, payment, forces and optional gift remain original. Other arrival parents, Advanced Moritani and wider family mixtures remain implementation boundaries, not printed Semuta prohibitions. | [Source and bounded evidence](SEMUTA_SOURCE_UPDATE_20260907.md), [native suffix](TERROR_DISCARD_CONTINUATIONS.md) | Shipment and mixed current-fact/shipment AND/OR follow the FAQ's whole-question truth: Yes to AND requires all, Yes to OR requires one, and No negates the whole. diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 5076c369..c93f4cc6 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -44,6 +44,15 @@ used Box is fresh; payment, private acquisition, shuffled pile and original battle remain committed. Claim or decline restores clean planning without repeating the search, shuffle or payment. Competing parents remain separate. +The local Semuta entry additionally admits Basic native Richese/Moritani with +classic opponents, CHOAM/Ecaz decks and no optional modules. Private Sabotage and +Robbery overflow after clean ordinary shipment/movement arrivals now offer the +fresh card before the original suffix. No face is projected before commitment. +The random victim/draw, removed token, payment and physical arrival remain +committed; Sabotage's optional gift is restored afterward. Other arrival parents +retain their automatic paths. Advanced Moritani and wider family/module mixtures +are not admitted by this entry. + Clean Once Around Sapho stages `saphoAuctionDiscard` after the open lot's finite bidder reorder, hand-slot release and public card disposal, but before settlement. The saved receipt binds the lot, prior funding, diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index e85b85e3..936bca35 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -76,8 +76,9 @@ Do not ask again about normal targets, forced-discard owner bookkeeping, simulta ## 30 September 2026 — bounded clean-discard runtime The explicit `semuta` development profile starts a fresh Richese roster -with CHOAM & Richese and optionally the physical Ix deck, without -opening public expansion starts. It interrupts **clean public +with CHOAM & Richese and optionally the physical Ix deck, or the bounded Basic +native Moritani entry described below, without opening public expansion starts. +It interrupts **clean public ordinary-card, paid Box, retired Ornithopter, used Distrans, final Truthtrance, four bounded Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene @@ -86,7 +87,8 @@ CHOAM sale or clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless discard, accepted moving-storm Jubba Cloak, battle and Ix-deck Thumper/Amal discards**, plus a clean **private** normal-auction or Black Market Ixian ally replacement discard -before its draw. Other +before its draw, or native Sabotage/Robbery overflow after a clean ordinary +shipment/movement arrival in the Basic Richese/Moritani entry. Other than the one explicitly suspended charity/Inflation response, the parent transaction has no competing response. Every seat sees the same neutral fresh-discard opportunity @@ -245,6 +247,44 @@ separate [reaction privacy audit](SEMUTA_PRIVACY_REVIEW.md) explains why auto-closing only when nobody secretly holds the card would leak possession. The main game and public expansion gates remain closed. +**11 October 2026 — native Basic Terror discards.** The same local `semuta` +entry additionally admits fresh Basic native Richese/Moritani with classic +opponents, exactly CHOAM/Ecaz decks and no optional modules. Native setup remains +genuine; a persistent table warning explicitly qualifies Semuta and the Basic +Moritani arrival scope. Existing CHOAM/optional-Ix entries are unchanged. +Advanced Moritani, native Ecaz and Ix/E3 mixtures remain outside this entry. + +Private Sabotage and Robbery-overflow `terrorDiscard` receipts now offer Semuta +after clean ordinary shipment/movement arrivals. The random victim or original +Robbery draw, removed token, payment and physical arrival already commit. Every +seat sees the same neutral response; no candidate face is shown before commitment. +Claim or all-seat decline restores the original optional Sabotage gift or closes +the existing Robbery overflow, never rerolling, drawing or arriving again. +Worm, Ambassador, advisor/Guild-transport, active Ornithopter and overlap parents +retain their automatic paths; this is implementation coverage, not a printed ban. +The existing Semuta printed face/FAQ source has no forced-discard or phase +exclusion; no new publisher timing or capacity ruling is asserted. + +Two permanent native regressions fail at the old entry guard and pass after, +covering the private receipt, claim/pass, original gift/draw and token, conserved +cards and arrival resources, and retired-event/second-shipment rejection. +Thirty-six isolated engine scenarios cover both producers and shipment/movement, +claim/pass and all four legal AI profiles, with Sabotage gift acceptance/decline. +Each finishes the original turn with Carthag11 forces2/reserves18 and the original +movement count. Four rejected fresh-lobby probes cover Advanced, native Ecaz, +missing Moritani and an extra Ix family without mutating their inputs. + +Real production GameTable at 390px displays the persistent preview qualification +and no candidate face, claims the private Stunner, restores Moritani's optional +Shield gift, then finishes the original Atreides turn to Richese with exactly two +Carthag forces and eighteen reserves. There is no horizontal overflow. The QA +adapter only switches seats around the real controls in memory; no saved store +or deployed game was targeted. Temporary scripts/server/browser are removed or +stopped. These are bounded prototype proofs, not complete Semuta/Moritani or +deployed acceptance. +Types/lint and 80 affected unit cases across eight files pass; actual CLI help +prints the entry without store access. + **11 October 2026 — paid battle Box.** A completed paid Nullentropy Box search now offers the used Box during battle. Only the battle-presence guard gained that receipt exception; payment, private acquisition, shuffled pile and diff --git a/docs/TERROR_DISCARD_CONTINUATIONS.md b/docs/TERROR_DISCARD_CONTINUATIONS.md index fe3f5625..bb3eb9a5 100644 --- a/docs/TERROR_DISCARD_CONTINUATIONS.md +++ b/docs/TERROR_DISCARD_CONTINUATIONS.md @@ -12,6 +12,15 @@ The saved `terrorDiscard` continuation contains source, Moritani owner, detached The suffix retires the discard sequence before restoring the arrival. Worm-ride completion continues through the existing `finishTerrorEntry`/`nextWormRide` path. A Nexus deferred by an intervening summoned worm still opens before the next ride or phase advancement. +11 October 2026 Semuta update: the explicit local entry admits Basic native +Richese/Moritani with classic opponents, CHOAM/Ecaz decks and no optional modules. +Sabotage and Robbery-overflow receipts after clean ordinary shipment/movement +arrivals now offer a private fresh-discard reaction. Claim or all-seat decline +restores the original gift/overflow suffix without repeating the victim, draw, +token reveal, payment or physical arrival. Faces stay hidden before commitment. +Worm/Ambassador/other nested arrivals retain automatic continuation. The table +persistently labels this incomplete development profile; no public gate opens. + ## Validation and privacy Read, action and automatic recovery all verify one private fresh card receipt, exact custody, current turn/phase, matching removed token and source, seated former owner and Moritani, stronghold/sector, typed arrival counts, pre-discard hand size and the appropriate consumed stage. Robbery validates that the hand exceeded its limit before the discard; four cards afterward is valid. diff --git a/game/card-presentation.ts b/game/card-presentation.ts index 98df59e2..c9fb6cce 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 or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Battle discards may pause before native Advanced Harkonnen capture; capture resumes after cleanup. 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 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 or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Battle discards may pause before native Advanced Harkonnen capture; capture resumes after cleanup. A Basic Richese/Moritani CHOAM/Ecaz profile also reacts to private Sabotage and Robbery overflow after clean shipment/movement arrivals. The approved full-hand exchange spends Semuta without increasing the completed hand. Other pending rewards, producers and nested responses remain unfinished.'; return definition && definition.card.effect !== 'karama' ? 'This Richese card’s effect is not implemented yet. You can inspect its rules.' : null; @@ -92,7 +92,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation { richese.card.effect === 'juiceOfSapho' ? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first or last while your bid remains open, bounded movement and battle-choice ordering, and aggressor during the shared pre-plan opportunity. Earlier bids stay committed. Later aggressor intervention and other phase or auction modes remain unfinished; expansion starts remain disabled.' : richese.card.effect === 'semutaDrug' - ? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before native Advanced Harkonnen capture) and private normal-auction or Black Market Ixian ally replacements. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Other pending rewards, producers, nested responses and normal expansion starts remain unfinished.' + ? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before native Advanced Harkonnen capture) and private normal-auction or Black Market Ixian ally replacements. The Basic Richese/Moritani CHOAM/Ecaz entry adds private Sabotage and Robbery overflow after clean shipment/movement arrivals, preserving the original victim/draw and arrival suffix. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Other pending rewards, producers, nested responses and normal expansion starts remain unfinished.' : richese.card.effect === 'mirrorWeapon' ? 'Mirror Weapon works in bounded CHOAM/Richese-deck classic-faction battles; copied Tooth and Stone choices resolve separately, and a winning physical Mirror may be kept even after copying Tooth or Artillery. This winner-retention treatment is a user-selected interpretation, not an official clarification. Combined modes and full Richese starts remain gated.' : richese.card.effect === 'stoneBurner' diff --git a/game/engine.ts b/game/engine.ts index df5c4845..47dd906e 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6649,6 +6649,7 @@ function semutaOfferSupported( if (continuation.kind !== 'ordinaryCardDiscard' && continuation.kind !== 'nullentropyDiscard' && continuation.kind !== 'distransDiscard' && + continuation.kind !== 'terrorDiscard' && continuation.kind !== 'saphoMovementDiscard' && continuation.kind !== 'saphoAggressorDiscard' && continuation.kind !== 'saphoAuctionDiscard' && @@ -6690,6 +6691,12 @@ function semutaOfferSupported( g.pendingFaceDance || g.pendingChoamBattleIncome || g.pendingWinnerDiscards || g.moritaniRetention || g.pendingIxSubstitution)) return false; + if (continuation.kind === 'terrorDiscard' && + (continuation.entry.resume !== 'none' || + (continuation.entry.cause !== 'shipment' && continuation.entry.cause !== 'movement') || + g.pendingArrivalOverlap || g.pendingChoamMove || g.pendingIxMove || + g.pendingFremenMove || g.ornithopter || g.summonedWorm || + g.wormRides.length > 0)) return false; if ((continuation.kind === 'distransDiscard' || continuation.kind === 'truthtranceDiscard' || continuation.kind === 'saphoMovementDiscard' || @@ -6716,7 +6723,7 @@ function semutaOfferSupported( return g.semutaPreview === true && g.status === 'playing' && g.players.some(p => p.faction === 'richese') && entries.length > 0 && - (continuation.kind === 'ixAllyCard' + (continuation.kind === 'ixAllyCard' || continuation.kind === 'terrorDiscard' ? entries.length === 1 && !entries[0].publicFace : continuation.kind === 'battleCleanup' || continuation.kind === 'winnerMandatoryDiscard' || @@ -6735,6 +6742,7 @@ function semutaOfferSupported( continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleResolved' || continuation.kind === 'ixAllyCard' || + continuation.kind === 'terrorDiscard' || continuation.kind === 'choamSaleDiscard' || continuation.kind === 'choamKulonDiscard' || continuation.kind === 'choamLaLaLaDiscard' || @@ -9527,14 +9535,19 @@ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game { initializeMoritaniAssassinateState(g); return g; } -/** Development-only Semuta reaction in fresh Richese games, optionally with - * the physical Ix Treachery deck; public expansion starts stay gated. */ +/** Development-only Semuta in fresh Richese games: optional Ix deck, or a + * bounded Basic native Moritani/E3 profile. Public expansion starts stay gated. */ export function initializeSemutaGameForAudit(state: Game): Game { + const nativeTerror = !state.advanced && state.expansions.length === 2 && + state.expansions.includes('choam') && state.expansions.includes('ecaz') && + state.players.some(p => p.faction === 'moritani') && + state.players.every(p => p.faction === 'richese' || p.faction === 'moritani' || + faction(p.faction).expansion === 'base'); requireRule(!state.semutaPreview && state.expansions.includes('choam') && - state.expansions.every(id => id === 'choam' || id === 'ix') && + (state.expansions.every(id => id === 'choam' || id === 'ix') || nativeTerror) && state.players.some(p => p.faction === 'richese'), - 'Semuta needs a fresh Richese faction game with no optional modules.'); + 'Semuta needs a fresh Richese faction game with no optional modules: CHOAM with optional Ix, or Basic native Moritani with CHOAM and Ecaz decks and classic opponents.'); const g = initializeFactionExpansionsGameForAudit(state); g.semutaPreview = true; return g; @@ -32104,6 +32117,7 @@ export function viewGame(state: Game, id: string) { schema: g.schema, botsPending: g.botsPending ?? false, semutaReaction: projectedSemutaReaction(g, me), + semutaPreview: g.semutaPreview === true, automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || homeworldOccupiedIncomeAutomatic(g) || grummanCollectionAutomatic(g) || tupileAutomatic(g), botNextActionAt: g.botNextActionAt ?? null, code: g.code, diff --git a/game/reference.ts b/game/reference.ts index e2710ee1..4a4e37b9 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1175,6 +1175,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Mandatory and winner battle discards also pause before native Advanced Harkonnen capture. Claim or Continue completes the original casualties and card cleanup before the capture offer; it does not sample a captive early or repeat capture. Other pending rewards remain outside this bounded path.', '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.', + 'The same local semuta entry also admits Basic native Richese and Moritani with classic opponents, exactly CHOAM/Ecaz decks and no optional modules. A clean shipment/movement Sabotage or Robbery overflow offers its private fresh discard without showing the face before commitment. Claim or Continue preserves the original victim/draw, removed token, shipment payment and forces; Sabotage may then offer its original optional gift. Advanced Moritani, native Ecaz, Ix/E3 mixtures and worm/Ambassador/overlap arrivals remain outside this bounded entry. The table visibly labels the development preview.', ] : []), ], ...(definition.card.effect === 'semutaDrug' diff --git a/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts index 4c3a0137..23eab89b 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -7,6 +7,7 @@ import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { spiceDeck } from '../game/cards'; import { saphoAggressorGame, aggressorAction, prepareAggressorPlans } from './fixture-sapho-aggressor'; import { takeSaphoBattleCard } from './fixture-sapho-battle-order'; +import { createTerrorState, placeTerror } from '../game/moritani-terror'; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); const player = (g: Game, id: string) => g.players.find(p => p.id === id)!; @@ -23,10 +24,11 @@ function inventory(g: Game) { assert.equal(new Set(ids).size, ids.length); return ids; } -function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emperor' | 'beneGesserit' | 'harkonnen' = 'emperor') { - let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), advanced, ix ? ['choam', 'ix'] : ['choam']); +function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emperor' | 'beneGesserit' | 'harkonnen' | 'moritani' = 'emperor') { + let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), advanced, + opponent === 'moritani' ? ['choam', 'ecaz'] : ix ? ['choam', 'ix'] : ['choam']); joinGame(g, newPlayer('a', 'Atreides', 'atreides')); - joinGame(g, newPlayer('e', ixianAlly ? 'Ixians' : opponent === 'beneGesserit' ? 'Bene Gesserit' : opponent === 'harkonnen' ? 'Harkonnen' : 'Emperor', + joinGame(g, newPlayer('e', ixianAlly ? 'Ixians' : opponent === 'beneGesserit' ? 'Bene Gesserit' : opponent === 'harkonnen' ? 'Harkonnen' : opponent === 'moritani' ? 'Moritani' : 'Emperor', ixianAlly ? 'ixians' : opponent)); for (const p of g.players) { p.bot = 'Medium'; p.ready = true; } g = initializeSemutaGameForAudit(g); @@ -50,6 +52,68 @@ function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emp return { g, semuta, hajr }; } +for (const kind of ['sabotage', 'robbery'] as const) void test( + `Semuta takes the private ${kind} discard before the original native arrival suffix`, () => { + const { g, semuta } = fixture(false, false, false, 'moritani'); + for (const id of ['a', 'e']) Object.assign(player(g, id), { forces: {}, reserves: 20, shipped: false, moved: 0 }); + const gift = take(g, g.deck.find(c => c.kind === 'shield')!.id); + if (kind === 'sabotage') { + player(g, 'e').hand.push(gift); + for (let i = 0; i < 2; i++) player(g, 'a').hand.push(take(g, g.deck.find(c => c.kind === 'projectile')!.id)); + } else { + g.deck.push(gift); + for (let i = 0; i < 4; i++) player(g, 'e').hand.push(take(g, + g.deck.find(c => !c.effect || c.kind === 'worthless')!.id)); + } + g.moritaniTerror = createTerrorState(() => 0); + const token = g.moritaniTerror.tokens.find(t => t.kind === kind)!; + g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'carthag', g.turn); + const original = inventory(g); + let state = applyAction(g, 'a', { type: 'ship', territory: 'carthag', sector: 11, amount: 2 }); + state = applyAction(state, 'e', { type: 'decision', reveal: true }); + let drawn: string | undefined; + if (kind === 'robbery') { + drawn = state.deck[0].id; + state = applyAction(state, 'e', { type: 'decision', choice: 'card' }); + assert.equal(player(state, 'e').hand.some(c => c.id === drawn), true); + state = applyAction(state, 'e', { type: 'decision', card: player(state, 'e').hand[0].id }); + } + assert.equal(state.pendingTreacheryDiscard?.continuation.kind, 'terrorDiscard'); + assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(state.pendingTreacheryDiscard!.batch.entries[0].publicFace, false); + const victim = state.pendingTreacheryDiscard!.batch.entries[0].card.id; + const event = state.pendingTreacheryDiscard!.batch.event; + assert.deepEqual(viewGame(state, 'r').semutaReaction?.candidates, []); + const settled = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice, + forces: p.forces, reserves: p.reserves, tanks: p.tanks, shipped: p.shipped, moved: p.moved })); + let declined = reload(state); + for (const id of ['a', 'e', 'r']) declined = applyAction(declined, id, { type: 'semutaPass', event }); + const claimed = applyAction(reload(state), 'r', { type: 'semutaCommit', event }); + for (let resumed of [declined, claimed]) { + assert.deepEqual(settled(resumed), settled(state)); + assert.equal(resumed.moritaniTerror!.tokens.find(t => t.id === token.id)!.status, 'removed'); + assert.throws(() => applyAction(resumed, 'r', { type: 'semutaCommit', event })); + if (kind === 'sabotage') { + assert.equal(resumed.pendingTerrorEntry?.stage, 'gift'); + resumed = applyAction(resumed, 'e', { type: 'decision', card: gift.id }); + assert.equal(player(resumed, 'a').hand.some(c => c.id === gift.id), true); + assert.equal(player(resumed, 'a').hand.length, 3); + } else { + assert.equal(player(resumed, 'e').hand.length, 4); + assert.equal(player(resumed, 'e').hand.some(c => c.id === drawn), true); + } + assert.equal(resumed.pendingTerrorEntry, null); + assert.equal(resumed.active, 'a'); + assert.equal(player(resumed, 'a').forces['carthag:11'], 2); + assert.equal(player(resumed, 'a').reserves, 18); + assert.deepEqual(inventory(resumed), original); + assert.throws(() => applyAction(resumed, 'a', { type: 'ship', territory: 'carthag', sector: 11, amount: 2 })); + } + assert.equal(player(claimed, 'r').hand.some(c => c.id === victim), true); + assert.equal(claimed.discard.some(c => c.id === semuta.id), true); + assert.equal(declined.discard.some(c => c.id === victim), true); + }); + void test('private Ixian ally discard offers Semuta before replacement without revealing its face', () => { const { g, semuta, hajr } = fixture(true, true); player(g, 'a').hand = []; diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 3d9283f6..90fb84d0 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -24,6 +24,7 @@ async function main() { if (values.help) { console.log( 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|homeworld-occupation|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|ecaz-occupy|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced roster with at least one selected native Ixians, Tleilaxu, CHOAM or Richese faction and otherwise classic opponents into Stronghold Cards, optionally preserving unused Tech Tokens at 3–6 seats. Select one or both distinct Ix/CHOAM decks; each native faction requires its family deck. The selected decks supply 47 Ix or combined, or 35 CHOAM canonical physical Treachery Cards, with a separate ten-card Richese cache when Richese plays. Native setup choices, HMS placement, Face Dancers, Richese cache and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced base-deck roster, supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill roster with its single required deck, or Advanced native Ixians, Tleilaxu and/or CHOAM plus classic roster with distinct required family decks, into automatic deferred normal income and native Mentat collection with all fourteen Leader Skills. Original bank payer costs exclude the separate Tleilaxu free-revival award; already-earned spice stays with its original faction under the existing provisional policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced base-deck roster, or Basic Tleilaxu plus classic opponents with the original Ix47 deck and no optional Sandtrout, into the exact normal-auction purchased-card replacement with Nexus alone. Native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded except for each profile’s documented composition. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', + '\nSemuta additionally admits a fresh Basic roster with native Richese and Moritani, classic opponents, exactly CHOAM and Ecaz decks, and no optional modules. Native setup remains genuine. Private Sabotage and Robbery overflow discards may offer Semuta before a clean ordinary shipment/movement arrival resumes. The victim/draw and original payment/forces are already committed; candidate faces stay hidden until commitment. Advanced Moritani, native Ecaz, Ix/E3 mixtures, worm/Ambassador/overlap arrivals and public starts remain outside this entry.', '\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.' +