From 288870c84289bb1172bd8e21dac046fbab3b5704 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:05:54 +1000 Subject: [PATCH] Integrate expanded native Stronghold faction rules Broaden existing Advanced Stronghold profile to selected E1/E2 native pairs/mixed families and classic opponents;35/47 ordinary decks with separate Richese10cache. Native No-Field/real Stone acquisition/battles, FaceDancer originalwinner/replacement/endMentat and legalAI reuse existinghandlers. Targeted16, actual11 rule cases,18unstaged games11066actions/no rejects/290JSON restores; corrected per-family reporter exercised on3two-seat families. Human390pxG7VDY6KBv12 Stone kill/Habbanya/retain and736HJUD3v8 two-reserve FaceDance. Finalquick26/build/guide205links. No new save/privacy/custody/HTTP/full6500 assurance campaign, pending rulings/public/deployed gates unchanged. --- README.md | 19 +- docs/CURRENT_STATUS.md | 11 + docs/IMPLEMENTATION_STATUS.md | 32 +++ docs/RULE_DECISIONS.md | 2 +- docs/STRONGHOLD_CARDS.md | 28 +++ game/engine.ts | 15 +- game/reference.ts | 22 +- tests/faction-games-cli.test.ts | 66 +++-- tests/fixture-richese-strongholds.ts | 273 +++++++++++++++++++++ tests/fixture-tleilaxu-strongholds.ts | 196 +++++++++++++++ tests/prototype-room.test.ts | 51 ++-- tests/richese-strongholds-runtime.test.ts | 213 ++++++++++++++++ tests/tleilaxu-strongholds-runtime.test.ts | 152 ++++++++++++ tools/faction-games.ts | 66 +++-- tools/prototype-room.ts | 1 + tools/start-prototype.ts | 2 +- 16 files changed, 1067 insertions(+), 82 deletions(-) create mode 100644 tests/fixture-richese-strongholds.ts create mode 100644 tests/fixture-tleilaxu-strongholds.ts create mode 100644 tests/richese-strongholds-runtime.test.ts create mode 100644 tests/tleilaxu-strongholds-runtime.test.ts diff --git a/README.md b/README.md index d910792f..36695587 100644 --- a/README.md +++ b/README.md @@ -227,16 +227,17 @@ original-faction retention after death/capture/reassignment is provisional in this profile; bounded verification is Partial/Development Verified and the existing question stays open. See the [normal-band source and scope](docs/SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026). -For local **Ixians + CHOAM + Stronghold Cards** integration, use +For local **expansion factions + Stronghold Cards**, use `node --import tsx tools/start-prototype.ts --profile stronghold-factions` -on a fresh ready Advanced native-Ixian/native-CHOAM lobby with unique classic -opponents and exactly `ix,choam` decks. Canonical combined Treachery count is47, -not49; the six separate cards start unowned and use native end-Mentat custody. -The same exact-version/private-backup contract applies. Other rosters/modules, -public expansion starts and existing games are unchanged. Bounded native -rules, legal AI, complete-game and human phone controls are Development Verified; -save/privacy/custody assurance follows all rules under the2 October priority. See the -[bounded integration](docs/STRONGHOLD_CARDS.md#native-ixian-and-choam-integration--2-october-2026). +on a fresh ready Advanced lobby with selected Ixian/Tleilaxu and/or +CHOAM/Richese native factions and classic opponents. At least one expansion +faction and its distinct `ix`/`choam` decks are required. CHOAM-only deck35; +Ix or both47; Richese cache10 stays separate. Stronghold Cards alone start +unowned and use native end-Mentat control. Other overlays/Ecaz/Moritani, +public expansion starts and existing games are unchanged. Paired/mixed E1/E2 +rules, legal AI and human phone controls are bounded Development Verified; +save/privacy/custody assurance follows all rules. See the +[bounded integration](docs/STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules). Above the board, **Treachery draw pile** and **Spice draw pile** show live card counts separately from hands, auction cards and discards. See diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index c530a8b0..1f941206 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -47,6 +47,17 @@ new per-feature assurance campaign or deployment claim. Immutable image: `ghcr.io/quintine/dune:sha-e9830b6ced80c95253cfa112bb8ed2eb6e50b74c`. +**Expanded Stronghold rules — bounded Development Verified:** selected E1/E2 +native pairs/mixed Ixians/Tleilaxu/CHOAM/Richese plus classic2..6 Advanced, +ordinary decks35/47 and separate Richese cache10. Types/lint, targeted16/16 +and11 actual rule-program cases pass. Eighteen unstaged games complete with +11,066 accepted actions, no rejections and290JSON restores. Human390px +original CLI rooms **G7VDY6KB v12** and **736HJUD3 v8** exercise real Stone +kill/Habbanya outcome and partial Face Dance replacement. A stale report +descriptor was fixed and exercised on all three two-seat families. +Mixed No-Field/full-plan, Stone/Poison Tooth and Ecaz questions remain guarded; +new save/privacy/custody/seat/full-suite assurance stays deferred. + **Stronghold rule integration — bounded Development Verified:** fresh `stronghold-factions` connects Advanced native Ixians/CHOAM plus classic2..6, canonical combined47 Treachery Cards and six Stronghold Cards alone. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 55e74d24..1f62dfa8 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,37 @@ # Dune implementation status +## 2 October 2026 — expanded Stronghold No-Field, Stone and Face Dancer rules + +The existing `stronghold-factions` profile now composes selected E1/E2 native +families with classic2..6-seat Advanced and Stronghold Cards alone. Ordinary +decks35/47, Richese cache10, native first setup/END Mentat and existing +No-Field/Stone/FaceDance handlers are reused. No public-start change, other +overlay or pending mixed No-Field/Stone/Poison Tooth/Ecaz ruling is inferred. + +Three rule-only owners supplied native Richese and Tleilaxu paths plus paired/ +mixed study/entry. Targeted16/16, types/lint and11 actual rule-program cases +pass. Eighteen unstaged games complete:11,066 accepted actions/no rejection/ +290JSON restores. A stale report descriptor was corrected; actual two-seat +Ixian/CHOAM, Ixian/Tleilaxu and CHOAM/Richese reports now bind47/47/35 ordinary +cards and0/0/10 separate cache respectively. Different-source reports remain +immutable; no historical evidence is rewritten. + +Original CLI/human390px **G7VDY6KB v12** chose real Stone kill mode, +completed the Habbanya-winning battle and retained Stone. **736HJUD3 v8** +chose two reserve forces for actual Face Dance while the original Ixian +kept its winner rewards and retained Stronghold card until the end boundary. +Native-program cases cover zero/partial replacement, reserve-limited0/3/5 +No-Field reveal and both Stone modes. Narrow feedback repaired Once Around +null-pass semantics, actual regenerated auction events and RNG typing; +whole-Game nonce equality and keep-action echoes were removed, not re-pinned. + +The new rules-first schedule used no new save/privacy/custody/seat/HTTP or +full6,500-suite assurance campaign. Final rules/valid-combination/recovery/ +privacy/custody/administration/deployed acceptance remains required later. +Final coherent rule/guide26/26, types/lint (`check:quick`), build and expanded +390px guide pass. Changed canonical document links205/205 are valid; owned +temporary rule drivers and managed tab/service are removed/closed. + ## 2 October 2026 — rules-first development throughput and compact context The user prioritizes full rules implementation, with comprehensive review, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 25c28f28..cdf55922 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -130,7 +130,7 @@ independent decisions are not reconciled by this cutover. | Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value for unmodified Smuggler collection. Advanced foreign gholas and existing skill questions remain gated. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md) | | Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) | | Normal Spice Banker local preview | Fresh classic/base33/all14-skills-only `banker-income` gains one additional deferred bank spice once per physical card/turn/phase from another payer's one actual bank payment of at least four. Original recipients/own-donor legs/waivers and native costs stay separate; no pooling smaller payments or using future income to fund the payment. Actual Mentat collects once to original gaining faction. Earned retention through trainer death/capture/reassignment and per-payer splitting are explicit conservative LOCAL choices, not answers to the earlier custody question or publisher examples. Public/old/combined modes stay gated; bounded verification is Partial/Development Verified, not deployed/full-module acceptance. | [Normal-band contract](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026), [skill authority](LEADER_SKILLS_RULES.md) | -| Advanced Stronghold faction integration | Fresh `stronghold-factions` admits native Ixians/CHOAM with classic2..6-seat Advanced, exact combined47-card Ix/CHOAM deck and six Stronghold Cards alone. CHOAM replaces shared Ix cards, not duplicate49. Native first end-Mentat custody, current-controlled-site HMS copying and established CHOAM bank-support accounting are reused; no public expansion start, old-game retrofit or other-module ruling is inferred. Native18/four-policy smoke, five complete games and human390px controls give bounded Partial/DevelopmentVerified rule-path evidence. Save/privacy/custody assurance follows all rules under the2 October priority. Richese/cache/Stone and Ecaz rounding remain outside this profile. | [Bounded native integration](STRONGHOLD_CARDS.md#native-ixian-and-choam-integration--2-october-2026) | +| Advanced Stronghold faction integration | Fresh `stronghold-factions` admits selected Ixian/Tleilaxu and CHOAM/Richese native families with classic2..6-seat Advanced, distinct selected E1/E2 decks and six Stronghold Cards alone. CHOAM-only deck35; Ix or both47; Richese cache10 stays separate. Native No-Field/Stone/FaceDance and end-Mentat control compose existing rules: targeted16, native11-case smoke, eighteen complete paired/mixed games and human390px decisions give bounded Partial/DevelopmentVerified evidence. Original winner rewards persist through Face Dance; retained cards change only at end-Mentat. Mixed No-Field/full-plan, Stone/Poison Tooth, Ecaz and other overlays remain guarded. Save/privacy/custody assurance follows all rules. | [Expanded rule batch](STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules) | | 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 8729f098..4d0438f9 100644 --- a/docs/STRONGHOLD_CARDS.md +++ b/docs/STRONGHOLD_CARDS.md @@ -2,6 +2,34 @@ 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. +## Expanded E1/E2 faction rules + +The same fresh `stronghold-factions` profile now admits selected Ix/Tleilaxu +and CHOAM/Richese native families with classic opponents, Advanced only, +two through six seats and Stronghold Cards alone. At least one native +expansion faction and its selected distinct `ix`/`choam` decks are required. +CHOAM-only ordinary deck35; Ix or both47; native Richese cache10 stays separate. +Ecaz/Moritani and other overlays remain excluded. The new-family rule paths +are **Partial / Development Verified**, not complete faction/module acceptance. + +Native No-Field0/3/5 control/reveal and real cache-acquired Stone outcomes, +plus native Face Dancer zero/partial/army replacement and next-turn card +control are integrated. Targeted16/16 rule cases, types/lint and11 actual +rule-program cases pass. Eighteen unstaged2..6-seat paired/mixed games +complete:11,066 accepted actions, zero rejections,290JSON restores. An old +Ixian/CHOAM-only report header was corrected to list per-family decks/cache; +an actual three-family report smoke completes with the corrected descriptor. + +Human390px controls used original CLI setups: **G7VDY6KB v12** chose Stone +kill mode and completed the Habbanya-winning battle with real Stone retained; +**736HJUD3 v8** selected two reserves for Face Dance and replaced the original +winner while its Stronghold card remained with Ixians until the turn boundary. +No new save/privacy/custody audit lane, full-suite or historical-seat campaign. +Mixed No-Field/full-plan and unresolved Stone/Poison Tooth cases stay guarded. +Final coherent rule/guide26/26, `check:quick`, build and actual expanded390px +guide pass;205 changed document links are valid. Owned temporary drivers and +the test tab/service are removed/closed. + ## Native Ixian and CHOAM integration — 2 October 2026 Fresh local `stronghold-factions` integration has **Partial / Development Verified** rule-path coverage: diff --git a/game/engine.ts b/game/engine.ts index fb82434f..76ddce46 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9080,14 +9080,15 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game { requireFreshFactionInventory(state); return initializeSetupGameForAudit(state, false, false, false, false, false, false, true); } -/** Fresh Advanced Ixian/CHOAM composition; normal starts and other modules stay gated. */ +/** Fresh Advanced E1/E2 faction composition; normal starts and other modules stay gated. */ export function initializeStrongholdFactionsGameForAudit(state: Game): Game { - requireRule(state.advanced === true && state.expansions.length === 2 && - state.expansions.includes('ix') && state.expansions.includes('choam') && - state.players.some(p => p.faction === 'ixians') && - state.players.some(p => p.faction === 'choam') && - state.players.every(p => p.faction === 'ixians' || p.faction === 'choam' || faction(p.faction).expansion === 'base'), - 'Stronghold factions require Advanced native Ixians and CHOAM, classic opponents, and exactly the Ix and CHOAM decks.'); + requireRule(state.advanced === true && Array.isArray(state.expansions) && + state.expansions.length >= 1 && state.expansions.length <= 2 && + state.expansions.every(id => id === 'ix' || id === 'choam') && + new Set(state.expansions).size === state.expansions.length && + state.players.some(p => faction(p.faction).expansion !== 'base') && + state.players.every(p => faction(p.faction).expansion === 'base' || state.expansions.includes(faction(p.faction).expansion)), + 'Stronghold factions require Advanced Ixian/Tleilaxu or CHOAM/Richese 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 && diff --git a/game/reference.ts b/game/reference.ts index 25a1e785..b1cfd137 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3210,27 +3210,27 @@ export const RULE_TOPICS: RuleTopic[] = [ }, { id: 'stronghold-factions', - title: 'Ixians and CHOAM with Stronghold Cards', + title: 'Expansion factions with Stronghold Cards', category: 'Advanced & expansions', coverage: 'Partial', developmentStage: 'Verified', - summary: 'A fresh Advanced development profile connects genuine Ixian and CHOAM setup to all six Stronghold Card effects and native continued games.', + summary: 'Fresh Advanced Stronghold play connects native Ixian/Tleilaxu and CHOAM/Richese paired and mixed families through existing rule handlers.', steps: [ - 'This private local profile requires native Ixians and CHOAM, unique two-to-six-seat rosters with classic opponents, exactly the Ix and CHOAM Treachery Decks and Stronghold Cards alone. Basic, Tleilaxu, Richese, Ecaz, Moritani and other module overlays remain outside it.', - 'The canonical combined deck contains 47 physical cards, not 49: CHOAM replaces the two shared Ix cards instead of duplicating them. Genuine setup retains native Ixian starting-card choices, all original leaders and forces, the Advanced CHOAM Auditor and six initially unowned Stronghold Cards.', + 'This private local profile admits selected native Ixian/Tleilaxu and CHOAM/Richese families with classic opponents in unique two-to-six-seat Advanced rosters. At least one expansion faction and its distinct selected ix/choam decks are required, with Stronghold Cards alone. Basic, Ecaz, Moritani and other overlays remain outside it.', + '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.', 'Arrakeen provides at most two spice toward actual support, not extra personal cash. Native CHOAM income uses the original bank and player payment shares, including its established traitor and own-donor exclusions. Original cyborg/suboid and starred casualties, bounty and other native income stay separate.', - 'Carthag, Habbanya, Sietch Tabr and Tuek’s Sietch retain their existing physical defense, tie, opposing-dial and played-Worthless contracts. This roster has no Richese cache or Stone Burner; that existing cross-roster interaction is not manufactured here.', - 'Bounded rule-path evidence covers native first end-Mentat ownership, eighteen original battle/copy cases, all four legal policies and five unstaged complete two-through-six-seat games. A390px human Ixian selected Arrakeen, sealed dial6/support3 with bank2/personal1 and completed the original opposed battle. Earlier comprehensive checks remain dated evidence; save/recovery/privacy/custody assurance now follows all rules. Full-card/faction/module/deployed acceptance and public expansion starts remain open.', + 'Carthag, Habbanya, Sietch Tabr and Tuek’s Sietch retain their existing defense, tie, opposing-dial and played-Worthless contracts. New Richese coverage uses actual No-Field shipment/reveal and native cache acquisition for Stone Burner; mixed No-Field dials/full-plan and unresolved Stone/Poison Tooth cases are not silently enabled.', + 'Bounded expanded-family evidence includes sixteen rule cases, eleven actual No-Field/Stone/FaceDance program cases, all four legal policies and eighteen unstaged complete paired/mixed2..6 games (11066 accepted actions, no rejections). Human390px Stone kill/Habbanya and two-reserve Face Dance controls complete their native decisions. Save/recovery/privacy/custody assurance follows all rules, not a new prototype gate. Full-card/faction/module/deployed acceptance and public expansion starts remain open.', ], related: ['stronghold-cards', 'mobile-stronghold', 'choam-combat', 'setup', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Implemented', detail: 'Fresh native Ixian/CHOAM setup, six-card creation, actual end-Mentat ownership and original HMS/copy/CHOAM battle economy compose the existing effects without public-start or old-game changes.', evidence: ['game/engine.ts', 'game/stronghold-cards.ts', 'game/battle-resolution-quote.ts'] }, - { area: 'Player controls', status: 'Implemented', detail: 'Existing gallery, inspector, mobile copy and support controls work through an original local setup at390px: copied bank support costs one actual personal spice and preserves the native opposed battle.', evidence: ['components/stronghold-cards.tsx', 'components/game-table.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'All four projected minimal policies produce legal current copy actions; five genuine Advanced2..6 native games complete. Strategy/calibration and other combinations remain deferred.', evidence: ['game/bots.ts', 'tools/faction-games.ts'] }, - { area: 'Documentation', status: 'Partial', detail: 'Advanced prerequisite, physical47-card identity, native module-only profile, existing effects and pending excluded combinations are explicit.', evidence: ['docs/STRONGHOLD_CARDS.md', 'docs/RULE_DECISIONS.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Native18/four-policy smoke, five unstaged games with3129 accepted actions/no rejections/82JSON restores, human390px rule-control battle and scoped review cover this profile. Earlier162 affected and6513 full checks pass; new save/privacy/custody assurance waits until all rules, not a repeated prototype gate.', evidence: ['tests/fixture-stronghold-factions.ts', 'tests/stronghold-factions-runtime.test.ts', 'tests/stronghold-factions-recovery.test.ts', 'tests/prototype-room.test.ts'] }, + { area: 'Implementation', status: 'Implemented', detail: 'Fresh selected E1/E2 setup, 35/47 ordinary decks plus separate native Richese cache, actual end-Mentat ownership and native HMS/No-Field/Stone/FaceDance/CHOAM economy compose existing effects without public-start or old-game changes.', evidence: ['game/engine.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 physical-source replacement. Native rules retain original winner benefits and turn-end controller changes.', 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'] }, + { area: 'Verification', status: 'Partial', detail: 'Targeted16 rule cases, native11-case program, eighteen complete games and390px human decisions cover expanded rule paths; prior Ixian/CHOAM proofs stay dated. No new save/privacy/custody or full-suite campaign. Final assurance remains after all rules.', evidence: ['tests/richese-strongholds-runtime.test.ts', 'tests/tleilaxu-strongholds-runtime.test.ts', 'tests/fixture-richese-strongholds.ts', 'tests/fixture-tleilaxu-strongholds.ts'] }, ], }, { diff --git a/tests/faction-games-cli.test.ts b/tests/faction-games-cli.test.ts index 20f3baf9..6ddae231 100644 --- a/tests/faction-games-cli.test.ts +++ b/tests/faction-games-cli.test.ts @@ -15,6 +15,7 @@ import { join } from 'node:path'; import type { Game } from '../game/engine'; import { isAuditorLeader, treacheryDeck } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; import { STRONGHOLD_CARDS } from '../game/stronghold-cards'; import { sampleInventory, verifySampleCustody } from '../tools/sample-custody'; import { createTechTokens } from '../game/tech-tokens'; @@ -105,41 +106,58 @@ void test('native Stronghold samples admit every two-to-six-seat roster and pres assert.equal(run(out, '--profile', 'stronghold-factions', '--players', 'all', '--seed', '1000', '--max-actions', '1').status, 1); const results = json<{ results: CliResult[] }>(join(out, 'results.json')).results; - assert.deepEqual(results.map(result => result.name), - [2, 3, 4, 5, 6].map(seats => `stronghold-factions-${seats}-advanced`)); - const roster = ['ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit']; - const canonical = treacheryDeck(['ix', 'choam']).map(card => card.id).sort(); - assert.equal(canonical.length, 47); - assert.equal(new Set(canonical).size, 47); + const families = [ + { native: 'ixians-choam', expansions: ['ix', 'choam'], + roster: ['ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'], counts: [2, 3, 4, 5, 6], ordinal: 151 }, + { native: 'ixians-tleilaxu', expansions: ['ix'], + roster: ['ixians', 'tleilaxu', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'], counts: [2, 3, 4, 5, 6], ordinal: 156 }, + { native: 'choam-richese', expansions: ['choam'], + roster: ['choam', 'richese', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'], counts: [2, 3, 4, 5, 6], ordinal: 161 }, + { native: 'ixians-tleilaxu-choam-richese', expansions: ['ix', 'choam'], + roster: ['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'fremen'], counts: [4, 5, 6], ordinal: 166 }, + ]; + const scenarios = families.flatMap(family => family.counts.map((count, index) => ({ + ...family, count, seed: 1000 + family.ordinal + index, + name: `stronghold-factions-${family.native}-${count}-advanced`, + }))); + assert.deepEqual(results.map(result => result.name), scenarios.map(scenario => scenario.name)); for (const [index, result] of results.entries()) { + const scenario = scenarios[index]; + assert.equal(result.seed, scenario.seed); assert.equal(result.error, 'Error: Action limit'); assert.equal(result.actions, 1); const game = json(join(out, `failed-${result.name}.json`)); assert.equal(game.advanced, true); - assert.deepEqual(game.expansions, ['ix', 'choam']); - assert.deepEqual(game.players.map(player => player.faction), roster.slice(0, index + 2)); + assert.deepEqual(game.expansions, scenario.expansions); + assert.deepEqual(game.players.map(player => player.faction), scenario.roster.slice(0, scenario.count)); assert.deepEqual(game.players.map(player => player.bot), - ['Easy', 'Medium', 'Hard', 'Brutal', 'Easy', 'Medium'].slice(0, index + 2)); + ['Easy', 'Medium', 'Hard', 'Brutal', 'Easy', 'Medium'].slice(0, scenario.count)); + const deck = treacheryDeck(scenario.expansions).map(card => card.id).sort(); + assert.equal(deck.length, scenario.expansions.includes('ix') ? 47 : 35); + const cache = scenario.roster.includes('richese') ? richeseCards().map(card => card.id).sort() : []; const inventory = sampleInventory(game); - assert.deepEqual(inventory.cards, canonical); + assert.deepEqual(inventory.cards, [...deck, ...cache].sort()); + assert.deepEqual((game.richeseCache ?? []).map(card => card.id).sort(), cache); verifySampleCustody(game, inventory); assert.deepEqual(Object.keys(game.strongholdCards!.owners).sort(), STRONGHOLD_CARDS.map(card => card.id).sort()); assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null)); assert.equal(game.strongholdCards!.claimedTurn, 0); - assert.equal(game.players.find(player => player.faction === 'choam')!.leaders - .filter(isAuditorLeader).length, 1); + const choam = game.players.find(player => player.faction === 'choam'); + if (choam) assert.equal(choam.leaders.filter(isAuditorLeader).length, 1); + } + for (const scenario of scenarios.filter(candidate => candidate.count === 6)) { + const snapshot = join(out, `failed-${scenario.name}.json`); + const original = readFileSync(snapshot, 'utf8'); + const continued = join(area, `continued-${scenario.native}`); + assert.equal(run(continued, '--resume', snapshot, '--seed', '1000', + '--max-actions', '1').status, 1); + const report = assertFailedEvidence(continued, scenario.name, scenario.seed, true); + assert.equal(report.options.resume?.path, snapshot); + verifySampleCustody(json(join(continued, `failed-${scenario.name}.json`)), + sampleInventory(json(snapshot)), json(snapshot)); + assert.equal(readFileSync(snapshot, 'utf8'), original); } - const snapshot = join(out, 'failed-stronghold-factions-6-advanced.json'); - const original = readFileSync(snapshot, 'utf8'); - const continued = join(area, 'continued'); - assert.equal(run(continued, '--resume', snapshot, '--seed', '1000', - '--max-actions', '1').status, 1); - const report = assertFailedEvidence(continued, 'stronghold-factions-6-advanced', 1155, true); - assert.equal(report.options.resume?.path, snapshot); - verifySampleCustody(json(join(continued, 'failed-stronghold-factions-6-advanced.json')), - sampleInventory(json(snapshot)), json(snapshot)); - assert.equal(readFileSync(snapshot, 'utf8'), original); }); void test('native Stronghold admission and resume reject foreign profiles and corrupted custody before gameplay', (t) => { @@ -150,11 +168,11 @@ void test('native Stronghold admission and resume reject foreign profiles and co const out = join(area, 'native'); assert.equal(run(out, '--profile', 'stronghold-factions', '--players', '2', '--max-actions', '1').status, 1); - const source = json(join(out, 'failed-stronghold-factions-2-advanced.json')); + const source = json(join(out, 'failed-stronghold-factions-ixians-choam-2-advanced.json')); const corruptions: [string, (game: Game) => void][] = [ ['basic', game => { game.advanced = false; }], ['wrong-deck', game => { game.expansions = ['ix']; }], - ['foreign-faction', game => { game.players[1].faction = 'richese'; }], + ['foreign-faction', game => { game.players[1].faction = 'ecaz'; }], ['missing-module', game => { delete game.strongholdCards; }], ['nexus', game => { game.nexusCards = { cards: null, phase: null }; }], ['tech', game => { game.techTokens = createTechTokens(); }], diff --git a/tests/fixture-richese-strongholds.ts b/tests/fixture-richese-strongholds.ts new file mode 100644 index 00000000..465a055c --- /dev/null +++ b/tests/fixture-richese-strongholds.ts @@ -0,0 +1,273 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { territory } from '../game/board'; +import type { Card } from '../game/cards'; +import type { NoFieldValue } from '../game/richese-no-field'; +import type { StrongholdId } from '../game/stronghold-cards'; +import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type RicheseStrongholdsOptions = { + initial?: Game; + seatIds?: [string, string, string]; + kind?: 'arrakeen' | 'habbanya_ridge_sietch'; + noFieldValue?: NoFieldValue; + markerBattle?: boolean; + reserves?: number; + stone?: boolean; + ownerDial?: number; + opponentDial?: number; + support?: number; + opponentSupport?: number; + traitors?: 'owner' | 'both'; + ownerDefense?: Card['kind']; + opponentWeapon?: Card['kind']; +}; +export type RicheseStrongholdsFixture = { + initial: Game; + afterSetup: Game; + beforeShipment: Game; + shipmentStep: StrongholdFactionsNativeStep; + afterShipment: Game; + beforeFirstMentat: Game; + firstMentatStep: StrongholdFactionsNativeStep; + afterFirstMentat: Game; + beforeReveal: Game; + revealStep: StrongholdFactionsNativeStep; + beforeBattle: Game; + battleStep: StrongholdFactionsNativeStep; + game: Game; + richese: string; + choam: string; + opponent: string; + kind: StrongholdId; + acquisition: { before: Game; steps: StrongholdFactionsNativeStep[]; after: Game } | null; + planActions: StrongholdFactionsNativeStep[]; + staging: string[]; +}; +export const richeseStrongholdsPlayer = (game: Game, actor: string) => { + const player = game.players.find(p => p.id === actor); + assert.ok(player, `Missing native seat ${actor}`); + return player; +}; +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; + +/** Consume only live native windows; ordinary setup choices come from real views. */ +export function nextRicheseStrongholdsNativeStep(game: Game): StrongholdFactionsNativeStep | null { + if (!game.phaseOpening && !game.response && game.decision) { + const decision = game.decision, actor = decision.player; + const event = game.richeseBidding?.event; + if (decision.kind === 'richeseBlackMarket') return { actor, action: { type: 'decision', event, decline: true } }; + if (decision.kind === 'richeseDeclaration') return { actor, action: { type: 'decision', event, position: 'first' } }; + if (decision.kind === 'richeseCache') { + const card = game.richeseCache?.find(c => c.id === 'richese-stone-burner') ?? game.richeseCache?.[0]; + assert.ok(card); + return { actor, action: { type: 'decision', event, card: card.id, method: 'onceAround', direction: 'counterclockwise' } }; + } + if (decision.kind === 'richeseUnbid') return { actor, action: { type: 'decision', event, keep: game.richeseAuction?.cardId === 'richese-stone-burner' } }; + if (decision.kind === 'stoneBurner') return { actor, action: { type: 'decision', event: decision.event, mode: 'ignore' } }; + } + if (clean(game) && game.richeseAuction && !game.richeseAuction.outcome) { + assert.ok(game.richeseAuction.active); + return { actor: game.richeseAuction.active, action: { type: 'richeseBid', event: game.richeseAuction.event, amount: game.richeseAuction.method === 'silent' ? 0 : null } }; + } + return nextStrongholdFactionsNativeStep(game); +} +function step(game: Game): Game { + const next = nextRicheseStrongholdsNativeStep(game); + assert.ok(next, 'A native Richese continuation requires an unsealed human plan'); + return applyAction(game, next.actor, next.action); +} +function advance(game: Game, predicate: (game: Game) => boolean): Game { + for (let count = 0; count < 1600; count++) { + if (predicate(game)) return game; + assert.equal(game.status, 'playing'); + const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); + // Select legal original dials so the next storm band cannot cross Habbanya. + if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = 4; + game = applyAction(game, next.actor, next.action); + } + throw Error('Native Richese continuation did not reach its requested boundary'); +} +function initialize(game: Game): Game { + 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 initializeStrongholdFactionsGameForAudit(game); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +function reserveBoard(game: Game) { + for (const player of game.players) { + player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0); + player.forces = {}; + } +} +function place(game: Game, actor: string, location: string, amount: number) { + const player = richeseStrongholdsPlayer(game, actor); + assert.ok(player.reserves >= amount && amount >= 0); + player.reserves -= amount; + if (amount) player.forces[location] = (player.forces[location] ?? 0) + amount; +} +function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[]) { + const player = richeseStrongholdsPlayer(game, actor); + const held = player.hand.find(c => c.kind === kind); + if (held) return held.id; + const at = game.deck.findIndex(c => c.kind === kind); + assert.ok(at >= 0, `Missing canonical native ${kind}`); + assert.ok(player.hand.length < 4); + const card = game.deck.splice(at, 1)[0]; player.hand.push(card); + staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`); + return card.id; +} +function traitor(game: Game, actor: string, identity: string, staging: string[]) { + const player = richeseStrongholdsPlayer(game, actor); + if (player.traitors.includes(identity)) return; + const returned = player.traitors[0]; assert.ok(returned); + const at = game.traitorReserve?.indexOf(identity) ?? -1; + const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity)); + assert.ok(at >= 0 || donor); + if (at >= 0) game.traitorReserve!.splice(at, 1, returned); + else donor!.traitors[donor!.traitors.indexOf(identity)] = returned; + player.traitors[0] = identity; + staging.push(`Conserved native traitor ${identity} exchanged for ${returned}`); +} + +/** Real Advanced E2 setup, native cache producer and one-force shipment, followed + * by actual first END Mentat. Only labeled conserved board/card/bank positions + * are staged after setup. No phases, owners, markers or cache faces are assigned. + * An original CLI initializer snapshot is continued without RNG or redealing. */ +export function createRicheseStrongholdsFixture(options: RicheseStrongholdsOptions = {}): RicheseStrongholdsFixture { + const kind = options.kind ?? 'habbanya_ridge_sietch'; + const location = `${kind}:${territory(kind).sectors[0]}`; + const staging: string[] = []; + let game: Game; + if (options.initial) { + game = structuredClone(options.initial); + assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => p.hand.length === 0), 'Continue only the original fresh undealt CLI setup'); + assert.equal(game.advanced, true); + assert.deepEqual(game.expansions, ['choam']); + assert.deepEqual(game.players.map(p => p.faction).sort(), ['choam', 'guild', 'richese']); + } else { + const ids = options.seatIds ?? ['richese', 'choam', 'guild']; + game = createGame('RICHESESTRONGHOLDS', newPlayer(ids[0], 'Richese', 'richese'), true, ['choam']); + joinGame(game, newPlayer(ids[1], 'CHOAM', 'choam')); + joinGame(game, newPlayer(ids[2], 'Guild', 'guild')); + } + if (game.status === 'lobby') { + for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); + game = initialize(game); + } + const initial = structuredClone(game); + for (let count = 0; game.status === 'setup' && count < 200; count++) { + let next: StrongholdFactionsNativeStep | undefined; + for (const player of game.players) { + const view = viewGame(game, player.id); view.players.find(p => p.id === player.id)!.bot = 'Easy'; + const action = botActions(view)[0]; + if (action) { next = { actor: player.id, action }; break; } + } + assert.ok(next, `Native Richese setup stalled at ${game.setupStage}`); + game = applyAction(game, next.actor, next.action); + } + assert.equal(game.status, 'playing'); + const afterSetup = structuredClone(game); + const richese = game.players.find(p => p.faction === 'richese')!.id; + const choam = game.players.find(p => p.faction === 'choam')!.id; + const opponent = game.players.find(p => p.faction === 'guild')!.id; + // Stone comes from the actual unbid-cache acquisition producer, never a hand injection. + game = advance(game, g => g.decision?.kind === 'richeseCache'); + const acquisitionBefore = structuredClone(game), acquisitionSteps: StrongholdFactionsNativeStep[] = []; + for (let count = 0; !richeseStrongholdsPlayer(game, richese).hand.some(c => c.id === 'richese-stone-burner') && count < 100; count++) { + const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); + acquisitionSteps.push(next); game = applyAction(game, next.actor, next.action); + } + assert.ok(richeseStrongholdsPlayer(game, richese).hand.some(c => c.id === 'richese-stone-burner')); + const acquisition = { before: acquisitionBefore, steps: acquisitionSteps, after: structuredClone(game) }; + game = advance(game, g => g.phase === 5 && clean(g) && g.active === richese); + reserveBoard(game); + for (const player of game.players) player.spice = 20; + staging.push('After real setup and cache acquisition: return original board counters to their own reserves; stage twenty-spice bank balances for original seats'); + const beforeShipment = structuredClone(game); + const token = richeseStrongholdsPlayer(game, richese).noField!.tokens.find(t => t.value === (options.noFieldValue ?? 5))!; + const shipmentStep: StrongholdFactionsNativeStep = { actor: richese, action: { type: 'ship', noField: token.id, event: richeseStrongholdsPlayer(game, richese).noFieldEvent, territory: kind, sector: territory(kind).sectors[0] } }; + game = applyAction(game, shipmentStep.actor, shipmentStep.action); + game = advance(game, g => !g.response && !g.decision && !g.phaseOpening); + const afterShipment = structuredClone(game); + game = advance(game, g => g.phase === 8 && clean(g)); + let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined; + for (let count = 0; game.turn === 1 && count < 100; count++) { + const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); + const before = structuredClone(game); game = applyAction(game, next.actor, next.action); + if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; } + } + assert.ok(beforeFirstMentat && firstMentatStep); + assert.equal(game.status, 'playing'); + assert.equal(game.strongholdCards!.owners[kind], richese); + const afterFirstMentat = structuredClone(game); + game = advance(game, g => g.phase === 5 && clean(g)); + reserveBoard(game); + const markerBattle = options.markerBattle ?? !options.stone; + const reserves = options.reserves ?? 20; + assert.ok(Number.isSafeInteger(reserves) && reserves >= 0 && reserves <= 20); + place(game, richese, 'polar_sink:0', 20 - reserves); + place(game, opponent, location, 6); + for (const player of game.players) player.spice = 20; + staging.push(`Before native second Movement completion: stage six opposing battle counters, ${20 - reserves} Richese counters in Polar Sink and twenty-spice bank balances; preserve real concealed marker and first-Mentat card ownership`); + const beforeReveal = structuredClone(game); + const revealStep: StrongholdFactionsNativeStep = { actor: richese, action: { type: 'revealNoField', event: richeseStrongholdsPlayer(game, richese).noFieldEvent, token: token.id } }; + if (!markerBattle) { + game = applyAction(game, richese, revealStep.action); + reserveBoard(game); place(game, richese, location, 6); place(game, opponent, location, 6); + staging.push('After actual voluntary materialization: conserve six ordinary physical counters per combatant at the printed Stronghold, never a mixed-marker battle'); + } + const defense = options.ownerDefense ? heldCard(game, richese, options.ownerDefense, staging) : null; + const enemyWeapon = options.opponentWeapon ? heldCard(game, opponent, options.opponentWeapon, staging) : null; + const ownLeader = richeseStrongholdsPlayer(game, richese).leaders.find(l => l.strength === 5 && !l.dead)!; + const otherLeader = richeseStrongholdsPlayer(game, opponent).leaders.find(l => l.strength === 5 && !l.dead)!; + assert.ok(ownLeader && otherLeader); + if (options.traitors) { + traitor(game, richese, otherLeader.id, staging); + if (options.traitors === 'both') traitor(game, opponent, ownLeader.id, staging); + } + game = advance(game, g => g.phase === 6 && clean(g)); + const beforeBattle = structuredClone(game), actor = game.active!; + assert.ok(actor === richese || actor === opponent); + const battleStep: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === richese ? opponent : richese } }; + game = applyAction(game, actor, battleStep.action); + for (let count = 0; count < 120; count++) { + const next = nextRicheseStrongholdsNativeStep(game); + if (!next) break; + game = applyAction(game, next.actor, next.action); + } + assert.ok(game.battle && !game.battle.revealed && !game.decision && !game.response); + const planActions: StrongholdFactionsNativeStep[] = [ + { actor: richese, action: { type: 'battlePlan', dial: options.ownerDial ?? 0, support: options.support ?? 0, leader: ownLeader.id, weapon: options.stone ? 'richese-stone-burner' : null, defense } }, + { actor: opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0, support: options.opponentSupport ?? 0, leader: otherLeader.id, weapon: enemyWeapon } }, + ]; + return { initial, afterSetup, beforeShipment, shipmentStep, afterShipment, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeReveal, revealStep, beforeBattle, battleStep, game, richese, choam, opponent, kind, acquisition, planActions, staging }; +} + +export function revealRicheseStrongholdsBattle(fixture: RicheseStrongholdsFixture): Game { + let game = structuredClone(fixture.game); + for (const plan of fixture.planActions) { + game = applyAction(game, plan.actor, plan.action); + while (game.response || game.decision?.kind === 'fullPlanOffer') game = step(game); + } + assert.ok(game.battle?.revealed); + return game; +} +export function finishRicheseStrongholdsBattle(fixture: RicheseStrongholdsFixture, state: Game, callers: string[] = []): Game { + let game = state; + for (let count = 0; count < 160; count++) { + if (!game.battle && !game.decision && !game.response && !game.pendingTreacheryDiscard) return game; + const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); + if (next.action.type === 'traitorCall') next.action.call = callers.includes(next.actor); + game = applyAction(game, next.actor, next.action); + } + throw Error('Native Richese battle suffix did not finish'); +} diff --git a/tests/fixture-tleilaxu-strongholds.ts b/tests/fixture-tleilaxu-strongholds.ts new file mode 100644 index 00000000..0891fcfc --- /dev/null +++ b/tests/fixture-tleilaxu-strongholds.ts @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, type Game } from '../game/engine'; +import { MOBILE_LOCATION, MOBILE_STRONGHOLD, territory } from '../game/board'; +import type { Card } from '../game/cards'; +import { strongholdControllers, type StrongholdId } from '../game/stronghold-cards'; +import { nextStrongholdFactionsNativeStep, quoteStrongholdFactionsBattle, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type TleilaxuStrongholdsOptions = { initial?: Game; kind?: 'sietch_tabr' | typeof MOBILE_STRONGHOLD }; +export type TleilaxuStrongholdsFixture = { + initial: Game; afterSetup: Game; beforeFirstMentat: Game; firstMentatStep: StrongholdFactionsNativeStep; + afterFirstMentat: Game; beforeBattle: Game; game: Game; revealed: Game; faceDance: Game; + ix: string; tleilaxu: string; opponent: string; kind: StrongholdId; location: string; + pointer: string; winnerLeader: string; loserLeader: string; weapon: string; defense: string; + battleAction: StrongholdFactionsNativeStep; planActions: StrongholdFactionsNativeStep[]; + sourceActions: StrongholdFactionsNativeStep[]; staging: string[]; +}; +export const tleilaxuStrongholdsPlayer = (game: Game, id: string) => { + const player = game.players.find(p => p.id === id); assert.ok(player); return player; +}; +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; +const key = (id: StrongholdId) => id === MOBILE_STRONGHOLD ? MOBILE_LOCATION : `${id}:${territory(id).sectors[0]}`; +function run(game: Game, action: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]): Game { + actions.push(structuredClone(action)); return applyAction(game, action.actor, action.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 action = nextStrongholdFactionsNativeStep(game); assert.ok(action, 'A real human battle plan is required'); + game = run(game, action, actions); + } + throw Error('Native Tleilaxu Stronghold boundary did not open'); +} +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, location: string, count: number, elite = 0) { + const player = tleilaxuStrongholdsPlayer(game, actor); + assert.ok(player.reserves >= count && count >= elite && (player.elites?.reserves ?? 0) >= elite); + player.reserves -= count; player.forces[location] = (player.forces[location] ?? 0) + count; + if (elite) { player.elites!.reserves -= elite; player.elites!.forces[location] = (player.elites!.forces[location] ?? 0) + elite; } +} +function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[]) { + const player = tleilaxuStrongholdsPlayer(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 < 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; +} +function matchingLeader(game: Game, ix: string, tleilaxu: string) { + const held = tleilaxuStrongholdsPlayer(game, tleilaxu).faceDancers!; + return tleilaxuStrongholdsPlayer(game, ix).leaders.filter(l => !l.dead && held.some(c => !c.revealed && c.leader === l.id)).sort((a, b) => b.strength - a.strength)[0]; +} + +/** Root Advanced PDF logical p38, lines 1066–1075: actual native draw, original + * winner's rewards, no second bounty, and zero/up-to-army replacement. Only + * the actual first native Face Dancer shuffle uses test entropy, scoped to + * its source setup action. Provided original CLI setup is cloned, never + * redealt; saved choices, deck and offers remain untouched. Conserved + * board/card staging is labeled; no owners, phase, plans or opportunities are + * assigned. This intentionally excludes foreign gholas, capture and overlays. */ +export function createTleilaxuStrongholdsFixture(options: TleilaxuStrongholdsOptions = {}): TleilaxuStrongholdsFixture { + const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = []; + const kind = options.kind ?? 'sietch_tabr'; + let game: Game; + if (options.initial) { + game = structuredClone(options.initial); + assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => p.hand.length === 0)); + assert.equal(game.advanced, true); + assert.ok(game.expansions.includes('ix') && game.expansions.every(deck => deck === 'ix' || deck === 'choam') && + new Set(game.expansions).size === game.expansions.length, 'Use the selected native Ix deck, optionally with CHOAM'); + } else { + game = createGame('TLEILAXUSTRONGHOLDS', newPlayer('ix', 'Ixians', 'ixians'), true, ['ix']); + joinGame(game, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu')); + joinGame(game, newPlayer('guild', 'Guild', 'guild')); + } + assert.deepEqual(game.players.map(p => p.faction).sort(), ['guild', 'ixians', 'tleilaxu']); + if (game.status === 'lobby') { + for (const player of game.players) if (!player.ready) game = run(game, { actor: player.id, action: { type: 'ready' } }, actions); + game = initializeStrongholdFactionsGameForAudit(game); + } + const initial = structuredClone(game); + for (let setup = 0; game.status === 'setup' && setup < 200; setup++) { + const action = nextStrongholdFactionsNativeStep(game); assert.ok(action); + if (game.decision?.kind !== 'ixSetup') { + game = run(game, action, actions); continue; + } + assert.equal(game.turn, 1); assert.equal(game.phase, 0); + assert.equal(game.setupStage, undefined); + assert.ok(game.players.every(p => p.faceDancers === undefined)); + // Ix first shuffles the unchosen starting Treachery Cards. Preserve that + // genuine shuffle; seed only the subsequent native Traitor Deck shuffle. + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const originalRandom = 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: T): T => { + assert.ok(array); + assert.ok(array instanceof Uint32Array, 'Native setup entropy uses integer random words'); + if (remainderCalls > 0) { remainderCalls--; originalRandom(array); return array; } + if (faceCalls <= 0) { originalRandom(array); return array; } + faceCalls--; + new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array; + }; + try { game = run(game, action, actions); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } + assert.equal(game.status, 'playing'); + staging.push('First-draw test entropy: only native Face Dancer Traitor Deck shuffle in the original Ix starting-card decision; restore immediately; no redeal, saved-choice rewrite or later draw override.'); + } + assert.equal(game.status, 'playing'); + const afterSetup = structuredClone(game); + const ix = game.players.find(p => p.faction === 'ixians')!.id; + const tleilaxu = game.players.find(p => p.faction === 'tleilaxu')!.id; + const opponent = game.players.find(p => p.faction === 'guild')!.id; + game = advance(game, state => state.phase === 8 && clean(state), actions); + reserveBoard(game); + place(game, ix, MOBILE_LOCATION, 6, 3); place(game, ix, key('sietch_tabr'), 1); + place(game, tleilaxu, key('arrakeen'), 1); place(game, opponent, key('carthag'), 1); + staging.push('Before real first END Mentat: redistribute original counters through their own reserves; Ix controls HMS and Tabr, Tleilaxu Arrakeen, Guild Carthag; no three-hold victory.'); + let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined; + while (game.turn === 1) { + const action = nextStrongholdFactionsNativeStep(game); assert.ok(action); + const before = structuredClone(game); game = run(game, action, actions); + if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = action; } + } + assert.ok(beforeFirstMentat && firstMentatStep); + const afterFirstMentat = structuredClone(game); + assert.equal(game.strongholdCards!.owners[kind], ix); + const winnerLeader = matchingLeader(game, ix, tleilaxu); + assert.ok(winnerLeader, 'Actual first native Face Dancer draw must provide an Ix leader'); + game = advance(game, state => state.phase === 5 && clean(state), actions); + const pointer = game.mobileStronghold!.location; assert.ok(pointer && pointer !== MOBILE_LOCATION); + reserveBoard(game); + const location = key(kind); + place(game, ix, location, 6, 3); place(game, opponent, location, 6); + if (kind !== MOBILE_STRONGHOLD) place(game, ix, MOBILE_LOCATION, 1, 1); + else place(game, ix, key('sietch_tabr'), 1); + place(game, ix, pointer, 2, 1); place(game, tleilaxu, key('arrakeen'), 3); + staging.push('Before native movement completion: conserve six counters per combatant (Ix three suboids/three cyborgs), two Ix outside the HMS pointer (one cyborg), real Tleilaxu board sources in Arrakeen; retain first-Mentat cards.'); + for (const player of game.players) player.spice = 20; + staging.push('Explicit bank-balance staging: original three seats each start this battle with twenty spice; no bounty, support or Stronghold receipt injected.'); + const weapon = heldCard(game, ix, 'projectile', staging), defense = heldCard(game, ix, 'shield', staging); + const loserLeader = tleilaxuStrongholdsPlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0]; assert.ok(loserLeader); + game = advance(game, state => state.phase === 6 && clean(state), actions); + const beforeBattle = structuredClone(game), actor = game.active!; assert.ok([ix, opponent].includes(actor)); + const battleAction: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === ix ? opponent : ix } }; + game = run(game, battleAction, actions); + if (kind === MOBILE_STRONGHOLD) { + game = advance(game, state => state.decision?.kind === 'strongholdCopy' || !!state.battle?.strongholdCopy, actions); + assert.equal(strongholdControllers(game.players, true).sietch_tabr, ix); + if (game.decision?.kind === 'strongholdCopy') + game = run(game, { actor: ix, action: { type: 'decision', event: game.decision.event, stronghold: 'sietch_tabr' } }, actions); + else assert.equal(game.battle!.strongholdCopy, 'sietch_tabr', 'Native sole-copy declaration is automatic'); + } + game = advance(game, state => !!state.battle && !state.battle.revealed && !nextStrongholdFactionsNativeStep(state), actions); + const planActions: StrongholdFactionsNativeStep[] = [ + { actor: ix, action: { type: 'battlePlan', dial: 2, support: 1, leader: winnerLeader.id, weapon, defense } }, + { actor: opponent, action: { type: 'battlePlan', dial: 1.5, support: 1, leader: loserLeader.id, weapon: null, defense: null } }, + ]; + const fixture = { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, game: structuredClone(game), ix, tleilaxu, opponent, kind, location, pointer, winnerLeader: winnerLeader.id, loserLeader: loserLeader.id, weapon, defense, battleAction, planActions, sourceActions: actions, staging }; + const revealed = revealTleilaxuStrongholdsBattle(fixture, actions); + const faceDance = resolveTleilaxuStrongholdsBattle({ ...fixture, revealed }, actions); + return { ...fixture, revealed, faceDance }; +} +export function revealTleilaxuStrongholdsBattle(fixture: Pick, sourceActions: StrongholdFactionsNativeStep[] = []): Game { + let game = structuredClone(fixture.game); + for (const plan of fixture.planActions) { + game = run(game, plan, sourceActions); + while (game.response || game.decision?.kind === 'fullPlanOffer') { + const action = nextStrongholdFactionsNativeStep(game); assert.ok(action); game = run(game, action, sourceActions); + } + } + assert.ok(game.battle?.revealed); return game; +} +export function resolveTleilaxuStrongholdsBattle(fixture: Pick, sourceActions: StrongholdFactionsNativeStep[] = []): Game { + const quote = quoteStrongholdFactionsBattle(fixture.revealed); assert.equal(quote.winner, fixture.ix); + let game = structuredClone(fixture.revealed); + for (let n = 0; n < 160; n++) { + if (game.decision?.kind === 'faceDance') return game; + const action = nextStrongholdFactionsNativeStep(game); assert.ok(action); game = run(game, action, sourceActions); + } + throw Error('Original winner aftermath did not reach native Face Dance'); +} +export function finishTleilaxuStrongholdsTurn(state: Game): Game { + const turn = state.turn; return advance(structuredClone(state), game => game.turn > turn, []); +} diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts index ea76b910..1124a89b 100644 --- a/tests/prototype-room.test.ts +++ b/tests/prototype-room.test.ts @@ -22,6 +22,8 @@ import { NEXUS_FACTIONS } from '../game/nexus-cards'; import { createLeaderSkills } from '../game/leader-skills'; import { createStrongholdCards } from '../game/stronghold-cards'; import { treacheryDeck } from '../game/cards'; +import type { FactionId } from '../game/catalog'; +import { richeseCards } from '../game/richese-cards'; function fixture(t: test.TestContext) { const db = new DatabaseSync(':memory:'); @@ -955,19 +957,33 @@ void test('Banker income CLI requires a fresh exact-version private backup and p assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials); }); -function strongholdFactionsLobby(count = 3) { - const game = createGame('PROTOTYP', newPlayer('i', 'Native Ixians', 'ixians'), true, ['ix', 'choam']); - const factions = ['choam', 'atreides', 'harkonnen', 'emperor', 'guild'] as const; +function strongholdFactionsLobby( + count = 3, + roster: readonly FactionId[] = ['ixians', 'choam', 'atreides', 'harkonnen', 'emperor', 'guild'], + expansions = ['ix', 'choam'], +) { + const game = createGame('PROTOTYP', newPlayer('i', roster[0], roster[0]), true, expansions); for (let index = 1; index < count; index++) - joinGame(game, newPlayer('seat-' + index, factions[index - 1], factions[index - 1])); + joinGame(game, newPlayer('seat-' + index, roster[index], roster[index])); game.players.forEach(player => { player.ready = true; }); return game; } void test('Stronghold factions admits only fresh exact-version native Advanced lobbies without changing seats or other rooms', t => { const { db } = fixture(t); - for (const count of [2, 3, 4, 5, 6]) { - const lobby = strongholdFactionsLobby(count); + const families: { roster: FactionId[]; expansions: string[]; minimum?: number }[] = [ + { roster: ['ixians', 'choam', 'atreides', 'harkonnen', 'emperor', 'guild'], expansions: ['ix', 'choam'] }, + { roster: ['ixians', 'tleilaxu', 'atreides', 'harkonnen', 'emperor', 'guild'], expansions: ['ix'] }, + { roster: ['choam', 'richese', 'atreides', 'harkonnen', 'emperor', 'guild'], expansions: ['choam'] }, + { roster: ['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'guild'], expansions: ['ix', 'choam'], minimum: 4 }, + ...(['ixians', 'tleilaxu', 'choam', 'richese'] as const).map(faction => ({ + roster: [faction, 'guild', 'atreides', 'harkonnen', 'emperor', 'fremen'] as FactionId[], + expansions: [faction === 'ixians' || faction === 'tleilaxu' ? 'ix' : 'choam'], + })), + ]; + for (const { family, count } of families.flatMap(family => + [2, 3, 4, 5, 6].filter(count => count >= (family.minimum ?? 2)).map(count => ({ family, count })))) { + const lobby = strongholdFactionsLobby(count, family.roster, family.expansions); const original = structuredClone(lobby); db.prepare('UPDATE rooms SET state=?,version=7 WHERE code=?').run(JSON.stringify(lobby), lobby.code); const seats = db.prepare('SELECT * FROM seats').all(); @@ -982,19 +998,21 @@ void test('Stronghold factions admits only fresh exact-version native Advanced l assert.equal(row.version, 8); assert.equal(saved.status, 'setup'); assert.equal(saved.advanced, true); - assert.deepEqual(saved.expansions, ['ix', 'choam']); + assert.deepEqual(saved.expansions, family.expansions); assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), original.players.map(p => [p.id, p.name, p.faction])); assert.deepEqual(saved.playerPositions, original.playerPositions); const cards = [...saved.deck, ...saved.discard, ...(saved.ixSetupCards ?? []), ...saved.players.flatMap(p => p.hand)]; - assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck(['ix', 'choam']).map(card => card.id).sort()); - assert.equal(new Set(cards.map(card => card.id)).size, 47); + assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck(family.expansions).map(card => card.id).sort()); + assert.equal(new Set(cards.map(card => card.id)).size, family.expansions.includes('ix') ? 47 : 35); + const cache = family.roster.includes('richese') ? richeseCards().map(card => card.id).sort() : []; + assert.deepEqual((saved.richeseCache ?? []).map(card => card.id).sort(), cache); assert.deepEqual(saved.strongholdCards, createStrongholdCards()); - const ix = saved.players.find(p => p.faction === 'ixians')!; - assert.equal((ix.elites?.reserves ?? 0) + (ix.elites?.tanks ?? 0) + + const ix = saved.players.find(p => p.faction === 'ixians'); + if (ix) assert.equal((ix.elites?.reserves ?? 0) + (ix.elites?.tanks ?? 0) + Object.values(ix.elites?.forces ?? {}).reduce((sum, amount) => sum + amount, 0), 7); - const choam = saved.players.find(p => p.faction === 'choam')!; - assert.equal(choam.leaders.filter(leader => leader.id === 'choam-auditor').length, 1); + const choam = saved.players.find(p => p.faction === 'choam'); + if (choam) assert.equal(choam.leaders.filter(leader => leader.id === 'choam-auditor').length, 1); for (const p of saved.players) { const view = viewGame(saved, p.id); assert.deepEqual(view.strongholdCards, saved.strongholdCards); @@ -1017,8 +1035,11 @@ void test('Stronghold faction admission rejects incompatible modules, history, o game => { game.advanced = false; }, game => { game.expansions = ['ix']; }, game => { game.expansions = ['ix', 'choam', 'ecaz']; }, - game => { game.players[1] = newPlayer(game.players[1].id, 'Classic', 'guild'); game.players[1].ready = true; }, - game => { game.players[2] = newPlayer(game.players[2].id, 'Tleilaxu', 'tleilaxu'); game.players[2].ready = true; }, + game => { game.expansions = ['choam']; }, + game => { game.expansions = ['ix', 'choam', 'ix']; }, + game => { game.expansions = []; }, + game => { game.players[2].faction = 'ecaz'; }, + game => { game.players[0].faction = 'guild'; game.players[1].faction = 'fremen'; }, game => { game.players[0].ready = false; }, game => { game.turn = 2; }, game => { game.players[0].battleLosses = 1; }, diff --git a/tests/richese-strongholds-runtime.test.ts b/tests/richese-strongholds-runtime.test.ts new file mode 100644 index 00000000..53f13b76 --- /dev/null +++ b/tests/richese-strongholds-runtime.test.ts @@ -0,0 +1,213 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { territory } from '../game/board'; +import { treacheryDeck } from '../game/cards'; +import { presenceAt } from '../game/force-presence'; +import { strongholdControllers } from '../game/stronghold-cards'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions'; +import { createRicheseStrongholdsFixture, finishRicheseStrongholdsBattle, nextRicheseStrongholdsNativeStep, revealRicheseStrongholdsBattle, richeseStrongholdsPlayer, type RicheseStrongholdsFixture } from './fixture-richese-strongholds'; + +function chooseStone(fixture: RicheseStrongholdsFixture, mode: 'kill' | 'ignore') { + const game = revealRicheseStrongholdsBattle(fixture); + assert.equal(game.decision?.kind, 'stoneBurner'); + assert.equal(game.decision?.player, fixture.richese); + return applyAction(game, fixture.richese, { type: 'decision', event: game.battle!.event, mode }); +} +/** Actual native settlement, independently accounting for later Collection. */ +function settle(fixture: RicheseStrongholdsFixture, revealed: Game, callers: string[] = []) { + // Calls are declared on the real battle before deriving the independent quote. + let voted = revealed; + while (voted.battle?.revealed && !voted.decision && !voted.response) { + const next = nextRicheseStrongholdsNativeStep(voted); + if (!next || next.action.type !== 'traitorCall') break; + const remaining = viewGame(voted, voted.host).battle!.traitorVoters.filter(id => voted.battle!.traitorCalls[id] === undefined); + if (remaining.length === 1) break; + next.action.call = callers.includes(next.actor); + voted = applyAction(voted, next.actor, next.action); + } + // The last vote resolves immediately, so quote the still-live original source + // with exactly those public calls; never alter the engine's actual source. + const quoted = structuredClone(voted); + for (const actor of viewGame(quoted, quoted.host).battle!.traitorVoters) quoted.battle!.traitorCalls[actor] = callers.includes(actor); + const quote = quoteStrongholdFactionsBattle(quoted); + const resolved = finishRicheseStrongholdsBattle(fixture, voted, callers); + const collection = resolved.phase === 7 && !resolved.phaseOpening + ? quoteSpiceCollection({ ...resolved, players: resolved.players.map(p => ({ ...p, spice: 0 })) }).receipts + : []; + for (const player of voted.players) { + const payment = quote.payments.find(p => p.player === player.id)?.ownPayment ?? 0; + const income = quote.strongholdIncome.filter(p => p.player === player.id).reduce((sum, p) => sum + p.amount, 0); + const bounty = quote.bounty?.player === player.id ? quote.bounty.amount : 0; + const choam = quote.choamIncome?.owner === player.id ? quote.choamIncome.amount : 0; + const collected = collection.filter(p => p.player === player.id).reduce((sum, p) => sum + p.strongholds + p.collected, 0); + assert.equal(richeseStrongholdsPlayer(resolved, player.id).spice, player.spice - payment + income + bounty + choam + collected, player.faction); + } + assert.equal(resolved.lastBattleContext?.winner, quote.winner); + return { resolved, quote }; +} + +void test('genuine E2 Richese setup supplies the separate cache, one-force shipment and first END Mentat control even for zero', () => { + for (const noFieldValue of [0, 3, 5] as const) { + const fixture = createRicheseStrongholdsFixture({ noFieldValue }); + assert.equal(treacheryDeck(fixture.initial.expansions).length, 35); + assert.equal(fixture.initial.deck.length, 35); + assert.equal(fixture.initial.richeseCache!.length, 10); + assert.equal(fixture.afterSetup.players.find(p => p.faction === 'richese')!.noField!.tokens.length, 3); + const before = richeseStrongholdsPlayer(fixture.beforeShipment, fixture.richese); + const shipped = richeseStrongholdsPlayer(fixture.afterShipment, fixture.richese); + assert.equal(shipped.spice, before.spice - 1, 'actual marker shipment pays one stronghold-force price'); + assert.equal(shipped.reserves, before.reserves, 'shipment does not yet transfer physical reserves'); + assert.equal(presenceAt(shipped, fixture.kind), 1); + assert.equal(strongholdControllers(fixture.beforeFirstMentat.players, false)[fixture.kind], fixture.richese); + assert.equal(fixture.beforeFirstMentat.strongholdCards!.owners[fixture.kind], null); + const ended = applyAction(fixture.beforeFirstMentat, fixture.firstMentatStep.actor, fixture.firstMentatStep.action); + assert.equal(ended.turn, 2); + assert.equal(ended.strongholdCards!.owners[fixture.kind], fixture.richese); + assert.equal(fixture.afterFirstMentat.strongholdCards!.claimedTurn, 1); + } +}); + +void test('original undealt CLI initializer continues without redealing, and native unbid-cache acquisition really supplies Stone', () => { + const original = createRicheseStrongholdsFixture().initial; + const before = structuredClone(original); + const fixture = createRicheseStrongholdsFixture({ initial: original, stone: true }); + assert.deepEqual(original, before); + assert.deepEqual(fixture.initial, before); + const acquisition = fixture.acquisition!; + assert.ok(acquisition.before.richeseCache!.some(c => c.id === 'richese-stone-burner')); + assert.equal(acquisition.before.players.some(p => p.hand.some(c => c.id === 'richese-stone-burner')), false); + let game = acquisition.before; + for (let count = 0; !richeseStrongholdsPlayer(game, fixture.richese).hand.some(card => card.id === 'richese-stone-burner') && count < 100; count++) { + const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); + game = applyAction(game, next.actor, next.action); + } + for (const player of acquisition.before.players) { + assert.equal(richeseStrongholdsPlayer(game, player.id).spice, player.spice, 'unbid acquisition has no original purchase charge'); + } + assert.deepEqual(richeseStrongholdsPlayer(game, fixture.richese).hand.map(card => card.id).sort(), + [...richeseStrongholdsPlayer(acquisition.before, fixture.richese).hand.map(card => card.id), 'richese-stone-burner'].sort()); + assert.ok(richeseStrongholdsPlayer(game, fixture.richese).hand.some(c => c.id === 'richese-stone-burner')); + assert.equal(game.richeseCache!.some(c => c.id === 'richese-stone-burner'), false); +}); + +void test('actual voluntary reveal removes zero presence or materializes 3/5 up to reserves, never exceeding the native twenty-counter pool', () => { + for (const noFieldValue of [0, 3, 5] as const) { + const fixture = createRicheseStrongholdsFixture({ noFieldValue, reserves: 2 }); + const before = fixture.beforeReveal; + const revealed = applyAction(before, fixture.revealStep.actor, fixture.revealStep.action); + const player = richeseStrongholdsPlayer(revealed, fixture.richese); + assert.equal(player.forces[`${fixture.kind}:${territory(fixture.kind).sectors[0]}`] ?? 0, Math.min(2, noFieldValue)); + assert.equal(player.reserves, 2 - Math.min(2, noFieldValue)); + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20); + assert.equal(presenceAt(player, fixture.kind), Math.min(2, noFieldValue)); + assert.equal(player.noField!.deployed, null); + assert.equal(revealed.strongholdCards!.owners[fixture.kind], fixture.richese, 'losing physical control does not reassign the retained card before END Mentat'); + } +}); + +void test('native marker-only plans use the reserve-limited private pool, reveal 0/3/5 only after both plans, and preserve supported costs', () => { + for (const noFieldValue of [0, 3, 5] as const) { + const fixture = createRicheseStrongholdsFixture({ noFieldValue, ownerDial: noFieldValue ? 1 : 0, support: noFieldValue ? 1 : 0 }); + const own = viewGame(fixture.game, fixture.richese).battle!; + assert.equal(own.ownForces!.normal, noFieldValue); + assert.ok(own.ownForces!.normal <= 20); + assert.equal(own.strongholdEffects[fixture.richese], fixture.kind); + const sealed = applyAction(fixture.game, fixture.richese, fixture.planActions[0].action); + assert.ok(richeseStrongholdsPlayer(sealed, fixture.richese).noField!.deployed); + assert.equal(richeseStrongholdsPlayer(sealed, fixture.richese).forces[`${fixture.kind}:${territory(fixture.kind).sectors[0]}`], undefined); + const revealed = revealRicheseStrongholdsBattle(fixture); + assert.equal(richeseStrongholdsPlayer(revealed, fixture.richese).noField!.deployed, null); + assert.equal(richeseStrongholdsPlayer(revealed, fixture.richese).forces[`${fixture.kind}:${territory(fixture.kind).sectors[0]}`] ?? 0, noFieldValue); + const { resolved, quote } = settle(fixture, revealed); + assert.equal(quote.winner, fixture.richese, 'printed Habbanya advantage applies to the zero-plan tie as well as ordinary supported victory'); + assert.equal(quote.payments.find(p => p.player === fixture.richese)!.ownPayment, noFieldValue ? 1 : 0); + assert.equal(richeseStrongholdsPlayer(resolved, fixture.opponent).tanks, 6); + assert.equal(richeseStrongholdsPlayer(resolved, fixture.richese).tanks, noFieldValue ? 1 : 0); + } +}); + +void test('Arrakeen allowance funds real supported marker battle costs without creating spendable spice', () => { + const fixture = createRicheseStrongholdsFixture({ kind: 'arrakeen', noFieldValue: 5, ownerDial: 3, support: 3 }); + const revealed = revealRicheseStrongholdsBattle(fixture); + assert.equal(richeseStrongholdsPlayer(revealed, fixture.richese).spice, 20, 'sealing a subsidized plan does not credit its wallet'); + const { quote, resolved } = settle(fixture, revealed); + const payment = quote.payments.find(p => p.player === fixture.richese)!; + assert.equal(payment.bankSupport, 2); + assert.equal(payment.ownPayment, 1); + assert.equal(quote.choamIncome!.amount, 1); + assert.equal(richeseStrongholdsPlayer(resolved, fixture.richese).forces['arrakeen:10'], 2); +}); + +void test('real cache-acquired Stone uses Habbanya undialed ties in both revealed modes, with declared support, native deaths and ordinary retention', () => { + for (const mode of ['kill', 'ignore'] as const) { + const fixture = createRicheseStrongholdsFixture({ stone: true, ownerDial: 2, opponentDial: 2, support: 2, opponentSupport: 2 }); + const chosen = chooseStone(fixture, mode); + const { resolved, quote } = settle(fixture, chosen); + assert.equal(quote.winner, fixture.richese); + assert.deepEqual(quote.stone!.attacker, [4]); + assert.deepEqual(quote.stone!.defender, [4]); + assert.equal(richeseStrongholdsPlayer(resolved, fixture.richese).forces[`${fixture.kind}:${territory(fixture.kind).sectors[0]}`], 4); + for (const plan of fixture.planActions) { + assert.equal(richeseStrongholdsPlayer(resolved, plan.actor).leaders.find(l => l.id === plan.action.leader)!.dead, mode === 'kill'); + assert.equal(quote.payments.find(p => p.player === plan.actor)!.cost, 2); + } + assert.equal(quote.choamIncome!.amount, 2, 'actual four-spice battle support produces native CHOAM income'); + assert.ok(richeseStrongholdsPlayer(resolved, fixture.richese).hand.some(c => c.id === 'richese-stone-burner')); + } + const loser = createRicheseStrongholdsFixture({ stone: true, ownerDial: 3, support: 3, opponentDial: 1, opponentSupport: 1 }); + const lost = settle(loser, chooseStone(loser, 'ignore')); + assert.equal(lost.quote.winner, loser.opponent, 'Habbanya does not override unequal undialed forces'); + assert.ok(lost.resolved.discard.some(c => c.id === 'richese-stone-burner')); +}); + +void test('native traitor and explosion precedence outrank real Stone plus Habbanya; successful traitor waives declared support', () => { + const traitor = createRicheseStrongholdsFixture({ stone: true, traitors: 'owner', ownerDial: 2, support: 2 }); + const called = settle(traitor, chooseStone(traitor, 'kill'), [traitor.richese]); + assert.equal(called.quote.winner, traitor.richese); + assert.equal(called.quote.payments.find(p => p.player === traitor.richese)!.freeByTraitor, true); + assert.equal(richeseStrongholdsPlayer(called.resolved, traitor.richese).leaders.find(l => l.id === traitor.planActions[0].action.leader)!.dead, false); + const mutual = createRicheseStrongholdsFixture({ stone: true, traitors: 'both' }); + assert.equal(settle(mutual, chooseStone(mutual, 'kill'), [mutual.richese, mutual.opponent]).quote.winner, null); + const explosion = createRicheseStrongholdsFixture({ stone: true, ownerDefense: 'shield', opponentWeapon: 'lasgun', ownerDial: 1, support: 1, opponentDial: 1, opponentSupport: 1 }); + const exploded = settle(explosion, chooseStone(explosion, 'kill')); + assert.equal(exploded.quote.explosion, true); + assert.equal(exploded.quote.winner, null); + assert.equal(exploded.quote.bounty, null); + assert.equal(richeseStrongholdsPlayer(exploded.resolved, explosion.richese).tanks, 6); + assert.equal(richeseStrongholdsPlayer(exploded.resolved, explosion.opponent).tanks, 6); +}); + +void test('all four existing policies legally consume real Richese marker plans, Stone admission and both-mode choice views', () => { + const marker = createRicheseStrongholdsFixture({ noFieldValue: 3, kind: 'arrakeen' }); + const stone = createRicheseStrongholdsFixture({ stone: true, ownerDial: 1, support: 1, opponentDial: 1, opponentSupport: 1 }); + for (const difficulty of DIFFICULTIES) { + for (const fixture of [marker, stone]) { + let game = structuredClone(fixture.game); + for (const actor of [fixture.richese, fixture.opponent]) { + const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty; + const plan = botActions(view).find(action => action.type === 'battlePlan'); assert.ok(plan, difficulty); + game = applyAction(game, actor, plan); + while (game.response || game.decision?.kind === 'fullPlanOffer') { + const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); + game = applyAction(game, next.actor, next.action); + } + } + while (game.decision || game.response) { + const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); + game = applyAction(game, next.actor, next.action); + } + settle(fixture, game); + } + const game = revealRicheseStrongholdsBattle(stone); + const view = viewGame(game, stone.richese); view.players.find(p => p.id === stone.richese)!.bot = difficulty; + const mode = botActions(view)[0]; + assert.ok(mode.mode === 'kill' || mode.mode === 'ignore'); + const chosen = applyAction(game, stone.richese, mode); + assert.equal(chosen.battle!.stoneBurner![stone.richese], mode.mode); + assert.equal(finishRicheseStrongholdsBattle(stone, chosen).lastBattleContext!.winner, stone.richese); + } +}); diff --git a/tests/tleilaxu-strongholds-runtime.test.ts b/tests/tleilaxu-strongholds-runtime.test.ts new file mode 100644 index 00000000..7062a8e6 --- /dev/null +++ b/tests/tleilaxu-strongholds-runtime.test.ts @@ -0,0 +1,152 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { MOBILE_LOCATION, MOBILE_STRONGHOLD, splitLocation, territory } from '../game/board'; +import { treacheryDeck } from '../game/cards'; +import { strongholdBenefit, strongholdControllers } from '../game/stronghold-cards'; +import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions'; +import { createTleilaxuStrongholdsFixture, finishTleilaxuStrongholdsTurn, tleilaxuStrongholdsPlayer as player, type TleilaxuStrongholdsFixture } from './fixture-tleilaxu-strongholds'; + +function at(game: Game, actor: string, site: string) { + return Object.entries(player(game, actor).forces).filter(([key]) => splitLocation(key).territory === site).reduce((sum, [, count]) => sum + count, 0); +} +function replace(fixture: TleilaxuStrongholdsFixture, sources: Record): Game { + return applyAction(fixture.faceDance, fixture.tleilaxu, { type: 'decision', reveal: true, sources, sector: territory(fixture.kind).sectors[0] }); +} +function returnedWinner(fixture: TleilaxuStrongholdsFixture, after: Game) { + const before = player(fixture.faceDance, fixture.ix), winner = player(after, fixture.ix); + const remaining = at(fixture.faceDance, fixture.ix, fixture.kind); + const elite = Object.entries(before.elites!.forces).filter(([key]) => splitLocation(key).territory === fixture.kind).reduce((sum, [, count]) => sum + count, 0); + assert.equal(at(after, fixture.ix, fixture.kind), 0); + assert.equal(winner.reserves, before.reserves + remaining); + assert.equal(winner.elites!.reserves, before.elites!.reserves + elite); + assert.equal(winner.tanks, before.tanks, 'Face Dance returns survivors, not a second set of battle casualties'); + assert.equal(winner.elites!.tanks, before.elites!.tanks); + assert.equal(winner.forces[fixture.pointer], before.forces[fixture.pointer], 'forces outside the HMS pointer are not inside its battle'); + assert.equal(winner.elites!.forces[fixture.pointer], before.elites!.forces[fixture.pointer]); + assert.equal(winner.spice, before.spice, 'the Face Dancer leader earns no extra bounty or clawback'); + assert.deepEqual(winner.hand, before.hand, 'original winner keeps its actual physical battle cards'); + const leader = winner.leaders.find(l => l.id === fixture.winnerLeader)!; + assert.equal(leader.dead, true); assert.equal(leader.deaths, before.leaders.find(l => l.id === fixture.winnerLeader)!.deaths + 1); + assert.equal(after.lastBattleContext!.winner, fixture.ix); + assert.deepEqual(after.strongholdCards, fixture.faceDance.strongholdCards); + assert.equal(player(after, fixture.tleilaxu).faceDancers!.find(c => c.leader === fixture.winnerLeader)!.revealed, true); +} + +void test('actual native Ix/Tleilaxu setup draws its matching Face Dancer and first END Mentat awards the Stronghold Cards', () => { + const fixture = createTleilaxuStrongholdsFixture(); + assert.equal(fixture.afterSetup.advanced, true); + assert.deepEqual(fixture.afterSetup.players.map(p => p.faction), ['ixians', 'tleilaxu', 'guild']); + const cards = [...fixture.afterSetup.deck, ...fixture.afterSetup.discard, ...fixture.afterSetup.players.flatMap(p => p.hand)]; + assert.deepEqual(cards.map(c => c.id).sort(), treacheryDeck(fixture.afterSetup.expansions).map(c => c.id).sort()); + assert.equal(player(fixture.afterSetup, fixture.ix).elites!.forces[MOBILE_LOCATION], 3); + assert.ok(player(fixture.afterSetup, fixture.tleilaxu).faceDancers!.some(c => c.leader === fixture.winnerLeader && !c.revealed), 'match came from actual first native draw'); + assert.equal(fixture.beforeFirstMentat.strongholdCards!.claimedTurn, 0); + assert.ok(Object.values(fixture.beforeFirstMentat.strongholdCards!.owners).every(owner => owner === null)); + const claimed = applyAction(fixture.beforeFirstMentat, fixture.firstMentatStep.actor, fixture.firstMentatStep.action); + assert.equal(claimed.turn, 2); + assert.deepEqual(claimed.strongholdCards!.owners, strongholdControllers(fixture.beforeFirstMentat.players, true)); + assert.deepEqual(claimed.strongholdCards, fixture.afterFirstMentat.strongholdCards); +}); + +void test('original undealt CLI setup is not mutated, reinitialized or deterministically redealt', () => { + const original = createTleilaxuStrongholdsFixture().initial, before = structuredClone(original); + const fixture = createTleilaxuStrongholdsFixture({ initial: original }); + assert.deepEqual(original, before); assert.deepEqual(fixture.initial, before); + assert.ok(player(fixture.faceDance, fixture.tleilaxu).faceDancers!.some(c => c.leader === fixture.winnerLeader && !c.revealed)); + assert.equal(fixture.faceDance.decision?.kind, 'faceDance'); +}); + +void test('original winner receives native casualties, leader bounty, physical card choice and retained Tabr income before Face Dance', () => { + for (const kind of ['sietch_tabr', MOBILE_STRONGHOLD] as const) { + const fixture = createTleilaxuStrongholdsFixture({ kind }); + const quote = quoteStrongholdFactionsBattle(fixture.revealed); + assert.equal(quote.winner, fixture.ix); + assert.deepEqual(quote.strongholdIncome, [{ player: fixture.ix, amount: 1 }]); + assert.deepEqual(quote.bounty, { player: fixture.ix, amount: player(fixture.revealed, fixture.opponent).leaders.find(l => l.id === fixture.loserLeader)!.strength }); + const original = player(fixture.revealed, fixture.ix), winner = player(fixture.faceDance, fixture.ix), 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); + assert.equal(at(fixture.faceDance, fixture.ix, kind), 6 - losses.normal - losses.elite); + assert.equal(winner.spice, original.spice - quote.payments.find(p => p.player === fixture.ix)!.ownPayment + quote.bounty!.amount + 1); + assert.ok(winner.hand.some(c => c.id === fixture.weapon)); assert.ok(winner.hand.some(c => c.id === fixture.defense)); + assert.equal(player(fixture.faceDance, fixture.opponent).tanks, 6); + assert.equal(player(fixture.faceDance, fixture.opponent).leaders.find(l => l.id === fixture.loserLeader)!.dead, true); + assert.equal(winner.leaders.find(l => l.id === fixture.winnerLeader)!.dead, false); + assert.equal(fixture.faceDance.decision?.kind, 'faceDance'); + assert.equal(fixture.faceDance.decision!.identity, fixture.winnerLeader); + assert.deepEqual(fixture.faceDance.strongholdCards, fixture.beforeBattle.strongholdCards); + if (kind === MOBILE_STRONGHOLD) assert.equal(fixture.revealed.battle!.strongholdCopy, 'sietch_tabr'); + } +}); + +void test('native replacement permits zero, partial and full quantities from actual reserves and board, without transferring cards mid-turn', () => { + const fixture = createTleilaxuStrongholdsFixture(), maximum = at(fixture.faceDance, fixture.ix, fixture.kind); + const board = `arrakeen:${territory('arrakeen').sectors[0]}`; + const choices: Record[] = [{}, { reserves: 1 }, { [board]: 2 }, { reserves: maximum - 2, [board]: 2 }]; + for (const sources of choices) { + const after = replace(fixture, sources), total = Object.values(sources).reduce((sum, count) => sum + count, 0); + returnedWinner(fixture, after); + assert.equal(at(after, fixture.tleilaxu, fixture.kind), total); + assert.equal(player(after, fixture.tleilaxu).reserves, player(fixture.faceDance, fixture.tleilaxu).reserves - (sources.reserves ?? 0)); + assert.equal(player(after, fixture.tleilaxu).forces[board], 3 - (sources[board] ?? 0)); + assert.equal(strongholdControllers(after.players, true)[fixture.kind], total ? fixture.tleilaxu : null); + assert.equal(strongholdBenefit(after.strongholdCards, fixture.ix, fixture.kind), fixture.kind); + assert.equal(strongholdBenefit(after.strongholdCards, fixture.tleilaxu, fixture.kind), null); + const next = finishTleilaxuStrongholdsTurn(after); + assert.equal(next.strongholdCards!.claimedTurn, after.turn); + assert.equal(next.strongholdCards!.owners[fixture.kind], total ? fixture.tleilaxu : null); + assert.equal(strongholdBenefit(next.strongholdCards, fixture.tleilaxu, fixture.kind), total ? fixture.kind : null); + } +}); + +void test('HMS replacement returns inside subtypes only, leaving the outside pointer and copied benefit intact until END Mentat', () => { + const fixture = createTleilaxuStrongholdsFixture({ kind: MOBILE_STRONGHOLD }); + const maximum = at(fixture.faceDance, fixture.ix, MOBILE_STRONGHOLD); + const after = replace(fixture, { reserves: maximum }); returnedWinner(fixture, after); + assert.equal(at(after, fixture.tleilaxu, MOBILE_STRONGHOLD), maximum); + assert.equal(after.mobileStronghold!.location, fixture.pointer); + assert.equal(player(after, fixture.ix).forces[fixture.pointer], 2); + assert.equal(player(after, fixture.ix).elites!.forces[fixture.pointer], 1); + assert.equal(after.strongholdCards!.owners[MOBILE_STRONGHOLD], fixture.ix); + assert.equal(after.strongholdCards!.owners.sietch_tabr, fixture.ix); + const next = finishTleilaxuStrongholdsTurn(after); + assert.equal(next.strongholdCards!.owners[MOBILE_STRONGHOLD], fixture.tleilaxu); + assert.equal(next.strongholdCards!.owners.sietch_tabr, fixture.ix); +}); + +void test('army cap and physical source availability reject illegal replacement without consuming the actual Face Dancer', () => { + const fixture = createTleilaxuStrongholdsFixture(), before = structuredClone(fixture.faceDance); + const maximum = at(before, fixture.ix, fixture.kind), board = `arrakeen:${territory('arrakeen').sectors[0]}`; + for (const sources of [{ reserves: maximum + 1 }, { [board]: 4 }, { reserves: -1 }, { reserves: 0.5 }, { [fixture.location]: 1 }]) { + assert.throws(() => replace(fixture, sources)); + assert.deepEqual(fixture.faceDance, before); + } +}); + +void test('declining the genuine matching Face Dancer leaves the winner, leader and end-turn control unchanged', () => { + const fixture = createTleilaxuStrongholdsFixture(); + const after = applyAction(fixture.faceDance, fixture.tleilaxu, { type: 'decision', reveal: false }); + assert.equal(at(after, fixture.ix, fixture.kind), at(fixture.faceDance, fixture.ix, fixture.kind)); + assert.equal(player(after, fixture.ix).leaders.find(l => l.id === fixture.winnerLeader)!.dead, false); + assert.equal(player(after, fixture.tleilaxu).faceDancers!.find(c => c.leader === fixture.winnerLeader)!.revealed, false); + assert.equal(finishTleilaxuStrongholdsTurn(after).strongholdCards!.owners[fixture.kind], fixture.ix); +}); + +void test('all four native policies execute the actual Stronghold Face Dance using legal physical sources', () => { + for (const kind of ['sietch_tabr', MOBILE_STRONGHOLD] as const) { + const fixture = createTleilaxuStrongholdsFixture({ kind }); + for (const level of DIFFICULTIES) { + const view = viewGame(fixture.faceDance, fixture.tleilaxu); view.players.find(p => p.id === fixture.tleilaxu)!.bot = level; + const action: Action | undefined = botActions(view).find(a => a.type === 'decision'); assert.ok(action); + assert.equal(action.reveal, true); + const sources = action.sources as Record, count = Object.values(sources).reduce((sum, n) => sum + n, 0); + assert.equal(count, at(fixture.faceDance, fixture.ix, kind)); + const after = applyAction(fixture.faceDance, fixture.tleilaxu, action); returnedWinner(fixture, after); + assert.equal(at(after, fixture.tleilaxu, kind), count); + assert.equal(finishTleilaxuStrongholdsTurn(after).strongholdCards!.owners[kind], fixture.tleilaxu); + } + } +}); diff --git a/tools/faction-games.ts b/tools/faction-games.ts index 11b8b83c..5f773d57 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -5,6 +5,8 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { botActions } from '../game/bots'; +import { treacheryDeck } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; import type { FactionId } from '../game/catalog'; import { applyAction, @@ -220,13 +222,36 @@ const CHOAM_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [ 'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit', ]; -const STRONGHOLD_FACTIONS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({ - ordinal: 151 + players - 2, - profile: 'stronghold-factions', - rules: 'advanced', - expansions: ['ix', 'choam'], - roster: STRONGHOLD_FACTIONS_ROSTER.slice(0, players), -})); +const STRONGHOLD_FACTIONS_SCENARIOS: readonly Scenario[] = [ + ...[2, 3, 4, 5, 6].map(players => ({ + ordinal: 151 + players - 2, + profile: 'stronghold-factions' as const, + rules: 'advanced' as const, + expansions: ['ix', 'choam'], + roster: STRONGHOLD_FACTIONS_ROSTER.slice(0, players), + })), + ...[2, 3, 4, 5, 6].map(players => ({ + ordinal: 156 + players - 2, + profile: 'stronghold-factions' as const, + rules: 'advanced' as const, + expansions: ['ix'], + roster: (['ixians', 'tleilaxu', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'] as FactionId[]).slice(0, players), + })), + ...[2, 3, 4, 5, 6].map(players => ({ + ordinal: 161 + players - 2, + profile: 'stronghold-factions' as const, + rules: 'advanced' as const, + expansions: ['choam'], + roster: (['choam', 'richese', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'] as FactionId[]).slice(0, players), + })), + ...[4, 5, 6].map(players => ({ + ordinal: 166 + players - 4, + profile: 'stronghold-factions' as const, + rules: 'advanced' as const, + expansions: ['ix', 'choam'], + roster: (['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'fremen'] as FactionId[]).slice(0, players), + })), +]; const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills'; type TraceEntry = { @@ -268,7 +293,7 @@ function usage() { '[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|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] ' + '[--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: native Ixians + CHOAM with classic opponents, two through six seats, the canonical 47-card Ix+CHOAM deck and six separate Stronghold Cards, without other modules. --players requires a supported profile. Output must be a new private directory outside the checkout.' + '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.' ); } @@ -299,8 +324,13 @@ function positive(value: string | undefined) { } function scenarioName(scenario: Scenario) { + if (scenario.profile === 'stronghold-factions') { + const native = scenario.roster.filter(faction => + ['ixians', 'tleilaxu', 'choam', 'richese'].includes(faction)); + return `${scenario.profile}-${native.join('-')}-${scenario.roster.length}-${scenario.rules}`; + } return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus' || - scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'stronghold-factions' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile) + scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile) ? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}` : `${scenario.profile}-${scenario.rules}`; } @@ -454,8 +484,12 @@ function simulate( let game = initial; const expected = sampleInventory(game); if (scenario.profile === 'stronghold-factions') { - assert.equal(expected.cards.length, 47, 'canonical Ix+CHOAM Treachery census'); - assert.equal(new Set(expected.cards).size, 47, 'unique Ix+CHOAM Treachery identities'); + const canonical = [ + ...treacheryDeck(scenario.expansions), + ...(scenario.roster.includes('richese') ? richeseCards() : []), + ].map(card => card.id).sort(); + assert.deepEqual(expected.cards, canonical, 'selected native Treachery deck and separate Richese cache census'); + assert.equal(new Set(expected.cards).size, canonical.length, 'unique native Treachery identities'); assert.equal(expected.strongholds?.length, 6, 'six physical Stronghold Cards'); } const trace: TraceEntry[] = []; @@ -748,11 +782,15 @@ async function main() { : 'Each scenario starts its random stream at seed plus scenario ordinal.', ...(selected.some(scenario => scenario.profile === 'stronghold-factions') ? { strongholdFactions: { - expansions: ['ix', 'choam'], - treacheryCards: 47, + families: selected.filter(scenario => scenario.profile === 'stronghold-factions').map(scenario => ({ + roster: scenario.roster, + expansions: scenario.expansions, + treacheryCards: scenario.expansions.includes('ix') ? 47 : 35, + richeseCache: scenario.roster.includes('richese') ? 10 : 0, + })), strongholdCards: 6, custody: 'Six separate physical cards checked after every accepted action and JSON restore; end-Mentat holders persist during the next turn, independently of current board control.', - scope: 'Advanced native Ixians and CHOAM with classic opponents; Stronghold Cards alone. This sample does not certify every Treachery effect, optional-module combination or deployed readiness.', + scope: 'Advanced selected Ixian/Tleilaxu and CHOAM/Richese native families with classic opponents; Stronghold Cards alone. Per-family ordinary decks and separate Richese caches remain explicit. This sample does not certify every Treachery effect, optional-module combination or deployed readiness.', }, } : {}), options: { diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index b2685321..6123f027 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -27,6 +27,7 @@ export const PROTOTYPE_PROFILES = [ 'leader-skills', 'banker-income', 'factions', + // Advanced native E1/E2 factions plus classic opponents; selected family decks only. 'stronghold-factions', 'nexus', 'moritani-assassinate', diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 441637d7..33dcc902 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -20,7 +20,7 @@ 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|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 native Ixians plus CHOAM and otherwise classic roster into Stronghold Cards alone, with exactly the Ix and CHOAM decks and all 47 canonical physical Treachery Cards. Native Ix setup choices, HMS placement 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 roster with base 33-card Treachery and all fourteen Leader Skills alone into automatic deferred normal-band income and native Mentat collection; already-earned spice stays with its original faction under this explicit local 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 roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; 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|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 roster with base 33-card Treachery and all fourteen Leader Skills alone into automatic deferred normal-band income and native Mentat collection; already-earned spice stays with its original faction under this explicit local 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 roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; 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.', ); return; }