diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 62f58654..e0931a1d 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,25 @@ open mode gates. ## Current checkpoint and work +**Native Tech / Strongholds without Skills — bounded Development Verified:** +fresh selected E1/E2 native or standalone Ecaz OR Moritani entry preserves +original Tech3..6 Basic/Advanced and optional Advanced Strongholds, without +Skills or public-start changes. New32 cases are included in affected187/187; +final types/lint, rebuilt surface and two final entropy consumers pass. +Thirty original admission probes, both below-three rejections, twelve actual +native programs and all four legal Face Dance policies pass. Fifteen genuine +three-seat games complete4,902 accepted actions/no rejection/125 JSON continuations. +Human390px **BZVLA22D v21/Mentat** chooses native Ecaz lead, real dial5/ +support1/Sanya4 versus dial2/Bashar2:9–4, fixed Ecaz3/Guild3 casualties, +held Tabr2 income, native2+1 survivors and original Axlotl+Heighliners. +**YENG8B6U v15/Mentat** keeps losing Tuek4/support1, Master3 bounty/death, +wallet18, Guild winner Tech and one actual private Bashar replacement. +Original CLI v8 setup, refresh, public owner labels and updated reference pass; +root390px has no overflow. Controlled positions are labeled; no natural +assassination claim. Read [scope and evidence](EXPANSION_FACTIONS_PROTOTYPE.md#native-tech-composition--4-october-2026). +Other overlays, pending rulings, public starts and full/deployed acceptance +remain open. Existing games and the user PDF are preserved. + **Standalone E3 Skills / Tech / Strongholds — bounded Development Verified:** fresh original Ecaz OR Moritani ecaz33/all14 profiles preserve Tech3..6 Basic/Advanced and Strongholds2..6 Advanced, optionally both. Original diff --git a/docs/EXPANSION_FACTIONS_PROTOTYPE.md b/docs/EXPANSION_FACTIONS_PROTOTYPE.md index 54b3f811..185dc509 100644 --- a/docs/EXPANSION_FACTIONS_PROTOTYPE.md +++ b/docs/EXPANSION_FACTIONS_PROTOTYPE.md @@ -55,10 +55,9 @@ variant and are absent from this profile. or Advanced lobby, two through six distinct ready players, and any roster whose faction sets are among the selected nonempty expansion list. It uses the shared prediction, traitor, force-placement, starting-card, and first-Storm pipeline. -It rejects an already dealt or modified lobby and does not add optional modules. -The ordinary browser lobby still creates Basic games only; Advanced profile -setup is currently an offline development/test path rather than a visible mode -selection. +It rejects an already dealt or modified lobby. Without optional modules, the +original selected-family scope remains unchanged. Unfinished public expansion +starts remain gated; the visible Advanced preview is still classic-only. Faction-specific setup uses the existing physical inventories and decisions: @@ -115,6 +114,79 @@ and the public start gate. Refresh the existing invitation to continue through the ordinary private setup controls. Reusing the command on the started game is rejected. +## Native Tech composition — 4 October 2026 + +Enable original Tech Tokens in a fresh ready lobby before using the existing +`factions` entry. This bounded composition needs three through six unique seats, +at least one native selected E1/E2 faction with distinct required `ix`/`choam` +decks, or standalone Ecaz OR Moritani with classic opponents and exact `ecaz`. +Basic and Advanced retain original decks, cache, leaders, force placement and +printed/first-Storm token assignment. It adds no Leader Skills. + +The existing Advanced `stronghold-factions` entry additionally preserves these +unused Tech Tokens with its six original Stronghold Cards. Enable both in the +lobby; actual end-Mentat ownership remains separate from first-Storm Tech setup. +Tech at two seats, Basic Strongholds, paired/mixed E3 rosters, Homeworlds, Nexus, +Discoveries, independent E3 cards and unrelated preview overlays stay excluded. +Advanced Moritani excludes Harkonnen under its existing assassination boundary. + +Ecaz uses original selected-lead battle rewards; an ally cannot share a token +or turn split Tech holdings into a joint occupied territory. Basic combined +Occupy remains even-force only. Original Moritani normal-call forfeiture, +printed assassination bounty and one real Mentat replacement remain. +Native payments, typed losses and original winner Tech before Face Dance reuse +the existing handlers; this entry does not certify every interaction. + +The authorized Advanced source permits variants together or separately +(supplied2.3 PDF physical21/printed19). That page assigns native/first-Storm +Tech owners, phase-end income and one-player complete-set victory. Physical38/ +printed36 preserves original winner cards, spice and Tech before Face Dance. +Printed faction/Stronghold effects supplement those passages; no pending +Basic odd-force or Richese cache/No-Field ruling is decided here. + +Genuine offline samples use the existing driver: + +```sh +node --import tsx tools/faction-games.ts --profile factions-tech \ + --players 3 --rules both --out /private/new-native-tech-games +node --import tsx tools/faction-games.ts --profile stronghold-factions-tech \ + --players 3 --rules advanced --out /private/new-native-stronghold-tech-games +``` + +These are development profiles, not public activation or saved-game conversion. + + +### Bounded development evidence + +Thirty actual fresh3/6-seat admission probes and both below-three rejections +pass. New32 consumer-facing cases are included in affected187/187 across +native Tech, existing token/Stronghold/E3, prototype/CLI and guide contracts. +Types/lint/build pass; the final scoped entropy dispatch additionally passes +both original Face Dance/bot consumers. Twelve actual native programs and +four legal Face Dance policies preserve original winner Tech and typed forces. + +Ten Tech-only and five Stronghold+Tech genuine3-seat games complete4,902 +accepted actions, no rejection and125 JSON continuations. Original reports: +`/tmp/dune-native-tech-games-20261004/report.json` and +`/tmp/dune-native-stronghold-tech-games-20261004/report.json`. +These captured unchanged source before a fixture-only generic dispatch repair; +the game engine did not change afterward. This is not all-roster certification. + +Original human rooms start at v8/setup through the exact existing CLI. +At390px **BZVLA22D v21/Mentat** selects native Ecaz/Sanya4, dial5/support1, +against Bashar2/dial2:9–4. Original fixed Ecaz3/Guild3 losses retain2+1 +physical survivors; held Tabr pays2 and original Axlotl+Heighliners go to +the actual lead. Ordinary Collection adds no city income at this Tabr-only +position. **YENG8B6U v15/Mentat** keeps losing Tuek4, original support1, +actual Master3 bounty/death, wallet18, Guild winner Tech and one private +Bashar replacement. Guild's separate Tuek Collection1 yields wallet4. +Native public token labels, leader inspector, original decisions, refresh and +updated guide are observed; root390px has no overflow. These conserved +controlled positions are not natural battle histories. Dedicated QA store: +`/tmp/dune-native-tech-qa-20261004`; existing user games are untouched. +Full rules, combinations, assurance, AI calibration and deployed acceptance +remain open. + ## Known incomplete play This entry point makes the faction setup real and reviewable; it does not mark diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index bad8ee97..9c447d7e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,44 @@ # Dune implementation status +## 4 October 2026 — native Tech and Advanced Strongholds without Skills + +Two bounded owners supplied independent E1/E2 and standalone E3 cases. +Parent integrated fresh native profile admission into existing faction and +Stronghold entries, original Ecaz coalition/Moritani contexts, genuine sample +and resume profiles, canonical guidance and original human controls. +Tech3..6 Basic/Advanced preserves selected E1/E2 or standalone Ecaz OR +Moritani decks/inventories; Advanced may compose six Stronghold Cards. +Public starts, old games, unrelated modules and existing rulings are unchanged. + +New32 meaningful cases are included in affected187/187. Types/lint/build, +thirty actual admission probes/two below-three rejections, twelve actual native +programs and four legal original Face Dance policies pass. Final fixture-only +generic entropy dispatch passes both original consumers and types/lint. +Obsolete Tech rejection, incidental wallet/placement/response pins and error +wording were removed, not repinned. Actual printed CHOAM half-income uses +two opponent spice→one income, excluding own bank-funded support. +Ecaz uses actual affordable support, not a fixture wallet grant. + +Ten Tech-only and five Stronghold+Tech genuine3-seat games complete4,902 +accepted actions/no rejection/125 JSON. Reports capture unchanged tree +`61521366bc68245741ec510546084e7bb7e2395b737474a78eda018f95a92676`; +final generic repair affects only the fixture, not the game engine. +Natural Moritani samples do not claim assassination. Original report roots: +`/tmp/dune-native-tech-games-20261004`, +`/tmp/dune-native-stronghold-tech-games-20261004`. + +Original CLI BZVLA22D/YENG8B6Uv8/setup retains unused selected modules and +all original seats. Controlled human390px **BZVLA22D v21/Mentat** selects +Ecaz/Sanya4, dial5/support1 against Bashar2/dial2:9–4. Fixed Ecaz3/Guild3 +casualties leave2+1 fighters, held Tabr2 income and original Axlotl+Heighliners. +No city Collection income is applicable to this Tabr-only position. +**YENG8B6U v15/Mentat** preserves support1/losing Tuek4, printed Master3 +death/bounty, wallet18, Guild winner Tech and one private Bashar replacement. +Guild independently collects Tuek1 to wallet4. Inspector, decisions, public +token labels, refresh and updated guide are observed; root390px has no overflow. +The new dedicated QA store preserves these rooms and never touches user saves. +Source/rule/faction/module/assurance/calibration/public/deployed gates stay open. + ## 4 October 2026 — standalone E3 skills with Tech and Advanced Strongholds Two bounded owners supplied Ecaz ordinary-disc/Duke and Moritani diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 08ccd990..8f83906c 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -203,7 +203,8 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit | Standalone E3 Skills / Tech / Stronghold Cards | Original Ecaz OR Moritani ecaz33/all14 rosters preserve Tech Basic/Advanced3..6 and Stronghold Advanced2..6, optionally both. Native Ecaz five-disc/temporary Duke/Advanced-Hark exclusion and Moritani skill-first assassination/normal-call forfeiture remain. Real claimed effects, original support/rescue, printed Duke6 or unused-disc bounty and one Mentat replacement compose native rewards without new ownership or income rules. E3 pairs, mixed families, allied Occupy skills, other overlays, public starts and save conversion remain excluded. | [Source contracts and bounded evidence](LEADER_SKILLS_RUNTIME.md#standalone-e3-module-composition--4-october-2026) | | Native Mentat preview | Explicit fresh `leader-skills --mentat-question` reuses the existing private uniform question for already-supported Basic Ixian/Tleilaxu/CHOAM/Moritani profiles. Exact/default card roles, held fallback and skill-before-faction timing are unchanged. Ordinary/public activation, empty-hand handling, Advanced native skills and other modules stay gated; this is integration, not a new answer to the pending UX question. | [Native integration and existing authority](MENTAT_QUESTION.md#native-skill-integration--2-october-2026) | | Normal Spice Banker local preview | Fresh classic/base33 Basic/Advanced or supported Basic native skill profiles, all14 skills alone. One additional deferred bank spice per physical card/turn/phase for another payer's one actual bank payment ≥4; no payer pooling or advance spending. Tleilaxu self-revival pays Bank; other-player revival pays Tleilaxu unless canceled. The separate free-revival award never contributes to payer debit. Actual initialized Mentat collects once; original earned-faction retention and separate-payer threshold remain provisional local policies, not answers to pending custody/split questions. No public start, retrofit or other overlays. | [Normal-band contract](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026), [native integration](SPICE_BANKER_RUNTIME.md#basic-native-banker-integration--2-october-2026), [skill authority](LEADER_SKILLS_RULES.md) | -| Advanced Stronghold faction integration | Fresh `stronghold-factions` admits selected E1/E2 native families and standalone Ecaz OR Moritani with classic2..6-seat Advanced, canonical selected decks and six Stronghold Cards alone. Standalone E3 uses ecaz33; Moritani excludes Harkonnen. Ecaz co-control holds the card at Ecaz, but only the selected actual holder-plan receives its effect; fixed Ecaz strength is free, and Arrakeen funds only actual variable support. Losing Moritani retains printed Tuek income before original assassination, printed bounty and one actual Mentat replacement; winner cleanup finishes once and normal Traitor reveal forfeits assassination for the game. Existing E1/E2 No-Field/Stone/FaceDance and retained winner rewards remain. E3 pairs/mixed families, mixed No-Field/full-plan, Stone/Poison Tooth and other overlays stay guarded. This composes existing contracts, not a new ruling. Save/privacy/custody assurance follows all rules. | [E1/E2 scope](STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules), [standalone E3 scope and evidence](STRONGHOLD_CARDS.md#standalone-e3-stronghold-composition--4-october-2026) | +| Advanced Stronghold faction integration | Fresh `stronghold-factions` admits selected E1/E2 native families and standalone Ecaz OR Moritani with classic2..6-seat Advanced, canonical selected decks and six Stronghold Cards; optional unused Tech Tokens requires3..6, without Leader Skills. Standalone E3 uses ecaz33; Moritani excludes Harkonnen. Ecaz co-control holds the card at Ecaz, but only the selected actual holder-plan receives its effect; fixed Ecaz strength is free, and Arrakeen funds only actual variable support. Losing Moritani retains printed Tuek income before original assassination, printed bounty and one actual Mentat replacement; winner cleanup finishes once and normal Traitor reveal forfeits assassination for the game. Existing E1/E2 No-Field/Stone/FaceDance and retained winner rewards remain. E3 pairs/mixed families, mixed No-Field/full-plan, Stone/Poison Tooth and other overlays stay guarded. This composes existing contracts, not a new ruling. Save/privacy/custody assurance follows all rules. | [E1/E2 scope](STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules), [standalone E3 scope and evidence](STRONGHOLD_CARDS.md#standalone-e3-stronghold-composition--4-october-2026), [native Tech composition](EXPANSION_FACTIONS_PROTOTYPE.md#native-tech-composition--4-october-2026) | +| Native Tech without Leader Skills | Fresh3..6 Basic/Advanced selected E1/E2 native families or standalone Ecaz OR Moritani plus classics preserve original unused three Tech Tokens through `factions`; Advanced Strongholds use the existing native entry. Physical setup, printed/first-Storm owners, phase-end income and single-player complete-set victory are unchanged. Actual Ecaz selected lead receives the original opposing token, never a joint allied holding; Basic odd Occupy remains guarded. Moritani original forfeiture/assassination and winner Tech before Face Dance reuse existing handlers. No new ruling, wallet grant, public start or save conversion. | [Original composition/source and evidence](EXPANSION_FACTIONS_PROTOTYPE.md#native-tech-composition--4-october-2026), [Stronghold contract](STRONGHOLD_CARDS.md#native-tech-composition-without-skills--4-october-2026) | | Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income, bounded sole-occupant Orgiz transfer and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine also have bounded paths. Contested and mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) | | Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) | | Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) | diff --git a/docs/STRONGHOLD_CARDS.md b/docs/STRONGHOLD_CARDS.md index b3e396cb..73420674 100644 --- a/docs/STRONGHOLD_CARDS.md +++ b/docs/STRONGHOLD_CARDS.md @@ -2,6 +2,30 @@ Historical development module checkpoint, 7 September 2026: the six printed effects, public card custody, mobile declaration, support accounting, AI choices and readable faces are integrated. The later user-authorized [classic Advanced preview](ADVANCED_PREVIEW.md) already permits Stronghold Cards; preserve that access. Complete Advanced/expansion release and arbitrary combinations remain gated. +## Native Tech composition without Skills — 4 October 2026 + +Fresh Advanced `stronghold-factions` may preserve original unused Tech Tokens +at3..6 seats, without Leader Skills, in selected E1/E2 native families or +standalone Ecaz OR Moritani plus classic opponents. Original selected decks, +cache, native first-Storm Tech owners and actual end-Mentat Stronghold claims +remain. Tech-only Basic/Advanced uses existing `factions` entry; read +[the native composition contract](EXPANSION_FACTIONS_PROTOTYPE.md#native-tech-composition--4-october-2026). + +The actual selected Ecaz lead receives its own held-card effect and original +winner Tech; neither card advantages nor split token holdings are shared. +Original typed casualties/payments, losing Tuek income, Moritani post-loss +assassination and original winner rewards before Face Dance remain distinct. +Two-player Tech, Basic Strongholds, mixed E3, other overlays and public starts +stay gated; existing unresolved rulings are unchanged. + +New32/affected187 cases, types/lint/build, twelve actual native programs and +all four legal Face Dance policies pass. Fifteen genuine3-seat module games +complete4,902 actions/no rejection/125 JSON continuations. Original human +**BZVLA22D v21/Mentat** retains actual selected-lead Tabr income and two +original Tech Tokens; **YENG8B6U v15/Mentat** retains losing Tuek income, +printed Master3 assassination, original winner Tech and one private replacement. +Read [source-bound evidence](EXPANSION_FACTIONS_PROTOTYPE.md#bounded-development-evidence). + ## Standalone E3 skills and optional Tech — 4 October 2026 Fresh original native Ecaz OR Moritani ecaz33/all14 skill profiles additionally diff --git a/game/engine.ts b/game/engine.ts index 51ef11e0..6882832c 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,4 @@ +import { nativeFactionTechProfile } from './faction-module-profile'; import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, strongholdLeaderSkillsProfile } from './leader-skill-profile'; import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; @@ -7514,9 +7515,9 @@ function leaderSkillAssignmentUnavailable( function e3StrongholdFactionProfile(g: Game): boolean { if (!g.advanced || g.expansions.length !== 1 || g.expansions[0] !== 'ecaz' || g.players.length < 2 || g.players.length > 6 || - g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || + g.homeworlds || g.nexusCards || g.leaderSkills || g.discoveryEnabled || g.discoveries || g.discoveryStash || g.greatMaker || - g.ecazTreachery) return false; + g.ecazTreachery || (g.techTokens && !nativeFactionTechProfile(g))) return false; let native: 'ecaz' | 'moritani' | null = null; for (const player of g.players) { if (player.faction === 'ecaz' || player.faction === 'moritani') { @@ -7534,7 +7535,7 @@ function moritaniAssassinateModeSupported(g: Game) { (!g.strongholdCards || e3StrongholdFactionProfile(g) || (!!g.leaderSkills && strongholdLeaderSkillsProfile(g))) && !g.homeworlds && !g.discoveryEnabled && !g.discoveries && - (!g.techTokens || (!!g.leaderSkills && nativeTechLeaderSkillsProfile(g))); + (!g.techTokens || nativeFactionTechProfile(g) || (!!g.leaderSkills && nativeTechLeaderSkillsProfile(g))); } function moritaniAssassinateContext(g: Game, receipt: MoritaniAssassinateReceipt) { const p = getPlayer(g,receipt.owner), opponent = getPlayer(g,receipt.opponent); @@ -9244,10 +9245,13 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game { 'The faction prototype needs a nonempty selection of distinct known expansions.'); requireRule(!state.homeworlds && !state.nexusCards && !state.leaderSkills && !state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker && - !state.techTokens && !state.strongholdCards, - 'The faction prototype excludes optional modules, including Leader Skills and Discoveries.'); + (!state.techTokens || nativeFactionTechProfile(state)) && !state.strongholdCards, + 'The faction prototype excludes other optional modules; Tech Tokens require a supported native three-through-six-seat lobby.'); requireFreshFactionInventory(state); - return initializeSetupGameForAudit(state, false, false, false, false, false, false, true); + const initialized = initializeSetupGameForAudit(state, false, false, false, false, false, false, true); + if (initialized.advanced && nativeFactionTechProfile(initialized) && byFaction(initialized, 'moritani')) + initializeMoritaniAssassinateState(initialized); + return initialized; } function ecazOccupyCompositionSupported(g: Game): boolean { return typeof g.advanced === 'boolean' && Array.isArray(g.expansions) && @@ -9264,7 +9268,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean { g.expansions.includes(faction(p.faction).expansion))) && !g.homeworlds && !g.nexusCards && !g.leaderSkills && (!g.strongholdCards || e3StrongholdFactionProfile(g)) && - !g.techTokens && !g.discoveryEnabled && !g.discoveries && + (!g.techTokens || nativeFactionTechProfile(g)) && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && (g.ecazTreachery === undefined || g.ecazTreachery === true); } @@ -9290,9 +9294,9 @@ export function initializeStrongholdFactionsGameForAudit(state: Game): Game { 'Stronghold factions require supported Advanced native factions, classic opponents and their selected distinct decks.'); requireRule(!state.homeworlds && !state.nexusCards && !state.leaderSkills && !state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker && - !state.techTokens && !state.ecazTreachery && + (!state.techTokens || nativeFactionTechProfile(state)) && !state.ecazTreachery && (!state.strongholdCards || JSON.stringify(state.strongholdCards) === JSON.stringify(createStrongholdCards())), - 'Stronghold factions admit only unused Stronghold Cards, without other optional modules.'); + 'Stronghold factions admit unused Stronghold Cards and supported native Tech Tokens, without other optional modules.'); requireFreshFactionInventory(state); requireFreshBaseRuntime(state); const initialized = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, false, true); @@ -9424,9 +9428,10 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f requireFreshSetup(g, homeworlds || nexus || ix || factions); const skillStronghold = leaderSkills && strongholdLeaderSkillsProfile(g); const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold); - requireRule(!g.techTokens || (skillTech && + const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g); + requireRule(!g.techTokens || ((skillTech || nativeTech) && JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())), - 'Leader Skills with Tech Tokens requires a fresh supported classic/native three-through-six-seat lobby with unused tokens.'); + 'Tech Tokens require a fresh supported three-through-six-seat lobby with unused tokens.'); requireRule(!g.strongholdCards || strongholdFactions || (skillStronghold && JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards())), 'Leader Skills with Stronghold Cards requires a fresh supported Advanced lobby with unused cards.'); @@ -9438,7 +9443,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f (discovery || !g.discoveryEnabled) && (nexus || !g.nexusCards) && (ecazTreachery || !g.ecazTreachery) && - (!g.techTokens || skillTech) && + (!g.techTokens || skillTech || nativeTech) && (strongholdFactions || skillStronghold || !g.strongholdCards) && (homeworlds || !g.homeworlds) && g.players.every((p) => @@ -9461,6 +9466,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f : 'The audit initializer supports base factions without expansions or optional modules.', ); initializeSetup(g, strongholdFactions || !!g.strongholdCards); + if (nativeTech && byFaction(g, 'ecaz')) g.ecazOccupyPreview = true; return normalizeAutomaticGame(g); } function ecazStartingForcesComplete(p: Player): boolean { diff --git a/game/faction-module-profile.ts b/game/faction-module-profile.ts new file mode 100644 index 00000000..1dd3d3d7 --- /dev/null +++ b/game/faction-module-profile.ts @@ -0,0 +1,68 @@ +import { FACTIONS } from './catalog'; + +/** Public lobby configuration only; physical freshness is checked by the initializer. */ +export type FactionModuleProfile = { + advanced: boolean; + expansions: readonly string[]; + players: readonly { faction: string }[]; + techTokens?: unknown; + strongholdCards?: unknown; + leaderSkills?: unknown; + homeworlds?: unknown; + nexusCards?: unknown; + discoveryEnabled?: unknown; + discoveries?: unknown; + discoveryStash?: unknown; + greatMaker?: unknown; + ecazTreachery?: unknown; + semutaPreview?: unknown; + advancedPreview?: unknown; + kullPreview?: unknown; + nexusKullPreview?: unknown; + guildBetrayalPreview?: unknown; + richeseBetrayalPreview?: unknown; + nexusIxianReplacementPreview?: unknown; + nexusIxianBetrayalPreview?: unknown; + nexusHarkonnenBetrayalPreview?: unknown; +}; + +/** Original E1/E2 families or standalone Ecaz/Moritani; no unrelated overlays. */ +export function nativeFactionTechProfile(game: FactionModuleProfile): boolean { + if (typeof game.advanced !== 'boolean' || !game.techTokens || + game.players.length < 3 || game.players.length > 6 || + game.expansions.length < 1 || game.expansions.length > 2 || + (game.strongholdCards && !game.advanced) || game.leaderSkills || + game.homeworlds || game.nexusCards || game.discoveryEnabled || game.discoveries || + game.discoveryStash || game.greatMaker || game.ecazTreachery || + game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview || + game.guildBetrayalPreview || game.richeseBetrayalPreview || + game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || + game.nexusHarkonnenBetrayalPreview) return false; + const e3 = game.expansions.length === 1 && game.expansions[0] === 'ecaz'; + if (!e3) { + for (let i = 0; i < game.expansions.length; i++) { + if (game.expansions[i] !== 'ix' && game.expansions[i] !== 'choam') return false; + for (let j = 0; j < i; j++) if (game.expansions[i] === game.expansions[j]) return false; + } + } + let native = 0; + let moritani = false; + let harkonnen = false; + for (let i = 0; i < game.players.length; i++) { + const player = game.players[i]; + for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false; + let expansion: string | undefined; + for (const faction of FACTIONS) + if (faction.id === player.faction) { expansion = faction.expansion; break; } + if (expansion === undefined) return false; + if (expansion !== 'base') { + if (e3 ? player.faction !== 'ecaz' && player.faction !== 'moritani' + : !game.expansions.includes(expansion)) return false; + native++; + } + if (player.faction === 'moritani') moritani = true; + if (player.faction === 'harkonnen') harkonnen = true; + } + return native > 0 && (!e3 || native === 1) && + !(game.advanced && moritani && harkonnen); +} diff --git a/game/reference.ts b/game/reference.ts index d242082a..1f3f11cf 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3254,7 +3254,7 @@ export const RULE_TOPICS: RuleTopic[] = [ developmentStage: 'Verified', summary: 'Fresh Advanced Stronghold play connects native E1/E2 families and standalone Ecaz or Moritani through original holder-only rule handlers.', steps: [ - 'This private local profile admits selected native Ixian/Tleilaxu and CHOAM/Richese families with classic opponents in unique two-to-six-seat Advanced rosters. Standalone Ecaz OR Moritani with classic opponents additionally uses the exact ecaz deck. Moritani excludes Harkonnen under its original assassination/Duke boundary. An E3 pair, E1/E2/E3 mixtures, Basic and other overlays remain separate; only Stronghold Cards are enabled.', + 'This private local profile admits selected native Ixian/Tleilaxu and CHOAM/Richese families with classic opponents in unique two-to-six-seat Advanced rosters. Standalone Ecaz OR Moritani with classic opponents additionally uses the exact ecaz deck. Moritani excludes Harkonnen under its original assassination/Duke boundary. Original unused Tech Tokens may additionally be selected with three through six seats, without Leader Skills; an E3 pair, E1/E2/E3 mixtures, Basic Strongholds and unrelated overlays remain separate.', 'CHOAM-only ordinary Treachery has 35 physical cards; Ix or both decks have47, not49, because CHOAM replaces the shared Ix cards. Native Richese’s ten-card cache remains separate. Genuine setup retains original faction offers/leaders/forces and any native Auditor/HMS, with all six Stronghold Cards initially unowned.', 'Card ownership begins only at the real first end of Mentat and is retained through the following turn. It is not assigned by a battle fixture or reset on refresh. Hidden Mobile Stronghold interior forces and its outside pointing territory stay distinct.', 'The mobile card copies an advantage of another currently controlled stronghold, not merely a retained card. A sole choice is automatic; multiple choices use the existing public pre-plan selection. Accepted choices stay bound to the same original battle through private plans and saved continuation.', @@ -3266,7 +3266,7 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['stronghold-cards', 'mobile-stronghold', 'choam-combat', 'setup', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Implemented', detail: 'Fresh selected E1/E2 setup and standalone Ecaz/Moritani exact ecaz deck, actual end-Mentat ownership, holder-only Ecaz coalition funding and original Moritani loss/assassination compose existing effects without public-start or old-game changes.', evidence: ['game/engine.ts', 'game/stronghold-cards.ts', 'game/battle-resolution-quote.ts'] }, + { area: 'Implementation', status: 'Implemented', detail: 'Fresh selected E1/E2 setup and standalone Ecaz/Moritani exact ecaz deck, actual end-Mentat ownership, holder-only Ecaz coalition funding and original Moritani loss/assassination compose existing effects. Optional original Tech requires three through six seats and awards the actual selected lead before Face Dance, without public-start or old-game changes.', evidence: ['game/engine.ts', 'game/faction-module-profile.ts', 'game/stronghold-cards.ts', 'game/battle-resolution-quote.ts'] }, { area: 'Player controls', status: 'Implemented', detail: 'Original local390px controls cover HMS copy/support, real cache Stone reveal mode and partial Face Dance replacement. Standalone E3 human controls select the Ecaz holder lead, show bank2/personal1 for actual support3, seal native plans and keep the physical Shield; Moritani reveals printed Master Bewt bounty through its original inspector and choice. Original Collection and Mentat continue after refresh.', evidence: ['components/stronghold-cards.tsx', 'components/game-table.tsx'] }, { area: 'AI', status: 'Partial', detail: 'Four minimal legal policies consume actual marker/Stone/FaceDance/copy choices; eighteen genuine paired/mixed Advanced2..6 games complete. Strategy/calibration and other combinations remain deferred.', evidence: ['game/bots.ts', 'tools/faction-games.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'Advanced family/deck/cache admission, existing six effects, original winner versus replacement and explicit guarded combinations are recorded without completing every card or faction.', evidence: ['docs/STRONGHOLD_CARDS.md', 'docs/RULE_DECISIONS.md'] }, @@ -3287,7 +3287,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Fremen reserve arrivals and Guild cross-planet or return shipments do not trigger Heighliners. A stopped shipment never arrives and cannot trigger it. Bene Gesserit accompanying forces do arrive from off planet and can trigger the token even after a Guild shipment.', 'A battle winner takes one tech token from the defeated faction after casualties and played-card decisions. With several available tokens, the winner chooses. A winner still takes the token if no winning forces survive. Mutual destruction has no winner and transfers no token.', 'Owning all three tokens counts as one additional stronghold at victory checks. An alliance cannot combine separate token holdings to qualify: one member must own the complete set. Multiple sides reaching the victory target can share the win.', - 'Token ownership and pending income are public and persist with the room. Fresh supported classic or E1/E2 native Leader Skills tables retain original three-through-six-seat Basic/Advanced setup and effects; Advanced may also retain Stronghold Cards. Skill rescue or board-spice awards finish before the mandatory original winner token reward and later Face Dance. Banker remains a separate source-to-Mentat opt-in. CHOAM’s separate native opening payout is not ordinary poverty Charity. Two-player tokens, E3/other combinations and full combined acceptance remain unfinished.', + 'Token ownership and pending income are public and persist with the room. Fresh supported classic, E1/E2 native or standalone Ecaz/Moritani Leader Skills tables retain original three-through-six-seat Basic/Advanced setup and effects; Advanced may also retain Stronghold Cards. Skill rescue or board-spice awards finish before the mandatory original winner token reward and later Face Dance. Banker remains a separate source-to-Mentat opt-in. CHOAM’s separate native opening payout is not ordinary poverty Charity. Two-player tokens, mixed E3/other combinations and full combined acceptance remain unfinished.', + 'Without Skills, fresh native E1/E2 families or standalone Ecaz OR Moritani with classic opponents may preserve unused original Tech Tokens through the local factions entry, Basic/Advanced three through six players; Advanced stronghold-factions adds the original six Stronghold Cards. Native payments, typed casualties, selected Ecaz lead rewards and Moritani original assassination remain separate. Split allied holdings never qualify as a complete set. Basic odd Occupy, Richese cache/No-Field rulings, unrelated overlays, public starts and saved-game conversion remain guarded.', ], example: 'You own Heighliners and Axlotl Tanks. When Emperor ships from reserves, place two spice on Heighliners. A later shipment adds nothing. Collect the two spice when Shipping and Movement ends.', diff --git a/tests/e3-native-tech-runtime.test.ts b/tests/e3-native-tech-runtime.test.ts new file mode 100644 index 00000000..9a1da143 --- /dev/null +++ b/tests/e3-native-tech-runtime.test.ts @@ -0,0 +1,278 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeFactionExpansionsGameForAudit, + initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Game, +} from '../game/engine'; +import type { FactionId } from '../game/catalog'; +import { treacheryDeck } from '../game/cards'; +import { createTechTokens, ownedTech } from '../game/tech-tokens'; +import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { strongholdControllers } from '../game/stronghold-cards'; +import { finishEcazOccupySetup } from './fixture-ecaz-occupy'; +import { advanceEcazStronghold, ecazStrongholdSeat as player, finishEcazStronghold } from './fixture-ecaz-strongholds'; +import { + advanceMoritaniStrongholds, holdMoritaniStrongholdsTraitor, nextMoritaniStrongholdsStep, + resolveMoritaniStrongholdsBattle, stageMoritaniStrongholdsBattle, +} from './fixture-moritani-strongholds'; +import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions'; +import { + createEcazNativeTechFixture, createMoritaniNativeTechFixture, + initializeE3NativeTechSetup, revealEcazNativeTechBattle, +} from './fixture-e3-native-tech'; + +function lobby(roster: FactionId[], advanced = true, expansions: Game['expansions'] = ['ecaz']): Game { + let game = createGame('E3TECHGUARD', newPlayer(`auth-${roster[0]}`, roster[0], roster[0]), advanced, expansions); + for (const faction of roster.slice(1)) joinGame(game, newPlayer(`auth-${faction}`, faction, faction)); + game.techTokens = createTechTokens(); + for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' }); + return game; +} +function physical(game: Game): void { + const cards = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(game.auction.index) ?? []), + ...game.players.flatMap(p => p.hand)].map(c => c.id).sort(); + assert.deepEqual(cards, treacheryDeck(['ecaz']).map(c => c.id).sort(), 'Every original selected physical card retains one custodian.'); + assert.equal(new Set(cards).size, cards.length); + for (const p of game.players) { + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20); + if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), + p.faction === 'emperor' ? 5 : 3); + } + assert.equal(Object.keys(game.techTokens!).length, 3); + for (const token of Object.values(game.techTokens!)) + assert.ok(token.owner === null || game.players.some(p => p.id === token.owner)); + const moritani = game.players.find(p => p.faction === 'moritani'); + if (moritani) { + assert.equal(moritani.leaders.length, 5, 'The five original Moritani leader discs remain physically present.'); + assert.equal(new Set(moritani.leaders.map(l => l.id)).size, 5); + assert.equal(game.moritaniTerror!.tokens.length, 6); + assert.equal(new Set(game.moritaniTerror!.tokens.map(t => t.id)).size, 6); + const traitors = [...game.players.flatMap(p => p.traitors), ...(game.traitorReserve ?? []), + ...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : []), + ...(game.moritaniAssassinate?.opportunities.flatMap(o => o.stage === 'replaced' && o.card ? [o.card] : []) ?? [])]; + assert.equal(new Set(traitors).size, traitors.length, 'Held, replacement-reserve and revealed spent Traitors have distinct physical custody.'); + } +} + +for (const native of ['ecaz', 'moritani'] as const) for (const advanced of [false, true]) + for (const seats of [3, 6]) void test(`${advanced ? 'Advanced' : 'Basic'} standalone ${native} plain Tech: ${seats} original seats complete real setup and receive three first-Storm tokens`, () => { + const roster: FactionId[] = [native, 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit'].slice(0, seats) as FactionId[]; + const admitted = lobby(roster, advanced); + const setup = initializeE3NativeTechSetup({ initial: admitted, native, advanced }); + let game = finishEcazOccupySetup(setup); + const actor = game.players.find(p => p.faction === native)!; + assert.equal(actor.id, `auth-${native}`); + physical(game); + game = advanceMoritaniStrongholds(game, g => g.phase === 2 && !g.phaseOpening && !g.response && !g.decision); + const owners = Object.values(game.techTokens!).map(token => token.owner); + assert.ok(owners.every(owner => typeof owner === 'string' && game.players.some(p => p.id === owner))); + assert.equal(new Set(owners).size, 3, 'Actual first-Storm order supplies unassigned tokens to three distinct seats.'); + physical(game); + }); + + +for (const strongholds of [false, true]) for (const lead of ['ecaz', 'ally'] as const) + void test(`Advanced standalone Ecaz ${strongholds ? 'Stronghold+Tech' : 'plain Tech'}: selected ${lead} earns only the enemy token after original holder reward and typed owner casualties`, () => { + const f = createEcazNativeTechFixture({ strongholds }); + const result = revealEcazNativeTechBattle(f, lead, true); + const { before, revealed, pending, actor } = result; + const battle = revealed.battle!, publicBattle = viewGame(revealed, actor).battle!; + const side = (id: string): ResolutionCombatant => { + const p = player(revealed, id), view = viewGame(revealed, id).battle!; + assert.ok(view.ownForces); + return { id, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, + plan: battle.plans[id], leader: p.leaders.find(l => l.id === battle.plans[id].leader), + forces: view.ownForces, stronghold: view.strongholdEffects[id] }; + }; + const quote = quoteBattleResolution({ advanced: true, turn: revealed.turn, territory: f.territory, + ecazOccupy: publicBattle.ecazOccupy!.profile!, aggressor: publicBattle.aggressor, + attacker: side(battle.attacker), defender: side(battle.defender), + voters: publicBattle.traitorVoters.map(id => ({ id, called: false, traitors: player(revealed, id).traitors, + beneficiary: [battle.attacker, battle.defender].includes(id) ? id : actor })), + participants: revealed.players, physicalCards: [...revealed.deck, ...revealed.discard, ...revealed.players.flatMap(p => p.hand)], + pendingAuditorPresent: false, pendingRetentionPresent: false }); + assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 3, elite: 0 }]); + assert.equal(quote.casualties!.owner, f.ally, 'The Guild variable army owns its losses even when Ecaz seals the winning plan.'); + assert.deepEqual(quote.casualties!.options, [{ normal: 3, elite: 0, + paidNormal: battle.plans[actor].support, paidElite: 0 }]); + const income = strongholds && lead === 'ecaz' ? 2 : 0; + assert.deepEqual(quote.strongholdIncome, income ? [{ player: actor, amount: income }] : []); + const payment = quote.payments.find(p => p.player === actor)!; + assert.equal(player(pending, actor).spice, player(before, actor).spice - payment.ownPayment + income); + assert.equal(pending.decision?.player, actor); + const enemyToken = ownedTech(before.techTokens, f.opponent); + assert.equal(enemyToken.length, 1); + assert.equal(pending.techTokens![enemyToken[0]].owner, f.opponent, + 'Stronghold reward is already paid while actual winner-card cleanup still precedes Tech custody.'); + const game = finishEcazStronghold(pending), nonLead = lead === 'ecaz' ? f.ally : f.ecaz; + assert.equal(game.lastBattleContext!.winner, actor); + assert.equal(game.techTokens![enemyToken[0]].owner, actor); + assert.equal(ownedTech(game.techTokens, actor).length, 2); + assert.equal(ownedTech(game.techTokens, nonLead).length, 1); + assert.equal(ownedTech(game.techTokens, f.opponent).length, 0); + assert.equal(player(game, f.ecaz).tanks - player(before, f.ecaz).tanks, 3); + assert.equal(player(game, f.ally).tanks - player(before, f.ally).tanks, 3); + assert.equal(player(game, f.opponent).tanks - player(before, f.opponent).tanks, 8); + assert.equal(player(game, f.ecaz).forces[f.location], 2); + assert.equal(player(game, f.ally).forces[f.location], 1); + const progress = viewGame(game, actor).victoryProgress.find(p => p.player === actor)!; + assert.equal(progress.strongholds.length, 3); + assert.equal(progress.jointlyOccupied.length, 1); + assert.equal(progress.techStronghold, false, 'Two plus one allied tokens do not produce a joint third-token stronghold.'); + assert.equal(progress.qualifies, false); + physical(game); + const collection = quoteSpiceCollection(game).receipts.find(p => p.player === actor)!; + const nextTurn = advanceEcazStronghold(game, g => g.turn === 4 && g.phase === 0); + assert.equal(nextTurn.status, 'playing', 'Original end-Mentat does not infer an allied three-token victory.'); + assert.deepEqual(nextTurn.winner, []); + assert.equal(player(nextTurn, actor).spice, player(pending, actor).spice + collection.strongholds + collection.collected, + 'Only actual native Collection follows the completed reward; the earlier Stronghold battle income does not replay.'); + }); + +for (const lead of ['ecaz', 'ally'] as const) + void test(`Basic plain Tech Ecaz ${lead}: even original coalition pays no support and attributes the token to its actual selected lead`, () => { + const f = createEcazNativeTechFixture({ advanced: false }); + const result = revealEcazNativeTechBattle(f, lead); + const token = ownedTech(result.before.techTokens, f.opponent)[0]; + assert.equal(player(result.pending, result.actor).spice, player(result.before, result.actor).spice); + const game = finishEcazStronghold(result.pending); + assert.equal(game.techTokens![token].owner, result.actor); + assert.equal(player(game, f.ecaz).forces[f.location], 2); + assert.equal(player(game, f.ally).forces[f.location], 1); + assert.equal(player(game, f.ecaz).tanks - player(result.before, f.ecaz).tanks, 2); + assert.equal(player(game, f.ally).tanks - player(result.before, f.ally).tanks, 3); + physical(game); + }); + +void test('Basic native Tech Ecaz retains the actual odd-force chooseBattle ruling guard', () => { + const f = createEcazNativeTechFixture({ advanced: false, ecazForces: 3, chooseBattle: false }); + assert.throws(() => applyAction(f.game, f.battleAction.actor, f.battleAction.action), /Odd-force Basic Occupy/); + physical(f.game); +}); + +for (const strongholds of [false, true]) + void test(`Advanced Moritani ${strongholds ? 'Stronghold+Tech' : 'plain Tech'}: loss retains original effects, assassination pays one bounty, actual winner earns Tech, and Mentat replaces one held Traitor`, () => { + const f = createMoritaniNativeTechFixture({ strongholds }); + assert.equal(f.afterSetup.players.find(p => p.id === f.moritani)!.leaders.length, 5); + assert.equal(f.afterPlacement.moritaniTerror!.tokens.filter(t => t.status === 'available').length, 5); + assert.equal(f.afterPlacement.moritaniTerror!.tokens.filter(t => t.status === 'placed').length, 1); + const discs = f.afterPlacement.moritaniTerror!.tokens.map(t => ({ ...t })); + const pending = f.pending, quote = quoteStrongholdFactionsBattle(f.revealed); + assert.equal(pending.decision?.kind, 'moritaniAssassinate'); + assert.equal(pending.lastBattleContext!.winner, f.opponent); + const income = strongholds ? 4 : 0; + assert.deepEqual(quote.strongholdIncome, income ? [{ player: f.moritani, amount: income }] : []); + assert.equal(player(pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1 + income); + assert.equal(player(pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3); + if (strongholds) { + assert.equal(pending.strongholdCards!.owners.tueks_sietch, f.moritani); + assert.equal(strongholdControllers(pending.players, false).tueks_sietch, f.opponent); + } + const token = ownedTech(pending.techTokens, f.moritani)[0]; + assert.ok(token); + const target = player(pending, f.opponent).leaders.find(l => l.id === f.target)!; + const held = [...player(pending, f.moritani).traitors], reserve = [...pending.traitorReserve!]; + const decision = pending.decision; + assert.ok(decision?.kind === 'moritaniAssassinate'); + let game = applyAction(pending, f.moritani, { type: 'decision', event: decision.event, card: f.target }); + const paid = player(game, f.moritani).spice; + assert.equal(paid, player(pending, f.moritani).spice + target.strength); + assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.deaths, target.deaths + 1); + assert.deepEqual(player(game, f.moritani).traitors, held, 'Revealed original physical Traitor remains held until Mentat.'); + assert.equal(game.techTokens![token].owner, f.moritani); + assert.equal(game.decision?.kind, 'battleCards'); + game = applyAction(game, f.opponent, { type: 'decision', discard: f.opponentCards }); + game = advanceMoritaniStrongholds(game, g => !g.battle && !g.response && !g.decision && !g.pendingTreacheryDiscard); + assert.equal(game.techTokens![token].owner, f.opponent); + assert.equal(ownedTech(game.techTokens, f.opponent).length, 2); + assert.equal(ownedTech(game.techTokens, f.moritani).length, 0); + assert.equal(player(game, f.moritani).tanks - player(f.revealed, f.moritani).tanks, 6); + assert.equal(player(game, f.opponent).tanks - player(f.revealed, f.opponent).tanks, 3); + assert.equal(player(game, f.moritani).spice, paid); + assert.deepEqual(game.moritaniTerror!.tokens, discs, 'Original assassination does not consume a Terror disc.'); + for (const card of [...f.moritaniCards, ...f.opponentCards]) assert.equal(game.discard.filter(c => c.id === card).length, 1); + physical(game); + game = advanceMoritaniStrongholds(game, g => g.phase === 8); + const receipt = game.moritaniAssassinate!.opportunities.at(-1)!; + assert.equal(receipt.stage, 'replaced'); assert.equal(receipt.bounty, target.strength); + assert.equal(receipt.replacement, reserve[0]); + assert.deepEqual(game.traitorReserve, reserve.slice(1)); + assert.deepEqual(player(game, f.moritani).traitors, [...held.filter(id => id !== f.target), reserve[0]]); + assert.equal(player(game, f.moritani).spice, paid, 'Neither Stronghold income nor bounty replays at Mentat.'); + const rival = viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!; + assert.equal(rival.card, f.target); assert.equal('replacement' in rival, false); + game = advanceMoritaniStrongholds(game, g => g.turn === 3 && g.phase === 0); + assert.equal(game.traitorReserve!.length, reserve.length - 1); + assert.equal(player(game, f.moritani).spice, paid); + assert.equal(game.techTokens![token].owner, f.opponent); + if (strongholds) assert.equal(game.strongholdCards!.owners.tueks_sietch, f.opponent); + physical(game); + }); + +for (const strongholds of [false, true]) + void test(`Advanced Moritani ${strongholds ? 'Stronghold+Tech' : 'plain Tech'}: genuine normal Traitor call forfeits the later original loss assassination across turns`, () => { + const f = createMoritaniNativeTechFixture({ strongholds, normalCall: true }); + assert.equal(f.pending.lastBattleContext!.winner, f.moritani); + assert.equal(f.pending.moritaniAssassinate!.normalTraitorCall, true); + assert.equal(f.pending.moritaniAssassinate!.opportunities.length, 0); + assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, true); + assert.equal(ownedTech(f.pending.techTokens, f.moritani).length, 2); + let game = advanceMoritaniStrongholds(f.pending, g => g.turn === 3 && g.phase === 5 && !g.response && !g.phaseOpening && !g.decision); + const emperor = game.players.find(p => p.faction === 'emperor')!.id; + stageMoritaniStrongholdsBattle(game, f.moritani, emperor, 'tueks_sietch'); + const loser = player(game, f.moritani).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0]; + const winner = player(game, emperor).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0]; + const target = player(game, emperor).leaders.find(l => !l.dead && l.id !== winner.id)!; + holdMoritaniStrongholdsTraitor(game, f.moritani, target.id); + const tokens = ownedTech(game.techTokens, f.moritani), reserve = [...game.traitorReserve!]; + game = advanceMoritaniStrongholds(game, g => g.phase === 6 && !g.response && !g.phaseOpening && !g.decision); + const actor = game.active!; + assert.ok([f.moritani, emperor].includes(actor)); + game = applyAction(game, actor, { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === f.moritani ? emperor : f.moritani }); + game = advanceMoritaniStrongholds(game, g => !!g.battle && !g.battle.revealed && !nextMoritaniStrongholdsStep(g)); + game = applyAction(game, f.moritani, { type: 'battlePlan', leader: loser.id, dial: 0, support: 0 }); + game = applyAction(game, emperor, { type: 'battlePlan', leader: winner.id, dial: 2, support: 2 }); + game = resolveMoritaniStrongholdsBattle(game, f.moritani); + assert.equal(game.lastBattleContext!.winner, emperor); + assert.equal(game.moritaniAssassinate!.normalTraitorCall, true); + assert.equal(game.moritaniAssassinate!.opportunities.length, 0); + assert.equal(player(game, emperor).leaders.find(l => l.id === target.id)!.dead, false); + assert.deepEqual(game.traitorReserve, reserve, 'No assassination means no hidden replacement draw.'); + assert.equal(tokens.filter(token => game.techTokens![token].owner === emperor).length, 1, + 'A real victory selects one loser token, not both allied or held tokens.'); + assert.equal(ownedTech(game.techTokens, emperor).length, 2); + physical(game); + }); + +for (const strongholds of [false, true]) { + const initialize = strongholds ? initializeStrongholdFactionsGameForAudit : initializeFactionExpansionsGameForAudit; + void test(`${strongholds ? 'Stronghold+Tech' : 'plain Tech'} initializer rejects paired E3 and mixed selected families without admitting unrelated overlays`, () => { + assert.throws(() => initialize(lobby(['ecaz', 'moritani', 'guild']))); + assert.throws(() => initialize(lobby(['ecaz', 'ixians', 'guild'], true, ['ecaz', 'ix']))); + assert.throws(() => initialize(lobby(['moritani', 'choam', 'guild'], true, ['ecaz', 'choam']))); + assert.throws(() => initialize(lobby(['moritani', 'harkonnen', 'guild']))); + assert.throws(() => initialize(lobby(['ecaz', 'guild']))); + if (strongholds) assert.throws(() => initialize(lobby(['ecaz', 'guild', 'emperor'], false))); + for (const overlay of ['leaderSkills', 'homeworlds', 'nexusCards', 'discoveryEnabled', 'ecazTreachery', + 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview', 'guildBetrayalPreview', + 'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview', 'nexusHarkonnenBetrayalPreview']) { + const game = lobby(['ecaz', 'guild', 'emperor']); Object.assign(game, { [overlay]: true }); + assert.throws(() => initialize(game), `${overlay} remains excluded from the bounded native Tech family.`); + } + for (const stale of ['owner', 'spice', 'triggeredTurn'] as const) { + const game = lobby(['ecaz', 'guild', 'emperor']); + Object.assign(game.techTokens!.axlotl, { [stale]: stale === 'owner' ? game.host : 1 }); + assert.throws(() => initialize(game)); + } + }); +} + +void test('Basic standalone Moritani Tech retains its original Harkonnen-compatible setup boundary', () => { + const game = finishEcazOccupySetup(initializeE3NativeTechSetup({ + initial: lobby(['moritani', 'harkonnen', 'guild'], false), native: 'moritani', advanced: false, + })); + assert.equal(player(game, 'auth-moritani').reserves, 14); + assert.equal(player(game, 'auth-harkonnen').traitors.length, 4); + physical(game); +}); diff --git a/tests/fixture-e3-native-tech.ts b/tests/fixture-e3-native-tech.ts new file mode 100644 index 00000000..f69c99d0 --- /dev/null +++ b/tests/fixture-e3-native-tech.ts @@ -0,0 +1,257 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeFactionExpansionsGameForAudit, + initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Game, +} from '../game/engine'; +import { territory } from '../game/board'; +import { createTechTokens } from '../game/tech-tokens'; +import { finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy'; +import { + advanceEcazStronghold, ecazStrongholdFixture, ecazStrongholdSeat as player, + openEcazStronghold, type EcazStrongholdFixture, +} from './fixture-ecaz-strongholds'; +import { + advanceMoritaniStrongholds, createMoritaniStrongholdsFixture, + holdMoritaniStrongholdsTraitor, nextMoritaniStrongholdsStep, + resolveMoritaniStrongholdsBattle, stageMoritaniStrongholdsBattle, + type MoritaniStrongholdsFixture, +} from './fixture-moritani-strongholds'; +import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type E3NativeTechOptions = { + /** Original authenticated lobby or its undealt native setup; identities are retained. */ + initial?: Game; + native?: 'ecaz' | 'moritani'; + advanced?: boolean; + strongholds?: boolean; +}; +const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision; + +/** Scope deterministic entropy to the genuine first initializer, never a later deal. */ +export function initializeE3NativeTechSetup(options: E3NativeTechOptions = {}): Game { + const native = options.native ?? 'ecaz'; + const advanced = options.advanced ?? options.initial?.advanced ?? true; + const strongholds = options.strongholds ?? false; + let game: Game; + if (options.initial) game = structuredClone(options.initial); + else { + game = createGame('E3NATIVETECH', newPlayer(native, native, native), advanced, ['ecaz']); + joinGame(game, newPlayer('guild', 'Guild', 'guild')); + joinGame(game, newPlayer('emperor', 'Emperor', 'emperor')); + if (native === 'ecaz') { + for (const [actor, position] of [['ecaz', 6], ['guild', 4], ['emperor', 5]] as const) + if (viewGame(game, actor).playerPositions[actor] !== position) + game = applyAction(game, actor, { type: 'seatPosition', position }); + } + } + assert.equal(game.advanced, advanced); + assert.deepEqual(game.expansions, ['ecaz']); + assert.ok(game.players.some(p => p.faction === native)); + if (game.status === 'setup') { + assert.equal(game.turn, 1); assert.equal(game.phase, 0); + assert.ok(game.players.every(p => p.hand.length === 0)); + assert.ok(game.techTokens); + assert.equal(!!game.strongholdCards, strongholds); + return game; + } + assert.equal(game.status, 'lobby', 'Started games cannot be retrofitted with Tech.'); + if (!game.techTokens) game.techTokens = createTechTokens(); + for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' }); + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + crypto.getRandomValues = (array: T): T => { + assert.ok(array); + new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); + return array; + }; + try { + return strongholds ? initializeStrongholdFactionsGameForAudit(game) + : initializeFactionExpansionsGameForAudit(game); + } finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +function reserveBoard(game: Game): void { + for (const p of game.players) { + p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {}; + if (p.elites) { + p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0); + p.elites.forces = {}; + } + if (p.advisors) p.advisors = {}; + } +} +function place(game: Game, actor: string, location: string, count: number): void { + const p = player(game, actor); + assert.ok(Number.isSafeInteger(count) && count >= 0 && p.reserves >= count); + p.reserves -= count; p.forces[location] = count; +} +function orderBlow(game: Game, worm: boolean, position: number): void { + const index = game.spiceDeck.findIndex((card, i) => i >= position && (worm + ? 'worm' in card && !card.greatMaker && !card.suppressed : 'territory' in card)); + assert.ok(index >= position, 'Only unplayed original Spice Cards are ordered.'); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); +} +export type EcazNativeTechOptions = Omit & { + ecazForces?: number; + /** Expose original chooseBattle for the preserved Basic odd-force rejection. */ + chooseBattle?: boolean; +}; +export type EcazNativeTechFixture = EcazStrongholdFixture & { battleAction: StrongholdFactionsNativeStep }; +function battleChoice(game: Game, kind: string): StrongholdFactionsNativeStep { + const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === kind); + assert.ok(choice, 'The actual conserved armies must quote an original battle.'); + return { actor: choice.chooser, action: { type: 'chooseBattle', territory: kind, + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }; +} +/** Original setup, worm Nexus, reciprocal alliance and (when selected) real + * Stronghold claim. Controlled board/card custody follows setup; wallets, phases, + * Tech owners and aftermath receipts are never assigned. */ +export function createEcazNativeTechFixture(options: EcazNativeTechOptions = {}): EcazNativeTechFixture { + const setup = initializeE3NativeTechSetup({ ...options, native: 'ecaz' }); + if (options.strongholds) { + const f = ecazStrongholdFixture({ initial: setup, kind: 'sietch_tabr', ecazForces: options.ecazForces }); + return { ...f, battleAction: battleChoice(f.beforeBattle, f.territory) }; + } + const initial = structuredClone(setup), staging: string[] = []; + let game = finishEcazOccupySetup(setup); + const afterSetup = structuredClone(game); + const ecaz = game.players.find(p => p.faction === 'ecaz')!.id; + const ally = game.players.find(p => p.faction === 'guild')!.id; + const opponent = game.players.find(p => p.faction === 'emperor')!.id; + assert.equal(game.players.length, 3); assert.ok(ecaz && ally && opponent); + const kind = 'sietch_tabr', location = `${kind}:${territory(kind).sectors[0]}`; + reserveBoard(game); orderBlow(game, false, 0); orderBlow(game, false, 1); + staging.push('After genuine setup: return physical counters to their own reserves; retain native wallets, cards, leaders and identities.'); + game = advanceEcazStronghold(game, g => g.turn === 2 && g.phase === 0 && clean(g)); + orderBlow(game, true, 0); orderBlow(game, false, 1); orderBlow(game, false, 2); + game = advanceEcazStronghold(game, g => g.phase === 1 && !!g.nexus && clean(g) && !g.spiceWindow && !g.spiceResolution); + game = applyAction(game, ecaz, { type: 'alliance', target: ally }); + game = applyAction(game, ally, { type: 'alliance', target: ecaz }); + game = advanceEcazStronghold(game, g => g.phase === 8 && clean(g)); + reserveBoard(game); place(game, ecaz, location, 1); place(game, ally, location, 1); + let beforeMentat: Game | undefined, mentatStep: StrongholdFactionsNativeStep | undefined; + for (let count = 0; game.turn === 2 && count < 200; count++) { + const next = nextStrongholdFactionsNativeStep(game); assert.ok(next); + const before = structuredClone(game); + game = applyAction(game, next.actor, next.action); + if (game.turn === 3) { beforeMentat = before; mentatStep = next; } + } + assert.ok(beforeMentat && mentatStep); + const afterMentat = structuredClone(game); + orderBlow(game, false, 0); orderBlow(game, false, 1); + game = advanceEcazStronghold(game, g => g.phase === 5 && clean(g)); + const ecazForces = options.ecazForces ?? (game.advanced ? 5 : 4), allyForces = 4; + reserveBoard(game); place(game, ecaz, location, ecazForces); + place(game, ally, location, allyForces); place(game, opponent, location, 8); + stageEcazOccupyCard(game, opponent, card => card.effect === 'karama'); + staging.push(`Before original movement completion: conserve ${ecazForces} Ecaz, four Guild and eight Emperor counters in Tabr; conserved Karama custody exposes original Occupy response.`); + game = advanceEcazStronghold(game, g => g.phase === 6 && clean(g)); + const beforeBattle = structuredClone(game), battleAction = battleChoice(game, kind); + if (options.chooseBattle !== false) game = applyAction(game, battleAction.actor, battleAction.action); + return { initial, afterSetup, beforeMentat, mentatStep, afterMentat, beforeBattle, game, + ecaz, ally, opponent, territory: kind, location, ecazForces, allyForces, staging, battleAction }; +} +export function revealEcazNativeTechBattle(f: EcazNativeTechFixture, lead: 'ecaz' | 'ally', victoryPosition = false) { + const actor = lead === 'ecaz' ? f.ecaz : f.ally; + const staged = { ...f, game: structuredClone(f.game) }; + if (victoryPosition) { + const holds = ['arrakeen', 'carthag', 'habbanya_ridge_sietch', 'tueks_sietch'] + .filter(id => territory(id).sectors[0] !== staged.game.storm); + for (const [index, owner] of [f.ecaz, f.ally].entries()) { + const hold = holds[index]; + assert.ok(hold); + place(staged.game, owner, `${hold}:${territory(hold).sectors[0]}`, 1); + f.staging.push(`Before selected-lead choice: conserve one reserve ${owner} counter alone in ${hold}; three alliance holds but only one jointly occupied hold.`); + } + } + const shield = stageEcazOccupyCard(staged.game, actor, c => c.kind === 'shield'); + let game = openEcazStronghold(staged, lead); + const before = structuredClone(game), profile = viewGame(game, actor).battle!.ecazOccupy!.profile!; + const own = player(game, actor).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0]; + const enemy = player(game, f.opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0]; + const support = game.advanced ? Math.min(3, player(game, actor).spice) : 0; + const variableDial = game.advanced ? (3 + support) / 2 : 3; + assert.ok(own && enemy); + game = applyAction(game, actor, { type: 'battlePlan', leader: own.id, + dial: profile.fixedEcazDial + variableDial, support, defense: shield }); + game = applyAction(game, f.opponent, { type: 'battlePlan', leader: enemy.id, dial: game.advanced ? 2 : 0, support: 0 }); + assert.ok(game.battle?.revealed); + const revealed = structuredClone(game); + for (const voter of viewGame(game, actor).battle!.traitorVoters) + game = applyAction(game, voter, { type: 'traitorCall', call: false }); + assert.equal(game.decision?.kind, 'battleCards', 'The original winner-card window precedes Tech transfer.'); + return { before, revealed, pending: game, actor, shield }; +} + +export type MoritaniNativeTechOptions = Omit & { normalCall?: boolean }; +/** Genuine six-disc Terror supply: the first printed Mentat placement leaves + * five supply discs and one placed disc. Advanced losses use original assassination. */ +export function createMoritaniNativeTechFixture(options: MoritaniNativeTechOptions = {}): MoritaniStrongholdsFixture { + const setup = initializeE3NativeTechSetup({ ...options, native: 'moritani' }); + if (options.strongholds) return createMoritaniStrongholdsFixture({ initial: setup, normalCall: options.normalCall }); + let game = setup; + const initial = structuredClone(game), sourceActions: StrongholdFactionsNativeStep[] = [], staging: string[] = []; + const run = (step: StrongholdFactionsNativeStep) => { + sourceActions.push(structuredClone(step)); game = applyAction(game, step.actor, step.action); + }; + for (let count = 0; game.status === 'setup' && count < 200; count++) { + const next = nextMoritaniStrongholdsStep(game); assert.ok(next); run(next); + } + assert.equal(game.status, 'playing', 'Genuine native Moritani setup must complete.'); + const afterSetup = structuredClone(game); + const moritani = game.players.find(p => p.faction === 'moritani')!.id; + const opponent = game.players.find(p => p.faction === 'guild')!.id; + assert.equal(game.players.length, 3); assert.ok(moritani && opponent); + orderBlow(game, false, 0); orderBlow(game, false, 1); + for (let count = 0; !(game.phase === 8 && game.decision?.kind === 'moritaniPlacement') && count < 1800; count++) { + const next = nextMoritaniStrongholdsStep(game); assert.ok(next); + if (next.action.type === 'stormDial' && game.turn === 1) next.action.amount = 4; + run(next); + } + assert.equal(game.decision?.kind, 'moritaniPlacement'); + const beforePlacement = structuredClone(game), token = game.moritaniTerror!.tokens.find(t => t.status === 'available'); + assert.ok(token); + run({ actor: moritani, action: { type: 'decision', token: token.id, territory: 'arrakeen' } }); + game = advanceMoritaniStrongholds(game, g => g.phase === 8 && clean(g), sourceActions); + const afterPlacement = structuredClone(game), kind = 'tueks_sietch'; + reserveBoard(game); place(game, moritani, `${kind}:${territory(kind).sectors[0]}`, 1); + place(game, opponent, 'carthag:11', 1); + let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined; + for (let count = 0; game.turn === 1 && count < 200; count++) { + const next = nextMoritaniStrongholdsStep(game); assert.ok(next); + const before = structuredClone(game); + sourceActions.push(structuredClone(next)); game = applyAction(game, next.actor, next.action); + if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; } + } + assert.ok(beforeFirstMentat && firstMentatStep); + const afterFirstMentat = structuredClone(game); + orderBlow(game, false, 0); orderBlow(game, false, 1); + game = advanceMoritaniStrongholds(game, g => g.phase === 5 && clean(g), sourceActions); + stageMoritaniStrongholdsBattle(game, moritani, opponent, kind, staging); + const own = player(game, moritani).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0]; + const enemy = player(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0]; + const target = player(game, opponent).leaders.find(l => !l.dead && l.id !== enemy.id)!; + assert.ok(own && enemy && target); + holdMoritaniStrongholdsTraitor(game, moritani, options.normalCall ? enemy.id : target.id, staging); + const moritaniCards = [stageEcazOccupyCard(game, moritani, c => c.kind === 'worthless')]; + const opponentCards = [stageEcazOccupyCard(game, opponent, c => c.kind === 'worthless')]; + game = advanceMoritaniStrongholds(game, g => g.phase === 6 && clean(g), sourceActions); + const beforeBattle = structuredClone(game), battleAction = battleChoice(game, kind); run(battleAction); + game = advanceMoritaniStrongholds(game, g => !!g.battle && !g.battle.revealed && !nextMoritaniStrongholdsStep(g), sourceActions); + const planActions: StrongholdFactionsNativeStep[] = [ + { actor: moritani, action: { type: 'battlePlan', leader: own.id, dial: 1, + support: game.advanced ? 1 : 0, weapon: moritaniCards[0] } }, + { actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 3, + support: game.advanced ? 3 : 0, weapon: opponentCards[0] } }, + ]; + const prepared = structuredClone(game); + for (const plan of planActions) run(plan); + assert.ok(game.battle?.revealed); + const revealed = structuredClone(game); + const pending = resolveMoritaniStrongholdsBattle(game, moritani, options.normalCall, sourceActions); + return { initial, afterSetup, beforePlacement, afterPlacement, beforeFirstMentat, firstMentatStep, + afterFirstMentat, beforeBattle, game: prepared, revealed, pending, moritani, opponent, + holder: moritani, kind, target: target.id, moritaniLeader: own.id, opponentLeader: enemy.id, + moritaniCards, opponentCards, battleAction, planActions, sourceActions, staging }; +} diff --git a/tests/fixture-native-tech-factions.ts b/tests/fixture-native-tech-factions.ts new file mode 100644 index 00000000..677a2882 --- /dev/null +++ b/tests/fixture-native-tech-factions.ts @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeFactionExpansionsGameForAudit, + initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, type Game, +} from '../game/engine'; +import { MOBILE_LOCATION, territory } from '../game/board'; +import type { Card } from '../game/cards'; +import type { FactionId } from '../game/catalog'; +import { nativeFactionTechProfile } from '../game/faction-module-profile'; +import { createTechTokens } from '../game/tech-tokens'; +import type { StrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; + +export type NativeTechFamily = 'ix-choam' | 'richese' | 'tleilaxu'; +export type NativeTechFactionsOptions = { + initial?: Game; + family?: NativeTechFamily; + strongholds?: boolean; + advanced?: boolean; + ownerFaction?: 'ixians' | 'choam'; + stone?: boolean; +}; +export type NativeTechFactionsFixture = { + initial: Game; + afterSetup: Game; + beforeFirstMentat?: Game; + firstMentatStep?: StrongholdFactionsNativeStep; + afterFirstMentat?: Game; + beforeShipment?: Game; + shipmentStep?: StrongholdFactionsNativeStep; + afterShipment?: Game; + acquisition?: { before: Game; after: Game; steps: StrongholdFactionsNativeStep[] }; + game: Game; + beforeBattle: Game; + battleStep: StrongholdFactionsNativeStep; + planActions: StrongholdFactionsNativeStep[]; + owner: string; + opponent: string; + family: NativeTechFamily; + kind: 'arrakeen' | 'sietch_tabr' | 'habbanya_ridge_sietch'; + location: string; + actions: StrongholdFactionsNativeStep[]; + staging: string[]; +}; +export const nativeTechFactionsPlayer = (game: Game, actor: string) => { + const player = game.players.find(p => p.id === actor); + assert.ok(player, `Missing native Tech seat ${actor}`); + return player; +}; +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; + +export function nextNativeTechFactionsStep(game: Game): StrongholdFactionsNativeStep | null { + if (!game.phaseOpening && !game.response && game.decision?.kind === 'techToken') + return { actor: game.decision.player, action: { type: 'decision', token: game.decision.choices[0] } }; + return nextRicheseStrongholdsNativeStep(game); +} +function run(game: Game, step: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]): Game { + actions.push(structuredClone(step)); + return applyAction(game, step.actor, step.action); +} +function advance(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[]): Game { + for (let n = 0; n < 1800; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextNativeTechFactionsStep(game); + assert.ok(step, 'The original battle requires an unsealed human plan'); + // Real first-turn dials leave every printed fixture site outside the next Storm band. + if (game.turn === 1 && step.action.type === 'stormDial') step.action.amount = 0; + game = run(game, step, actions); + } + throw Error('Native Tech continuation did not reach its requested boundary'); +} +function withSetupEntropy(action: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + crypto.getRandomValues = (array: V): V => { + assert.ok(array); + new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); + return array; + }; + try { return action(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +/** Original fresh native setup; no Leader Skills, public-start bypass or redeal. + * Default initializer entropy is deterministic; supplied undealt setup is continued + * as saved. Actual wallets and Tech assignments are never staged or repaired. */ +export function initializeNativeTechFactionsSetup(options: NativeTechFactionsOptions = {}): Game { + const family = options.family ?? 'ix-choam'; + const factions: FactionId[] = family === 'ix-choam' ? ['ixians', 'choam', 'guild'] + : family === 'richese' ? ['richese', 'choam', 'guild'] : ['ixians', 'tleilaxu', 'guild']; + const decks = family === 'ix-choam' ? ['ix', 'choam'] : family === 'richese' ? ['choam'] : ['ix']; + const strongholds = options.strongholds ?? true; + let game = options.initial ? structuredClone(options.initial) + : createGame('NATIVETECHFACTIONS', newPlayer(factions[0], factions[0], factions[0]), options.advanced ?? true, decks); + if (!options.initial) for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction)); + assert.deepEqual(game.players.map(p => p.faction).sort(), [...factions].sort()); + assert.deepEqual([...game.expansions].sort(), [...decks].sort()); + assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => !p.hand.length), 'Use the original fresh undealt native lobby or setup'); + if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced); + if (strongholds) assert.equal(game.advanced, true); + if (game.status === 'setup') { + assert.ok(nativeFactionTechProfile(game), 'Continue only the bounded original native Tech setup without unrelated overlays'); + assert.equal(!!game.strongholdCards, strongholds); + return game; + } + if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); + assert.deepEqual(game.techTokens, createTechTokens()); + for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); + const initialize = () => strongholds ? initializeStrongholdFactionsGameForAudit(game) : initializeFactionExpansionsGameForAudit(game); + return options.initial ? initialize() : withSetupEntropy(initialize); +} +function reserveBoard(game: Game) { + for (const player of game.players) { + player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0); + player.forces = {}; + if (player.elites) { + player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0); + player.elites.forces = {}; + } + } +} +function place(game: Game, actor: string, key: string, count: number, elite = 0) { + const player = nativeTechFactionsPlayer(game, actor); + assert.ok(player.reserves >= count && (player.elites?.reserves ?? 0) >= elite && count >= elite); + player.reserves -= count; player.forces[key] = (player.forces[key] ?? 0) + count; + if (elite) { player.elites!.reserves -= elite; player.elites!.forces[key] = (player.elites!.forces[key] ?? 0) + elite; } +} +function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[]) { + const player = nativeTechFactionsPlayer(game, actor), held = player.hand.find(c => c.kind === kind); + if (held) return held.id; + const index = game.deck.findIndex(c => c.kind === kind); assert.ok(index >= 0); + assert.ok(player.hand.length < (player.faction === 'choam' ? 5 : 4)); + const card = game.deck.splice(index, 1)[0]; player.hand.push(card); + staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`); + return card.id; +} + +/** Controlled positions conserve native physical counters/cards after genuine + * setup. Real ship, cache acquisition, final Mentat, plans, payments and aftermath + * remain applyAction decisions. No wallet, owner, phase or earned receipt assignment. */ +export function createNativeTechFactionsFixture(options: NativeTechFactionsOptions = {}): NativeTechFactionsFixture { + const family = options.family ?? 'ix-choam', strongholds = options.strongholds ?? true; + const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = []; + let game = initializeNativeTechFactionsSetup(options); + const initial = structuredClone(game); + for (let n = 0; game.status === 'setup' && n < 200; n++) { + const step = nextNativeTechFactionsStep(game); assert.ok(step); + // Preserve the native Ix remainder shuffle. Seed only the actual first + // Face Dancer draw, as in the existing Tleilaxu Stronghold fixture. + if (family === 'tleilaxu' && game.decision?.kind === 'ixSetup') { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const original = crypto.getRandomValues.bind(crypto); + let remainderCalls = Math.max(0, game.ixSetupCards!.length - 2); + let faceCalls = game.players.reduce((sum, p) => sum + p.leaders.length, 0) + 1 - new Set(game.players.flatMap(p => p.traitors)).size - 1; + crypto.getRandomValues = (array: V): V => { + assert.ok(array); + if (remainderCalls > 0) { remainderCalls--; Reflect.apply(original, crypto, [array]); return array; } + if (faceCalls-- > 0) { new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array; } + Reflect.apply(original, crypto, [array]); return array; + }; + try { game = run(game, step, actions); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } + staging.push('Scoped first-draw entropy: only original native Face Dancer Traitor Deck shuffle; preserve Ix remainder and restore immediately.'); + } else game = run(game, step, actions); + } + assert.equal(game.status, 'playing'); + const afterSetup = structuredClone(game); + const owner = game.players.find(p => p.faction === (family === 'richese' ? 'richese' : family === 'tleilaxu' ? 'ixians' : options.ownerFaction ?? 'ixians'))!.id; + const opponent = game.players.find(p => p.faction === 'guild')!.id; + const kind = family === 'richese' ? 'habbanya_ridge_sietch' : family === 'tleilaxu' ? 'sietch_tabr' : 'arrakeen'; + const location = `${kind}:${territory(kind).sectors[0]}`; + let acquisition: NativeTechFactionsFixture['acquisition']; + if (family === 'richese') { + game = advance(game, state => state.decision?.kind === 'richeseCache', actions); + const before = structuredClone(game), steps: StrongholdFactionsNativeStep[] = []; + while (!nativeTechFactionsPlayer(game, owner).hand.some(c => c.id === 'richese-stone-burner')) { + const step = nextNativeTechFactionsStep(game); assert.ok(step); + steps.push(structuredClone(step)); game = run(game, step, actions); + assert.ok(steps.length < 100); + } + acquisition = { before, steps, after: structuredClone(game) }; + } + game = advance(game, state => state.phase === 5 && clean(state) && (family !== 'richese' || state.active === owner), actions); + reserveBoard(game); + staging.push('After real setup: return existing board counters through their own subtype reserves; keep actual native bank balances and assigned Tech custody.'); + let beforeShipment: Game | undefined, shipmentStep: StrongholdFactionsNativeStep | undefined, afterShipment: Game | undefined; + if (family === 'richese') { + const token = nativeTechFactionsPlayer(game, owner).noField!.tokens.find(t => t.value === 5)!; + beforeShipment = structuredClone(game); + shipmentStep = { actor: owner, action: { type: 'ship', noField: token.id, event: nativeTechFactionsPlayer(game, owner).noFieldEvent, territory: kind, sector: territory(kind).sectors[0] } }; + game = run(game, shipmentStep, actions); + game = advance(game, clean, actions); afterShipment = structuredClone(game); + } + let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined, afterFirstMentat: Game | undefined; + if (strongholds) { + game = advance(game, state => state.phase === 8 && clean(state), actions); + reserveBoard(game); + if (family !== 'richese') place(game, owner, location, 1); + const ix = game.players.find(p => p.faction === 'ixians'); + if (ix) place(game, ix.id, MOBILE_LOCATION, 6, 3); + staging.push('Before actual first END Mentat: native owner controls its printed battle site (Richese by real marker); Ix retains six original HMS counters including three cyborgs. Nobody controls three Strongholds.'); + while (game.turn === 1) { + const step = nextNativeTechFactionsStep(game); assert.ok(step); + const before = structuredClone(game); game = run(game, step, actions); + if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; } + } + afterFirstMentat = structuredClone(game); + assert.equal(game.strongholdCards!.owners[kind], owner); + game = advance(game, state => state.phase === 5 && clean(state), actions); + reserveBoard(game); + } + if (family === 'richese' && options.stone) { + const token = nativeTechFactionsPlayer(game, owner).noField!.deployed!.tokenId; + game = run(game, { actor: owner, action: { type: 'revealNoField', event: nativeTechFactionsPlayer(game, owner).noFieldEvent, token } }, actions); + reserveBoard(game); + } + if (family !== 'richese' || options.stone) place(game, owner, location, 6, nativeTechFactionsPlayer(game, owner).faction === 'ixians' ? 3 : 0); + place(game, opponent, location, 6); + if (family === 'tleilaxu') { + const tleilaxu = game.players.find(p => p.faction === 'tleilaxu')!.id; + place(game, tleilaxu, `arrakeen:${territory('arrakeen').sectors[0]}`, 3); + } + staging.push('Before genuine movement completion: conserved six opposing counters, six winner counters (Ix three suboids/three cyborgs) or actual Richese marker; Tleilaxu has three physical Arrakeen replacement sources. No money or ownership staging.'); + const player = nativeTechFactionsPlayer(game, owner); + const leader = family === 'tleilaxu' + ? player.leaders.filter(l => !l.dead && game.players.find(p => p.faction === 'tleilaxu')!.faceDancers!.some(c => c.leader === l.id && !c.revealed)).sort((a, b) => b.strength - a.strength)[0] + : player.leaders.filter(l => l.strength > 0 && !l.dead).sort((a, b) => b.strength - a.strength)[0]; + assert.ok(leader, 'The original native draw must supply the winner leader'); + const enemyLeader = family === 'tleilaxu' || options.ownerFaction === 'choam' + ? nativeTechFactionsPlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0] + : nativeTechFactionsPlayer(game, opponent).leaders.find(l => l.strength === 5 && !l.dead); + assert.ok(enemyLeader); + const weapon = family === 'tleilaxu' ? heldCard(game, owner, 'projectile', staging) : options.stone ? 'richese-stone-burner' : null; + const defense = family === 'tleilaxu' ? heldCard(game, owner, 'shield', staging) : null; + game = advance(game, state => state.phase === 6 && clean(state), actions); + const beforeBattle = structuredClone(game), actor = game.active!; + assert.ok(actor === owner || actor === opponent); + const battleStep: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === owner ? opponent : owner } }; + game = run(game, battleStep, actions); + game = advance(game, state => !!state.battle && !state.battle.revealed && !nextNativeTechFactionsStep(state), actions); + const ix = player.faction === 'ixians'; + const dial = family === 'tleilaxu' ? 2 : family === 'richese' ? options.stone ? 2 : 1 : ix ? game.advanced ? 6 : 4 : 3; + const support = game.advanced ? family === 'tleilaxu' ? 1 : family === 'richese' ? dial : 3 : 0; + const enemyDial = options.ownerFaction === 'choam' ? 2 : family === 'richese' ? options.stone ? 2 : 0 : 1.5; + const planActions: StrongholdFactionsNativeStep[] = [ + { actor: owner, action: { type: 'battlePlan', dial, support, leader: leader.id, weapon, defense } }, + { actor: opponent, action: { type: 'battlePlan', dial: game.advanced ? enemyDial : 1, support: game.advanced ? enemyDial === 2 ? 2 : enemyDial ? 1 : 0 : 0, leader: enemyLeader.id, weapon: null, defense: null } }, + ]; + return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeShipment, shipmentStep, afterShipment, acquisition, game, beforeBattle, battleStep, planActions, owner, opponent, family, kind, location, actions, staging }; +} +export function revealNativeTechFactionsBattle(fixture: Pick): Game { + let game = structuredClone(fixture.game); + for (const step of fixture.planActions) { + game = applyAction(game, step.actor, step.action); + while (game.response || game.decision?.kind === 'fullPlanOffer') { + const next = nextNativeTechFactionsStep(game); assert.ok(next); + game = applyAction(game, next.actor, next.action); + } + } + assert.ok(game.battle?.revealed); + return game; +} +export function advanceNativeTechFactions(state: Game, until: (game: Game) => boolean): Game { + return advance(structuredClone(state), until, []); +} +export function finishNativeTechFactionsBattle(state: Game): Game { + return advanceNativeTechFactions(state, game => !game.battle && !game.decision && !game.response && !game.pendingTreacheryDiscard); +} +export function finishNativeTechFactionsTurn(state: Game): Game { + return advanceNativeTechFactions(state, game => game.turn > state.turn); +} diff --git a/tests/native-tech-factions-runtime.test.ts b/tests/native-tech-factions-runtime.test.ts new file mode 100644 index 00000000..6369eabd --- /dev/null +++ b/tests/native-tech-factions-runtime.test.ts @@ -0,0 +1,264 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyAction, createGame, initializeFactionExpansionsGameForAudit, + initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import { territory } from '../game/board'; +import type { FactionId } from '../game/catalog'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { createLeaderSkills } from '../game/leader-skills'; +import { ownedTech, TECH_TOKENS } from '../game/tech-tokens'; +import { presenceAt } from '../game/force-presence'; +import { strongholdBenefit, strongholdControllers } from '../game/stronghold-cards'; +import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions'; +import { + advanceNativeTechFactions, createNativeTechFactionsFixture, finishNativeTechFactionsBattle, + finishNativeTechFactionsTurn, nativeTechFactionsPlayer as player, revealNativeTechFactionsBattle, + type NativeTechFactionsFixture, +} from './fixture-native-tech-factions'; + +function lobby(factions: FactionId[], expansions = ['ix'], advanced = true): Game { + let game = createGame('NATIVETECHBOUNDARY', newPlayer('seat-0', factions[0], factions[0]), advanced, expansions); + for (let n = 1; n < factions.length; n++) joinGame(game, newPlayer(`seat-${n}`, factions[n], factions[n])); + game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); + for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' }); + return game; +} +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before, 'Rejected native decisions cannot consume counters, cards, tokens or money'); +} +function inventory(game: Game) { + return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...(game.ixSetupCards ?? []), + ...(game.ixAuction?.cards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []), + ...game.players.flatMap(p => p.hand)].map(c => c.id).sort(); +} +function custody(game: Game, original: Game) { + assert.deepEqual(inventory(game), inventory(original), 'Every original physical card retains exactly one custody location'); + for (const p of game.players) { + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction); + if (p.elites) { + const originalElite = player(original, p.id).elites!; + const total = originalElite.reserves + originalElite.tanks + Object.values(originalElite.forces).reduce((a, b) => a + b, 0); + assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), total); + for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[key] ?? 0)); + } + } +} +function money(revealed: Game, resolved: Game) { + const quote = quoteStrongholdFactionsBattle(revealed); + const collection = resolved.phase === 7 && !resolved.phaseOpening + ? quoteSpiceCollection(resolved).receipts : []; + for (const p of revealed.players) { + const payment = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0; + const stronghold = quote.strongholdIncome.filter(r => r.player === p.id).reduce((a, r) => a + r.amount, 0); + const bounty = quote.bounty?.player === p.id ? quote.bounty.amount : 0; + const choam = quote.choamIncome?.owner === p.id ? quote.choamIncome.amount : 0; + const collected = collection.filter(r => r.player === p.id).reduce((a, r) => a + r.strongholds + r.collected, 0); + assert.equal(player(resolved, p.id).spice, p.spice - payment + stronghold + bounty + choam + collected, p.faction); + } + return quote; +} +function award(fixture: NativeTechFactionsFixture, game: Game) { + const tokens = ownedTech(fixture.game.techTokens, fixture.opponent); + assert.equal(tokens.length, 1, 'Actual three-seat first Storm gives the loser one native token'); + assert.equal(game.lastBattleContext!.winner, fixture.owner); + assert.equal(game.techTokens![tokens[0]].owner, fixture.owner); + assert.equal(ownedTech(game.techTokens, fixture.owner).length, 2); + assert.equal(ownedTech(game.techTokens, fixture.opponent).length, 0); + assert.equal(game.techTokens![tokens[0]].spice, fixture.game.techTokens![tokens[0]].spice, 'Reward changes custody, not bank receipts'); +} + +void test('native Tech seats three through six complete actual Basic/Advanced setup and first Storm with three distinct physical recipients', () => { + const factions: FactionId[] = ['ixians', 'tleilaxu', 'emperor', 'fremen', 'guild', 'harkonnen']; + for (const advanced of [false, true]) for (let count = 3; count <= 6; count++) { + const setup = initializeFactionExpansionsGameForAudit(lobby(factions.slice(0, count), ['ix'], advanced)); + const storm = advanceNativeTechFactions(setup, g => g.status === 'playing' && g.phase === 1 && !g.phaseOpening && !g.response && !g.decision); + assert.equal(storm.techTokens!.heighliners.owner, storm.players.find(p => p.faction === 'ixians')!.id); + assert.equal(storm.techTokens!.axlotl.owner, storm.players.find(p => p.faction === 'tleilaxu')!.id); + const recipients = TECH_TOKENS.map(t => storm.techTokens![t.id].owner); + assert.equal(new Set(recipients).size, 3); + assert.ok(recipients.every(id => storm.players.some(p => p.id === id))); + } +}); + +void test('native Tech rejects below-three, missing-family, wrong-deck and already-owned inventory without dealing', () => { + const invalid = [ + lobby(['ixians', 'guild']), + lobby(['emperor', 'fremen', 'guild']), + lobby(['ixians', 'emperor', 'guild'], ['choam']), + ]; + const owned = lobby(['ixians', 'emperor', 'guild']); + owned.techTokens!.production.owner = owned.host; invalid.push(owned); + for (const game of invalid) { + const before = structuredClone(game); + assert.throws(() => initializeFactionExpansionsGameForAudit(game)); + assert.throws(() => initializeStrongholdFactionsGameForAudit(game)); + assert.deepEqual(game, before); + } +}); + +void test('Advanced Stronghold constraint and unrelated modules stay excluded from both native Tech initializers', () => { + const basic = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam'], false); + reject(basic, basic.host, { type: 'strongholdCards', enabled: true }); + assert.throws(() => initializeStrongholdFactionsGameForAudit(basic), /Advanced/); + const overlays: ((g: Game) => void)[] = [ + g => { g.leaderSkills = createLeaderSkills(() => 0.5); }, + g => { g.homeworlds = { custody: null }; }, + g => { g.nexusCards = { cards: null, phase: null }; }, + g => { g.discoveryEnabled = true; }, + g => { g.ecazTreachery = true; }, + g => { g.semutaPreview = true; }, + ]; + for (const overlay of overlays) { + const game = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam']); overlay(game); + const before = structuredClone(game); + assert.throws(() => initializeFactionExpansionsGameForAudit(game)); + assert.throws(() => initializeStrongholdFactionsGameForAudit(game)); + assert.deepEqual(game, before); + } +}); + +for (const strongholds of [false, true]) void test(`${strongholds ? 'Advanced Stronghold+Tech' : 'Advanced Tech'} Ix: actual support, cyborg casualties, equal physical substitution and original loser token reward`, () => { + const fixture = createNativeTechFactionsFixture({ strongholds }); + const revealed = revealNativeTechFactionsBattle(fixture), quote = quoteStrongholdFactionsBattle(revealed); + assert.equal(quote.winner, fixture.owner); + assert.equal(quote.casualties!.forces.normalFixedHalf, true); + assert.ok(quote.casualties!.options.every(o => o.normal === 0 && o.elite === 3)); + const payment = quote.payments.find(p => p.player === fixture.owner)!; + assert.equal(payment.bankSupport, strongholds ? 2 : 0); + assert.equal(payment.ownPayment, strongholds ? 1 : 3); + const pending = advanceNativeTechFactions(revealed, g => g.decision?.kind === 'ixSubstitution'); + assert.equal(player(pending, fixture.owner).tanks, 3); + assert.equal(player(pending, fixture.owner).elites!.tanks, 3); + assert.equal(player(pending, fixture.owner).forces[fixture.location], 3); + assert.equal(player(pending, fixture.owner).elites!.forces[fixture.location] ?? 0, 0); + assert.equal(player(pending, fixture.owner).spice, player(revealed, fixture.owner).spice - payment.ownPayment); + assert.deepEqual(ownedTech(pending.techTokens, fixture.opponent), ownedTech(revealed.techTokens, fixture.opponent), 'Substitution precedes original Tech award'); + reject(pending, fixture.owner, { type: 'decision', sources: { [fixture.location]: 3 }, recover: { [fixture.location]: 4 } }); + reject(pending, fixture.opponent, { type: 'decision', sources: { [fixture.location]: 3 }, recover: { [fixture.location]: 3 } }); + const exchanged = applyAction(pending, fixture.owner, { type: 'decision', sources: { [fixture.location]: 3 }, recover: { [fixture.location]: 3 } }); + const resolved = finishNativeTechFactionsBattle(exchanged); + assert.equal(player(resolved, fixture.owner).forces[fixture.location], 3); + assert.equal(player(resolved, fixture.owner).elites!.forces[fixture.location], 3); + assert.equal(player(resolved, fixture.owner).tanks, 3, 'Three sacrificed suboids replace three recovered cyborgs, not extra counters'); + assert.equal(player(resolved, fixture.owner).elites!.tanks, 0); + assert.equal(player(resolved, fixture.opponent).tanks, 6); + money(revealed, resolved); award(fixture, resolved); custody(resolved, fixture.initial); + if (strongholds) { + assert.equal(fixture.afterFirstMentat!.strongholdCards!.owners.arrakeen, fixture.owner); + assert.equal(resolved.strongholdCards!.owners.arrakeen, fixture.owner); + const mentat = finishNativeTechFactionsTurn(resolved); + assert.equal(mentat.strongholdCards!.owners.arrakeen, fixture.owner); + custody(mentat, fixture.initial); + } +}); + +void test('CHOAM Stronghold+Tech: own paid support is debited, only opponent bank support earns CHOAM income, and the winner gets the original token', () => { + const fixture = createNativeTechFactionsFixture({ ownerFaction: 'choam' }); + const revealed = revealNativeTechFactionsBattle(fixture), resolved = finishNativeTechFactionsBattle(revealed); + const quote = money(revealed, resolved), payment = quote.payments.find(p => p.player === fixture.owner)!; + assert.equal(payment.cost, 1); assert.equal(payment.bankSupport, 2); assert.equal(payment.ownPayment, 1); + assert.deepEqual(quote.choamIncome, { owner: fixture.owner, amount: 1 }); + assert.equal(quote.payments.find(p => p.player === fixture.opponent)!.ownPayment, 2); + assert.equal(player(resolved, fixture.owner).tanks, 3); + assert.equal(player(resolved, fixture.owner).forces[fixture.location], 3); + award(fixture, resolved); custody(resolved, fixture.initial); + assert.equal(finishNativeTechFactionsTurn(resolved).strongholdCards!.owners.arrakeen, fixture.owner); +}); + +void test('Richese Stronghold+Tech: real one-spice marker shipment and post-plan five-force materialization retain Habbanya and award the loser token', () => { + const fixture = createNativeTechFactionsFixture({ family: 'richese' }); + const shipped = player(fixture.afterShipment!, fixture.owner), payer = player(fixture.beforeShipment!, fixture.owner); + assert.equal(shipped.spice, payer.spice - 1); assert.equal(shipped.reserves, payer.reserves); + assert.equal(presenceAt(shipped, fixture.kind), 1); + assert.equal(fixture.afterFirstMentat!.strongholdCards!.owners[fixture.kind], fixture.owner); + const sealed = applyAction(fixture.game, fixture.owner, fixture.planActions[0].action); + assert.ok(player(sealed, fixture.owner).noField!.deployed); + assert.equal(player(sealed, fixture.owner).forces[fixture.location], undefined); + const revealed = revealNativeTechFactionsBattle(fixture); + assert.equal(player(revealed, fixture.owner).noField!.deployed, null); + assert.equal(player(revealed, fixture.owner).forces[fixture.location], 5); + const resolved = finishNativeTechFactionsBattle(revealed), quote = money(revealed, resolved); + assert.equal(quote.payments.find(p => p.player === fixture.owner)!.ownPayment, 1); + assert.equal(player(resolved, fixture.owner).tanks, 1); + assert.equal(player(resolved, fixture.owner).forces[fixture.location], 4); + assert.equal(player(resolved, fixture.opponent).tanks, 6); + award(fixture, resolved); custody(resolved, fixture.initial); + assert.equal(finishNativeTechFactionsTurn(resolved).strongholdCards!.owners[fixture.kind], fixture.owner); +}); + +void test('Richese Stronghold+Tech: actual unbid cache Stone, legal materialized battle and kill/ignore outcomes retain the original typed reward', () => { + const fixture = createNativeTechFactionsFixture({ family: 'richese', stone: true }); + assert.ok(fixture.acquisition!.before.richeseCache!.some(c => c.id === 'richese-stone-burner')); + assert.ok(player(fixture.acquisition!.after, fixture.owner).hand.some(c => c.id === 'richese-stone-burner')); + assert.equal(fixture.acquisition!.after.richeseCache!.some(c => c.id === 'richese-stone-burner'), false); + for (const mode of ['kill', 'ignore'] as const) { + const revealed = revealNativeTechFactionsBattle(fixture); + assert.equal(revealed.decision?.kind, 'stoneBurner'); + const chosen = applyAction(revealed, fixture.owner, { type: 'decision', event: revealed.battle!.event, mode }); + const resolved = finishNativeTechFactionsBattle(chosen), quote = money(chosen, resolved); + assert.equal(quote.winner, fixture.owner); + assert.equal(player(resolved, fixture.owner).forces[fixture.location], 4); + assert.equal(player(resolved, fixture.owner).tanks, 2); + assert.equal(player(resolved, fixture.opponent).tanks, 6); + assert.ok(player(resolved, fixture.owner).hand.some(c => c.id === 'richese-stone-burner')); + for (const plan of fixture.planActions) assert.equal(player(resolved, plan.actor).leaders.find(l => l.id === plan.action.leader)!.dead, mode === 'kill'); + award(fixture, resolved); custody(resolved, fixture.initial); + } +}); + +void test('Tleilaxu Stronghold+Tech: original Ix bounty/cards/token settle before actual Face Dance; replacement returns surviving subtypes without transferring rewards', () => { + const fixture = createNativeTechFactionsFixture({ family: 'tleilaxu' }); + const revealed = revealNativeTechFactionsBattle(fixture); + const faceDance = advanceNativeTechFactions(revealed, g => g.decision?.kind === 'faceDance'); + const quote = money(revealed, faceDance); + assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: 1 }]); + assert.equal(quote.bounty!.player, fixture.owner); + award(fixture, faceDance); + const winner = player(faceDance, fixture.owner), tleilaxu = faceDance.decision!.player; + const original = player(revealed, fixture.owner), losses = quote.casualties!.options[0]; + assert.equal(winner.tanks, original.tanks + losses.normal + losses.elite); + assert.equal(winner.elites!.tanks, original.elites!.tanks + losses.elite); + for (const id of [fixture.planActions[0].action.weapon, fixture.planActions[0].action.defense]) assert.ok(winner.hand.some(c => c.id === id)); + const remaining = winner.forces[fixture.location], elite = winner.elites!.forces[fixture.location] ?? 0; + const board = `arrakeen:${territory('arrakeen').sectors[0]}`; + reject(faceDance, tleilaxu, { type: 'decision', reveal: true, sources: { reserves: remaining + 1 }, sector: territory(fixture.kind).sectors[0] }); + const after = applyAction(faceDance, tleilaxu, { type: 'decision', reveal: true, sources: { [board]: 2, reserves: remaining - 2 }, sector: territory(fixture.kind).sectors[0] }); + const returned = player(after, fixture.owner), replacement = player(after, tleilaxu); + assert.equal(returned.forces[fixture.location] ?? 0, 0); + assert.equal(returned.elites!.forces[fixture.location] ?? 0, 0); + assert.equal(returned.reserves, winner.reserves + remaining); + assert.equal(returned.elites!.reserves, winner.elites!.reserves + elite); + assert.equal(returned.tanks, winner.tanks); assert.equal(returned.elites!.tanks, winner.elites!.tanks); + assert.equal(returned.spice, winner.spice); assert.deepEqual(returned.hand, winner.hand); + assert.deepEqual(after.techTokens, faceDance.techTokens, 'A later replacement never claws back or redirects original winner Tech'); + assert.equal(replacement.forces[fixture.location], remaining); assert.equal(replacement.forces[board], 1); + assert.equal(replacement.reserves, player(faceDance, tleilaxu).reserves - remaining + 2); + assert.equal(strongholdControllers(after.players, true)[fixture.kind], tleilaxu); + assert.equal(strongholdBenefit(after.strongholdCards, fixture.owner, fixture.kind), fixture.kind); + assert.equal(strongholdBenefit(after.strongholdCards, tleilaxu, fixture.kind), null); + assert.equal(returned.leaders.find(l => l.id === fixture.planActions[0].action.leader)!.dead, true); + custody(after, fixture.initial); + const mentat = finishNativeTechFactionsTurn(after); + assert.equal(mentat.strongholdCards!.owners[fixture.kind], tleilaxu); + assert.deepEqual(mentat.techTokens, after.techTokens); + custody(mentat, fixture.initial); +}); + +void test('representative native bot continues the genuine Stronghold+Tech Face Dance with legal physical sources', () => { + const fixture = createNativeTechFactionsFixture({ family: 'tleilaxu' }); + const game = advanceNativeTechFactions(revealNativeTechFactionsBattle(fixture), g => g.decision?.kind === 'faceDance'); + const actor = game.decision!.player, view = viewGame(game, actor); + view.players.find(p => p.id === actor)!.bot = 'Easy'; + const action = botActions(view).find(a => a.type === 'decision'); assert.ok(action); + assert.equal(action.reveal, true); + const after = applyAction(game, actor, action); + assert.equal(player(after, fixture.owner).forces[fixture.location] ?? 0, 0); + assert.equal(player(after, actor).forces[fixture.location], player(game, fixture.owner).forces[fixture.location]); + assert.deepEqual(after.techTokens, game.techTokens); + custody(after, fixture.initial); +}); diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts index 44939064..9354f55c 100644 --- a/tests/prototype-room.test.ts +++ b/tests/prototype-room.test.ts @@ -1047,6 +1047,7 @@ void test('Stronghold faction admission rejects incompatible modules, history, o game => { game.strongholdCards = createStrongholdCards(); game.strongholdCards.claimedTurn = 1; }, game => { game.nexusCards = { cards: null, phase: null }; }, game => { game.leaderSkills = createLeaderSkills(() => 0); }, + game => { game.homeworlds = { custody: null }; }, ]; for (const mutate of variants) { const game = structuredClone(native); @@ -1058,14 +1059,6 @@ void test('Stronghold faction admission rejects incompatible modules, history, o assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before); assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats); } - for (const type of ['homeworlds', 'techTokens']) { - const game = applyAction(native, native.host, { type, enabled: true }); - game.players.forEach(player => { player.ready = true; }); - db.prepare('UPDATE rooms SET state=?,version=7 WHERE code=?').run(JSON.stringify(game), native.code); - const before = db.prepare('SELECT * FROM rooms ORDER BY code').all(); - assert.throws(() => startPrototypeRoom(db, native.code, 7, 'stronghold-factions')); - assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before); - } const original = structuredClone(native); for (const action of [{ type: 'start', profile: 'stronghold-factions' }, { type: 'initializeStrongholdFactionsGameForAudit' }, { type: 'strongholdFactions', enabled: true }]) diff --git a/tools/faction-games.ts b/tools/faction-games.ts index bc4826c2..1e2367ab 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -4,6 +4,7 @@ import { readFileSync, statSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; +import { nativeFactionTechProfile } from '../game/faction-module-profile'; import { botActions } from '../game/bots'; import { treacheryDeck } from '../game/cards'; import { richeseCards } from '../game/richese-cards'; @@ -38,7 +39,8 @@ import { privateOutputDirectory, sourceSnapshot } from './verification'; const DEFAULT_SEED = 20_260_926; const DEFAULT_MAX_ACTIONS = 3_500; const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; -type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills' | 'skills-tech' | 'skills-stronghold' | 'skills-stronghold-tech'; +type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills' | 'skills-tech' | 'skills-stronghold' | 'skills-stronghold-tech' + | 'factions-tech' | 'stronghold-factions-tech'; type Rules = 'basic' | 'advanced'; type Scenario = { @@ -353,6 +355,15 @@ const STRONGHOLD_FACTIONS_SCENARIOS: readonly Scenario[] = [ roster: (['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'fremen'] as FactionId[]).slice(0, players), })), ]; +const NATIVE_TECH_SCENARIOS: readonly Scenario[] = STRONGHOLD_FACTIONS_SCENARIOS + .filter(scenario => scenario.roster.length >= 3) + .flatMap((scenario, index): Scenario[] => [ + { ...scenario, ordinal: 700 + index * 3, profile: 'factions-tech', rules: 'basic' }, + { ...scenario, ordinal: 701 + index * 3, profile: 'factions-tech' }, + { ...scenario, ordinal: 702 + index * 3, profile: 'stronghold-factions-tech' }, + ]); +const nativeTechProfile = (profile: string) => profile === 'factions-tech' || profile === 'stronghold-factions-tech'; +const nativeStrongholdProfile = (profile: string) => profile === 'stronghold-factions' || profile === 'stronghold-factions-tech'; const SKILLS_TECH_SCENARIOS: readonly Scenario[] = [ ...[3, 4, 5, 6].flatMap(players => (['basic', 'advanced'] as const).map((rules, band) => ({ ordinal: 308 + (players - 3) * 2 + band, profile: 'skills-tech' as const, @@ -380,7 +391,7 @@ const strongholdSkillProfile = (profile: string) => profile === 'skills-strongho const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile); const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' && scenario.roster.includes('moritani') && - (scenario.profile === 'moritani-assassinate' || scenario.profile === 'stronghold-factions' || skillProfile(scenario.profile)); + (scenario.profile === 'moritani-assassinate' || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile)); type TraceEntry = { attempt: number; @@ -418,7 +429,7 @@ type Result = { function usage() { return ( 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + - '[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' + + '[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--resume FAILED_GAME.json]\n' + 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' + @@ -429,6 +440,7 @@ function usage() { + '\nSkills-tech uses three-through-six unique seats in Basic/Advanced: classic/base33, supported E1/E2 native skill rosters with original ix/choam decks/cache, or standalone Ecaz/Moritani ecaz33 rosters. All fourteen Skills and three original Tech Tokens retain original setup/native owners, phase-end income, aftermath and mandatory original-winner token transfer before Face Dance. Other modules, mixed E3 families, public starts and played-game conversion stay separate.' + '\nSkills-stronghold uses fresh Advanced classic or supported native two-through-six-seat skill rosters with all14 skills and six original Stronghold Cards. Skills-stronghold-tech adds three original Tech Tokens and requires three-through-six seats. Original native decks/cache, real end-Mentat Stronghold ownership and printed/first-Storm Tech assignment remain; other modules, mixed E3 families, public starts and played-game conversion stay separate.' + '\nAll three combined skill/module profiles additionally admit standalone native Ecaz OR Moritani with classic opponents and the exact ecaz33 deck. Original five-disc Ecaz/Duke assignment and Advanced Harkonnen exclusion remain; Advanced Moritani retains its original non-Harkonnen skill-first assassination and normal-traitor forfeiture. E3 pairs, mixed-family rosters, allied Occupy skills and other overlays stay guarded.' + + '\nFactions-tech uses native E1/E2 families or standalone Ecaz OR Moritani plus classics, three-through-six seats, Basic/Advanced, original selected decks and three Tech Tokens without Skills. Stronghold-factions-tech is its Advanced six-card Stronghold composition. Native Tech setup, real payments, typed battle rewards and selected Ecaz lead attribution remain; Basic odd Occupy, Richese cache/No-Field rulings, other modules and public starts stay guarded.' ); } @@ -464,7 +476,7 @@ function scenarioName(scenario: Scenario) { const extraIx = scenario.expansions.includes('ix') && !native.some(faction => faction === 'ixians' || faction === 'tleilaxu'); return `${scenario.profile}-${native.join('-')}${extraIx ? '-ix-deck' : ''}-${scenario.roster.length}-${scenario.rules}`; } - if (scenario.profile === 'stronghold-factions') { + if (nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile)) { const native = scenario.roster.filter(faction => ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].includes(faction)); return `${scenario.profile}-${native.join('-')}-${scenario.roster.length}-${scenario.rules}`; @@ -491,7 +503,7 @@ function scenarioName(scenario: Scenario) { function parseProfile(value: string | undefined) { const profile = value ?? 'all'; - if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills', 'richese-skills', 'ecaz-skills', 'skills-tech', 'skills-stronghold', 'skills-stronghold-tech'].includes(profile)) + if (!nativeTechProfile(profile) && !['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills', 'richese-skills', 'ecaz-skills', 'skills-tech', 'skills-stronghold', 'skills-stronghold-tech'].includes(profile)) throw new Error('--profile must name a documented base, module, expansion or native skill sample profile.'); return profile as Profile | 'all'; } @@ -537,7 +549,7 @@ function freshGame(scenario: Scenario) { player.ready = true; } if (scenario.ecazTreachery) game.ecazTreachery = true; - if (scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech') game.techTokens = createTechTokens(); + if (scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile)) game.techTokens = createTechTokens(); if (strongholdSkillProfile(scenario.profile)) game.strongholdCards = createStrongholdCards(); if (scenario.profile === 'homeworld' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus') game.homeworlds = { custody: null }; @@ -553,7 +565,7 @@ function freshGame(scenario: Scenario) { return initializeEcazOccupyGameForAudit(game); if (scenario.profile === 'moritani-assassinate') return initializeMoritaniAssassinateGameForAudit(game); - if (scenario.profile === 'stronghold-factions') + if (nativeStrongholdProfile(scenario.profile)) return initializeStrongholdFactionsGameForAudit(game); return skillProfile(scenario.profile) ? initializeLeaderSkillsGameForAudit(game) @@ -604,20 +616,20 @@ function resumedGame(path: string) { game.discoveries || game.discoveryStash || game.greatMaker || - (game.techTokens && !composedSkills) || + (game.techTokens && !composedSkills && !nativeFactionTechProfile(game)) || game.mentatQuestionPreview ) throw new Error('--resume sample scenarios exclude unsupported optional modules.'); - const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS].find( + const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS].find( (candidate) => (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && (candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards && skillProfile(candidate.profile) === !!game.leaderSkills && - (candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech') === !!game.techTokens && - (candidate.profile === 'stronghold-factions' || strongholdSkillProfile(candidate.profile)) === !!game.strongholdCards && + (candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile)) === !!game.techTokens && + (nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile)) === !!game.strongholdCards && (candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery && assassinationScenario(candidate) === !!game.moritaniAssassinatePreview && - (candidate.profile === 'ecaz-occupy') === !!game.ecazOccupyPreview && + (candidate.profile === 'ecaz-occupy' || (nativeTechProfile(candidate.profile) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview && assassinationScenario(candidate) === !!game.moritaniAssassinate && candidate.rules === (game.advanced ? 'advanced' : 'basic') && JSON.stringify(candidate.expansions) === @@ -631,7 +643,7 @@ function resumedGame(path: string) { ); // Projection validates the engine-facing shape and every private seat boundary. for (const player of game.players) viewGame(game, player.id); - if (scenario.profile === 'stronghold-factions') + if (nativeStrongholdProfile(scenario.profile)) verifySampleCustody(game, sampleInventory(game)); return { game, scenario, hash, path: resolve(path) }; } @@ -824,7 +836,7 @@ async function main() { const profile = parseProfile(values.profile); const rules = parseRules(values.rules); const players = parsePlayers(values.players); - if (supplied('players') && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile)) + if (supplied('players') && !nativeTechProfile(profile) && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile)) throw new Error('--players requires a roster or optional-module profile.'); if ((profile === 'combined-nexus' || profile === 'combined-homeworld-nexus') && players !== 'all' && ![5, 6].includes(players)) throw new Error(`--profile ${profile} supports only five or six players.`); @@ -842,6 +854,7 @@ async function main() { : profile === 'ecaz-occupy' ? ECAZ_OCCUPY_SCENARIOS : profile === 'moritani-assassinate' ? MORITANI_ASSASSINATE_SCENARIOS : profile === 'stronghold-factions' ? STRONGHOLD_FACTIONS_SCENARIOS + : nativeTechProfile(profile) ? NATIVE_TECH_SCENARIOS : profile === 'skills-tech' ? SKILLS_TECH_SCENARIOS : strongholdSkillProfile(profile) ? SKILLS_STRONGHOLD_SCENARIOS : profile === 'choam-nexus' || profile === 'ecaz-nexus' || profile === 'ix-nexus' ? PAIRED_NEXUS_SCENARIOS diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index 9c363604..5e382f64 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -28,7 +28,7 @@ export const PROTOTYPE_PROFILES = [ 'leader-skills', 'banker-income', 'factions', - // Advanced native E1/E2 factions plus classic opponents; selected family decks only. + // Advanced selected native families or standalone E3; optional Tech requires 3+ seats. 'stronghold-factions', 'nexus', 'moritani-assassinate', diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 27e850d6..94a667c2 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -22,13 +22,14 @@ async function main() { }); if (values.help) { console.log( - 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|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 alone. 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. 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.', + 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|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.', '\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. Explicit --mentat-question stays private; 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 only. 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.' + + '\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.' + '\nLeader-skills and banker-income preserve canonical unused Tech Tokens selected in fresh three-through-six-seat classic or supported native Basic/Advanced lobbies. Original selected decks, Richese cache, all fourteen skills, printed native Tech owners, phase-end income and mandatory original-winner reward before Face Dance remain. Banker income and --mentat-question stay separate existing opt-ins. Mixed E3 families, other modules and played-game conversion remain excluded.', '\nLeader-skills and banker-income also preserve canonical unused Stronghold Cards in fresh Advanced classic or supported native two-through-six-seat lobbies. Optional Tech Tokens requires three-through-six seats. All14 original skills, decks/cache and actual end-Mentat Stronghold assignment remain. Basic Strongholds, mixed E3 families, other overlays, public activation and save conversion stay excluded.', '\nThese skill/module entries additionally preserve standalone Ecaz OR Moritani ecaz33/all14 profiles. Tech is Basic/Advanced three-through-six seats; Strongholds is Advanced two-through-six. Native Ecaz five-disc/Duke assignment and Advanced Harkonnen exclusion, plus original Advanced Moritani skill-first assassination/normal-call forfeiture, remain. E3 pairs, mixed families, allied Occupy skills and other overlays are not opened.', + '\nWithout Skills, factions preserves unused original Tech Tokens in fresh 3–6-seat Basic/Advanced selected E1/E2 native or standalone Ecaz OR Moritani lobbies with classic opponents; stronghold-factions composes them with Advanced Stronghold Cards. Original physical inventories, first-Storm owners, selected Ecaz lead rewards, even-force Basic Occupy and non-Harkonnen Advanced Moritani assassination remain. Skills, unrelated overlays, paired/mixed E3 rosters and public starts remain separate.', ); return; }