From 90c6f0020c0d22d27cf99e82ddb0024f9f30bcab Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:27:20 +1100 Subject: [PATCH] Compose explicit native Leader Skills with Semuta continuations --- components/game-table.tsx | 4 +- components/leader-skills.tsx | 7 +- docs/CURRENT_STATUS.md | 20 +++ docs/IMPLEMENTATION_STATUS.md | 53 ++++++ docs/LEADER_SKILLS_RUNTIME.md | 28 +++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 38 +++- game/engine.ts | 18 +- game/leader-skill-profile.ts | 14 +- game/reference.ts | 3 +- tests/fixture-semuta-skill-aftermath.ts | 203 +++++++++++++++++++++ tests/fixture-semuta-skills.ts | 226 ++++++++++++++++++++++++ tests/semuta-skill-aftermath.test.ts | 213 ++++++++++++++++++++++ tests/semuta-skills.test.ts | 175 ++++++++++++++++++ tools/prototype-room.ts | 6 +- tools/start-prototype.ts | 10 +- 15 files changed, 998 insertions(+), 20 deletions(-) create mode 100644 tests/fixture-semuta-skill-aftermath.ts create mode 100644 tests/fixture-semuta-skills.ts create mode 100644 tests/semuta-skill-aftermath.test.ts create mode 100644 tests/semuta-skills.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 80bd3635..7070733e 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1167,7 +1167,9 @@ export function GameTable({ may remain in supported fresh 3–6-seat CHOAM/Ix profiles; the original 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. Native Moritani + only at the end of Mentat. The explicit Leader Skills entry retains all + fourteen original skills and native training; 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 continuations remain guarded.{' '} diff --git a/components/leader-skills.tsx b/components/leader-skills.tsx index a4b6cf8a..d54b2bcb 100644 --- a/components/leader-skills.tsx +++ b/components/leader-skills.tsx @@ -113,11 +113,12 @@ export function LeaderSkillsPanel({ modifiers for Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and Prana-Bindu Adept, plus Planetologist movement and Special-card battle use, Suk Graduate force rescue, Mentat’s private - weapon-question preview and battle bonus, Bureaucrat payment redirection and battle scores, Sandmaster ground-route collection and + weapon question and battle bonus, Bureaucrat payment redirection and battle scores, Sandmaster ground-route collection and victory spice, Rihani inspections and exchanges, optional Smuggler reserve shipping, Spice Banker battle spending, and Diplomat copying - an opposing base defense with a committed Worthless card after plans - are revealed. Other Leader Skill effects are unfinished. + an opposing canonical printed defensive role with a committed Worthless card + after plans are revealed. Source-specific timing and combinations remain + guarded; complete mode acceptance is unfinished.

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 6e68fab8..3dde981d 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,26 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Semuta / explicit original Leader Skills — 11 October.** The initializer's +optional second argument and `--profile semuta --leader-skills` now delegate +original all14 native Basic Richese/CHOAM or Advanced E1/E2 training, retaining +original Tech/Advanced Stronghold prerequisites. Default callers are unchanged. +Three original profile gates recognize explicit native-Richese context; ordinary +and native-Tech skill eligibility survives Semuta. Original Suk/Rihani handlers +already preserve the correct order and need no separate effect rewrite. +No E3, Homeworld/Nexus/Discovery/Banker, new Mentat ruling or public gate opens. +Types/lint and184 affected unit cases across18 files pass. Four genuine starts, +36 private-question and48 aftermath runtime cases cover claim/pass, four legal +profiles and original modules. Actual memory-only prototype dispatch persists +original offers/version1; CLI parses the new flag. Styled390px controls complete +Hajr→Commit→Crysknife naming/showing/face-up Mentat; Suk inspector shows14reserves, +1normalTank,5board including2Sardaukar; Rihani keeps Flinto/reveals Lady Jessica +before Artillery and Shield cleanup. Human training in all three native seats +reaches original Traitors, with Auditor excluded from CHOAM's eligible five. +Observed stale skill guidance was corrected and reinspected. Existing games, +storage, user PDF/deployment untouched; full skill/mode acceptance remains open. +See [explicit original skill contract](LEADER_SKILLS_RUNTIME.md#explicit-semuta-composition--11-october-2026). + **Semuta / original Advanced Strongholds — 11 October.** Fresh Richese CHOAM/optional-Ix games now retain original unused Stronghold Cards at2–6 Advanced seats, optionally original Tech at3–6. The existing native initializer diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 37b73e79..019781f8 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,58 @@ # Dune implementation status +## 11 October 2026 — explicit Semuta/original Leader Skills composition + +`initializeSemutaGameForAudit(state,skills=false)` and the new scoped +`--profile semuta --leader-skills` option reuse original all14 training before +marking Semuta. Default callers retain their old entry. Only explicit +native-Richese context passes three original Basic/Advanced native profile +gates; original roster/module predicates still govern. Semuta integrity uses +ordinary/native-Tech skill eligibility. Original Tech3+ and Advanced +Strongholds2+ remain; E3, Homeworld/Nexus/Discovery/Banker, unapproved Mentat +steps and public starts are not opened. + +The before probe's requested option silently produced faction-only Traitors +with no skills; after it produces original private skill offers. Two disjoint +fixture/test slices then connect actual Hajr→Movement→Mentat and mandatory +loser disposal→Suk/Rihani→original winner cleanup. Native handlers/signatures +already preserve timing; no rescue/exchange rewrite was needed. Compulsory +winning Artillery stays reserved until Rihani finishes. Traitor/Face Dancer +movements never become fresh Treachery targets. + +Types/lint and184 unit cases across18 affected files pass. Twenty-four new +behavioral cases avoid constructor-only assertions. Fixture repair, not rule +change: Basic Emperor has no elite counters; Advanced uses its real two. +Internal paid-loss shape and discard-sequence assertions were removed rather +than repinned. Original Rihani normal inspection is inherited alongside its +skilled exchange; the fixture's proposed suppression was wrong. A fresh typed +cleanup binding repairs one stale TypeScript narrowing; unused setup snapshot +and incidental posture-shape assertions were removed. + +Four actual Basic/Advanced Emperor/CHOAM starts,36 question cases and48 +aftermath cases pass. Both Semuta outcomes preserve original ownership, +private exact/fallback replies, actual wallets/leaders/forces and physical +cards/skills. Suk retains one ordinary in its pool, returns two to reserves +and leaves one Tank casualty; Rihani peeks normally, optionally exchanges +two new/one old, then actual Artillery/Shield cleanup and reward finish once. +A fresh memory-only prototype-room option writes original offers/version1; +actual CLI help accepts the new flag, without existing-store access. + +Styled390px GameTable completes Hajr→Commit→original Movement→private Crysknife +question/show→face-up posture. Original Suk choice/cleanup reaches Collection; +force inspection shows14reserves/1normalTank/5board with2Sardaukar. Rihani draws, +keeps Flinto and reveals/returns Lady Jessica before later Artillery +acknowledgements and Shield cleanup, retaining one new Traitor. Native CHOAM +training lists five eligible normal discs, not Auditor; human choices in all +three seats reach original Traitors. Observed stale component guidance is +corrected to normal Mentat/printed defense/current timing gates and its actual +surface reinspected. Action notices were acknowledged, not treated as rule +stalls; all tested surfaces have no overflow. + +Warning, reference, launcher metadata/help and canonical Semuta/skill +contracts updated. Temporary probes/services/tabs removed; no existing game, +storage, user PDF or deployment touched. Complete skill/mode and deployed +acceptance remain unfinished. + ## 11 October 2026 — original Advanced Strongholds in fresh Semuta games Fresh Advanced Richese/CHOAM/optional-Ix Semuta now retains unused original diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index af5b68b5..0fe567dd 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -5,6 +5,34 @@ Normal mode starts and publication remain gated. The [source contract](LEADER_SK contains all fourteen physical cards; the [capture interpretation](LEADER_SKILLS_CAPTURE.md) records the fresh publisher/designer search requested by the user. +## Explicit Semuta composition — 11 October 2026 + +`--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. + +Actual offers and training precede real phase/battle controls. Completed Hajr +claim/pass resumes the original Movement owner, then the approved normal +Mentat private naming/card-show contract. Completed mandatory loser disposal +may precede trained Suk rescue or original Rihani normal inspection and optional +skilled exchange. Winning compulsory Artillery stays reserved until that +skill finishes; only then is it fresh Treachery. Traitor/Face Dancer movement +does not enter a Semuta batch. Original native handlers/skill rulings are reused. + +Four genuine starts,36 question cases and48 aftermath cases cover claim/pass, +four legal profiles and original module envelopes. The isolated prototype-room +option stores a real original skill offer in one fresh memory-only record. +Styled390px controls exercise all three continuations plus actual native +CHOAM/other-seat training; Auditor is absent from its five eligible discs. +Suk inspector shows14reserves/1normalTank/5board including2Sardaukar. Rihani keeps +one new private Traitor and returns its selected old card before Artillery and +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. + + ## Mixed E1/E2 and paired E3 Nexus — 7 October 2026 The original all14/Nexus entry now composes natives from both selected diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 030aa0f6..e2418d0f 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -82,7 +82,8 @@ Original unused Tech Tokens may also remain in the CHOAM/optional-Ix entry at three through six seats, Basic or Advanced, under its existing native roster prerequisites. Genuine setup and printed/first-Storm ownership remain native. This does not add Tech to the separate Basic CHOAM/Ecaz Moritani entry or admit -Skills, other overlays, played-game conversion or public starts. +other overlays, played-game conversion or public starts. Original Leader Skills +require the explicit option described below; the default entry is unchanged. Advanced additionally preserves original unused Stronghold Cards at two through six seats in the same CHOAM/optional-Ix entry. Combining original Tech @@ -91,6 +92,14 @@ its shared roster predicate supply genuine setup; original first-Storm Tech and final-Mentat Stronghold custody remain native. The six separate public module cards never become Treachery discard candidates. +`--profile semuta --leader-skills` (or the initializer's explicit second +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. + + It interrupts **clean public ordinary-card, paid Box, retired Ornithopter, used Distrans, final @@ -734,3 +743,30 @@ claim2, Arrakeen held by Atreides and Carthag unclaimed, with no overflow. The combo's original Tech owners remain visible; source/reference/help and persistent warning match the boundary. Full mode/deployment acceptance is not certified by these local changed-path proofs. + +**11 October 2026 — explicit original Leader Skills.** The opt-in delegates +original native setup before marking Semuta. Three existing profile gates +recognize only explicit native-Richese context; original roster/module +predicates still govern legality. Ordinary/native-Tech skill consumers retain +their eligibility after Semuta. Suk and Rihani handlers, receipt signatures +and effect ordering need no separate rewrite. + +Four genuine native starts include Basic/Advanced Emperor and CHOAM, with the +Auditor excluded from training. Thirty-six private-question runtime cases +cover all six supported module configurations, claim/pass/four legal profiles: +Hajr resumes its original Movement owner before native Mentat naming/showing. +Hands/cards remain physical; the target still explicitly shows an exact +Crysknife or chooses its fallback card. Forty-eight aftermath cases cover both +skills and original module profiles. Typed Suk saves one ordinary in its pool +and returns two to reserves; Rihani performs its original normal private peek +and optional exchange before compulsory winning Artillery disposal. Traitor +movement is not a Treachery event and no effect repeats. + +A real memory-only prototype-room dispatch saves the explicit skill setup at +version1 with original offers; CLI help parses the new flag without store +access. Styled390px GameTable completes Hajr→Commit→native question/show/posture, +Suk→original card cleanup→Collection, and Rihani draw→keep/reveal→Artillery +acknowledgements→Shield cleanup→Collection. Native CHOAM's five eligible normal +discs exclude Auditor; human training in all three seats reaches real Traitors. +Observed stale skill guidance was corrected and its actual surface reinspected. +This is bounded local evidence, not full skill/mode or deployed certification. diff --git a/game/engine.ts b/game/engine.ts index 47e40f5f..5adb0207 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -5528,8 +5528,10 @@ function suspendedControlsIntegrity( function treacheryDiscardIntegrity(g: Game) { requireRule(g.semutaPreview === undefined || g.semutaPreview === true && g.expansions.includes('choam') && !!byFaction(g, 'richese') && - !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.discoveries && - !g.discoveryEnabled && (!g.techTokens || nativeFactionTechProfile(g, true)) && + !g.homeworlds && !g.nexusCards && + (!g.leaderSkills || ordinaryLeaderSkillModeSupported(g)) && !g.discoveries && + !g.discoveryEnabled && (!g.techTokens || (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; @@ -9602,9 +9604,9 @@ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game { initializeMoritaniAssassinateState(g); return g; } -/** Development-only Semuta in fresh Richese games: original native Tech and - * Advanced Stronghold compositions, or bounded Basic Moritani/E3. Public starts stay gated. */ -export function initializeSemutaGameForAudit(state: Game): Game { +/** Development-only Semuta with original native Tech/Advanced Strongholds 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') && state.players.some(p => p.faction === 'moritani') && @@ -9614,9 +9616,9 @@ export function initializeSemutaGameForAudit(state: Game): 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 native Tech prerequisites and Advanced Stronghold Cards, or Basic native Moritani with CHOAM and Ecaz decks and classic opponents; other optional modules remain excluded.'); - const g = state.strongholdCards - ? initializeStrongholdFactionsGameForAudit(state) + '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.'); + const g = skills ? initializeLeaderSkillsGameForAudit(state) + : state.strongholdCards ? initializeStrongholdFactionsGameForAudit(state) : initializeFactionExpansionsGameForAudit(state); g.semutaPreview = true; return g; diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 0307ddf8..b9c5a727 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -192,6 +192,12 @@ function discoveryLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean !game.spiceBankerIncomePreview; } +/** Recognize only the explicit overlay; native predicates still own roster/module legality. */ +function semutaLeaderSkillContext(game: LeaderSkillProfile): boolean { + return game.semutaPreview === true && !game.spiceBankerIncomePreview && + game.expansions.includes('choam') && !!game.players?.some(p => p.faction === 'richese'); +} + /** E1/E2 native skills may add Tech and Advanced Strongholds; original roster/decks remain. */ function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { if (game.homeworlds) return homeworldLeaderSkillModulesSupported(game); @@ -204,7 +210,7 @@ function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { (game.strongholdCards && game.advanced !== true) || game.expansions.length < 1 || game.expansions.length > 2 || game.homeworlds || game.nexusCards || game.discoveries || game.discoveryEnabled || - game.ecazTreachery || game.semutaPreview || + game.ecazTreachery || (game.semutaPreview && !semutaLeaderSkillContext(game)) || game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || @@ -372,7 +378,8 @@ export function basicRicheseLeaderSkillsProfile(game: LeaderSkillProfile): boole const players = game.players; if (game.advanced !== false || game.expansions.length !== 1 || game.expansions[0] !== 'choam' || !players || players.length < 2 || players.length > 6 || - !nativeLeaderSkillModulesSupported(game) || game.semutaPreview || game.moritaniAssassinatePreview || + !nativeLeaderSkillModulesSupported(game) || + (game.semutaPreview && !semutaLeaderSkillContext(game)) || game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || @@ -404,7 +411,8 @@ export function advancedNativeLeaderSkillsProfile(game: LeaderSkillProfile): boo game.expansions.length < 1 || game.expansions.length > 2 || (game.expansions.length === 2 && game.expansions[0] === game.expansions[1]) || !nativeLeaderSkillModulesSupported(game) || - game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview || + (game.semutaPreview && !semutaLeaderSkillContext(game)) || + game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || diff --git a/game/reference.ts b/game/reference.ts index c66bb320..721b3ff2 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1176,10 +1176,11 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Mandatory and winner battle discards also pause before native Tleilaxu Face Dance. Claim or Continue preserves the original winner’s casualties and card cleanup, then restores the same private Face Dancer opportunity. Replacement, leader death and force sources remain native; the claimed Treachery card stays with its claimant, and no Face Dancer card becomes a Semuta target.', 'Mandatory and winner battle discards also pause before quoted Advanced CHOAM force-payment income. Claim or Continue leaves the original amount unpaid through casualties and card cleanup, then opens the same allow/Karama response. Allow pays once; cancellation sends that income to the bank without refunding the original force payments. The original post-battle market still completes afterward.', 'Mandatory and winner battle discards also pause before a native Auditor opportunity. Claim or Continue preserves the original survival limit and opponent-used card identities, then eligibility and the full cancellation price use the current opposing hand. A card acquired from the other combatant can be eligible if the audited player did not use it. Payment or private inspection resumes once; only CHOAM receives the historical snapshot.', - 'Fresh three-through-six-seat Basic/Advanced Richese games with CHOAM and optional Ix may retain original unused Tech Tokens under the existing native roster prerequisites. Mandatory and winner discards now offer Semuta before the original winner takes one loser-owned token. Claim or Continue leaves every token and income ledger in place until original cleanup finishes; the winner’s selected award still precedes Face Dance. Advanced may additionally retain original unused Stronghold Cards at two through six seats; combining Tech requires three through six. The six separate public module cards remain unowned until the actual final Mentat assignment, using the board after effects such as Gamont. Neither Semuta claim nor its original closing CHOAM continuation awards a Stronghold Card early. Skills, other overlays and the separate Basic Moritani entry remain excluded.', + 'Fresh three-through-six-seat Basic/Advanced Richese games with CHOAM and optional Ix may retain original unused Tech Tokens under the existing native roster prerequisites. Mandatory and winner discards now offer Semuta before the original winner takes one loser-owned token. Claim or Continue leaves every token and income ledger in place until original cleanup finishes; the winner’s selected award still precedes Face Dance. Advanced may additionally retain original unused Stronghold Cards at two through six seats; combining Tech requires three through six. The six separate public module cards remain unowned until the actual final Mentat assignment, using the board after effects such as Gamont. Neither Semuta claim nor its original closing CHOAM continuation awards a Stronghold Card early. Original Leader Skills require the explicit entry described below; other overlays and the separate Basic Moritani entry remain excluded.', 'A mandatory loser-card discard may also offer Semuta while the original winner’s compulsory used card remains reserved. For native Ixians, claim or Continue settles the original casualties, then restores the one-for-one Suboid/Cyborg substitution and its Karama response. The winning used Poison Tooth is not discarded until substitution or decline finishes; only then is that physical Tooth a separate fresh target before optional winner cleanup.', '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.', '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.', diff --git a/tests/fixture-semuta-skill-aftermath.ts b/tests/fixture-semuta-skill-aftermath.ts new file mode 100644 index 00000000..b51cc4fd --- /dev/null +++ b/tests/fixture-semuta-skill-aftermath.ts @@ -0,0 +1,203 @@ +import assert from 'node:assert/strict'; +import { applyAction, type Action, type Game, type Player } from '../game/engine'; +import type { Card } from '../game/cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { initializeSemutaSkillsGame, swapSemutaSkill } from './fixture-semuta-skills'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; + +export type SemutaSkillAftermathStep = { actor: string; action: Action }; +export type SemutaSkillAftermathOptions = { + skill?: 'suk-graduate' | 'rihani-decipherer'; + advanced?: boolean; + tech?: boolean; + strongholds?: boolean; +}; +export type SemutaSkillAftermathFixture = { + beforeBattle: Game; + game: Game; + actions: SemutaSkillAftermathStep[]; + /** Direct replay from beforeBattle to the active mandatory loser offer. */ + battleActions: SemutaSkillAftermathStep[]; + staging: string[]; + inventory: string[]; + skill: 'suk-graduate' | 'rihani-decipherer'; + owner: string; + opponent: string; + holder: string; + trainer: string; + losingLeader: string; + key: string; + weapon: string; + defense: string; + losingCard: string; +}; +export const reloadSemutaSkillAftermath = (game: Game): Game => JSON.parse(JSON.stringify(game)); +export function semutaSkillAftermathPlayer(game: Game, actor: string): Player { + const player = game.players.find(p => p.id === actor); + assert.ok(player, `Missing native skill seat ${actor}`); + return player; +} +export function semutaSkillAftermathInventory(game: Game): string[] { + const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), + ...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand)]; + const ids = cards.map(c => c.id).sort(); + assert.equal(new Set(ids).size, ids.length); + return ids; +} +export function assertSemutaSkillAftermathComponents(game: Game, inventory: string[]): void { + assert.deepEqual(semutaSkillAftermathInventory(game), inventory); + validateLeaderSkills(game.leaderSkills!, game.players); + for (const player of game.players) { + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20); + if (player.elites) { + assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0), 5); + for (const [key, amount] of Object.entries(player.elites.forces)) assert.ok(amount <= (player.forces[key] ?? 0)); + } + } +} + +/** Human no-spend/no-call policy at original controls; never manufactures a window. */ +export function nextSemutaSkillAftermathStep(game: Game): SemutaSkillAftermathStep | null { + if (game.pendingTreacheryDiscard?.reaction || game.decision?.kind === 'sukRescue' || game.decision?.kind === 'rihani') return null; + const decision = game.decision; + if (!game.phaseOpening && !game.response && decision?.kind === 'leaderSkillVisibility') + return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event, hide: true } }; + if (!game.phaseOpening && !game.response && decision?.kind === 'mentatQuestion') + return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } }; + return nextRicheseStrongholdsNativeStep(game); +} +function advance(game: Game, until: (game: Game) => boolean, actions: SemutaSkillAftermathStep[]): Game { + for (let count = 0; count < 1200; count++) { + if (until(game)) return game; + assert.equal(game.turn, 1); + const next = nextSemutaSkillAftermathStep(game); + assert.ok(next, 'Stop at an original human skill, Semuta or private-plan control'); + actions.push(next); + game = applyAction(game, next.actor, next.action); + } + throw new Error('Native Semuta skill aftermath did not reach its requested control'); +} +function take(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[]): string { + for (const [source, zone] of [['deck', game.deck], ['discard', game.discard], ['Richese cache', game.richeseCache ?? []], + ...game.players.map(p => [p.id, p.hand] as const)] as const) { + const index = zone.findIndex(matches); + if (index < 0) continue; + const card = zone.splice(index, 1)[0]; + semutaSkillAftermathPlayer(game, actor).hand.push(card); + staging.push(`Conserved physical ${card.id}: ${source} → ${actor} hand`); + return card.id; + } + throw new Error('Required original physical Treachery Card is unavailable'); +} + +/** Genuine native all14 setup and training, then actual first Storm/Bidding/Movement. + * Only skill identity, unplayed Spice Card order and conserved physical positions + * are staged. Phases, wallets, leader strength, outcomes and module owners are not. */ +export function createSemutaSkillAftermathFixture(options: SemutaSkillAftermathOptions = {}): SemutaSkillAftermathFixture { + const skill = options.skill ?? 'suk-graduate', owner = 'e', opponent = 'a', holder = 'r'; + const advanced = options.advanced ?? false, tech = options.tech ?? false, strongholds = options.strongholds ?? false; + assert.ok(advanced || !strongholds); + let game = initializeSemutaSkillsGame(advanced, tech, strongholds); + const actions: SemutaSkillAftermathStep[] = [], staging: string[] = []; + const trainer = swapSemutaSkill(game, owner, skill); + staging.push(`Conserved ${skill} exchanged into ${owner}'s genuinely trained living ${trainer}; original assignment/disc retained`); + const inventory = semutaSkillAftermathInventory(game); + // Prevent an incidental worm/alliance program; every unplayed card stays in its deck. + for (const [position, card] of game.spiceDeck.filter(c => 'territory' in c).slice(0, 2).entries()) + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]); + staging.push('Conserved first two unplayed non-worm Spice Cards ordered first in native deck'); + const clean = (state: Game) => !state.phaseOpening && !state.response && !state.decision; + game = advance(game, state => state.phase === 5 && clean(state), actions); + for (const player of game.players) { + game.deck.push(...player.hand); player.hand = []; + player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0); player.forces = {}; + if (player.elites) { + player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0); + player.elites.forces = {}; + } + } + const key = 'arrakeen:10'; + assert.notEqual(game.storm, 10); + for (const actor of [owner, opponent]) { + const player = semutaSkillAftermathPlayer(game, actor); + assert.ok(player.reserves >= 8); + player.reserves -= 8; player.forces[key] = 8; + } + const emperor = semutaSkillAftermathPlayer(game, owner); + if (advanced) { + assert.ok(emperor.elites && emperor.elites.reserves >= 2); + emperor.elites.reserves -= 2; emperor.elites.forces[key] = 2; + } + staging.push(advanced + ? 'Conserved board through own reserves; Emperor stages six ordinary/two elite and Atreides eight ordinary at Arrakeen' + : 'Conserved board through own reserves; both Basic combatants stage eight ordinary counters at Arrakeen'); + game = advance(game, state => state.phase === 6 && clean(state), actions); + const weapon = take(game, owner, c => c.kind === (skill === 'suk-graduate' ? 'projectile' : 'artillery'), staging); + const defense = take(game, owner, c => c.kind === 'shield', staging); + const losingCard = take(game, opponent, c => c.kind === 'worthless', staging); + take(game, holder, c => c.id === SEMUTA_DRUG_ID, staging); + const assigned = new Set(game.leaderSkills!.assignments.map(a => a.leader)); + const losingLeader = semutaSkillAftermathPlayer(game, opponent).leaders.filter(l => !assigned.has(l.id) && !l.dead && !l.usedAt) + .sort((a, b) => a.strength - b.strength)[0]; + assert.ok(losingLeader); + assertSemutaSkillAftermathComponents(game, inventory); + const beforeBattle = structuredClone(game); + const battleStart = actions.length; + assert.ok(game.active === owner || game.active === opponent); + const choose: SemutaSkillAftermathStep = { actor: game.active!, action: { type: 'chooseBattle', territory: 'arrakeen', target: game.active === owner ? opponent : owner } }; + actions.push(choose); game = applyAction(game, choose.actor, choose.action); + game = advance(game, state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed === true && clean(state), actions); + for (const next of [ + { actor: owner, action: { type: 'battlePlan', leader: trainer, dial: 4, support: advanced ? 4 : 0, weapon, defense } }, + { actor: opponent, action: { type: 'battlePlan', leader: losingLeader.id, dial: 0, support: 0, weapon: losingCard } }, + ] satisfies SemutaSkillAftermathStep[]) { + actions.push(next); game = applyAction(game, next.actor, next.action); + if (game.response || game.decision?.kind === 'fullPlanOffer') + game = advance(game, state => !state.response && state.decision?.kind !== 'fullPlanOffer', actions); + } + game = advance(game, state => state.pendingTreacheryDiscard?.reaction?.stage === 'offer', actions); + assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'battleResolved'); + assert.equal(game.lastBattleContext!.winner, owner); + return { beforeBattle, game, actions, battleActions: actions.slice(battleStart), + staging, inventory, skill, owner, opponent, holder, + trainer, losingLeader: losingLeader.id, key, weapon, defense, losingCard }; +} + +export function passSemutaSkillAftermath(game: Game): Game { + const pending = game.pendingTreacheryDiscard; + assert.equal(pending?.reaction?.stage, 'offer'); + const event = pending!.batch.event; + const reaction = pending!.reaction; assert.ok(reaction?.stage === 'offer'); + for (const player of game.players) if (!reaction.passed.includes(player.id)) + game = applyAction(game, player.id, { type: 'semutaPass', event }); + return game; +} +export function claimSemutaSkillAftermath(game: Game, holder: string, card: string): Game { + const event = game.pendingTreacheryDiscard!.batch.event; + assert.ok(game.pendingTreacheryDiscard!.batch.entries.some(entry => entry.card.id === card)); + game = applyAction(game, holder, { type: 'semutaCommit', event }); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = applyAction(reloadSemutaSkillAftermath(game), holder, { type: 'semutaSelect', event, card }); + assert.ok(semutaSkillAftermathPlayer(game, holder).hand.some(held => held.id === card)); + return game; +} +/** Select the original typed four-ordinary/zero-elite casualty allocation. */ +export function allocateSemutaSkillAftermathLosses(game: Game): Game { + if (game.decision?.kind !== 'battleLosses') return game; + const choice = game.decision.options.findIndex(o => o.normal === 4 && o.elite === 0); + assert.ok(choice >= 0); + return applyAction(game, game.decision.player, { type: 'decision', choice }); +} +/** Complete only original post-skill winner cleanup/reward, not Collection. */ +export function finishSemutaSkillAftermath(game: Game): Game { + for (let count = 0; count < 100; count++) { + if (!game.pendingTreacheryDiscard && !game.decision && !game.response && !game.battle) return game; + if (game.pendingTreacheryDiscard?.reaction) game = passSemutaSkillAftermath(game); + else { + const next = nextSemutaSkillAftermathStep(game); assert.ok(next); + game = applyAction(game, next.actor, next.action); + } + } + throw new Error('Original post-skill cleanup did not complete'); +} diff --git a/tests/fixture-semuta-skills.ts b/tests/fixture-semuta-skills.ts new file mode 100644 index 00000000..cf7d3155 --- /dev/null +++ b/tests/fixture-semuta-skills.ts @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, + viewGame, type Action, type Game, type Player, +} from '../game/engine'; +import { TERRITORIES } from '../game/board'; +import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech'; +import { assertAdvancedNativeCustody } from './fixture-advanced-native-skills'; + +export type SemutaSkillsStep = { actor: string; action: Action }; +export const reloadSemutaSkills = (game: Game): Game => JSON.parse(JSON.stringify(game)); +export function semutaSkillsPlayer(game: Game, id: string): Player { + const player = game.players.find(p => p.id === id); + assert.ok(player, `Missing original ${id} seat`); + return player; +} + +/** Control only the original fourteen-card shuffle, not native setup lotteries. + * Safe companion skills keep this fixture focused on the normal Mentat consumer. */ +function withOriginalOffers(initialize: () => T): T { + const selected: LeaderSkillId[] = ['mentat', 'killer-medic', 'swordmaster-of-ginaz']; + const originalCards = LEADER_SKILL_CARDS.map(c => c.id); + const remaining = originalCards.filter(c => !selected.includes(c)); + const desired = selected.flatMap(c => [c, remaining.shift()!]); + desired.push(...remaining); + const working = [...originalCards], rolls: number[] = []; + for (let i = working.length - 1; i > 0; i--) { + const j = working.indexOf(desired[i]); + assert.ok(j >= 0 && j <= i); + rolls.push(Math.floor(((j + 0.5) / (i + 1)) * 0x100000000)); + [working[i], working[j]] = [working[j], working[i]]; + } + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const original = crypto.getRandomValues.bind(crypto); + let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (cursor < rolls.length) { + assert.ok(array instanceof Uint32Array && array.length === 1); + array[0] = rolls[cursor++]; + } else Reflect.apply(original, crypto, [array]); + return array; + }; + try { return initialize(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +/** Fresh native Richese/CHOAM setup; no played save conversion or invented + * assignment. Every seat selects its actual two-card offer on an eligible disc. */ +export function initializeSemutaSkillsGame(advanced = false, tech = false, strongholds = false): Game { + assert.ok(advanced || !strongholds, 'Original Stronghold Cards require Advanced rules'); + let game = createGame('SEMUTASKILLS', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']); + joinGame(game, newPlayer('a', 'Atreides', 'atreides')); + joinGame(game, newPlayer('e', 'Emperor', 'emperor')); + if (tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); + if (strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true }); + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + game = withOriginalOffers(() => initializeSemutaGameForAudit(game, true)); + const selected: Record = { + r: 'mentat', a: 'killer-medic', e: 'swordmaster-of-ginaz', + }; + for (let step = 0; game.status === 'setup' && step < 250; step++) { + if (game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0]; + const offer = game.leaderSkills!.offers[actor], own = viewGame(game, actor).leaderSkills!; + const eligible = new Set(own.eligibleLeaders.map(l => l.id)); + const leader = semutaSkillsPlayer(game, actor).leaders.filter(l => eligible.has(l.id)) + .sort((a, b) => b.strength - a.strength)[0]; + const skill = selected[actor]; + assert.ok(offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader, + 'Actual original offer and living eligible leader must supply training'); + game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill, leader: leader.id }); + } else { + const next = nextRicheseSkillsTechNativeStep(game); + game = applyAction(game, next.actor, next.action); + } + } + assert.equal(game.status, 'playing'); + assertAdvancedNativeCustody(game); + return game; +} + +/** Explicit physical staging AFTER original offers/training: exchange the + * requested card with the owner's existing card, retaining both exact slots + * and the original living trained leader. No assignment is added or removed. */ +export function swapSemutaSkill(game: Game, owner: string, skill: LeaderSkillId): string { + const skills = game.leaderSkills!; + validateLeaderSkills(skills, game.players); + const assignment = skills.assignments.find(a => a.owner === owner); + assert.ok(assignment, 'Only an actually trained original owner may exchange a physical card'); + const leader = semutaSkillsPlayer(game, owner).leaders.find(l => l.id === assignment.leader); + assert.ok(leader && !leader.dead && !leader.capturedBy && !leader.gholaBy); + if (assignment.skill === skill) return assignment.leader; + const previous = assignment.skill, donor = skills.assignments.find(a => a.skill === skill); + if (donor) donor.skill = previous; + else { + const offer = Object.values(skills.offers).find(o => o.cards.includes(skill)); + if (offer) offer.cards[offer.cards.indexOf(skill)] = previous; + else { + const index = skills.deck.indexOf(skill); + assert.ok(index >= 0, 'Requested original physical skill must have a real custody slot'); + skills.deck[index] = previous; + } + } + assignment.skill = skill; + validateLeaderSkills(skills, game.players); + return assignment.leader; +} + +export function semutaSkillsInventory(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, 'Each original Treachery Card has one physical slot'); + return cards; +} +export function semutaSkillsPhysical(game: Game) { + return structuredClone({ + players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, + reserves: p.reserves, tanks: p.tanks, elites: p.elites, leaders: p.leaders, + traitors: p.traitors, revealedTraitors: p.revealedTraitors })), + skills: game.leaderSkills, tech: game.techTokens, strongholds: game.strongholdCards, + }); +} +export type SemutaSkillsMentatFixture = { + game: Game; owner: 'r'; target: 'e'; spender: 'a'; trainer: string; + semuta: string; hajr: string; weapon: string; fallback: string; territory: string; location: string; +}; +function takeCard(game: Game, actor: string, identity: string): string { + const zones = [game.deck, game.discard, game.richeseCache!, ...game.players.map(p => p.hand)]; + for (const zone of zones) { + const index = zone.findIndex(c => c.id === identity || c.name === identity || c.effect === identity); + if (index < 0) continue; + const card = zone.splice(index, 1)[0]; + semutaSkillsPlayer(game, actor).hand.push(card); + return card.id; + } + throw Error(`No original physical ${identity} card remains available`); +} + +/** Genuine setup and original phase controls reach Movement. Only the conserved + * board/hand position is staged: native forces return to their own reserves, + * then five original counters per combatant and canonical cards change slots. + * Wallets, printed leaders, skill training, turn, phase and module custody stay real. */ +export function createSemutaSkillsMentatFixture(advanced = false, tech = false, strongholds = false): SemutaSkillsMentatFixture { + let game = initializeSemutaSkillsGame(advanced, tech, strongholds); + for (let n = 0; n < 1500; n++) { + if (game.phase === 5 && !game.phaseOpening && !game.response && !game.decision) break; + assert.equal(game.turn, 1); + const next = nextRicheseSkillsTechNativeStep(game); + game = applyAction(game, next.actor, next.action); + } + assert.equal(game.phase, 5); + assert.ok(!game.phaseOpening && !game.response && !game.decision); + // End preceding actual movement owners, leaving Atreides as the real Hajr owner. + for (let n = 0; game.active !== 'a' && n < 3; n++) + game = applyAction(game, game.active!, { type: 'endMovement' }); + assert.equal(game.active, 'a'); + const physical = semutaSkillsPhysical(game), cards = semutaSkillsInventory(game); + for (const player of game.players) { + game.deck.push(...player.hand); player.hand = []; + player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0); + player.forces = {}; + if (player.elites) { + player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0); + player.elites.forces = {}; + } + } + const battleTerritory = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm)); + assert.ok(battleTerritory); + const location = `${battleTerritory.id}:${battleTerritory.sectors[0]}`; + for (const actor of ['r', 'e']) { + const player = semutaSkillsPlayer(game, actor); + assert.ok(player.reserves >= 5); + player.reserves -= 5; player.forces[location] = 5; + } + const semuta = takeCard(game, 'r', SEMUTA_DRUG_ID), hajr = takeCard(game, 'a', 'hajr'); + const weapon = takeCard(game, 'e', 'Crysknife'), fallback = takeCard(game, 'e', 'Snooper'); + assertAdvancedNativeCustody(game); + assert.deepEqual(semutaSkillsInventory(game), cards); + for (const player of game.players) { + const before = physical.players.find(p => p.id === player.id)!; + assert.equal(player.spice, before.spice); + assert.deepEqual(player.leaders, before.leaders); + } + assert.deepEqual(game.leaderSkills, physical.skills); + return { game, owner: 'r', target: 'e', spender: 'a', + trainer: game.leaderSkills!.assignments.find(a => a.owner === 'r')!.leader, + semuta, hajr, weapon, fallback, territory: battleTerritory.id, location }; +} +export function semutaSkillsDiscardStep(fixture: SemutaSkillsMentatFixture): SemutaSkillsStep { + return { actor: fixture.spender, action: { type: 'card', card: fixture.hajr } }; +} +export function reactSemutaSkillsDiscard(state: Game, claim: boolean): Game { + let game = state; + const pending = game.pendingTreacheryDiscard!; + assert.equal(pending.reaction?.stage, 'offer'); + const event = pending.batch.event; + if (claim) return applyAction(game, 'r', { type: 'semutaCommit', event }); + for (const player of game.players) + game = applyAction(game, player.id, { type: 'semutaPass', event }); + return game; +} +/** The discarded Hajr continuation must return Atreides' Movement first. + * Native movement closings then choose the original Richese/Emperor battle. */ +export function openSemutaSkillsMentat(state: Game, fixture: SemutaSkillsMentatFixture): Game { + let game = state; + assert.equal(game.pendingTreacheryDiscard, null); + assert.equal(game.active, fixture.spender); + assert.equal(game.phase, 5); + for (let n = 0; game.phase === 5 && n < 5; n++) + game = applyAction(game, game.active!, { type: 'endMovement' }); + assert.equal(game.phase, 6); + for (let n = 0; game.phaseOpening && n < 3; n++) { + const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id))!; + game = applyAction(game, actor.id, { type: 'ready' }); + } + const actor = game.active!; + assert.ok(actor === fixture.owner || actor === fixture.target); + return applyAction(game, actor, { type: 'chooseBattle', territory: fixture.territory, + target: actor === fixture.owner ? fixture.target : fixture.owner }); +} diff --git a/tests/semuta-skill-aftermath.test.ts b/tests/semuta-skill-aftermath.test.ts new file mode 100644 index 00000000..b3dcf314 --- /dev/null +++ b/tests/semuta-skill-aftermath.test.ts @@ -0,0 +1,213 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { ownedTech } from '../game/tech-tokens'; +import { + allocateSemutaSkillAftermathLosses, assertSemutaSkillAftermathComponents, + claimSemutaSkillAftermath, createSemutaSkillAftermathFixture, finishSemutaSkillAftermath, + passSemutaSkillAftermath, reloadSemutaSkillAftermath, semutaSkillAftermathPlayer, +} from './fixture-semuta-skill-aftermath'; + +const profiles = [ + { name: 'Basic native', advanced: false, tech: false, strongholds: false }, + { name: 'Advanced native', advanced: true, tech: false, strongholds: false }, + { name: 'Advanced original Tech', advanced: true, tech: true, strongholds: false }, + { name: 'Advanced original Strongholds', advanced: true, tech: false, strongholds: true }, +]; +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +for (const profile of profiles) for (const claim of [false, true]) { + void test(`${profile.name}: mandatory loser ${claim ? 'claim' : 'pass'} restores exactly one original typed Suk rescue`, () => { + const f = createSemutaSkillAftermathFixture(profile); + let game = reloadSemutaSkillAftermath(f.game); + const original = semutaSkillAftermathPlayer(f.beforeBattle, f.owner); + const enemy = semutaSkillAftermathPlayer(f.beforeBattle, f.opponent); + const event = game.pendingTreacheryDiscard!.batch.event; + assert.equal(game.pendingTreacheryDiscard!.batch.cause, 'battle:mandatory'); + assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.losingCard]); + assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'battleResolved'); + assert.equal(game.pendingSukRescue!.losses, null); + assert.equal(game.lastBattleContext!.sukRescue!.completed, false); + assert.equal(semutaSkillAftermathPlayer(game, f.owner).forces[f.key], 8, 'Winner casualties await the fresh loser discard reaction'); + assert.equal(semutaSkillAftermathPlayer(game, f.owner).tanks, original.tanks); + assert.equal(semutaSkillAftermathPlayer(game, f.opponent).forces[f.key] ?? 0, 0); + assert.equal(semutaSkillAftermathPlayer(game, f.opponent).tanks, enemy.tanks + 8); + assert.equal(semutaSkillAftermathPlayer(game, f.opponent).leaders.find(l => l.id === f.losingLeader)!.dead, true); + if (profile.tech) assert.deepEqual(game.techTokens, f.beforeBattle.techTokens, 'Original reward remains after rescue/card cleanup'); + reject(game, f.owner, { type: 'decision', event: game.pendingSukRescue!.event, choice: 0 }); + game = claim ? claimSemutaSkillAftermath(game, f.holder, f.losingCard) : passSemutaSkillAftermath(game); + reject(game, f.holder, { type: 'semutaCommit', event }); + reject(game, f.holder, { type: 'semutaSelect', event, card: f.losingCard }); + game = allocateSemutaSkillAftermathLosses(reloadSemutaSkillAftermath(game)); + assert.ok(game.decision?.kind === 'sukRescue'); + assert.equal(game.decision.mode, 'skilled'); + assert.equal(semutaSkillAftermathPlayer(game, f.owner).forces[f.key], 8); + const rescue = game.decision; + const choice = rescue.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.kind === 'normal' && o.kept.key === f.key); + assert.ok(choice >= 0); + const rescueAction: Action = { type: 'decision', event: rescue.event, choice }; + reject(game, f.opponent, rescueAction); + reject(game, f.owner, { ...rescueAction, event: `${rescue.event}-stale` }); + game = applyAction(reloadSemutaSkillAftermath(game), f.owner, rescueAction); + const rescued = semutaSkillAftermathPlayer(game, f.owner); + assert.equal(rescued.forces[f.key], 5, 'Three ordinary board casualties removed; saved fourth remains'); + if (profile.advanced) assert.equal(rescued.elites!.forces[f.key], 2, 'Neither elite counter was selected as a casualty'); + assert.equal(rescued.reserves, original.reserves + 2); + assert.equal(rescued.tanks, original.tanks + 1); + if (profile.advanced) { + assert.equal(rescued.elites!.reserves, original.elites!.reserves); + assert.equal(rescued.elites!.tanks, original.elites!.tanks); + } + assert.equal(rescued.battleLosses, original.battleLosses + 4); + assert.equal(game.pendingSukRescue, null); + assert.equal(game.lastBattleContext!.sukRescue!.completed, true); + assert.ok(game.decision?.kind === 'battleCards'); + assert.deepEqual([...game.decision.cards].sort(), [f.weapon, f.defense].sort()); + reject(game, f.owner, rescueAction); + assert.equal(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.losingCard), claim); + assert.equal(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claim); + assertSemutaSkillAftermathComponents(game, f.inventory); + // Original optional winner disposal happens AFTER the skill, using its own receipt. + game = applyAction(reloadSemutaSkillAftermath(game), f.owner, { type: 'decision', discard: [f.weapon] }); + assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'battleCleanup'); + assert.equal(game.pendingTreacheryDiscard!.batch.cause, 'battle:winner'); + assert.equal(game.lastBattleContext!.sukRescue!.completed, true); + const cleanupEvent = game.pendingTreacheryDiscard!.batch.event; + assert.notEqual(cleanupEvent, event); + const physicalAfterRescue = structuredClone({ forces: rescued.forces, reserves: rescued.reserves, tanks: rescued.tanks, elites: rescued.elites }); + game = finishSemutaSkillAftermath(reloadSemutaSkillAftermath(game)); + const finished = semutaSkillAftermathPlayer(game, f.owner); + assert.deepEqual({ forces: finished.forces, reserves: finished.reserves, tanks: finished.tanks, elites: finished.elites }, physicalAfterRescue); + assert.ok(finished.hand.some(c => c.id === f.defense)); + assert.equal(finished.hand.some(c => c.id === f.weapon), false); + assert.equal(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.losingCard), claim); + assert.equal(game.phase, 7); + if (profile.tech) { + const won = ownedTech(f.beforeBattle.techTokens, f.opponent)[0]; + assert.ok(won); + assert.equal(game.techTokens![won].owner, f.owner); + assert.equal(ownedTech(game.techTokens, f.opponent).length, 0); + } + reject(game, f.owner, rescueAction); + reject(game, f.holder, { type: 'semutaCommit', event: cleanupEvent }); + assertSemutaSkillAftermathComponents(game, f.inventory); + }); +} + +for (const profile of profiles) for (const claim of [false, true]) { + void test(`${profile.name}: loser ${claim ? 'claim' : 'pass'} restores Rihani ${claim ? 'exchange' : 'decline'} before compulsory winning Artillery disposal`, () => { + const f = createSemutaSkillAftermathFixture({ ...profile, skill: 'rihani-decipherer' }); + let game = reloadSemutaSkillAftermath(f.game); + const ownerBefore = semutaSkillAftermathPlayer(f.beforeBattle, f.owner); + const event = game.pendingTreacheryDiscard!.batch.event; + assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'battleResolved'); + assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.losingCard]); + assert.equal(game.lastBattleContext!.rihani!.completed, false); + assert.deepEqual(game.pendingWinnerDiscards!.cards, [f.weapon]); + assert.ok(semutaSkillAftermathPlayer(game, f.owner).hand.some(c => c.id === f.weapon), 'Winning compulsory Artillery is not an invented pre-skill target'); + assert.equal(game.rihaniHistory?.length ?? 0, 0); + assert.equal(game.discard.some(c => c.id === f.weapon), false); + const initialTraitors = [...semutaSkillAftermathPlayer(game, f.owner).traitors]; + game = claim ? claimSemutaSkillAftermath(game, f.holder, f.losingCard) : passSemutaSkillAftermath(game); + game = allocateSemutaSkillAftermathLosses(reloadSemutaSkillAftermath(game)); + assert.ok(game.decision?.kind === 'rihani' && game.decision.stage === 'offer'); + assert.equal(game.pendingTreacheryDiscard, null, 'Traitor drawing is not a Treachery discard reaction'); + assert.equal(semutaSkillAftermathPlayer(game, f.owner).forces[f.key], 4); + if (profile.advanced) assert.equal(semutaSkillAftermathPlayer(game, f.owner).elites!.forces[f.key], 2); + assert.equal(semutaSkillAftermathPlayer(game, f.owner).tanks, ownerBefore.tanks + 4); + assert.equal(semutaSkillAftermathPlayer(game, f.owner).reserves, ownerBefore.reserves); + assert.ok(semutaSkillAftermathPlayer(game, f.owner).leaders.find(l => l.id === f.trainer && !l.dead)); + const rihaniEvent = game.decision.event; + const drawAction: Action = { type: 'decision', event: rihaniEvent, draw: claim }; + reject(game, f.opponent, drawAction); + reject(game, f.holder, { type: 'semutaCommit', event }); + reject(game, f.holder, { type: 'semutaSelect', event, card: f.weapon }); + game = applyAction(reloadSemutaSkillAftermath(game), f.owner, drawAction); + let exchangeAction: Action | undefined; + let kept: string | undefined, given: string | undefined, unkept: string | undefined; + if (claim) { + assert.ok(game.decision?.kind === 'rihani' && game.decision.stage === 'return'); + const receipt = game.rihaniHistory![0]; + assert.equal(receipt.drawn.length, 2); + assert.deepEqual(semutaSkillAftermathPlayer(game, f.owner).traitors, [...initialTraitors, ...receipt.drawn]); + assert.equal(game.pendingTreacheryDiscard, null); + assert.equal(game.pendingWinnerDiscards!.cards[0], f.weapon); + kept = receipt.drawn[0]; unkept = receipt.drawn[1]; given = receipt.eligible[0]; + exchangeAction = { type: 'decision', event: rihaniEvent, cards: [kept, given] }; + reject(game, f.owner, drawAction); + reject(game, f.owner, { type: 'decision', event: rihaniEvent, cards: [given, kept] }); + game = applyAction(reloadSemutaSkillAftermath(game), f.owner, exchangeAction); + assert.deepEqual(semutaSkillAftermathPlayer(game, f.owner).traitors, [...initialTraitors.filter(id => id !== given), kept]); + assert.ok(game.traitorReserve!.includes(given) && game.traitorReserve!.includes(unkept)); + assert.equal(game.traitorReserve!.includes(kept), false); + } else { + assert.deepEqual(semutaSkillAftermathPlayer(game, f.owner).traitors, initialTraitors); + assert.deepEqual(game.rihaniHistory![0].drawn, []); + assert.equal(game.rihaniHistory![0].given, null); + } + assert.equal(game.lastBattleContext!.rihani!.completed, true); + assert.equal(game.rihaniHistory!.length, 1); + assert.equal(game.rihaniHistory![0].stage, 'complete'); + assert.equal(game.pendingWinnerDiscards, null); + assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'winnerMandatoryDiscard'); + assert.equal(game.pendingTreacheryDiscard!.batch.cause, 'battle:winnerMandatory'); + assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.weapon]); + assert.ok(game.pendingTreacheryDiscard!.batch.entries.every(e => e.card.kind === 'artillery'), 'No Traitor or Face Dancer is a Treachery discard target'); + const compulsoryEvent = game.pendingTreacheryDiscard!.batch.event; + reject(game, f.owner, drawAction); + if (exchangeAction) reject(game, f.owner, exchangeAction); + game = passSemutaSkillAftermath(reloadSemutaSkillAftermath(game)); + assert.ok(game.decision?.kind === 'battleCards'); + assert.deepEqual(game.decision.cards, [f.defense]); + const cleanup = applyAction(reloadSemutaSkillAftermath(game), f.owner, { type: 'decision', discard: [f.defense] }); + assert.equal(cleanup.pendingTreacheryDiscard!.continuation.kind, 'battleCleanup'); + game = finishSemutaSkillAftermath(reloadSemutaSkillAftermath(cleanup)); + assert.equal(game.rihaniHistory!.length, 1); + assert.equal(game.phase, 7); + assert.equal(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.losingCard), claim); + if (claim) { + assert.equal(game.rihaniHistory![0].kept, kept); + assert.equal(game.rihaniHistory![0].given, given); + assert.ok(semutaSkillAftermathPlayer(game, f.owner).traitors.includes(kept!)); + assert.ok(game.traitorReserve!.includes(given!) && game.traitorReserve!.includes(unkept!)); + } + reject(game, f.holder, { type: 'semutaCommit', event: compulsoryEvent }); + reject(game, f.owner, drawAction); + assertSemutaSkillAftermathComponents(game, f.inventory); + }); +} + +void test('A late compulsory Artillery claim cannot precede or replay the original Rihani exchange', () => { + const f = createSemutaSkillAftermathFixture({ advanced: true, skill: 'rihani-decipherer' }); + let game = allocateSemutaSkillAftermathLosses(passSemutaSkillAftermath(reloadSemutaSkillAftermath(f.game))); + assert.ok(game.decision?.kind === 'rihani'); + const event = game.decision.event; + game = applyAction(reloadSemutaSkillAftermath(game), f.owner, { type: 'decision', event, draw: true }); + const receipt = game.rihaniHistory![0], kept = receipt.drawn[0], given = receipt.eligible[0]; + const exchange: Action = { type: 'decision', event, cards: [kept, given] }; + game = applyAction(reloadSemutaSkillAftermath(game), f.owner, exchange); + assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'winnerMandatoryDiscard'); + const compulsoryEvent = game.pendingTreacheryDiscard!.batch.event; + const skillReceipt = structuredClone(game.rihaniHistory), forces = structuredClone(semutaSkillAftermathPlayer(game, f.owner).forces); + game = claimSemutaSkillAftermath(reloadSemutaSkillAftermath(game), f.holder, f.weapon); + assert.ok(game.decision?.kind === 'battleCards'); + assert.deepEqual(game.rihaniHistory, skillReceipt); + assert.deepEqual(semutaSkillAftermathPlayer(game, f.owner).forces, forces); + assert.ok(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.weapon)); + assert.ok(semutaSkillAftermathPlayer(game, f.owner).traitors.includes(kept)); + reject(game, f.owner, exchange); + reject(game, f.holder, { type: 'semutaCommit', event: compulsoryEvent }); + game = applyAction(reloadSemutaSkillAftermath(game), f.owner, { type: 'decision', discard: [] }); + game = finishSemutaSkillAftermath(game); + assert.ok(semutaSkillAftermathPlayer(game, f.owner).hand.some(c => c.id === f.defense)); + assert.ok(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.weapon)); + assert.deepEqual(game.rihaniHistory, skillReceipt); + assertSemutaSkillAftermathComponents(game, f.inventory); + // Projection at the completed JSON boundary exercises canonical skill receipts. + for (const player of game.players) viewGame(reloadSemutaSkillAftermath(game), player.id); +}); diff --git a/tests/semuta-skills.test.ts b/tests/semuta-skills.test.ts new file mode 100644 index 00000000..27da8b75 --- /dev/null +++ b/tests/semuta-skills.test.ts @@ -0,0 +1,175 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { assertAdvancedNativeCustody } from './fixture-advanced-native-skills'; +import { + createSemutaSkillsMentatFixture, semutaSkillsDiscardStep, reactSemutaSkillsDiscard, + openSemutaSkillsMentat, reloadSemutaSkills as reload, semutaSkillsPlayer, + semutaSkillsInventory, semutaSkillsPhysical, type SemutaSkillsMentatFixture, +} from './fixture-semuta-skills'; + +const profiles = [ + { advanced: false, tech: false, strongholds: false }, + { advanced: false, tech: true, strongholds: false }, + { advanced: true, tech: false, strongholds: false }, + { advanced: true, tech: true, strongholds: false }, + { advanced: true, tech: false, strongholds: true }, + { advanced: true, tech: true, strongholds: true }, +]; +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} +function questionResources(game: Game) { + return structuredClone({ players: game.players, deck: game.deck, discard: game.discard, + cache: game.richeseCache, removed: game.richeseRemoved, skills: game.leaderSkills, + tech: game.techTokens, strongholds: game.strongholdCards }); +} +function pause(f: SemutaSkillsMentatFixture): Game { + const step = semutaSkillsDiscardStep(f), game = applyAction(reload(f.game), step.actor, step.action); + const pending = game.pendingTreacheryDiscard!; + assert.equal(pending.reaction?.stage, 'offer'); + assert.equal(pending.batch.cause, 'ordinary:hajr'); + assert.deepEqual(pending.batch.entries.map(e => ({ id: e.card.id, actor: e.discardedBy, publicFace: e.publicFace })), + [{ id: f.hajr, actor: f.spender, publicFace: true }]); + assert.equal(pending.continuation.kind, 'ordinaryCardDiscard'); + if (pending.continuation.kind !== 'ordinaryCardDiscard') throw Error('Expected the original Hajr continuation'); + assert.equal(pending.continuation.player, f.spender); + assert.equal(pending.continuation.effect, 'hajr'); + assert.equal(game.active, f.spender); + assert.equal(game.phase, 5); + assert.equal(game.hajr.filter(id => id === f.spender).length, 1); + assert.deepEqual(semutaSkillsPhysical(game), semutaSkillsPhysical(f.game)); + assert.deepEqual(semutaSkillsInventory(game), semutaSkillsInventory(f.game)); + reject(game, f.spender, { type: 'endMovement' }); + return game; +} +function resumedQuestion(pending: Game, f: SemutaSkillsMentatFixture, claim: boolean): Game { + const event = pending.pendingTreacheryDiscard!.batch.event; + const movement = reactSemutaSkillsDiscard(reload(pending), claim); + assert.equal(movement.pendingTreacheryDiscard, null); + assert.equal(movement.phase, 5); + assert.equal(movement.active, f.spender, 'The original Atreides closing owner resumes before Battle'); + assert.deepEqual(movement.movementRemaining, f.game.movementRemaining); + assert.equal(movement.hajr.filter(id => id === f.spender).length, 1); + assert.equal(semutaSkillsPlayer(movement, f.owner).hand.some(c => c.id === f.hajr), claim); + assert.equal(semutaSkillsPlayer(movement, f.owner).hand.some(c => c.id === f.semuta), !claim); + assert.equal(movement.discard.filter(c => c.id === f.hajr).length, Number(!claim)); + assert.equal(movement.discard.filter(c => c.id === f.semuta).length, Number(claim)); + assert.deepEqual(semutaSkillsPhysical(movement), semutaSkillsPhysical(f.game)); + assert.deepEqual(semutaSkillsInventory(movement), semutaSkillsInventory(f.game)); + reject(movement, f.owner, { type: 'semutaCommit', event }); + const game = openSemutaSkillsMentat(reload(movement), f); + assert.equal(game.decision?.kind, 'mentatQuestion', 'Semuta must retain the original native skill consumer'); + assert.equal(game.decision?.player, f.owner); + assert.equal(game.battle?.mentatQuestion?.leader, f.trainer); + assert.equal(game.battle?.mentatQuestion?.stage, 'name'); + assert.equal(game.battle?.mentatQuestion?.owner, f.owner); + assert.equal(game.battle?.mentatQuestion?.target, f.target); + assertAdvancedNativeCustody(game); + assert.deepEqual(semutaSkillsInventory(game), semutaSkillsInventory(f.game)); + return game; +} + +for (const profile of profiles) { + const label = `${profile.advanced ? 'Advanced' : 'Basic'}${profile.tech ? ' Tech' : ''}${profile.strongholds ? ' Strongholds' : ''}`; + void test(`${label} Semuta claim/pass resumes the native owner before a genuine private Mentat question and reply`, () => { + const f = createSemutaSkillsMentatFixture(profile.advanced, profile.tech, profile.strongholds); + const pending = pause(f); + for (const claim of [false, true]) for (const matched of [false, true]) { + const game = resumedQuestion(pending, f, claim), before = questionResources(game); + const event = game.battle!.mentatQuestionEvent!; + const weapon = matched ? 'Crysknife' : 'Lasgun'; + const expected = matched ? f.weapon : f.fallback; + assert.ok(viewGame(game, f.owner).mentat.pending!.weapons.includes(weapon)); + for (const id of [f.target, f.spender]) { + assert.deepEqual(viewGame(game, id).mentat.pending!.weapons, []); + assert.deepEqual(viewGame(game, id).mentat.pending!.cards, []); + reject(game, id, { type: 'decision', event, weapon }); + } + const named = applyAction(reload(game), f.owner, { type: 'decision', event, weapon }); + assert.equal(named.decision?.kind, 'mentatQuestion'); + assert.equal(named.decision?.player, f.target, 'Both exact and fallback questions use the same target-owned reply'); + assert.equal(named.battle!.mentatQuestion!.stage, 'reveal'); + assert.equal(named.battle!.mentatQuestionEvent, event); + const choices = viewGame(named, f.target).mentat.pending!.cards; + assert.deepEqual(choices.map(c => c.id), matched ? [f.weapon] : [f.weapon, f.fallback]); + for (const id of [f.owner, f.spender]) { + assert.deepEqual(viewGame(named, id).mentat.pending!.cards, []); + assert.deepEqual(viewGame(named, id).mentat.history, []); + reject(named, id, { type: 'decision', event, card: expected }); + } + if (matched) reject(named, f.target, { type: 'decision', event, card: f.fallback }); + reject(named, f.target, { type: 'decision', event: 'stale', card: expected }); + const shown = applyAction(reload(named), f.target, { type: 'decision', event, card: expected }); + assert.equal(shown.battle!.mentatQuestion!.stage, 'answered'); + assert.equal(shown.decision?.kind, 'leaderSkillVisibility'); + assert.deepEqual(questionResources(shown), before, 'A normal question never transfers, reserves, spends or edits components'); + assert.deepEqual(semutaSkillsPlayer(shown, f.target).hand.map(c => c.id), [f.weapon, f.fallback]); + for (const id of [f.owner, f.target]) { + const history = viewGame(shown, id).mentat.history; + assert.equal(history.length, 1); + assert.equal(history[0].event, event); + assert.equal(history[0].card.id, expected); + assert.equal(history[0].weapon, weapon); + } + assert.deepEqual(viewGame(shown, f.spender).mentat.history, []); + assert.ok(!JSON.stringify(viewGame(shown, f.spender)).includes(expected)); + assert.ok(!JSON.stringify(shown.log).includes(expected)); + reject(shown, f.owner, { type: 'decision', event, weapon }); + reject(shown, f.target, { type: 'decision', event, card: expected }); + let posture = reload(shown); + while (posture.decision?.kind === 'leaderSkillVisibility') { + const decision = posture.decision; + posture = applyAction(posture, decision.player, + { type: 'leaderSkillVisibility', event: decision.event, hide: false }); + } + assert.equal(posture.battle!.leaderSkillHidden!.r, false, 'The normal Mentat may stay face-up after the private question'); + assert.ok(posture.battle?.preparation || posture.decision?.kind === 'fullPlanOffer' || posture.battle?.preLeader); + assertAdvancedNativeCustody(posture); + assert.deepEqual(semutaSkillsInventory(posture), semutaSkillsInventory(f.game)); + } + }); +} + +void test('all four AI profiles participate legally in the original private Mentat contract after Semuta claim/pass', () => { + for (const profile of profiles) { + const f = createSemutaSkillsMentatFixture(profile.advanced, profile.tech, profile.strongholds); + const pending = pause(f); + for (const claim of [false, true]) { + const question = resumedQuestion(pending, f, claim); + for (const difficulty of DIFFICULTIES) { + const own = viewGame(question, f.owner); + own.players.find(p => p.id === f.owner)!.bot = difficulty; + const name = botActions(own)[0]; + assert.ok(name && own.mentat.pending!.weapons.includes(name.weapon as string)); + assert.equal(name.event, question.battle!.mentatQuestionEvent); + for (const id of [f.target, f.spender]) { + const other = viewGame(question, id); + other.players.find(p => p.id === id)!.bot = difficulty; + assert.deepEqual(botActions(other), []); + } + const named = applyAction(reload(question), f.owner, name), before = questionResources(named); + const target = viewGame(named, f.target); + target.players.find(p => p.id === f.target)!.bot = difficulty; + const answer = botActions(target)[0]; + assert.ok(answer && target.mentat.pending!.cards.some(c => c.id === answer.card)); + assert.equal(answer.event, name.event); + for (const id of [f.owner, f.spender]) { + const other = viewGame(named, id); + other.players.find(p => p.id === id)!.bot = difficulty; + assert.deepEqual(botActions(other), []); + } + const shown = applyAction(reload(named), f.target, answer); + assert.equal(shown.decision?.kind, 'leaderSkillVisibility'); + assert.deepEqual(questionResources(shown), before); + assert.equal(viewGame(shown, f.owner).mentat.history[0].card.id, answer.card); + assert.deepEqual(viewGame(shown, f.spender).mentat.history, []); + assertAdvancedNativeCustody(shown); + } + } + } +}); diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index a08c384a..637df32a 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -55,6 +55,8 @@ export type PrototypeOptions = { nexusCards?: boolean; /** Explicit classic Discovery/Skills composition, not a public activation. */ discoveries?: boolean; + /** Explicit original native skills only with the Semuta profile. */ + leaderSkills?: boolean; }; export function isPrototypeProfile(value: string): value is PrototypeProfile { return PROTOTYPE_PROFILES.some((profile) => profile === value); @@ -100,6 +102,8 @@ export function startPrototypeRoom( throw new Error('--nexus-cards requires the leader-skills 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') + throw new Error('--leader-skills requires the semuta profile.'); if (options.ecazTreachery) initial.ecazTreachery = true; if ((profile === 'nexus' || options.nexusCards) && !initial.nexusCards) initial.nexusCards = { cards: null, phase: null }; @@ -121,7 +125,7 @@ export function startPrototypeRoom( : profile === 'moritani-assassinate' ? initializeMoritaniAssassinateGameForAudit(initial) : profile === 'semuta' - ? initializeSemutaGameForAudit(initial) + ? initializeSemutaGameForAudit(initial, options.leaderSkills) : profile === 'kull' ? initializeKullGameForAudit(initial) : profile === 'richese-betrayal' diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index eea757f9..ed0e57da 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -18,6 +18,7 @@ async function main() { 'ecaz-treachery': { type: 'boolean' }, 'nexus-cards': { type: 'boolean' }, discoveries: { type: 'boolean' }, + 'leader-skills': { type: 'boolean' }, help: { type: 'boolean' }, }, }); @@ -25,7 +26,8 @@ async function main() { 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.', - '\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 at 2–6 seats; 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 Stronghold claim. Skills, other modules, the separate CHOAM/Ecaz Moritani entry, played-game retrofit and public starts remain excluded.', + '\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.', '\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.' + @@ -59,6 +61,8 @@ async function main() { throw new Error('--nexus-cards requires --profile leader-skills.'); 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') + throw new Error('--leader-skills requires --profile semuta.'); if ( !isPrototypeProfile(values.profile) || !values.db || @@ -82,7 +86,8 @@ async function main() { const db = new DatabaseSync(values.db); try { const result = startPrototypeRoom(db, values.room, version, values.profile, - { ecazTreachery: values['ecaz-treachery'], nexusCards: values['nexus-cards'], discoveries: values.discoveries }); + { ecazTreachery: values['ecaz-treachery'], nexusCards: values['nexus-cards'], discoveries: values.discoveries, + leaderSkills: values['leader-skills'] }); writeFileSync( resolve(values.out, 'prototype.json'), JSON.stringify( @@ -92,6 +97,7 @@ async function main() { profile: values.profile, ecazTreachery: !!values['ecaz-treachery'], nexusCards: !!values['nexus-cards'], + leaderSkills: !!values['leader-skills'], source, beforeVersion: version, ...result,