diff --git a/components/game-table.tsx b/components/game-table.tsx index 7070733e..7ee79f19 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1168,7 +1168,11 @@ export function GameTable({ winner’s award follows card cleanup and precedes Face Dance. Original Stronghold Cards may remain in Advanced 2–6-seat profiles and are claimed only at the end of Mentat. The explicit Leader Skills entry retains all - fourteen original skills and native training; other overlays stay guarded. + fourteen original skills and native training. The explicit Nexus entry + retains all twelve original Nexus cards and qualifying closing draws, + never a starting Nexus hand. CHOAM Secret Ally trade and native CHOAM + Betrayal discards can react before their original receipts complete; + Homeworlds, Discovery and other overlays stay guarded. Native Moritani support is Basic only: clean shipment/movement Sabotage and Robbery overflow plus native losing-ally card-retention cleanup; other arrival diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index db2c61bd..4cba64de 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,30 @@ 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 / original native Nexus — 11 October.** Fresh `semuta --nexus-cards` +delegates original single/paired CHOAM or both selected E1/E2 setup, optionally +explicit Skills and original Tech/Advanced Strongholds. There is no starting +Nexus hand: actual turn-two worm, reciprocal alliance and unallied closing draws +precede the changed producers. CHOAM-absent Secret Ally trade pays two bank spice +and spends both cards before its public Worthless offer; seated-CHOAM Betrayal +commits one server-random physical hand card before its private offer. +Semuta claim/pass retires the same original receipt without payment/sampling +again. Existing singleton-at-Commit behavior remains; an incorrect test-only +manual selection assumption was removed, not added to runtime. +Types/lint and119 affected unit cases across10 files pass. Eight original entry +scenarios, two memory-only prototype option dispatches and sixteen actual +reaction scenarios cover claim/pass, full hands and four legal profiles. +Styled390px human trade→Commit→all Collection readiness reaches Mentat with +Atreides12 spice and Richese's acquired Trip to Gamont at4/4. Native +Betrayal→Commit acquires the exact Snooper, leaves CHOAM Maula Pistol/Kulon and +enables original Charity readiness; both enlarged card inspectors work. +Post-action frames measure390px width; initial four-seat snapshots measured +554px scroll width, so this is not full presentation acceptance. +Live preview guidance now names original Nexus. All probes/tabs/services removed; +existing games/stores, user PDF, deployment and public gates remain untouched. +See [Nexus composition](NEXUS_CARD_RULES.md#semuta-with-original-native-nexus--11-october-2026) +and [Semuta ordering](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime). + **Semuta / trained Ixian response ownership — 11 October.** Newly admitted native Ixian skills exposed a real projection failure: during a printed substitution-cancellation cost, the validator ignored the response saved in diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index b134c90f..b8980fd3 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,58 @@ # Dune implementation status +## 11 October 2026 — original native Nexus and Semuta discard receipts + +`initializeSemutaGameForAudit` delegates existing unused original Nexus setup +when selected; paired CHOAM/Richese uses its original paired initializer, +single or both selected E1/E2 native families use the generic initializer. +Explicit Skills remains first and keeps original all14 prerequisites. +`--profile semuta --nexus-cards`, optionally `--leader-skills`, uses the same +private fresh-room dispatch, never a player-action gate or played-save retrofit. +Three original no-Skills Nexus predicates and the shared skill/Nexus module +predicate recognize only explicit native-Richese Semuta context; original +roster/module checks remain authoritative. Homeworlds, Discovery/E3 and +unrelated overlays remain excluded. + +The existing `nexusChoamTrade` and `nexusChoamBetrayal` continuations now enter +Semuta's bounded allowlist. Betrayal uses the original private-single-entry +branch; both bypass unrelated suspended-response fields. Neither original +producer nor receipt retirement changed: trade has already paid two bank spice, +spent Nexus and discarded its Worthless; Betrayal has already spent Nexus and +uniformly sampled/removed the native CHOAM card without compensation. +Foreign acquisition cannot repeat either original effect. + +Two disjoint lanes supplied genuine four-seat fixture/regression files. +Original native setup/first Storm and real first turn precede reordered +still-unplayed turn-two worm, reciprocal alliance and qualifying closing draw. +Only afterward are original Nexus/Treachery identities moved between conserved +slots. No phase, turn, wallet, starting held Nexus, skill training or module +ownership is fabricated. The initial trade tests wrongly assumed a second +singleton selection: existing Commit completes automatically. The helper and +bot cases now exercise that behavior; its obsolete manual-selection test was +deleted rather than changing runtime to satisfy it. + +Types/lint pass; affected union119/119 across10 unit files. Eight real +2/3/6-seat setup/first-Storm scenarios cover single/paired/mixed original +entry envelopes; two actual SQLite `:memory:` option dispatches save version1 +with original Traitors or Skills and no Nexus hand. Sixteen native reaction +scenarios cover Basic/Advanced claim/pass/full hands and four legal profiles. +Actual CLI help executes. Styled390px human trade→Commit→original four-seat +Collection readiness reaches Mentat: original trader12 spice, spent Nexus, +Richese4/4 with acquired Trip to Gamont. Native Betrayal→Commit acquires exact +server-sampled Snooper, leaves CHOAM Maula Pistol/Kulon, and original Charity +readiness works. Actual acquired-card and printed CHOAM Nexus inspectors render. +Initial four-seat snapshots measured554px scroll width; post-action frames390, +so full mobile presentation acceptance remains open. + +The isolated QA harness initially omitted image-shim optimizer environment +defines. Installed Vite8 uses Rolldown, and its optimizer needed the same +development defines independently of regular transform defines. Corrected +probe used actual installed Vinext image shim, actual Tailwind PostCSS and real +GameTable/engine; this was not an application-rule failure. Updated Semuta +preview guidance was reinspected. All throwaways, own service and tab removed. +No existing game/store, user PDF, provider/deployment or public gate changed; +no comprehensive save/privacy/custody campaign was run. + ## 11 October 2026 — trained Ixian response retained across Semuta cost The explicit skill entry makes the formerly unadmitted receipt path reachable. diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 0fe567dd..eb9261fd 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -10,8 +10,11 @@ records the fresh publisher/designer search requested by the user. `--profile semuta --leader-skills` reuses original all14 native setup for Basic Richese/CHOAM with exact CHOAM, or Advanced E1/E2 with native Richese and selected CHOAM/optional Ix. Original Tech3+ and Advanced Strongholds2+ may remain. Default -Semuta callers are unchanged. Skills do not add Homeworld/Nexus/Discovery/E3, -Banker overlays, Basic Ix mixtures, public starts or played-save conversion. +Semuta callers are unchanged. Adding `--nexus-cards` uses the original +single/paired CHOAM or both selected E1/E2 Nexus/Skills envelope, retaining +the actual qualifying closing draw and no starting Nexus hand. Without that +original envelope, Basic Ix mixtures remain excluded. Homeworlds, Discovery/E3, +Banker overlays, public starts and played-save conversion stay gated. Actual offers and training precede real phase/battle controls. Completed Hajr claim/pass resumes the original Movement owner, then the approved normal @@ -32,6 +35,14 @@ Shield cleanup; normal private inspection remains inherited, not suppressed. No comprehensive privacy/custody/recovery campaign or mode certification is claimed by these changed-path proofs. +The Nexus follow-up retains original all14 training through public CHOAM +Secret Ally trade and private native CHOAM Betrayal discard reactions. +Semuta never re-pays the two-spice trade or re-samples Betrayal. Actual +Advanced Skills/Tech/Strongholds controls and full-hand exchanges reuse the +original singleton-at-Commit rule; there is no extra selection acknowledgement. +See [Nexus composition](NEXUS_CARD_RULES.md#semuta-with-original-native-nexus--11-october-2026). + + ## Mixed E1/E2 and paired E3 Nexus — 7 October 2026 diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 8d6c01e4..167891a6 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -2,6 +2,26 @@ Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module. +## Semuta with original native Nexus — 11 October 2026 + +Fresh `semuta --nexus-cards` composes native Richese with the original +single/paired CHOAM or both selected E1/E2 Nexus envelope, Basic/Advanced2–6. +Optional explicit `--leader-skills`, Tech3+ and Advanced Strongholds2+ keep +their original prerequisites. Actual native setup and a qualifying end-Spice +draw remain mandatory: no starting Nexus hand, Homeworlds, Discovery, E3, +played-game retrofit or public mode gate is introduced. + +The two existing CHOAM discard producers retain their printed mode distinction: +Secret Ally with CHOAM absent spends a Worthless and Nexus for two bank spice +during Collection; Betrayal with CHOAM seated spends its Nexus and commits a +uniformly random native CHOAM held Treachery discard without compensation. +Their original semantic receipts now offer Semuta claim/pass before retirement. +Public trade cost and private Betrayal face keep their existing disclosure +boundaries; the victim/former controller is the Betrayal discarder. +Acquisition does not reroll, repeat payment, undo Nexus spending or create a +Treachery copy of the Nexus card. Original source/component authority above +is unchanged; this is composition, not a new timing or capacity ruling. + ## Mixed E3 and three-family Nexus — 7 October 2026 The original `nexus` and `leader-skills --nexus-cards` entries, and the diff --git a/docs/NEXUS_CHOAM_SECRET_ALLY.md b/docs/NEXUS_CHOAM_SECRET_ALLY.md index f9236408..881fa85e 100644 --- a/docs/NEXUS_CHOAM_SECRET_ALLY.md +++ b/docs/NEXUS_CHOAM_SECRET_ALLY.md @@ -28,6 +28,19 @@ exchange, or queued automatic Collection choice. It may still be used while its owner is ready if Collection has not ended. Exhaustive simultaneous Collection combinations remain part of the later integration pass. +### Semuta composition — 11 October 2026 + +The fresh native Richese `semuta --nexus-cards` entry retains original all12 +Nexus, optionally all14 Skills, original Tech3+ and Advanced Strongholds2+. +The original qualifying closing draw is required; there is no starting hand. +The trade remains the absent-CHOAM Secret Ally panel, never native Cunning. +Its two bank spice and both card costs are committed before Semuta's neutral +public Worthless opportunity. Claim or Continue completes the original receipt +once and cannot pay another two spice or reclaim the separate Nexus component. +Full-hand Semuta uses the existing user-selected atomic exchange. +Homeworlds, Discovery, E3, public starts and played-save conversion stay gated. + + ## Connected paths - `game/nexus-choam-trade.ts` provides the private quote and saved receipt checks. diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 76fe54b6..3767e4c3 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -96,8 +96,33 @@ module cards never become Treachery discard candidates. argument) composes original all14 training in native Basic Richese/CHOAM with exact CHOAM, or Advanced E1/E2 with native Richese and selected CHOAM/optional Ix decks. Original Tech/Advanced Stronghold prerequisites remain. This does not -open Basic Ix mixtures, E3, Homeworld/Nexus/Discovery/Banker, unapproved -empty-hand/Moritani Mentat steps or general public starts. +open Basic Ix mixtures without their original Nexus envelope, E3, +Homeworld/Discovery/Banker, unapproved empty-hand/Moritani Mentat steps or public starts. + +**Original Nexus composition — 11 October 2026.** + +`--profile semuta --nexus-cards` delegates the original unused native Nexus +initializer; adding `--leader-skills` delegates its original all14 training. +Single or paired CHOAM and both selected E1/E2 native-family envelopes retain +their original roster, two-through-six-seat and component prerequisites. +Original Tech requires three or more seats; Strongholds requires Advanced. +No starting Nexus hand is added. Actual setup and first Storm precede a +qualifying Nexus, settled alliance and unallied closing draw. +Homeworlds, Discovery, E3, played-save conversion and public starts stay excluded. + +CHOAM Secret Ally trade remains available only with CHOAM absent and the +holder unallied during Spice Collection. It spends the printed Nexus and +selected actual Worthless, paying two bank spice before the public fresh +discard. Semuta claim/pass completes that same receipt without paying again. + +With native CHOAM seated, Betrayal spends an unallied rival's actual CHOAM +Nexus and commits one uniformly server-sampled held CHOAM Treachery Card. +The neutral Semuta opportunity retains its private fresh face until commitment; +the former CHOAM controller remains the discarder, even when a different +player invoked Betrayal. Claim/pass completes the same sampled receipt. +CHOAM receives no compensation and the random sample is never repeated. +Nexus cards are separate components and never become Treachery targets. + diff --git a/game/engine.ts b/game/engine.ts index 59d6a4ba..632a16bf 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -5525,13 +5525,21 @@ function suspendedControlsIntegrity( strongholdIntegrity(context); auditorIntegrity(context); } +/** Original native Nexus roster/modules with no extra overlay-specific setup. */ +function semutaNexusProfile(g: Game): boolean { + return !!g.nexusCards && (g.leaderSkills + ? pairedNexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g) || + mixedE1E2NexusLeaderSkillsProfile(g) + : pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g) || mixedE1E2NexusProfile(g)); +} function treacheryDiscardIntegrity(g: Game) { requireRule(g.semutaPreview === undefined || g.semutaPreview === true && g.expansions.includes('choam') && !!byFaction(g, 'richese') && - !g.homeworlds && !g.nexusCards && + !g.homeworlds && (!g.nexusCards || semutaNexusProfile(g)) && (!g.leaderSkills || ordinaryLeaderSkillModeSupported(g)) && !g.discoveries && - !g.discoveryEnabled && (!g.techTokens || (g.leaderSkills - ? nativeTechLeaderSkillsProfile(g) : nativeFactionTechProfile(g, true))) && + !g.discoveryEnabled && (!g.techTokens || (g.nexusCards + ? semutaNexusProfile(g) : g.leaderSkills + ? nativeTechLeaderSkillsProfile(g) : nativeFactionTechProfile(g, true))) && (!g.strongholdCards || nativeStrongholdFactionRoster(g)), 'This Semuta preview no longer matches its original Richese profile.'); const pending = g.pendingTreacheryDiscard; @@ -6700,6 +6708,8 @@ function semutaOfferSupported( continuation.kind !== 'nullentropyDiscard' && continuation.kind !== 'distransDiscard' && continuation.kind !== 'terrorDiscard' && + continuation.kind !== 'nexusChoamTrade' && + continuation.kind !== 'nexusChoamBetrayal' && continuation.kind !== 'saphoMovementDiscard' && continuation.kind !== 'saphoAggressorDiscard' && continuation.kind !== 'saphoAuctionDiscard' && @@ -6777,7 +6787,8 @@ 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 === 'ixAllyCard' || continuation.kind === 'terrorDiscard' || + continuation.kind === 'nexusChoamBetrayal' ? entries.length === 1 && !entries[0].publicFace : continuation.kind === 'battleCleanup' || continuation.kind === 'winnerMandatoryDiscard' || @@ -6800,6 +6811,8 @@ function semutaOfferSupported( continuation.kind === 'battleResolved' || continuation.kind === 'ixAllyCard' || continuation.kind === 'terrorDiscard' || + continuation.kind === 'nexusChoamTrade' || + continuation.kind === 'nexusChoamBetrayal' || continuation.kind === 'choamSaleDiscard' || continuation.kind === 'choamKulonDiscard' || continuation.kind === 'choamLaLaLaDiscard' || @@ -9604,8 +9617,8 @@ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game { initializeMoritaniAssassinateState(g); return g; } -/** Development-only Semuta with original native Tech/Advanced Strongholds and - * explicit native skills, or bounded Basic Moritani/E3. Public starts stay gated. */ +/** Development-only Semuta with original native Tech/Advanced Strongholds/Nexus + * and explicit native skills, or bounded 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') && @@ -9616,8 +9629,13 @@ 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, 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, 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.'); + 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) + : state.nexusCards ? state.expansions.length === 1 && !!byFaction(state, 'choam') + ? initializePairedNexusGameForAudit(state) : initializeNexusGameForAudit(state) : state.strongholdCards ? initializeStrongholdFactionsGameForAudit(state) : initializeFactionExpansionsGameForAudit(state); g.semutaPreview = true; diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index b9c5a727..3d499482 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -41,7 +41,9 @@ function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassinatio (!game.discoveries && !game.discoveryEnabled && !game.discoveryStash && !game.greatMaker)) && (!game.techTokens || game.players.length >= 3) && (!game.strongholdCards || game.advanced === true) && - !game.ecazTreachery && !game.semutaPreview && + !game.ecazTreachery && (!game.semutaPreview || + (semutaLeaderSkillContext(game) && !game.homeworlds && + !game.discoveryEnabled && !game.discoveries)) && (!game.moritaniAssassinatePreview || assassination) && !game.advancedPreview && !game.kullPreview && !game.nexusKullPreview && !game.guildBetrayalPreview && !game.richeseBetrayalPreview && diff --git a/game/nexus-module-profile.ts b/game/nexus-module-profile.ts index 4eec2a90..6d8cc997 100644 --- a/game/nexus-module-profile.ts +++ b/game/nexus-module-profile.ts @@ -2,6 +2,13 @@ import { FACTIONS } from './catalog'; import { mixedE3NexusRosterProfile, type FactionModuleProfile } from './faction-module-profile'; import { classicDiscoveryNexusProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; +/** Explicit Richese composition only; original predicates still own roster and module legality. */ +function semutaNexusContext(game: FactionModuleProfile): boolean { + return game.semutaPreview === true && !game.homeworlds && + !game.discoveryEnabled && !game.discoveries && + game.expansions.includes('choam') && game.players.some(p => p.faction === 'richese'); +} + /** Original classic Nexus modules, optionally Homeworlds; initializer checks undealt components. */ export function classicNexusModulesProfile(game: FactionModuleProfile): boolean { if (game.discoveryEnabled || game.discoveries) return classicDiscoveryNexusProfile(game); @@ -30,12 +37,12 @@ export function classicNexusModulesProfile(game: FactionModuleProfile): boolean export function pairedNexusModulesProfile(game: FactionModuleProfile): boolean { if (game.discoveryEnabled || game.discoveries) return pairedDiscoveryNexusProfile(game); if (typeof game.advanced !== 'boolean' || !game.nexusCards || - (!game.techTokens && !game.strongholdCards) || game.expansions.length !== 1 || + (!game.techTokens && !game.strongholdCards && !semutaNexusContext(game)) || game.expansions.length !== 1 || game.players.length < 2 || game.players.length > 6 || (game.techTokens && game.players.length < 3) || (game.strongholdCards && !game.advanced) || game.leaderSkills || game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker || - game.ecazTreachery || game.semutaPreview || game.advancedPreview || + game.ecazTreachery || (game.semutaPreview && !semutaNexusContext(game)) || game.advancedPreview || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; @@ -97,7 +104,7 @@ export function singleE1E2NexusProfile(game: FactionModuleProfile): boolean { (game.techTokens && game.players.length < 3) || (game.strongholdCards && !game.advanced) || game.leaderSkills || game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker || - game.ecazTreachery || game.semutaPreview || game.advancedPreview || + game.ecazTreachery || (game.semutaPreview && !semutaNexusContext(game)) || game.advancedPreview || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; @@ -125,7 +132,7 @@ export function mixedE1E2NexusProfile(game: FactionModuleProfile): boolean { (game.techTokens && game.players.length < 3) || (game.strongholdCards && !game.advanced) || game.leaderSkills || game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker || - game.ecazTreachery || game.semutaPreview || game.advancedPreview || + game.ecazTreachery || (game.semutaPreview && !semutaNexusContext(game)) || game.advancedPreview || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; diff --git a/game/reference.ts b/game/reference.ts index 721b3ff2..71c3453a 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1181,6 +1181,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'In the Basic native Moritani entry, winner compulsory/optional card discards may pause before the losing ally’s original retention choice. After that ally chooses one eligible played card or declines, native Karama finishes first; the completed unkept-card batch then offers Semuta. A kept or unrelated hand card is never a target. Multiple fresh cards require commitment and one private selection; taking a winner’s card does not add it to the original retention pool. Advanced Moritani, Nexus retention and unresolved casualty parents remain separate.', 'A printed Karama canceling native Harkonnen capture, CHOAM Auditor or quoted battle income, Ixian casualty substitution, or Basic Moritani allied retention now offers that exact public cost before the original cancellation finishes. Claim or Continue restores the saved response and cancels once. Acquiring the cost card cannot restore a captive, inspect a hand, pay the canceled income, exchange casualties or keep the proposed allied card. Later native card cleanup and markets still finish; Worthless conversion, other cancellations and module combinations remain separate.', 'The explicit fresh Leader Skills entry composes Semuta with original all-fourteen-card native Basic Richese/CHOAM or Advanced E1/E2 setup, retaining original Tech and Advanced Stronghold prerequisites. Actual offers train original living discs; the native Auditor remains ineligible. A completed Hajr discard can resume the same Movement owner before an original private Mentat question. Mandatory loser disposal can resume trained Suk casualty rescue or Rihani’s normal private inspection and optional skilled exchange before winner cleanup. Winning compulsory Artillery stays held through the original skill, then becomes its own fresh Treachery discard. Traitor/Face Dancer movement is never a Semuta target; no rescue, question, inspection or exchange repeats. Basic Ix mixtures, E3, other module/Banker overlays and public starts stay separate.', + 'The fresh semuta --nexus-cards entry retains original native E1/E2 Nexus setup, optionally explicit Skills and original Tech/Advanced Strongholds. There is no starting Nexus hand: an actual qualifying closing draw follows a Nexus and settled alliance. CHOAM Secret Ally with CHOAM absent spends a Worthless and its Nexus for two bank spice before the public discard offer; CHOAM Betrayal with CHOAM seated commits one random actual hand-card discard without compensation before its private offer. Claim or Continue retires the original receipt once, without another payment or sample. Separate Nexus cards never become Treachery targets. Homeworlds, Discovery, E3, played-save conversion and public starts stay gated.', '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.', @@ -1243,6 +1244,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/truthtrance-discard-recovery.test.ts', 'tests/ordinary-card-discard-continuations.test.ts', 'tests/ordinary-card-discard-recovery.test.ts', + 'tests/semuta-nexus-trade.test.ts', + 'tests/semuta-nexus-betrayal.test.ts', ], }, ], @@ -4253,6 +4256,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The Collection trade and post-victory inspection are available only while CHOAM is absent and you are unallied. This works in Basic and Advanced play.', 'During Spice Collection, choose one Worthless card from your own hand. The chosen card and your CHOAM Nexus card are discarded, and you receive two spice from the bank.', 'Finish any current choice, Truthtrance or automatic continuation first. Continuing the phase without trading keeps your cards.', + 'In the explicit native Richese Semuta composition, the already-paid public Worthless discard offers claim or Continue before its original trade receipt retires. Taking that card never pays again or makes the spent Nexus a Treachery target.', 'After winning a battle, the private inspection choice may instead spend the Nexus card to reveal one random opposing hand card that was not used in that battle. Only you see the sampled face; if none is eligible, continue without spending the card.', ], related: ['nexus-cards', 'collection', 'battle'], @@ -4275,6 +4279,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Hold the CHOAM Nexus card while CHOAM is seated and holds at least one Treachery Card. Finish other choices or automatic continuations before using Betrayal; the printed panel has no phase restriction.', 'The server chooses uniformly from CHOAM’s held cards, not from a client choice. Discard both the Nexus card and the selected Treachery Card; CHOAM receives no spice.', 'Only the holder sees the Betrayal control. CHOAM’s hand count is shown only during Bidding; no rival card identity is disclosed before the ordinary discard reveal. Outside Bidding an empty-hand attempt rejects without spending the card.', + 'In the explicit native Richese Semuta composition, the committed random CHOAM discard offers a neutral reaction with its face hidden until commitment. Claim or Continue completes that original receipt without sampling or compensating again; the CHOAM victim remains the discarder.', 'Basic and Advanced share this bounded effect. Other Nexus effects and complete expansion combinations retain their separate gates.', ], related: ['nexus-cards', 'choam-modules'], diff --git a/tests/fixture-semuta-nexus-betrayal.ts b/tests/fixture-semuta-nexus-betrayal.ts new file mode 100644 index 00000000..fe4dc7cb --- /dev/null +++ b/tests/fixture-semuta-nexus-betrayal.ts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, + viewGame, type Action, type Game, type Player, +} from '../game/engine'; +import type { Card } from '../game/cards'; +import { validateNexusCards } from '../game/nexus-cards'; +import { nexusChoamBetrayalAction } from '../game/nexus-choam-betrayal-options'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech'; + +export type SemutaNexusBetrayalStep = { actor: string; action: Action }; +export type SemutaNexusBetrayalOptions = { + initial?: Game; + advanced?: boolean; + tech?: boolean; + strongholds?: boolean; + skills?: boolean; + fullHand?: boolean; +}; +export type SemutaNexusBetrayalFixture = { + game: Game; + initial: Game; + setup: Game; + drawBefore: Game; + drawAfter: Game; + actions: SemutaNexusBetrayalStep[]; + staging: string[]; + owner: string; + target: string; + spender: string; + observer: string; + semuta: string; + targetCards: Card[]; + cards: string[]; + action: Action; +}; +export function semutaNexusBetrayalPlayer(game: Game, id: string): Player { + const player = game.players.find(p => p.id === id); + assert.ok(player, `Missing original ${id} seat`); + return player; +} +export function semutaNexusBetrayalInventory(game: Game): string[] { + const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), + ...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort(); + assert.equal(new Set(cards).size, cards.length, 'Original Treachery Cards retain one physical slot'); + validateNexusCards(game.nexusCards!.cards!, game.players); + return cards; +} +/** The existing no-spend native policy, with actual optional training and neutral + * Semuta acknowledgements. No Nexus card is played while reaching the draw. */ +export function nextSemutaNexusBetrayalStep(game: Game): SemutaNexusBetrayalStep { + const pending = game.pendingTreacheryDiscard; + const reaction = pending?.reaction; + if (reaction?.stage === 'offer' && pending) { + const actor = game.players.find(p => !reaction.passed.includes(p.id))!.id; + return { actor, action: { type: 'semutaPass', event: pending.batch.event } }; + } + if (game.setupStage === 'leaderSkills' && game.status === 'setup') { + const actor = Object.keys(game.leaderSkills!.offers)[0]; + const offer = game.leaderSkills!.offers[actor], own = viewGame(game, actor).leaderSkills!; + const safe = ['warmaster', 'swordmaster-of-ginaz', 'killer-medic', 'prana-bindu-adept']; + const skill = offer.cards.find(c => safe.includes(c) && !own.unavailableSkills?.[c]) + ?? offer.cards.find(c => !own.unavailableSkills?.[c]); + const eligible = new Set(own.eligibleLeaders.map(l => l.id)); + const leader = semutaNexusBetrayalPlayer(game, actor).leaders.find(l => eligible.has(l.id)); + assert.ok(skill && leader, 'An actual original offer must have an eligible training choice'); + return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }; + } + return nextRicheseSkillsTechNativeStep(game); +} +function orderUnplayedSpice(game: Game, kinds: ('land' | 'worm')[]): void { + for (const [position, kind] of kinds.entries()) { + const index = game.spiceDeck.findIndex((c, i) => i >= position && + (kind === 'land' ? 'territory' in c : 'worm' in c)); + assert.ok(index >= position, `An original unplayed ${kind} Spice Card must remain`); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); + } +} +function takePhysicalCard(game: Game, actor: string, predicate: (card: Card) => boolean): Card { + for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.map(p => p.hand)]) { + const index = zone.findIndex(predicate); + if (index < 0) continue; + const card = zone.splice(index, 1)[0]; + semutaNexusBetrayalPlayer(game, actor).hand.push(card); + return card; + } + throw new Error('The requested original physical Treachery Card is unavailable'); +} +/** Exchange identities only after an original qualifying closing draw has + * supplied a real held card. Never add a hand, draw directly, or rewrite phase. */ +function exchangeDrawnChoam(game: Game, owner: string): void { + const cards = game.nexusCards!.cards!, returned = cards.hands[owner]; + assert.ok(returned, 'The owner must first receive an actual closing Nexus draw'); + if (returned === 'choam') return; + const donor = Object.keys(cards.hands).find(id => cards.hands[id] === 'choam'); + if (donor) cards.hands[donor] = returned; + else { + const zone = [cards.deck, cards.discard].find(z => z.includes('choam')); + assert.ok(zone, 'The original CHOAM Nexus identity must have a physical custody slot'); + zone[zone.indexOf('choam')] = returned; + } + cards.hands[owner] = 'choam'; + validateNexusCards(cards, game.players); +} + +/** Genuine four-seat paired CHOAM/Richese setup, first Storm and turn-two + * worm/alliance/closing draws. Only still-unplayed Spice order and conserved + * later Treachery/Nexus identities are staged. Wallets, turn, phase, original + * training, Tech/Stronghold custody and discard receipts are never assigned. */ +export function createSemutaNexusBetrayalFixture(options: SemutaNexusBetrayalOptions = {}): SemutaNexusBetrayalFixture { + let game = options.initial ? structuredClone(options.initial) + : createGame('SEMUTANEXUSBETRAYAL', newPlayer('r', 'Richese', 'richese'), options.advanced ?? false, ['choam']); + if (!options.initial) { + joinGame(game, newPlayer('e', 'CHOAM', 'choam')); + joinGame(game, newPlayer('a', 'Atreides', 'atreides')); + joinGame(game, newPlayer('b', 'Emperor', 'emperor')); + } + assert.equal(game.status, 'lobby', 'Only a fresh undealt lobby may enter'); + assert.deepEqual(game.expansions, ['choam']); + assert.equal(game.players.length, 4); + if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced); + const idFor = (faction: Player['faction']) => { + const player = game.players.find(p => p.faction === faction); + assert.ok(player, `The original ${faction} seat is required`); + return player.id; + }; + const owner = idFor('richese'), target = idFor('choam'), spender = idFor('atreides'), observer = idFor('emperor'); + if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); + if (options.strongholds && !game.strongholdCards) + game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true }); + for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); + // Fresh module selection only; the original initializer creates all custody. + game.nexusCards ??= { cards: null, phase: null }; + assert.equal(game.nexusCards.cards, null); + assert.equal(game.nexusCards.phase, null); + const initial = structuredClone(game), actions: SemutaNexusBetrayalStep[] = []; + const staging = ['Reordered only original unplayed Spice Cards: first-turn land blows, turn-two worm then land blows.']; + game = initializeSemutaGameForAudit(game, options.skills ?? false); + const setup = structuredClone(game); + const step = () => { + const next = nextSemutaNexusBetrayalStep(game); + actions.push(next); game = applyAction(game, next.actor, next.action); + }; + const reach = (predicate: () => boolean, label: string) => { + for (let n = 0; !predicate() && n < 1800; n++) { + assert.notEqual(game.status, 'finished', `Original native game ended before ${label}`); + step(); + } + assert.ok(predicate(), `Original native actions must reach ${label}`); + }; + reach(() => game.status === 'playing', 'original setup completion'); + assert.ok(Object.values(game.nexusCards!.cards!.hands).every(c => c === null), 'Nexus has no starting hand'); + orderUnplayedSpice(game, ['land', 'land']); + reach(() => game.turn === 2 && game.phase === 1 && !game.phaseOpening && !game.response && !game.decision, + 'actual second Spice Blow after original first Storm and Mentat'); + assert.ok(Object.values(game.nexusCards!.cards!.hands).every(c => c === null)); + orderUnplayedSpice(game, ['worm', 'land', 'land']); + reach(() => game.nexus && !game.spiceWindow && !game.spiceResolution, 'actual worm Nexus negotiations'); + for (const [actor, ally] of [[owner, target], [target, owner]]) { + const action: Action = { type: 'alliance', target: ally }; + actions.push({ actor, action }); game = applyAction(game, actor, action); + } + reach(() => game.nexusCards!.phase?.stage === 'drawing', 'qualifying end-Spice Nexus opportunity'); + assert.equal(game.nexusCards!.phase!.occurred, true); + assert.deepEqual([...game.nexusCards!.phase!.eligible].sort(), [spender, observer].sort()); + const drawBefore = structuredClone(game); + for (const actor of game.nexusCards!.phase!.eligible) { + const action: Action = { type: 'nexusCardChoice', turn: game.turn, + card: game.nexusCards!.cards!.hands[actor], choice: 'draw', ownRedraws: 0 }; + actions.push({ actor, action }); game = applyAction(game, actor, action); + } + assert.equal(game.nexusCards!.phase!.stage, 'complete'); + const drawAfter = structuredClone(game); + exchangeDrawnChoam(game, spender); + staging.push('Exchanged the actual drawn Nexus identity with CHOAM in an existing hand/deck/discard slot.'); + reach(() => game.phase === 2 && !game.phaseOpening && !game.response && !game.decision, + 'original clean Charity boundary'); + const cards = semutaNexusBetrayalInventory(game), wallets = game.players.map(p => p.spice); + for (const player of game.players) game.deck.push(...player.hand.splice(0)); + const semuta = takePhysicalCard(game, owner, c => c.id === SEMUTA_DRUG_ID).id; + const targetCards = ['projectile', 'snooper', 'worthless'].map(kind => + takePhysicalCard(game, target, c => c.kind === kind)); + if (options.fullHand) semutaNexusBetrayalPlayer(game, owner).hand.push(...game.deck.splice(0, 3)); + staging.push('Moved canonical Semuta to Richese and three existing Treachery Cards to CHOAM; returned other held cards to the original deck.'); + assert.deepEqual(semutaNexusBetrayalInventory(game), cards); + assert.deepEqual(game.players.map(p => p.spice), wallets); + const action = nexusChoamBetrayalAction(viewGame(game, spender)); + assert.ok(action, 'The original unallied CHOAM Betrayal quote must supply a legal action'); + return { game, initial, setup, drawBefore, drawAfter, actions, staging, + owner, target, spender, observer, semuta, targetCards: structuredClone(targetCards), cards, action }; +} +/** No target-card input: the original server producer performs its own random + * sample and commits that exact physical card before Semuta can react. */ +export function commitSemutaNexusBetrayal(fixture: SemutaNexusBetrayalFixture, game = fixture.game): Game { + return applyAction(game, fixture.spender, fixture.action); +} +export function reactSemutaNexusBetrayal(state: Game, fixture: SemutaNexusBetrayalFixture, claim: boolean): Game { + const event = state.pendingTreacheryDiscard!.batch.event; + if (claim) return applyAction(state, fixture.owner, { type: 'semutaCommit', event }); + let game = state; + for (const player of state.players) game = applyAction(game, player.id, { type: 'semutaPass', event }); + return game; +} diff --git a/tests/fixture-semuta-nexus-trade.ts b/tests/fixture-semuta-nexus-trade.ts new file mode 100644 index 00000000..4454f3b5 --- /dev/null +++ b/tests/fixture-semuta-nexus-trade.ts @@ -0,0 +1,241 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame, + newPlayer, viewGame, type Action, type Game, type Player, +} from '../game/engine'; +import type { Card } from '../game/cards'; +import { botSemutaActions } from '../game/bot-semuta'; +import { validateNexusCards } from '../game/nexus-cards'; +import { nexusChoamTradeAction } from '../game/nexus-choam-trade-options'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech'; + +export type SemutaNexusTradeStep = { actor: string; action: Action }; +export type SemutaNexusTradeOptions = { + /** Fresh undealt Richese/Atreides/Emperor/Guild lobby; retain authenticated IDs. */ + initial?: Game; + advanced?: boolean; + tech?: boolean; + strongholds?: boolean; + skills?: boolean; + fullHand?: boolean; +}; +export type SemutaNexusTradeFixture = { + game: Game; + initial: Game; + afterSetup: Game; + afterFirstStorm: Game; + beforeAlliance: Game; + beforeDraw: Game; + afterDraw: Game; + actions: SemutaNexusTradeStep[]; + staging: string[]; + holder: string; + trader: string; + allies: [string, string]; + cost: Card; + spare: Card; + semuta: string; + action: Action; +}; +export function semutaNexusTradePlayer(game: Game, id: string): Player { + const player = game.players.find(p => p.id === id); + assert.ok(player, `Missing original ${id} seat`); + return player; +} +export function semutaNexusTradeInventory(game: Game): string[] { + const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), + ...(game.richeseRemoved ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []), + ...game.players.flatMap(p => p.hand)].map(c => c.id).sort(); + assert.equal(new Set(ids).size, ids.length, 'One physical slot per original Treachery Card'); + validateNexusCards(game.nexusCards!.cards!, game.players); + return ids; +} +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; + +/** Existing no-spend native phase policy, with the actual closing Nexus choice + * and neutral Semuta acknowledgements. No clock, receipt or payment is staged. */ +export function nextSemutaNexusTradeNativeStep(game: Game): SemutaNexusTradeStep { + if (game.pendingTreacheryDiscard?.reaction) { + for (const player of game.players) { + const action = botSemutaActions(viewGame(game, player.id))[0]; + if (action) return { actor: player.id, action }; + } + throw Error('No legal original Semuta acknowledgement remains'); + } + if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') { + const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id)); + assert.ok(actor); + return { actor, action: { type: 'nexusCardChoice', turn: game.turn, + card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } }; + } + if (clean(game) && game.status === 'setup' && game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0]; + const offer = game.leaderSkills!.offers[actor], own = viewGame(game, actor).leaderSkills!; + const skill = offer.cards.find(id => !own.unavailableSkills?.[id]); + assert.ok(skill && own.eligibleLeaders[0], 'Train an actual original offer on an eligible living disc'); + return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id } }; + } + return nextRicheseSkillsTechNativeStep(game); +} +export function advanceSemutaNexusTrade( + state: Game, until: (game: Game) => boolean, actions: SemutaNexusTradeStep[] = [], +): Game { + let game = state; + for (let n = 0; n < 1800; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'Original phase path must remain playable'); + const next = nextSemutaNexusTradeNativeStep(game); + actions.push(next); + game = applyAction(game, next.actor, next.action); + } + throw Error('Native Semuta/Nexus path did not reach the requested boundary'); +} +function orderUndealtSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void { + const remainder = [...game.spiceDeck]; + const front = kinds.map(kind => { + const index = remainder.findIndex(c => kind === 'land' ? 'territory' in c : 'worm' in c); + assert.ok(index >= 0, `Original undealt ${kind} must remain available`); + return remainder.splice(index, 1)[0]; + }); + game.spiceDeck = [...front, ...remainder]; + staging.push(`Conserved undealt Spice order: ${kinds.join(', ')}; discard history unchanged.`); +} +function reserveBoard(game: Game, staging: string[]): void { + for (const player of game.players) { + player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0); + player.forces = {}; + if (player.elites) { + player.elites.reserves += Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0); + player.elites.forces = {}; + } + } + staging.push('After native setup/first Storm, original board counters return to their own reserves; no battle, wallet or clock is invented.'); +} +/** Only an already-drawn holder can swap identity with another original slot. */ +export function swapSemutaNexusTradeIdentity(game: Game, owner: string, desired: Player['faction']): void { + const cards = game.nexusCards!.cards!; + validateNexusCards(cards, game.players); + const previous = cards.hands[owner]; + assert.ok(previous, 'The owner must earn a real qualifying Nexus draw first'); + if (previous === desired) return; + const donor = Object.keys(cards.hands).find(id => cards.hands[id] === desired); + if (donor) cards.hands[donor] = previous; + else { + const zone = cards.deck.includes(desired) ? cards.deck : cards.discard; + const index = zone.indexOf(desired); + assert.ok(index >= 0, 'Requested Nexus identity must have its original physical slot'); + zone[index] = previous; + } + cards.hands[owner] = desired; + validateNexusCards(cards, game.players); +} +function holdOriginal(game: Game, owner: string, matches: (card: Card) => boolean, staging: string[]): Card { + const player = semutaNexusTradePlayer(game, owner); + const held = player.hand.find(matches); + if (held) return held; + assert.ok(player.hand.length < handLimit(player)); + const zones: [string, Card[]][] = [['deck', game.deck], ['discard', game.discard], + ['Richese cache', game.richeseCache!], ...game.players.filter(p => p.id !== owner) + .map(p => [p.id, p.hand] as [string, Card[]])]; + for (const [name, zone] of zones) { + const index = zone.findIndex(matches); + if (index < 0) continue; + const card = zone.splice(index, 1)[0]; + player.hand.push(card); + staging.push(`Conserved physical ${card.id}: ${name} → ${owner}'s hand.`); + return card; + } + throw Error('Required original physical Treachery Card is unavailable'); +} + +/** Real fresh native setup, first Storm, first Mentat, second-turn worm, reciprocal + * alliance and end-Spice draw. Later controlled positions use existing physical + * slots only. No starting Nexus hand, fabricated phase/payment or cloned stock. */ +export function createSemutaNexusTradeFixture(options: SemutaNexusTradeOptions = {}): SemutaNexusTradeFixture { + let game = options.initial ? structuredClone(options.initial) + : createGame('SEMUTANEXUSTRADE', newPlayer('r', 'Richese', 'richese'), options.advanced ?? false, ['choam']); + if (!options.initial) { + joinGame(game, newPlayer('a', 'Atreides', 'atreides')); + joinGame(game, newPlayer('e', 'Emperor', 'emperor')); + joinGame(game, newPlayer('g', 'Guild', 'guild')); + } + assert.equal(game.status, 'lobby'); + assert.deepEqual(game.expansions, ['choam']); + assert.equal(game.players.length, 4); + if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced); + const id = (faction: Player['faction']) => { + const player = game.players.find(p => p.faction === faction); assert.ok(player); return player.id; + }; + const holder = id('richese'), trader = id('atreides'), allies: [string, string] = [id('emperor'), id('guild')]; + assert.ok(game.nexusCards == null || (game.nexusCards.cards === null && game.nexusCards.phase === null)); + // The original development-only lobby selection has no player-action switch. + // Both physical packet and phase remain undealt until the original initializer. + game.nexusCards ??= { cards: null, phase: null }; + if (options.tech !== undefined) game = applyAction(game, game.host, { type: 'techTokens', enabled: options.tech }); + if (options.strongholds !== undefined) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: options.strongholds }); + for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); + const initial = structuredClone(game), actions: SemutaNexusTradeStep[] = [], staging: string[] = []; + game = initializeSemutaGameForAudit(game, options.skills ?? false); + game = advanceSemutaNexusTrade(game, state => state.status === 'playing', actions); + const afterSetup = structuredClone(game); + assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null), 'Nexus has no starting hand'); + const inventory = semutaNexusTradeInventory(game); + game = advanceSemutaNexusTrade(game, state => state.phase === 1 && clean(state), actions); + const afterFirstStorm = structuredClone(game); + orderUndealtSpice(game, ['land', 'land'], staging); + game = advanceSemutaNexusTrade(game, state => state.phase === 5 && clean(state), actions); + reserveBoard(game, staging); + game = advanceSemutaNexusTrade(game, state => state.turn === 2 && state.phase === 1 && clean(state), actions); + orderUndealtSpice(game, ['worm', 'land', 'land'], staging); + game = advanceSemutaNexusTrade(game, state => state.nexus && !state.spiceWindow && clean(state), actions); + const beforeAlliance = structuredClone(game); + for (const [actor, target] of [allies, [allies[1], allies[0]]] as [string, string][]) { + const next: SemutaNexusTradeStep = { actor, action: { type: 'alliance', target } }; + actions.push(next); game = applyAction(game, actor, next.action); + } + game = advanceSemutaNexusTrade(game, state => state.nexusCards?.phase?.stage === 'drawing', actions); + const beforeDraw = structuredClone(game); + const draw: SemutaNexusTradeStep = { actor: trader, action: { + type: 'nexusCardChoice', turn: game.turn, card: game.nexusCards!.cards!.hands[trader], choice: 'draw', ownRedraws: 0, + } }; + actions.push(draw); game = applyAction(game, draw.actor, draw.action); + const afterDraw = structuredClone(game); + swapSemutaNexusTradeIdentity(game, trader, 'choam'); + staging.push('Only after actual qualifying closing draw, exchange its existing Nexus identity with the original CHOAM slot.'); + game = advanceSemutaNexusTrade(game, state => state.phase === 7 && clean(state), actions); + // This is explicit later hand-position staging, not a new deal or acquisition rule. + for (const actor of [holder, trader]) { + const player = semutaNexusTradePlayer(game, actor); + game.deck.push(...player.hand); player.hand = []; + } + staging.push('At real second-turn Collection, return holder/trader original hand cards to the original deck before conserved cost/Semuta staging.'); + const cost = holdOriginal(game, trader, card => card.kind === 'worthless', staging); + const spare = holdOriginal(game, trader, card => card.kind === 'worthless' && card.id !== cost.id, staging); + const semuta = holdOriginal(game, holder, card => card.id === SEMUTA_DRUG_ID, staging).id; + if (options.fullHand) { + const player = semutaNexusTradePlayer(game, holder); + while (player.hand.length < handLimit(player)) + holdOriginal(game, holder, card => !player.hand.some(held => held.id === card.id) && card.kind !== 'worthless', staging); + } + assert.deepEqual(semutaNexusTradeInventory(game), inventory); + const action = nexusChoamTradeAction(viewGame(game, trader), cost.id); + assert.ok(action, 'Original CHOAM Secret Ally must quote the actual held Worthless at real Collection'); + return { game, initial, afterSetup, afterFirstStorm, beforeAlliance, beforeDraw, afterDraw, + actions, staging, holder, trader, allies, cost, spare, semuta, action }; +} +export function passSemutaNexusTrade(state: Game): Game { + let game = state; + const event = game.pendingTreacheryDiscard!.batch.event; + for (const player of game.players) game = applyAction(game, player.id, { type: 'semutaPass', event }); + return game; +} +export function claimSemutaNexusTrade(state: Game, fixture: SemutaNexusTradeFixture): Game { + const event = state.pendingTreacheryDiscard!.batch.event; + return applyAction(state, fixture.holder, { type: 'semutaCommit', event }); +} +export function finishSemutaNexusTradeCollection(state: Game): Game { + assert.equal(state.pendingTreacheryDiscard, null); + assert.equal(state.phase, 7); + return advanceSemutaNexusTrade(state, game => game.phase === 8 && clean(game)); +} diff --git a/tests/semuta-nexus-betrayal.test.ts b/tests/semuta-nexus-betrayal.test.ts new file mode 100644 index 00000000..50533f50 --- /dev/null +++ b/tests/semuta-nexus-betrayal.test.ts @@ -0,0 +1,153 @@ +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 { + commitSemutaNexusBetrayal, createSemutaNexusBetrayalFixture, + nextSemutaNexusBetrayalStep, reactSemutaNexusBetrayal, + semutaNexusBetrayalInventory, semutaNexusBetrayalPlayer, +} from './fixture-semuta-nexus-betrayal'; + +for (const advanced of [false, true]) { + for (const claim of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} Semuta ${claim ? 'claims' : 'passes'} the committed random CHOAM Betrayal discard before its receipt completes`, () => { + const f = createSemutaNexusBetrayalFixture({ advanced }); + assert.equal(f.setup.status, 'setup'); + assert.ok(Object.values(f.setup.nexusCards!.cards!.hands).every(card => card === null)); + assert.equal(f.drawBefore.phase, 1); + assert.equal(f.drawBefore.turn, 2); + assert.equal(f.drawBefore.nexusCards!.phase!.occurred, true); + assert.equal(f.drawBefore.nexusCards!.phase!.stage, 'drawing'); + assert.equal(semutaNexusBetrayalPlayer(f.drawBefore, f.owner).ally, f.target); + assert.equal(semutaNexusBetrayalPlayer(f.drawBefore, f.target).ally, f.owner); + assert.ok(!semutaNexusBetrayalPlayer(f.drawBefore, f.spender).ally); + assert.equal(f.drawAfter.nexusCards!.phase!.stage, 'complete'); + assert.ok(f.drawAfter.nexusCards!.cards!.hands[f.spender]); + assert.equal(f.drawAfter.nexusCards!.cards!.deck.length, 10); + assert.equal(viewGame(f.game, f.spender).nexusChoamBetrayal!.target.id, f.target); + assert.equal(viewGame(f.game, f.spender).nexusChoamBetrayal!.blocked, null); + const wallets = f.game.players.map(p => p.spice); + const targetBefore = structuredClone(semutaNexusBetrayalPlayer(f.game, f.target).hand); + const logStart = f.game.log.length; + const pending = commitSemutaNexusBetrayal(f); + const batch = pending.pendingTreacheryDiscard!.batch; + const receipt = pending.nexusChoamBetrayals!.at(-1)!; + const fresh = targetBefore.filter(card => !semutaNexusBetrayalPlayer(pending, f.target).hand.some(c => c.id === card.id)); + assert.equal(fresh.length, 1, 'The server must randomly remove exactly one actual held card'); + assert.deepEqual(semutaNexusBetrayalPlayer(pending, f.target).hand, + targetBefore.filter(card => card.id !== fresh[0].id)); + assert.equal(batch.cause, 'nexus:choamBetrayal'); + assert.deepEqual(batch.entries, [{ card: fresh[0], discardedBy: f.target, publicFace: false }]); + assert.equal(pending.pendingTreacheryDiscard!.reaction?.stage, 'offer'); + assert.equal(receipt.stage, 'discard'); + assert.equal(receipt.card, fresh[0].id); + assert.equal(pending.discard.filter(c => c.id === fresh[0].id).length, 1); + assert.equal(pending.nexusCards!.cards!.hands[f.spender], null); + assert.equal(pending.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1); + assert.deepEqual(pending.players.map(p => p.spice), wallets, 'Random discard gives no compensation'); + const offer = viewGame(pending, f.owner).semutaReaction!; + assert.equal(offer.canCommit, true, 'Semuta may recover another player’s card even from its own ally'); + assert.deepEqual(offer.candidates, [], 'The discard offer precedes any committed inspection'); + assert.equal(pending.log.slice(logStart).some(row => row.text.includes(fresh[0].name)), false); + const done = reactSemutaNexusBetrayal(pending, f, claim); + assert.equal(done.pendingTreacheryDiscard, null); + assert.equal(done.resolvedTreacheryDiscardSequence, pending.pendingTreacheryDiscard!.sequence); + assert.equal(done.nexusChoamBetrayals!.length, 1); + assert.equal(done.nexusChoamBetrayals![0].stage, 'complete'); + assert.equal(done.nexusChoamBetrayals![0].card, fresh[0].id, 'The original random identity remains committed'); + assert.equal(semutaNexusBetrayalPlayer(done, f.owner).hand.filter(c => c.id === fresh[0].id).length, Number(claim)); + assert.equal(semutaNexusBetrayalPlayer(done, f.owner).hand.some(c => c.id === f.semuta), !claim); + assert.equal(done.discard.filter(c => c.id === fresh[0].id).length, Number(!claim)); + assert.equal(done.discard.filter(c => c.id === f.semuta).length, Number(claim)); + assert.deepEqual(semutaNexusBetrayalPlayer(done, f.target).hand, + semutaNexusBetrayalPlayer(pending, f.target).hand, 'Reaction cannot resample or repeat the discard'); + assert.deepEqual(done.players.map(p => p.spice), wallets); + assert.equal(done.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1); + assert.deepEqual(semutaNexusBetrayalInventory(done), f.cards); + assert.equal(viewGame(done, f.owner).semutaReaction, null); + assert.equal(viewGame(done, f.spender).nexusChoamBetrayal, null); + assert.throws(() => applyAction(done, f.spender, f.action)); + assert.throws(() => applyAction(done, f.owner, { type: 'semutaCommit', event: batch.event })); + const next = nextSemutaNexusBetrayalStep(done); + assert.equal(next.action.type, 'ready', 'The original Charity acknowledgement is available after retirement'); + const resumed = applyAction(done, next.actor, next.action); + assert.ok(resumed.ready.includes(next.actor)); + assert.equal(resumed.nexusChoamBetrayals!.length, 1); + assert.equal(resumed.nexusChoamBetrayals![0].stage, 'complete'); + assert.deepEqual(semutaNexusBetrayalPlayer(resumed, f.target).hand, + semutaNexusBetrayalPlayer(done, f.target).hand); + assert.deepEqual(semutaNexusBetrayalInventory(resumed), f.cards); + }, + ); + + void test(`${advanced ? 'Advanced' : 'Basic'} full-hand Semuta exchanges the exact random Betrayal discard without overflow or payment`, () => { + const f = createSemutaNexusBetrayalFixture({ advanced, fullHand: true }); + assert.equal(semutaNexusBetrayalPlayer(f.game, f.owner).hand.length, 4); + const pending = commitSemutaNexusBetrayal(f), fresh = pending.pendingTreacheryDiscard!.batch.entries[0].card; + assert.equal(viewGame(pending, f.owner).semutaReaction!.canCommit, true); + const done = reactSemutaNexusBetrayal(pending, f, true); + assert.equal(semutaNexusBetrayalPlayer(done, f.owner).hand.length, 4); + assert.ok(semutaNexusBetrayalPlayer(done, f.owner).hand.some(c => c.id === fresh.id)); + assert.ok(!semutaNexusBetrayalPlayer(done, f.owner).hand.some(c => c.id === f.semuta)); + assert.equal(done.decision, null); + assert.deepEqual(done.players.map(p => p.spice), pending.players.map(p => p.spice)); + assert.deepEqual(semutaNexusBetrayalInventory(done), f.cards); + }); +} + +void test('CHOAM Betrayal rejects a client-picked card, and its pending Semuta reaction blocks original phase actions until resolved', () => { + const f = createSemutaNexusBetrayalFixture(); + assert.throws(() => applyAction(f.game, f.spender, { ...f.action, card: f.targetCards[0].id })); + assert.equal(f.game.nexusCards!.cards!.hands[f.spender], 'choam'); + assert.deepEqual(semutaNexusBetrayalPlayer(f.game, f.target).hand, f.targetCards); + const pending = commitSemutaNexusBetrayal(f), event = pending.pendingTreacheryDiscard!.batch.event; + assert.throws(() => applyAction(pending, f.spender, { type: 'ready' })); + assert.throws(() => applyAction(pending, f.owner, { type: 'semutaSelect', event, card: f.targetCards[0].id })); + assert.equal(pending.nexusChoamBetrayals![0].stage, 'discard'); + const done = reactSemutaNexusBetrayal(pending, f, false); + assert.equal(done.nexusChoamBetrayals![0].stage, 'complete'); + assert.deepEqual(semutaNexusBetrayalInventory(done), f.cards); +}); + +void test('existing minimal bot reactions settle the random Betrayal claim and neutral passes through the original receipt', () => { + const f = createSemutaNexusBetrayalFixture(); + const pending = commitSemutaNexusBetrayal(f), fresh = pending.pendingTreacheryDiscard!.batch.entries[0].card.id; + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const own = viewGame(pending, f.owner); + own.players.find(p => p.id === f.owner)!.bot = difficulty; + const action = botActions(own)[0]; + assert.ok(action); + assert.equal(action.type, 'semutaCommit'); + const claimed = applyAction(pending, f.owner, action); + assert.ok(semutaNexusBetrayalPlayer(claimed, f.owner).hand.some(c => c.id === fresh)); + assert.equal(claimed.nexusChoamBetrayals![0].stage, 'complete'); + assert.deepEqual(semutaNexusBetrayalInventory(claimed), f.cards); + } + let passed: Game = pending; + for (const seat of pending.players) { + const own = viewGame(passed, seat.id); + own.players.find(p => p.id === seat.id)!.bot = 'Easy'; + const action = seat.id === f.owner + ? { type: 'semutaPass', event: passed.pendingTreacheryDiscard!.batch.event } + : botActions(own)[0]; + assert.ok(action); + assert.equal(action.type, 'semutaPass'); + passed = applyAction(passed, seat.id, action); + } + assert.equal(passed.nexusChoamBetrayals![0].stage, 'complete'); + assert.equal(passed.discard.filter(c => c.id === fresh).length, 1); + assert.deepEqual(semutaNexusBetrayalInventory(passed), f.cards); +}); + +for (const options of [ + { advanced: false, tech: true }, + { advanced: true, tech: true, strongholds: true, skills: true }, +]) void test(`Semuta random Betrayal remains playable with original ${options.skills ? 'Advanced Skills/Tech/Strongholds' : 'Basic Tech'} setup`, () => { + const f = createSemutaNexusBetrayalFixture(options); + assert.ok(Object.values(f.game.techTokens!).every(token => f.game.players.some(p => p.id === token.owner))); + const pending = commitSemutaNexusBetrayal(f), fresh = pending.pendingTreacheryDiscard!.batch.entries[0].card.id; + const done = reactSemutaNexusBetrayal(pending, f, true); + assert.ok(semutaNexusBetrayalPlayer(done, f.owner).hand.some(c => c.id === fresh)); + assert.equal(done.nexusChoamBetrayals![0].stage, 'complete'); + assert.deepEqual(semutaNexusBetrayalInventory(done), f.cards); +}); diff --git a/tests/semuta-nexus-trade.test.ts b/tests/semuta-nexus-trade.test.ts new file mode 100644 index 00000000..d32a8a7e --- /dev/null +++ b/tests/semuta-nexus-trade.test.ts @@ -0,0 +1,155 @@ +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 { DIFFICULTIES } from '../game/bot-profiles'; +import { + createSemutaNexusTradeFixture, claimSemutaNexusTrade, finishSemutaNexusTradeCollection, + nextSemutaNexusTradeNativeStep, passSemutaNexusTrade, semutaNexusTradeInventory, + semutaNexusTradePlayer, type SemutaNexusTradeFixture, +} from './fixture-semuta-nexus-trade'; + +function openTrade(f: SemutaNexusTradeFixture): Game { + assert.equal(f.afterFirstStorm.phase, 1); + assert.ok(Object.values(f.afterSetup.nexusCards!.cards!.hands).every(card => card === null)); + assert.equal(f.beforeAlliance.turn, 2); + assert.equal(f.beforeAlliance.nexus, true); + assert.equal(f.beforeDraw.nexusCards!.phase!.occurred, true); + assert.equal(f.beforeDraw.nexusCards!.phase!.stage, 'drawing'); + assert.deepEqual([...f.beforeDraw.nexusCards!.phase!.eligible].sort(), [f.holder, f.trader].sort()); + assert.equal(semutaNexusTradePlayer(f.beforeDraw, f.allies[0]).ally, f.allies[1]); + assert.equal(semutaNexusTradePlayer(f.beforeDraw, f.allies[1]).ally, f.allies[0]); + assert.equal(f.beforeDraw.nexusCards!.cards!.hands[f.trader], null); + assert.ok(f.afterDraw.nexusCards!.cards!.hands[f.trader], 'Actual closing draw precedes identity staging'); + assert.ok(f.afterDraw.nexusCards!.phase!.done.includes(f.trader)); + assert.equal(semutaNexusTradePlayer(f.game, f.trader).ally, null); + assert.ok(!f.game.players.some(p => p.faction === 'choam')); + assert.equal(f.game.nexusCards!.cards!.hands[f.trader], 'choam'); + assert.equal(f.game.phase, 7); + const own = viewGame(f.game, f.trader); + assert.equal(own.nexusChoamTrade!.blocked, null); + assert.equal(own.nexusChoamTrade!.spice, 2); + assert.deepEqual(own.nexusChoamTrade!.cards.map(card => card.id), [f.cost.id, f.spare.id]); + const pending = applyAction(f.game, f.trader, f.action); + assert.equal(semutaNexusTradePlayer(pending, f.trader).spice, semutaNexusTradePlayer(f.game, f.trader).spice + 2); + assert.equal(pending.nexusCards!.cards!.hands[f.trader], null); + assert.deepEqual(pending.nexusCards!.cards!.discard.filter(card => card === 'choam'), ['choam']); + assert.equal(pending.nexusChoamTrades!.length, 1); + assert.equal(pending.nexusChoamTrades![0].stage, 'discard'); + assert.equal(pending.pendingTreacheryDiscard!.continuation.kind, 'nexusChoamTrade'); + assert.equal(pending.pendingTreacheryDiscard!.batch.cause, 'nexus:choamTrade'); + assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries, + [{ card: f.cost, discardedBy: f.trader, publicFace: true }]); + assert.equal(pending.pendingTreacheryDiscard!.reaction!.stage, 'offer'); + for (const player of pending.players) { + const reaction = viewGame(pending, player.id).semutaReaction!; + assert.equal(reaction.event, pending.pendingTreacheryDiscard!.batch.event); + assert.equal(reaction.canCommit, player.id === f.holder); + assert.equal(reaction.stage, 'offer'); + } + return pending; +} +function assertCompletedTrade(f: SemutaNexusTradeFixture, completed: Game, claim: boolean): void { + const before = semutaNexusTradePlayer(f.game, f.trader), after = semutaNexusTradePlayer(completed, f.trader); + assert.equal(after.spice, before.spice + 2); + assert.deepEqual(after.hand.map(card => card.id), [f.spare.id]); + for (const player of completed.players.filter(p => p.id !== f.trader)) + assert.equal(player.spice, semutaNexusTradePlayer(f.game, player.id).spice, 'Semuta and neutral acknowledgements pay no spice'); + assert.equal(completed.pendingTreacheryDiscard, null); + assert.equal(completed.nexusChoamTrades!.length, 1); + assert.equal(completed.nexusChoamTrades![0].stage, 'complete'); + assert.deepEqual(completed.nexusChoamTradeLast, { event: f.action.event, stage: 'complete' }); + assert.equal(completed.resolvedTreacheryDiscardSequence, completed.treacheryDiscardSequence); + const held = semutaNexusTradePlayer(completed, f.holder).hand; + assert.equal(held.some(card => card.id === f.cost.id), claim); + assert.equal(held.some(card => card.id === f.semuta), !claim); + assert.equal(completed.discard.filter(card => card.id === f.cost.id).length, claim ? 0 : 1); + assert.equal(completed.discard.filter(card => card.id === f.semuta).length, claim ? 1 : 0); + assert.equal(completed.nexusCards!.cards!.hands[f.trader], null); + assert.deepEqual(completed.nexusCards!.cards!.discard.filter(card => card === 'choam'), ['choam']); + assert.deepEqual(semutaNexusTradeInventory(completed), semutaNexusTradeInventory(f.game)); + assert.ok(![...completed.deck, ...completed.discard, ...completed.players.flatMap(p => p.hand)] + .some(card => card.id === 'choam'), 'A spent Nexus identity is never a Treachery Card'); + assert.throws(() => applyAction(completed, f.trader, f.action), /current CHOAM Nexus trade|Worthless|trade/i); + const continued = finishSemutaNexusTradeCollection(completed); + assert.equal(continued.phase, 8, 'The original Collection readiness completes into Mentat'); + assert.equal(continued.nexusChoamTrades!.length, 1); + assert.equal(continued.nexusChoamTrades![0].stage, 'complete'); + assert.equal(semutaNexusTradePlayer(continued, f.trader).spice, before.spice + 2); + const next = nextSemutaNexusTradeNativeStep(continued); + const moved = applyAction(continued, next.actor, next.action); + assert.equal(moved.nexusChoamTrades!.length, 1); + assert.equal(moved.nexusChoamTrades![0].stage, 'complete'); + assert.deepEqual(semutaNexusTradeInventory(moved), semutaNexusTradeInventory(f.game)); +} + +for (const advanced of [false, true]) { + for (const claim of [false, true]) { + void test(`${advanced ? 'Advanced' : 'Basic'} native Secret Ally public Worthless trade ${claim ? 'claim' : 'pass'} resumes its paid receipt once`, () => { + const f = createSemutaNexusTradeFixture({ advanced }), pending = openTrade(f); + const completed = claim ? claimSemutaNexusTrade(pending, f) : passSemutaNexusTrade(pending); + assertCompletedTrade(f, completed, claim); + }); + } + void test(`${advanced ? 'Advanced' : 'Basic'} full Richese hand exchanges Semuta for the exact spent Worthless without overflow`, () => { + const f = createSemutaNexusTradeFixture({ advanced, fullHand: true }), pending = openTrade(f); + const holder = semutaNexusTradePlayer(f.game, f.holder); + assert.equal(holder.hand.length, handLimit(holder)); + const completed = claimSemutaNexusTrade(pending, f); + assert.equal(semutaNexusTradePlayer(completed, f.holder).hand.length, handLimit(holder)); + assert.deepEqual(semutaNexusTradePlayer(completed, f.holder).hand.map(card => card.id).sort(), + [...holder.hand.filter(card => card.id !== f.semuta).map(card => card.id), f.cost.id].sort()); + assertCompletedTrade(f, completed, true); + }); + void test(`${advanced ? 'Advanced Tech/Strongholds' : 'Basic Tech'} and explicit original Skills retain native Semuta/Nexus trade behavior`, () => { + const f = createSemutaNexusTradeFixture({ advanced, tech: true, strongholds: advanced, skills: true }); + const pending = openTrade(f); + assert.ok(Object.values(f.afterFirstStorm.techTokens!).every(token => token.owner && + f.afterFirstStorm.players.some(player => player.id === token.owner)), 'Original first Storm earns token assignment'); + assert.equal(f.game.leaderSkills!.assignments.length, 4); + const completed = claimSemutaNexusTrade(pending, f); + assert.deepEqual(completed.leaderSkills, f.game.leaderSkills); + assert.deepEqual(completed.techTokens, f.game.techTokens); + assert.deepEqual(completed.strongholdCards, f.game.strongholdCards); + assertCompletedTrade(f, completed, true); + }); +} + +void test('neutral passes retain the public cost and paid original receipt until the final acknowledgement', () => { + const f = createSemutaNexusTradeFixture(), pending = openTrade(f); + let game = pending; + const event = pending.pendingTreacheryDiscard!.batch.event; + for (const actor of [f.trader, ...f.allies]) { + game = applyAction(game, actor, { type: 'semutaPass', event }); + assert.equal(game.nexusChoamTrades![0].stage, 'discard'); + assert.equal(game.pendingTreacheryDiscard!.reaction!.stage, 'offer'); + assert.equal(game.discard.filter(card => card.id === f.cost.id).length, 1); + assert.equal(semutaNexusTradePlayer(game, f.trader).spice, semutaNexusTradePlayer(f.game, f.trader).spice + 2); + } + assert.throws(() => applyAction(game, f.trader, { type: 'ready' }), /discard|Semuta|reaction/i); + game = applyAction(game, f.holder, { type: 'semutaPass', event }); + assertCompletedTrade(f, game, false); +}); + +for (const difficulty of DIFFICULTIES) { + void test(`${difficulty} uses the original trade and exact Semuta decisions through the existing bot API`, () => { + const f = createSemutaNexusTradeFixture({ fullHand: true }); + const tradeView = viewGame(f.game, f.trader); + tradeView.players.find(p => p.id === f.trader)!.bot = difficulty; + let game = applyAction(f.game, f.trader, botActions(tradeView)[0]); + const event = game.pendingTreacheryDiscard!.batch.event; + for (const actor of [f.trader, ...f.allies]) { + const view = viewGame(game, actor); + view.players.find(p => p.id === actor)!.bot = difficulty; + const action = botActions(view)[0]; + assert.deepEqual(action, { type: 'semutaPass', event }); + game = applyAction(game, actor, action); + } + const own = viewGame(game, f.holder); + own.players.find(p => p.id === f.holder)!.bot = difficulty; + const commit = botActions(own)[0]; + assert.deepEqual(commit, { type: 'semutaCommit', event }); + game = applyAction(game, f.holder, commit); + assertCompletedTrade(f, game, true); + }); +} diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index 637df32a..0a3b9f0a 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -51,7 +51,7 @@ export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number]; export type PrototypeOptions = { /** Explicit independent card variant for the fresh combined-army profile. */ ecazTreachery?: boolean; - /** Independent classic Nexus/Skills composition, not a public activation. */ + /** Independent original Nexus composition with Skills or Semuta, not public activation. */ nexusCards?: boolean; /** Explicit classic Discovery/Skills composition, not a public activation. */ discoveries?: boolean; @@ -98,8 +98,8 @@ export function startPrototypeRoom( throw new Error('Unknown prototype profile.'); if (options.ecazTreachery && profile !== 'ecaz-occupy') throw new Error('--ecaz-treachery requires the ecaz-occupy profile.'); - if (options.nexusCards && profile !== 'leader-skills') - throw new Error('--nexus-cards requires the leader-skills profile.'); + if (options.nexusCards && profile !== 'leader-skills' && profile !== 'semuta') + throw new Error('--nexus-cards requires the leader-skills or semuta profile.'); if (options.discoveries && profile !== 'leader-skills' && profile !== 'nexus') throw new Error('--discoveries requires the leader-skills or nexus profile.'); if (options.leaderSkills && profile !== 'semuta') diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index ed0e57da..286b6ba2 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -27,7 +27,8 @@ async function main() { '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.', '\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, E3/Moritani, Homeworld/Nexus/Discovery/Banker overlays, public starts and played-game conversion are not opened.', + '\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.', '\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.' + @@ -57,8 +58,8 @@ async function main() { } if (values['ecaz-treachery'] && values.profile !== 'ecaz-occupy') throw new Error('--ecaz-treachery requires --profile ecaz-occupy.'); - if (values['nexus-cards'] && values.profile !== 'leader-skills') - throw new Error('--nexus-cards requires --profile leader-skills.'); + if (values['nexus-cards'] && values.profile !== 'leader-skills' && values.profile !== 'semuta') + throw new Error('--nexus-cards requires --profile leader-skills or semuta.'); if (values.discoveries && values.profile !== 'leader-skills' && values.profile !== 'nexus') throw new Error('--discoveries requires --profile leader-skills or nexus.'); if (values['leader-skills'] && values.profile !== 'semuta')