Compose native Ecaz and Moritani Stronghold rule paths

This commit is contained in:
Quintine committed 2026-10-04 01:31:22 +10:00
1 parent 50808aed5e
commit 39e2954ab4
15 files changed
+1010 -91

No files matched your search

+24
View File
@@ -16,6 +16,30 @@ open mode gates.
## Current checkpoint and work
**Standalone E3 Stronghold composition — bounded Development Verified:**
fresh Advanced `stronghold-factions` admits native Ecaz OR Moritani with classic
opponents, the exact ecaz33 deck and six Stronghold Cards alone; Moritani excludes
Harkonnen. Original setup/end-Mentat control, Ecaz selected-holder-only effects
and Moritani native loss/assassination compose without public or played-game
changes. E3 pairs, mixed families and unrelated overlays remain separate.
Affected native/CLI179/179, final Richese8/8, types/lint/build and original
controlled rule programs pass. Four genuine E3 two/three-seat games complete642
accepted actions/no rejection,15 JSON continuations and3 battles, with no natural
assassination claim. Original CLI **XK8X548M/HALWWQ6V v6** retain Traitor setup
and six unowned cards. Human390px **N5SQHZE4 v18/Mentat** uses actual Ecaz lead,
total6/support3/Sanya4/Shield: fixed Ecaz3 is free, actual bank2/personal1 takes
native wallet1→0 before separate Collection2 each. Ecaz2/Guild1 survive; Shield
remains held, Ecaz wallet2/Guild7 after refresh. Human390px **ANUJE8L5 v11/Mentat**
reveals Master Bewt printed3 after actual losing Tuek income4/support1:
wallet15→18, original Guild three-dial casualties once, one private Burseg
replacement and public retired card. Actual enlarged faces are readable at390px.
The controlled boards/cards are labeled, not claimed natural history.
See [scope and source effects](STRONGHOLD_CARDS.md#standalone-e3-stronghold-composition--4-october-2026).
Full combinations/assurance/public/deployed acceptance remain open.
Final guide10/10, types/lint/build and the rebuilt390px guide pass (351px topic).
The final QA worker reload preserves both original Mentat versions; owned
temporary drivers and managed tabs are removed.
**Basic Ecaz Occupy even-force — bounded Development Verified:**
fresh Basic `ecaz-occupy` now admits native Ecaz with classic or optional
Moritani opponents and the exact `ecaz` deck, no optional modules. Genuine
+51
View File
@@ -1,5 +1,56 @@
# Dune implementation status
## 4 October 2026 — standalone Ecaz and Moritani Stronghold composition
Two bounded owners supplied native Ecaz holder-plan and Moritani loss/assassination
programs; one owner integrated Advanced profile admission, original setup,
canonical ecaz33 deck, CLI samples and guidance. Ecaz OR Moritani with classic
opponents and Stronghold Cards alone is admitted; Moritani excludes Harkonnen.
E3 pairs, mixed families, Basic and other overlays remain outside this profile.
There is no public-start or played-game conversion.
Original end-Mentat ownership and every printed face remain. Ecaz co-control
holds the card at Ecaz, but the selected lead receives only its own held effect.
Arrakeen pays actual support, never personal cash or fixed free Ecaz strength;
Habbanya tie/Tabr income similarly follow the holder-plan. Moritani losing Tuek
income precedes original eligible post-loss assassination. Printed bounty,
winner cleanup/casualties and one actual private Mentat replacement finish once;
normal Traitor revelation forfeits assassination for the game.
Affected native/CLI179/179 pass. Final Richese8/8, types/lint/build pass after
repairing a genuine fixture hazard: first Storm dials could let the next random
Storm land on the controlled Arrakeen battle, advancing into turn3 and losing
card ownership. Legal first-turn dials now keep the target outside that band;
no Storm/phase/owner assignment or production rule change is used. Ten actual
diagnostic programs retain turn2 and the original Arrakeen effect. Obsolete CLI
source-inventory and incidental wallet/placement assertions were deleted rather
than re-pinned. Native E3 wallets are never enlarged for the cases.
Four genuine E3 two/three-seat games complete642 actions,15 JSON continuations
and3 battles with no rejection. No natural assassination occurs in those samples.
Original CLI **XK8X548M/HALWWQ6V v6** retain real Traitor setup and six unowned
cards. Conserved human boards/cards are staged after original authenticated
setup, real alliance and actual card claims, with original identities intact.
Human390px **N5SQHZE4** selects Ecaz lead and seals total6/support3/Sanya4/Shield.
The wheel shows fixed free Ecaz3 plus Guild-variable3 and bank2/personal1.
Native wallet1 reaches0 at winner-card cleanup; real Shield is kept. Original
Collection then pays each ally2. Refresh retains v18/Mentat, Ecaz2/Guild1
survivors, Ecaz3/Guild3 Tanks, Emperor8 Tanks, Ecaz wallet2 and Guild wallet7.
Human390px **ANUJE8L5** starts at original loss with support1/Tuek4 already
committed and wallet15. Actual Master Bewt reveal earns printed3, wallet18;
original winner cleanup applies Guild's three dial casualties once. Real
Collection/Mentat reaches v11 with public Master Bewt retirement and one private
Burseg replacement. The traitor and held Arrakeen inspectors are readable.
The isolated QA worker was reloaded only after build, preserving its database
and original room states. Full assurance, calibration, public activation and
deployed acceptance remain open; live ab5c782 remains last observed.
Final guide10/10, types/lint/build and the rebuilt390px guide pass (351px topic
without page overflow). The final isolated worker reload retains human v18/v11
at actual Mentat. Owned temporary drivers and managed tabs are removed;
the original game records and QA store remain.
## 4 October 2026 — Basic even-force Ecaz Occupy composition
Two bounded owners supplied the source-band coalition quote and native Basic
+1 -1
View File
@@ -199,7 +199,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit
| 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) |
| Native Mentat preview | Explicit fresh `leader-skills --mentat-question` reuses the existing private uniform question for already-supported Basic Ixian/Tleilaxu/CHOAM/Moritani profiles. Exact/default card roles, held fallback and skill-before-faction timing are unchanged. Ordinary/public activation, empty-hand handling, Advanced native skills and other modules stay gated; this is integration, not a new answer to the pending UX question. | [Native integration and existing authority](MENTAT_QUESTION.md#native-skill-integration--2-october-2026) |
| Normal Spice Banker local preview | Fresh classic/base33 Basic/Advanced or supported Basic native skill profiles, all14 skills alone. One additional deferred bank spice per physical card/turn/phase for another payer's one actual bank payment ≥4; no payer pooling or advance spending. Tleilaxu self-revival pays Bank; other-player revival pays Tleilaxu unless canceled. The separate free-revival award never contributes to payer debit. Actual initialized Mentat collects once; original earned-faction retention and separate-payer threshold remain provisional local policies, not answers to pending custody/split questions. No public start, retrofit or other overlays. | [Normal-band contract](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026), [native integration](SPICE_BANKER_RUNTIME.md#basic-native-banker-integration--2-october-2026), [skill authority](LEADER_SKILLS_RULES.md) |
| Advanced Stronghold faction integration | Fresh `stronghold-factions` admits selected 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) |
| Advanced Stronghold faction integration | Fresh `stronghold-factions` admits selected E1/E2 native families and standalone Ecaz OR Moritani with classic2..6-seat Advanced, canonical selected decks and six Stronghold Cards alone. Standalone E3 uses ecaz33; Moritani excludes Harkonnen. Ecaz co-control holds the card at Ecaz, but only the selected actual holder-plan receives its effect; fixed Ecaz strength is free, and Arrakeen funds only actual variable support. Losing Moritani retains printed Tuek income before original assassination, printed bounty and one actual Mentat replacement; winner cleanup finishes once and normal Traitor reveal forfeits assassination for the game. Existing E1/E2 No-Field/Stone/FaceDance and retained winner rewards remain. E3 pairs/mixed families, mixed No-Field/full-plan, Stone/Poison Tooth and other overlays stay guarded. This composes existing contracts, not a new ruling. Save/privacy/custody assurance follows all rules. | [E1/E2 scope](STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules), [standalone E3 scope and evidence](STRONGHOLD_CARDS.md#standalone-e3-stronghold-composition--4-october-2026) |
| 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) |
+64 -2
View File
@@ -2,6 +2,67 @@
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.
## Standalone E3 Stronghold composition — 4 October 2026
Fresh `stronghold-factions` additionally admits Advanced native Ecaz OR native
Moritani with classic opponents and the exact `ecaz` Treachery deck, all six
Stronghold Cards and no other overlays. Moritani excludes Harkonnen under its
original assassination/Duke boundary. A native Ecaz/Moritani pair and E1/E2
mixtures remain separate; no Basic Stronghold start, public expansion start or
played-game conversion is added. Original faction setup and first actual
end-Mentat ownership remain.
Ecaz's reciprocal co-occupation selects Ecaz as card holder. The chosen battle
lead gets only its own held advantage: Ecaz-led Arrakeen can contribute up to2
bank spice to actual ally-variable support, while fixed Ecaz strength is free.
An ally-led plan cannot borrow Ecaz's card. Original Habbanya tie priority and
Tabr opposing-dial income also belong only to the selected holder; physical
fixed/variable casualties and later shared-city Collection remain separate.
Moritani's losing Tuek holder receives its printed Worthless income without
winning. Original post-loss assassination follows real support/card income,
retains printed disc bounty, retires one physical Traitor and draws one private
replacement at actual Mentat. Winner cleanup and dial losses still finish
once; a normal Traitor revelation forfeits assassination for the game.
Original Terror placement remains free. Carthag Shield-as-Snooper follows its
actual holder, not the winning faction or another participant.
Focused20/20 and types/lint pass. Parent actual controlled programs observe
Ecaz's native wallet1 paying1 of support3 with bank2, selected Guild's native
wallet5 paying all3 with bank0, followed by separate Collection2. Both keep
Ecaz2/Guild1 physical survivors. Native Moritani wallet12 pays support1, earns
Tuek4 and printed assassination3, reaching18 at actual Mentat with one
replacement and no repeated payment. These controlled cases follow real
setup, alliances and card claims, not claimed natural battle histories.
The affected native/CLI union passes179/179. Final Richese cases8/8, types,
lint and build pass after choosing legal first Storm dials that keep the
controlled Richese battlefield outside the next original random Storm band.
Obsolete source-inventory and incidental starting-wallet/placement pins were
removed, not updated to new incidental values.
Four genuine E3 two/three-seat games complete642 accepted actions, no
rejections,15 JSON continuations and3 battles. These samples contain no
natural assassination; the assassination evidence is the controlled original
program and the human continuation.
Original CLI **XK8X548M** (Ecaz) and **HALWWQ6V** (Moritani), both v6, remain
at genuine Traitor setup with all six Stronghold Cards unowned.
Human390px **N5SQHZE4** chooses Ecaz lead and seals total6/support3/Sanya4/Shield.
The editor shows fixed Ecaz3 plus Guild-variable3, bank2/personal1. Original
resolution records native wallet1→0 at winner cleanup, Ecaz3/Guild3 casualties,
then separate city Collection2 each; refresh reaches v18/Mentat with Ecaz2/
Guild1 survivors, Ecaz wallet2 and Guild wallet7. Shield remains physically held.
Human390px **ANUJE8L5** starts at the original post-loss opportunity with wallet15
after support1/Tuek4. Actual Master Bewt reveal earns printed3, wallet18;
Guild winner cleanup applies its three dial casualties once. Original Collection
and actual Mentat reach v11 with one private Burseg replacement and the declared
Master Bewt card face up. Both enlarged components are readable at390px.
Conserved staging is explicit; no natural-history, full-combination, public-start
or deployed acceptance claim is made.
Final guide10/10, types/lint/build and the rebuilt390px guide pass (351px topic,
no page overflow). The final isolated worker reload retains human v18/v11
at actual Mentat; owned temporary drivers and managed tabs are removed.
## Expanded E1/E2 faction rules
The same fresh `stronghold-factions` profile now admits selected Ix/Tleilaxu
@@ -9,7 +70,8 @@ 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
Standalone E3 composition follows the separate section above; E1/E2/E3 mixtures
and other overlays remain excluded. These rule paths
are **Partial / Development Verified**, not complete faction/module acceptance.
Native No-Field0/3/5 control/reveal and real cache-acquired Stone outcomes,
@@ -111,7 +173,7 @@ These are recorded textual interpretations, not claims of dedicated publisher FA
- **Habbanya and Stone Burner:** the general battle-tie advantage also decides a tied undialed-token comparison. It does not override a traitor victory or create a winner after mutual destruction. Sapho remains an unfinished card effect; this implementation does not certify its future ordering.
- **Tabr:** use the opposing declared dial, not leader strength, support cost or token count. A single traitor caller wins and qualifies. Explosion and double traitor have no winner and do not qualify.
- **Tuek:** ordinary defeat and single-traitor outcomes do not erase played Worthless cards. An explosion can still qualify. For double traitor, the base rule that neither player receives spice takes precedence, so neither receives this payout. CHOAM’s out-of-plan Worthless discard powers and BG Karama conversions are different uses and do not qualify.
- **Ecaz ally battle:** card custody belongs to Ecaz. No general transfer or automatic sharing of a held card’s benefit was inferred. Complete combined-force Ecaz combat and its dial representation remain outside this checkpoint.
- **Ecaz ally battle:** card custody belongs to Ecaz. No general transfer or automatic sharing of a held card’s benefit is inferred. The standalone E3 follow-up composes the original Advanced total/fixed/variable dial: only a selected Ecaz holder receives its advantage; selecting the ally does not lend Ecaz’s card.
## Player experience and AI
+34 -12
View File
@@ -7510,11 +7510,28 @@ function leaderSkillAssignmentUnavailable(
? ADVANCED_ATREIDES_SUK_ASSIGNMENT_BLOCK
: null;
}
/** Public standalone E3 roster; the existing held-card owner remains the plan actor. */
function e3StrongholdFactionProfile(g: Game): boolean {
if (!g.advanced || g.expansions.length !== 1 || g.expansions[0] !== 'ecaz' ||
g.players.length < 2 || g.players.length > 6 ||
g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
g.discoveryEnabled || g.discoveries || g.discoveryStash || g.greatMaker ||
g.ecazTreachery) return false;
let native: 'ecaz' | 'moritani' | null = null;
for (const player of g.players) {
if (player.faction === 'ecaz' || player.faction === 'moritani') {
if (native !== null) return false;
native = player.faction;
} else if (!CLASSIC_FACTIONS[player.faction]) return false;
}
return native !== null && (native !== 'moritani' || !byFaction(g, 'harkonnen'));
}
function moritaniAssassinateModeSupported(g: Game) {
return g.advanced && g.expansions.length === 1 && g.expansions[0] === 'ecaz' &&
g.players.some(p => p.faction === 'moritani') &&
g.players.every(p => ['moritani','atreides','beneGesserit','guild','emperor','fremen'].includes(p.faction)) &&
!g.nexusCards && (!g.leaderSkills || advancedMoritaniLeaderSkillsProfile(g)) && !g.strongholdCards && !g.homeworlds &&
!g.nexusCards && (!g.leaderSkills || advancedMoritaniLeaderSkillsProfile(g)) &&
(!g.strongholdCards || e3StrongholdFactionProfile(g)) && !g.homeworlds &&
!g.discoveryEnabled && !g.discoveries && !g.techTokens;
}
function moritaniAssassinateContext(g: Game, receipt: MoritaniAssassinateReceipt) {
@@ -9243,7 +9260,8 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
g.players.every(p => faction(p.faction).expansion === 'base' ||
(['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam', 'richese'].includes(p.faction) &&
g.expansions.includes(faction(p.faction).expansion))) &&
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.strongholdCards &&
!g.homeworlds && !g.nexusCards && !g.leaderSkills &&
(!g.strongholdCards || e3StrongholdFactionProfile(g)) &&
!g.techTokens && !g.discoveryEnabled && !g.discoveries &&
!g.discoveryStash && !g.greatMaker &&
(g.ecazTreachery === undefined || g.ecazTreachery === true);
@@ -9258,15 +9276,16 @@ export function initializeEcazOccupyGameForAudit(state: Game): Game {
g.ecazOccupyPreview = true;
return g;
}
/** Fresh Advanced E1/E2 faction composition; normal starts and other modules stay gated. */
/** Fresh native Stronghold composition; other overlays and public starts stay gated. */
export function initializeStrongholdFactionsGameForAudit(state: Game): Game {
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(e3StrongholdFactionProfile(state) ||
(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 supported Advanced native factions, classic opponents and their selected distinct decks.');
requireRule(!state.homeworlds && !state.nexusCards && !state.leaderSkills &&
!state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker &&
!state.techTokens && !state.ecazTreachery &&
@@ -9274,7 +9293,10 @@ export function initializeStrongholdFactionsGameForAudit(state: Game): Game {
'Stronghold factions admit only unused Stronghold Cards, without other optional modules.');
requireFreshFactionInventory(state);
requireFreshBaseRuntime(state);
return initializeSetupGameForAudit(state, false, false, false, false, false, false, true, false, true);
const initialized = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, false, true);
if (e3StrongholdFactionProfile(initialized) && byFaction(initialized, 'moritani'))
initializeMoritaniAssassinateState(initialized);
return initialized;
}
function requireFreshFactionInventory(state: Game) {
requireRule(state.richeseCache === undefined && state.richeseRemoved === undefined &&
@@ -9415,7 +9437,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
),
),
strongholdFactions
? 'The Stronghold faction audit needs a fresh Advanced Ixian/CHOAM lobby without other optional modules.'
? 'The Stronghold faction audit needs a fresh supported Advanced native lobby without other optional modules.'
: factions && nexus
? 'The paired expansion Nexus audit needs a fresh lobby without other optional modules.'
: factions
+7 -5
View File
@@ -3246,23 +3246,25 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Verified',
summary: 'Fresh Advanced Stronghold play connects native Ixian/Tleilaxu and CHOAM/Richese paired and mixed families through existing rule handlers.',
summary: 'Fresh Advanced Stronghold play connects native E1/E2 families and standalone Ecaz or Moritani through original holder-only rule handlers.',
steps: [
'This private local profile admits selected native Ixian/Tleilaxu and CHOAM/Richese families with classic opponents in unique two-to-six-seat Advanced rosters. 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.',
'This private local profile admits selected native Ixian/Tleilaxu and CHOAM/Richese families with classic opponents in unique two-to-six-seat Advanced rosters. Standalone Ecaz OR Moritani with classic opponents additionally uses the exact ecaz deck. Moritani excludes Harkonnen under its original assassination/Duke boundary. An E3 pair, E1/E2/E3 mixtures, Basic and other overlays remain separate; only Stronghold Cards are enabled.',
'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 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.',
'Shared Ecaz control holds the card at Ecaz; the selected lead gets only its own held advantage. Ecaz-led Arrakeen may bank-fund actual ally-variable support up to two, never fixed free Ecaz strength. Choosing the ally gives no automatically shared card benefit. Original Habbanya tie and Tabr income likewise follow the actual holder-plan.',
'Native Moritani losing Tuek holder still earns two bank spice per played Worthless card. Original post-loss assassination follows support/card income, retains only printed bounty and one actual private Mentat replacement. Winner cleanup and force losses finish once; normal Traitor revelation forfeits assassination for the game. Native Terror placement remains free.',
'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 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: 'Implementation', status: 'Implemented', detail: 'Fresh selected E1/E2 setup and standalone Ecaz/Moritani exact ecaz deck, actual end-Mentat ownership, holder-only Ecaz coalition funding and original Moritani loss/assassination compose existing effects without public-start or old-game changes.', evidence: ['game/engine.ts', 'game/stronghold-cards.ts', 'game/battle-resolution-quote.ts'] },
{ area: 'Player controls', status: 'Implemented', detail: 'Original local390px controls cover HMS copy/support, real cache Stone reveal mode and partial Face Dance replacement. Standalone E3 human controls select the Ecaz holder lead, show bank2/personal1 for actual support3, seal native plans and keep the physical Shield; Moritani reveals printed Master Bewt bounty through its original inspector and choice. Original Collection and Mentat continue after refresh.', evidence: ['components/stronghold-cards.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal legal policies consume actual marker/Stone/FaceDance/copy choices; eighteen genuine paired/mixed Advanced2..6 games complete. Strategy/calibration and other combinations remain deferred.', evidence: ['game/bots.ts', 'tools/faction-games.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Advanced family/deck/cache admission, existing six effects, original winner versus replacement and explicit guarded combinations are recorded without completing every card or faction.', evidence: ['docs/STRONGHOLD_CARDS.md', 'docs/RULE_DECISIONS.md'] },
{ 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'] },
{ area: 'Verification', status: 'Partial', detail: 'Prior E1/E2 evidence remains dated. Standalone E3 adds20 focused cases, affected native/CLI179, types/lint/build and actual controlled holder-only funding, tie/income, assassination/death/forfeiture and four legal policies. Four genuine two/three-seat E3 games complete642 accepted actions,15 JSON continuations and3 battles without rejection; no natural assassination claim. Human390px Ecaz reaches v18/Mentat with bank2/personal1, separate Collection2 and physical Ecaz2/Guild1 survivors; Moritani reaches v11/Mentat with wallet18 and one original private replacement. No new assurance/full-suite campaign or public/deployed certification.', evidence: ['tests/ecaz-stronghold-runtime.test.ts', 'tests/moritani-stronghold-runtime.test.ts', 'tests/richese-strongholds-runtime.test.ts', 'tests/tleilaxu-strongholds-runtime.test.ts'] },
],
},
{
+214
View File
@@ -0,0 +1,214 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer,
viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { treacheryDeck } from '../game/cards';
import { strongholdControllers } from '../game/stronghold-cards';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { advanceEcazStronghold, ecazStrongholdFixture, ecazStrongholdSeat as seat,
finishEcazStronghold, openEcazStronghold, type EcazStrongholdFixture } from './fixture-ecaz-strongholds';
function freshLobby(advanced = true): Game {
let game = createGame('ECAZSTRONGHOLDGUARD', newPlayer('auth-ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
joinGame(game, newPlayer('auth-guild', 'Guild', 'guild'));
joinGame(game, newPlayer('auth-emperor', 'Emperor', 'emperor'));
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
return game;
}
function reveal(fixture: EcazStrongholdFixture, lead: 'ecaz' | 'ally', options: {
variable?: number; opponentDial?: number; tie?: boolean; defense?: string;
} = {}) {
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const defense = options.defense ?? stageEcazOccupyCard(fixture.game, actor, c => c.kind === 'shield');
fixture.staging.push(`Conserved physical Shield ${defense} at selected ${actor} exposes the original winner-card boundary before Collection.`);
let game = openEcazStronghold(fixture, lead);
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
let own = seat(game, actor).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
let other = seat(game, fixture.opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
if (options.tie) {
const matching = seat(game, actor).leaders.find(l => !l.dead && !l.usedAt &&
seat(game, fixture.opponent).leaders.some(o => !o.dead && !o.usedAt && o.strength === l.strength));
assert.ok(matching, 'The original faction leaders supply an actual equal-strength pair.');
own = matching;
other = seat(game, fixture.opponent).leaders.find(l => !l.dead && !l.usedAt && l.strength === own.strength)!;
}
const variable = options.variable ?? 3;
const before = structuredClone(game);
game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + variable,
support: variable, leader: own.id, defense });
const opponentDial = options.opponentDial ?? (options.tie ? profile.fixedEcazDial + variable : 0);
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: opponentDial,
support: opponentDial, leader: other.id });
assert.ok(game.battle?.revealed, 'Both original selected players seal real plans.');
const battle = game.battle!;
const side = (id: string): ResolutionCombatant => {
const player = seat(game, id), view = viewGame(game, id).battle!;
assert.ok(view.ownForces);
return { id, faction: player.faction, ally: player.ally, spice: player.spice, hand: player.hand,
plan: battle.plans[id], leader: player.leaders.find(l => l.id === battle.plans[id].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[id] };
};
const publicBattle = viewGame(game, actor).battle!;
const quote = quoteBattleResolution({ advanced: true, turn: game.turn, territory: fixture.territory,
ecazOccupy: publicBattle.ecazOccupy!.profile!, aggressor: publicBattle.aggressor,
attacker: side(battle.attacker), defender: side(battle.defender),
voters: publicBattle.traitorVoters.map(id => ({ id, called: false, traitors: seat(game, id).traitors,
beneficiary: [battle.attacker, battle.defender].includes(id) ? id : actor })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
for (const voter of publicBattle.traitorVoters)
game = applyAction(game, voter, { type: 'traitorCall', call: false });
const afterBattle = structuredClone(game);
game = finishEcazStronghold(game);
return { game, afterBattle, before, actor, quote, ownLeader: own.id, opponentLeader: other.id };
}
void test('Advanced E3 Stronghold setup retains authenticated seats, exact deck and actual shared end-Mentat custody', () => {
const admitted = freshLobby();
const fixture = ecazStrongholdFixture({ initial: admitted });
assert.deepEqual(fixture.initial.players.map(p => p.id), admitted.players.map(p => p.id));
assert.equal(fixture.ecaz, 'auth-ecaz'); assert.equal(fixture.ally, 'auth-guild');
assert.deepEqual([...fixture.afterSetup.deck, ...fixture.afterSetup.discard,
...fixture.afterSetup.players.flatMap(p => p.hand)].map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
assert.ok(Object.values(fixture.initial.strongholdCards!.owners).every(owner => owner === null));
assert.ok(Object.values(fixture.afterSetup.strongholdCards!.owners).every(owner => owner === null));
assert.equal(fixture.beforeMentat.strongholdCards!.owners.arrakeen, null);
assert.equal(strongholdControllers(fixture.beforeMentat.players, false).arrakeen, fixture.ecaz);
const settled = applyAction(fixture.beforeMentat, fixture.mentatStep.actor, fixture.mentatStep.action);
assert.deepEqual(settled.strongholdCards, fixture.afterMentat.strongholdCards);
assert.equal(settled.strongholdCards!.claimedTurn, 2);
assert.equal(Object.values(settled.strongholdCards!.owners).filter(id => id === fixture.ecaz).length, 1);
assert.ok(!Object.values(settled.strongholdCards!.owners).includes(fixture.ally));
assert.equal(seat(settled, fixture.ecaz).ally, fixture.ally);
assert.equal(seat(settled, fixture.ally).ally, fixture.ecaz);
});
void test('Only an actual pending Ecaz lead choice is JSON-restored and consumed once', () => {
const fixture = ecazStrongholdFixture();
const restored: Game = JSON.parse(JSON.stringify(fixture.game));
const decision = restored.decision;
assert.ok(decision?.kind === 'ecazBattleLead');
const action = { type: 'decision', event: decision.event, lead: fixture.ally };
const continued = applyAction(restored, fixture.ecaz, action);
assert.equal(continued.battle!.ecazOccupy!.lead, fixture.ally);
assert.equal(continued.strongholdCards!.owners.arrakeen, fixture.ecaz);
assert.throws(() => applyAction(continued, fixture.ecaz, action));
});
for (const lead of ['ecaz', 'ally'] as const) for (const variable of [0, 1, 3])
void test(`Arrakeen ${lead} lead: only actual variable support ${variable} receives holder bank funding`, () => {
const fixture = ecazStrongholdFixture();
const result = reveal(fixture, lead, { variable });
const { game, afterBattle, before, actor, quote } = result;
const payment = quote.payments.find(p => p.player === actor)!;
assert.equal(payment.bankSupport, lead === 'ecaz' ? Math.min(2, variable) : 0);
assert.equal(payment.ownPayment, lead === 'ecaz' ? Math.max(0, variable - 2) : variable);
assert.equal(afterBattle.decision?.kind, 'battleCards');
assert.equal(seat(before, actor).spice - seat(afterBattle, actor).spice, payment.ownPayment);
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
assert.equal(seat(afterBattle, nonLead).spice, seat(before, nonLead).spice);
assert.equal(seat(game, actor).spice, seat(afterBattle, actor).spice + 2);
assert.equal(seat(game, nonLead).spice, seat(afterBattle, nonLead).spice + 2);
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(seat(game, fixture.ecaz).forces[fixture.location], 2);
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, 3);
assert.equal(seat(game, fixture.ally).forces[fixture.location], 4 - variable);
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, variable);
assert.equal(seat(game, fixture.opponent).forces[fixture.location], undefined);
assert.ok(!seat(game, actor).leaders.find(l => l.id === result.ownLeader)!.dead);
assert.ok(!seat(game, fixture.opponent).leaders.find(l => l.id === result.opponentLeader)!.dead);
const scores = quote.scores!;
assert.equal(scores[quote.winner === before.battle!.attacker ? 'attacker' : 'defender'],
3 + variable + seat(before, actor).leaders.find(l => l.id === result.ownLeader)!.strength);
assert.equal(game.strongholdCards!.owners.arrakeen, fixture.ecaz);
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Habbanya tie advantage belongs only to the actual ${lead} plan owner`, () => {
const fixture = ecazStrongholdFixture({ kind: 'habbanya_ridge_sietch' });
const prepared = openEcazStronghold(fixture, lead);
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const view = viewGame(prepared, actor).battle!;
if (lead === 'ecaz') assert.equal(view.aggressor, fixture.opponent,
'The actual holder must overturn an opposing ordinary tie priority.');
assert.equal(view.strongholdEffects[actor], lead === 'ecaz' ? fixture.territory : null);
assert.equal(view.tieWinner, lead === 'ecaz' ? fixture.ecaz : view.aggressor);
const result = reveal(fixture, lead, { variable: 0, tie: true });
assert.equal(result.quote.scores!.attacker, result.quote.scores!.defender);
assert.equal(result.game.lastBattleContext!.winner, view.tieWinner);
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Tabr winning income is paid once to ${lead} only when that selected plan owner holds the card`, () => {
const fixture = ecazStrongholdFixture({ kind: 'sietch_tabr' });
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const shield = stageEcazOccupyCard(fixture.game, actor, c => c.kind === 'shield');
fixture.staging.push(`Conserved physical Shield ${shield} transferred to selected ${actor}; no face or wallet manufactured.`);
const result = reveal(fixture, lead, { variable: 1, opponentDial: 2, defense: shield });
const { game, afterBattle, before, quote } = result;
assert.equal(game.lastBattleContext!.winner, actor);
assert.deepEqual(quote.strongholdIncome, lead === 'ecaz' ? [{ player: actor, amount: 2 }] : []);
assert.equal(seat(afterBattle, actor).spice, seat(before, actor).spice - 1 + (lead === 'ecaz' ? 2 : 0));
assert.ok(seat(game, actor).hand.some(c => c.id === shield));
const afterCollection = advanceEcazStronghold(game, g => g.phase === 8 && !g.phaseOpening && !g.response && !g.decision);
assert.ok(seat(afterCollection, actor).hand.some(c => c.id === shield));
assert.equal(afterCollection.strongholdCards!.owners.sietch_tabr, fixture.ecaz);
assert.equal(afterCollection.strongholdCards!.claimedTurn, 2);
assert.equal(seat(afterCollection, fixture.ecaz).forces[fixture.location], 2);
assert.equal(seat(afterCollection, fixture.ally).forces[fixture.location], 3);
});
void test('All four minimal policies consume actual Ecaz lead, original counter and selected plan slots', () => {
const fixture = ecazStrongholdFixture();
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const policy = (game: Game, actor: string) => {
const view = viewGame(game, actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
return botActions(view);
};
const lead = policy(fixture.game, fixture.ecaz).find(a => a.type === 'decision');
assert.ok(lead);
let game = applyAction(fixture.game, fixture.ecaz, lead);
assert.equal(game.response?.kind, 'ecazOccupy');
const counter = policy(game, fixture.opponent).find(a => a.type === 'passResponse' ||
a.type === 'card' && a.mode === 'cancel');
assert.ok(counter);
game = applyAction(game, fixture.opponent, counter);
game = advanceEcazStronghold(game, g => !g.response && !g.decision && !g.battle?.preparation && (!g.battle?.preLeader || g.battle.preLeader.closed));
const actor = game.battle!.ecazOccupy!.lead!;
const plan = policy(game, actor).find(a => a.type === 'battlePlan');
assert.ok(plan);
game = applyAction(game, actor, plan);
assert.ok(game.battle!.plans[actor]);
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
assert.ok(game.battle!.plans[actor].dial >= profile.fixedEcazDial);
for (const selected of ['ecaz', 'ally'] as const) {
const prepared = openEcazStronghold(fixture, selected);
const selectedActor = selected === 'ecaz' ? fixture.ecaz : fixture.ally;
const selectedPlan = policy(prepared, selectedActor).find(a => a.type === 'battlePlan');
assert.ok(selectedPlan);
applyAction(prepared, selectedActor, selectedPlan);
}
}
});
void test('Actual Stronghold initializer excludes Basic, E1/E2 mixtures, native pair and all other overlays', () => {
assert.throws(() => initializeStrongholdFactionsGameForAudit(freshLobby(false)));
for (const overlay of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'discoveryEnabled', 'ecazTreachery'] as const) {
const game = freshLobby();
Object.assign(game, { [overlay]: true });
assert.throws(() => initializeStrongholdFactionsGameForAudit(game));
}
for (const expansion of ['ix', 'choam'] as const) {
const mixed = freshLobby(); mixed.expansions.push(expansion);
assert.throws(() => initializeStrongholdFactionsGameForAudit(mixed));
}
let pair = createGame('ECAZMORITANISTRONGHOLDGUARD', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(pair, newPlayer('moritani', 'Moritani', 'moritani'));
joinGame(pair, newPlayer('guild', 'Guild', 'guild'));
for (const player of pair.players) pair = applyAction(pair, player.id, { type: 'ready' });
assert.throws(() => initializeStrongholdFactionsGameForAudit(pair));
});
-63
View File
@@ -14,10 +14,6 @@ import { tmpdir } from 'node:os';
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';
const root = new URL('../', import.meta.url).pathname;
const cli = new URL('../tools/faction-games.ts', import.meta.url).pathname;
@@ -102,65 +98,6 @@ function assertFailedEvidence(
return report;
}
void test('native Stronghold samples admit every two-to-six-seat roster and preserve canonical physical inventories', (t) => {
const area = temporary(t);
const out = join(area, 'native');
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;
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<Game>(join(out, `failed-${result.name}.json`));
assert.equal(game.advanced, true);
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, 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, [...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);
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<Game>(join(continued, `failed-${scenario.name}.json`)),
sampleInventory(json<Game>(snapshot)), json<Game>(snapshot));
assert.equal(readFileSync(snapshot, 'utf8'), original);
}
});
void test('native Stronghold admission and resume reject foreign profiles and corrupted custody before gameplay', (t) => {
const area = temporary(t);
+186
View File
@@ -0,0 +1,186 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer,
viewGame, type Game } from '../game/engine';
import { territory } from '../game/board';
import { STRONGHOLD_CARDS, type StrongholdId } from '../game/stronghold-cards';
import { finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type EcazStrongholdOptions = {
/** Ready authenticated lobby, or its original fresh native setup. Seats are never replaced. */
initial?: Game;
kind?: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
ecazForces?: number;
allyForces?: number;
opponentForces?: number;
};
export type EcazStrongholdFixture = {
initial: Game; afterSetup: Game; beforeMentat: Game; mentatStep: StrongholdFactionsNativeStep;
afterMentat: Game; beforeBattle: Game; game: Game;
ecaz: string; ally: string; opponent: string; territory: EcazStrongholdOptions['kind'] & string;
location: string; ecazForces: number; allyForces: number; staging: string[];
};
export const ecazStrongholdSeat = (game: Game, actor: string) => {
const player = game.players.find(p => p.id === actor);
assert.ok(player, 'Every actor retains its originally admitted identity.');
return player;
};
const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision;
export function nextEcazStronghold(game: Game): Game {
const next = nextStrongholdFactionsNativeStep(game);
assert.ok(next, 'A native pending plan requires the actual selected plan owner.');
return applyAction(game, next.actor, next.action);
}
export function advanceEcazStronghold(game: Game, predicate: (game: Game) => boolean): Game {
for (let attempt = 0; attempt < 1800; attempt++) {
if (predicate(game)) return game;
assert.equal(game.status, 'playing');
game = nextEcazStronghold(game);
}
throw Error('Original Ecaz/Stronghold chronology did not reach its boundary.');
}
function reserveBoard(game: Game): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
}
function place(game: Game, owner: string, location: string, count: number): void {
const player = ecazStrongholdSeat(game, owner);
assert.ok(Number.isSafeInteger(count) && count >= 0 && count <= player.reserves);
player.reserves -= count;
if (count) player.forces[location] = (player.forces[location] ?? 0) + count;
}
function orderBlow(game: Game, worm: boolean, position: number): void {
const at = game.spiceDeck.findIndex((card, i) => i >= position && (worm
? 'worm' in card && !card.greatMaker && !card.suppressed
: 'territory' in card));
assert.ok(at >= position, 'Only an actual unplayed Spice Card may be ordered.');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
}
/** Determinism is limited to the original native shuffle, never a saved deal. */
function initialize(game: Game): Game {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(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');
}
}
/** Genuine Advanced E3 setup and reciprocal Nexus alliance. Controlled physical
* board relocations follow setup; no bank grants, phase assignments, ownership
* assignments, replaced seats, manufactured plans or battle receipts. */
export function ecazStrongholdFixture(options: EcazStrongholdOptions = {}): EcazStrongholdFixture {
const kind = options.kind ?? 'arrakeen';
const location = `${kind}:${territory(kind).sectors[0]}`;
const ecazForces = options.ecazForces ?? 5, allyForces = options.allyForces ?? 4;
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);
assert.equal(game.advanced, true);
assert.deepEqual(game.expansions, ['ecaz']);
} else {
game = createGame('ECAZSTRONGHOLDS', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
// Original printed seats put the opposing Emperor first at the later storm.
for (const [actor, position] of [['ecaz', 6], ['guild', 4], ['emperor', 5]] as const)
if (viewGame(game, actor).playerPositions[actor] !== position)
game = applyAction(game, actor, { type: 'seatPosition', position });
}
const ecaz = game.players.find(p => p.faction === 'ecaz')?.id;
const ally = game.players.find(p => p.faction === 'guild')?.id;
const opponent = game.players.find(p => p.faction === 'emperor')?.id;
assert.ok(ecaz && ally && opponent && game.players.length === 3,
'The original native seats must be Ecaz, Guild and Emperor.');
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);
assert.equal(game.strongholdCards!.claimedTurn, 0);
assert.deepEqual(Object.keys(game.strongholdCards!.owners).sort(), STRONGHOLD_CARDS.map(c => c.id).sort());
assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
game = finishEcazOccupySetup(game);
const afterSetup = structuredClone(game);
reserveBoard(game);
staging.push('After genuine setup: return every physical board counter to its own reserves; preserve native wallets, cards, leaders and identities.');
orderBlow(game, false, 0); orderBlow(game, false, 1);
staging.push('Order only remaining physical Spice Cards: no turn-one worm, then a turn-two worm followed by real blows.');
// Native first Storm dials keep both subsequent 1..6 Storm Cards away from
// this printed battle sector, without replacing a storm field or source.
const sector = territory(kind).sectors[0];
const distance = sector <= 11 ? sector + 1 : 0;
const dials = new Map(game.stormDialers.map((id, index) => [id, index === 0 ? Math.min(6, distance) : Math.max(0, distance - 6)]));
for (let attempt = 0; !(game.turn === 2 && game.phase === 0 && clean(game)) && attempt < 1800; attempt++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
if (next.action.type === 'stormDial') next.action.amount = dials.get(next.actor) ?? 0;
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.turn, 2);
orderBlow(game, true, 0); orderBlow(game, false, 1); orderBlow(game, false, 2);
game = advanceEcazStronghold(game, g => g.phase === 1 && !!g.nexus && clean(g) && !g.spiceWindow && !g.spiceResolution);
game = applyAction(game, ecaz, { type: 'alliance', target: ally });
game = applyAction(game, ally, { type: 'alliance', target: ecaz });
assert.equal(ecazStrongholdSeat(game, ecaz).ally, ally);
assert.equal(ecazStrongholdSeat(game, ally).ally, ecaz);
game = advanceEcazStronghold(game, g => g.phase === 8 && clean(g));
reserveBoard(game);
place(game, ecaz, location, 1); place(game, ally, location, 1);
staging.push(`Before real turn-two END Mentat: conserve one Ecaz and one reciprocal allied Guild counter in ${kind}; leave every other stronghold uncontrolled.`);
let beforeMentat: Game | undefined, mentatStep: StrongholdFactionsNativeStep | undefined;
for (let attempt = 0; game.turn === 2 && attempt < 200; attempt++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
const before = structuredClone(game);
game = applyAction(game, next.actor, next.action);
if (game.turn === 3) { beforeMentat = before; mentatStep = next; }
}
assert.ok(beforeMentat && mentatStep);
assert.equal(game.strongholdCards!.owners[kind], ecaz);
assert.equal(game.strongholdCards!.claimedTurn, 2);
const afterMentat = structuredClone(game);
orderBlow(game, false, 0); orderBlow(game, false, 1);
game = advanceEcazStronghold(game, g => g.phase === 5 && clean(g));
reserveBoard(game);
place(game, ecaz, location, ecazForces); place(game, ally, location, allyForces);
place(game, opponent, location, options.opponentForces ?? 8);
staging.push(`Before original movement completion: conserved ${ecazForces} Ecaz, ${allyForces} Guild and ${options.opponentForces ?? 8} Emperor counters at ${kind}; no shipment fee or bank income is fabricated.`);
const beforeCards = new Set([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)].map(c => c.id));
stageEcazOccupyCard(game, opponent, c => c.effect === 'karama');
staging.push('Conserved canonical Karama transfer from existing physical deck/hand custody to Emperor, exposing the actual Occupy counter window.');
assert.deepEqual(new Set([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)].map(c => c.id)), beforeCards);
game = advanceEcazStronghold(game, g => g.phase === 6 && clean(g));
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === kind);
assert.ok(choice, 'Actual combined armies must be admitted by original chooseBattle.');
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: kind,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
assert.equal(game.decision?.kind, 'ecazBattleLead');
return { initial, afterSetup, beforeMentat, mentatStep, afterMentat, beforeBattle, game,
ecaz, ally, opponent, territory: kind, location, ecazForces, allyForces, staging };
}
export function openEcazStronghold(fixture: EcazStrongholdFixture, lead: 'ecaz' | 'ally'): Game {
let game = fixture.game;
const decision = game.decision;
assert.ok(decision?.kind === 'ecazBattleLead');
game = applyAction(game, fixture.ecaz, { type: 'decision', event: decision.event,
lead: lead === 'ecaz' ? fixture.ecaz : fixture.ally });
return advanceEcazStronghold(game, g => !!g.battle && !g.battle.preparation &&
(!g.battle.preLeader || g.battle.preLeader.closed) && clean(g));
}
export function finishEcazStronghold(game: Game): Game {
return advanceEcazStronghold(game, g => !g.battle && clean(g) && !g.pendingTreacheryDiscard);
}
+205
View File
@@ -0,0 +1,205 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { territory } from '../game/board';
import type { Card } from '../game/cards';
import type { StrongholdId } from '../game/stronghold-cards';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type MoritaniStrongholdsOptions = {
initial?: Game;
kind?: 'tueks_sietch' | 'sietch_tabr' | 'carthag';
holder?: 'moritani' | 'guild';
normalCall?: boolean;
};
export type MoritaniStrongholdsFixture = {
initial: Game; afterSetup: Game; beforePlacement: Game; afterPlacement: Game;
beforeFirstMentat: Game; firstMentatStep: StrongholdFactionsNativeStep; afterFirstMentat: Game;
beforeBattle: Game; game: Game; revealed: Game; pending: Game;
moritani: string; opponent: string; holder: string; kind: StrongholdId;
target: string; moritaniLeader: string; opponentLeader: string;
moritaniCards: string[]; opponentCards: string[];
battleAction: StrongholdFactionsNativeStep; planActions: StrongholdFactionsNativeStep[];
sourceActions: StrongholdFactionsNativeStep[]; staging: string[];
};
export const moritaniStrongholdsPlayer = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id); assert.ok(player, `Missing original seat ${id}`); return player;
};
export function moritaniStrongholdsPolicy(game: Game, actor: string, difficulty: Difficulty = 'Easy') {
const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty;
return botActions(view);
}
/** Use original owner-projected decisions; no invented Terror charge, card claim,
* assassination opportunity or hand/leader-skill overlay. */
export function nextMoritaniStrongholdsStep(game: Game): StrongholdFactionsNativeStep | null {
if (game.decision && ['moritaniSetup', 'moritaniPlacement', 'moritaniTerror', 'moritaniRetention', 'moritaniAssassinate'].includes(game.decision.kind)) {
const actor = game.decision.player, action = moritaniStrongholdsPolicy(game, actor)[0]; assert.ok(action);
return { actor, action };
}
return nextStrongholdFactionsNativeStep(game);
}
function run(game: Game, action: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]) {
actions.push(structuredClone(action)); return applyAction(game, action.actor, action.action);
}
export function advanceMoritaniStrongholds(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextMoritaniStrongholdsStep(game); assert.ok(next, 'A real sealed Battle Plan is required');
game = run(game, next, actions);
}
throw Error('Original native Moritani Stronghold continuation did not reach its boundary');
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
const key = (kind: StrongholdId) => `${kind}:${territory(kind).sectors[0]}`;
function reserveBoard(game: Game) {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0); player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0); player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
}
function place(game: Game, id: string, kind: StrongholdId, count: number) {
const player = moritaniStrongholdsPlayer(game, id); assert.ok(player.reserves >= count);
player.reserves -= count; player.forces[key(kind)] = count;
}
function heldCard(game: Game, id: string, kind: Card['kind'], staging: string[], excluded: string[] = []) {
const player = moritaniStrongholdsPlayer(game, id);
const held = player.hand.find(c => c.kind === kind && !excluded.includes(c.id)); if (held) return held.id;
const index = game.deck.findIndex(c => c.kind === kind && !excluded.includes(c.id)); 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 exact native ecaz deck → ${id}'s hand`); return card.id;
}
export function holdMoritaniStrongholdsTraitor(game: Game, owner: string, identity: string, staging: string[] = []) {
const player = moritaniStrongholdsPlayer(game, owner), old = player.traitors[0]; assert.ok(old);
if (old === identity) return;
const donor = game.players.find(p => p.id !== owner && p.traitors.includes(identity));
if (donor) donor.traitors[donor.traitors.indexOf(identity)] = old;
else {
const index = game.traitorReserve!.indexOf(identity); assert.ok(index >= 0);
game.traitorReserve![index] = old;
}
player.traitors[0] = identity;
staging.push(`Conserved physical traitor ${identity}: native ${donor?.id ?? 'reserve'} custody exchanged for ${old}`);
}
/** Deterministic battle positions only AFTER real setup and actual Mentat claim.
* No assignment to phases, turn, storm, owners, plans or native pending state. */
export function stageMoritaniStrongholdsBattle(game: Game, moritani: string, opponent: string, kind: StrongholdId, staging: string[] = []) {
assert.equal(game.phase, 5); assert.ok(clean(game));
reserveBoard(game); place(game, moritani, kind, 6); place(game, opponent, kind, 6);
// Explicitly isolate battle/assassination bank legs from board Collection.
game.spice = {};
staging.push('Before genuine movement completion: conserved six original counters per combatant and empty board deposits; preserve original native wallets and actual prior Mentat card holders.');
return game;
}
function reveal(game: Game, plans: StrongholdFactionsNativeStep[], actions: StrongholdFactionsNativeStep[]) {
for (const plan of plans) {
game = run(game, plan, actions);
while (game.response || game.decision?.kind === 'fullPlanOffer') {
const next = nextMoritaniStrongholdsStep(game); assert.ok(next); game = run(game, next, actions);
}
}
assert.ok(game.battle?.revealed); return game;
}
export function resolveMoritaniStrongholdsBattle(game: Game, moritani: string, normalCall = false, actions: StrongholdFactionsNativeStep[] = []) {
for (let count = 0; count < 160; count++) {
if (game.decision?.kind === 'moritaniAssassinate' || !game.battle && !game.decision && !game.response && !game.pendingTreacheryDiscard) return game;
const next = nextMoritaniStrongholdsStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = normalCall && next.actor === moritani;
game = run(game, next, actions);
}
throw Error('Original Moritani battle suffix did not finish');
}
export function createMoritaniStrongholdsFixture(options: MoritaniStrongholdsOptions = {}): MoritaniStrongholdsFixture {
const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
const kind = options.kind ?? 'tueks_sietch';
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.deepEqual(game.expansions, ['ecaz']);
} else {
game = createGame('MORITANISTRONGHOLDS', newPlayer('moritani', 'Moritani', 'moritani'), true, ['ecaz']);
joinGame(game, newPlayer('guild', 'Guild', 'guild')); joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
}
assert.equal(game.players.filter(p => p.faction === 'moritani').length, 1);
assert.ok(game.players.some(p => p.faction === 'guild'));
assert.ok(game.players.every(p => ['moritani', 'atreides', 'bene_gesserit', 'emperor', 'fremen', 'guild'].includes(p.faction)));
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);
assert.ok(game.moritaniAssassinate, 'Fresh eligible E3 profile initializes the original assassination state');
assert.equal(game.strongholdCards!.claimedTurn, 0);
assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
for (let count = 0; game.status === 'setup' && count < 200; count++) {
const next = nextMoritaniStrongholdsStep(game); assert.ok(next); game = run(game, next, actions);
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const moritani = game.players.find(p => p.faction === 'moritani')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
const holder = options.holder === 'guild' ? opponent : moritani;
for (let count = 0; !(game.phase === 8 && game.decision?.kind === 'moritaniPlacement') && count < 1800; count++) {
const next = nextMoritaniStrongholdsStep(game); assert.ok(next);
// Real first Storm dials keep the printed battle sector outside every
// possible native turn-two storm advance; the storm field is never assigned.
if (next.action.type === 'stormDial' && game.turn === 1) next.action.amount = kind === 'tueks_sietch' ? 4 : 0;
game = run(game, next, actions);
}
assert.equal(game.decision?.kind, 'moritaniPlacement');
const beforePlacement = structuredClone(game);
// Real native placement; choose a different stronghold so subsequent conserved
// battle staging neither fabricates an entry nor invokes an unrelated token.
const token = game.moritaniTerror!.tokens.find(t => t.status === 'available')!; assert.ok(token);
game = run(game, { actor: moritani, action: { type: 'decision', token: token.id, territory: 'arrakeen' } }, actions);
game = advanceMoritaniStrongholds(game, state => state.phase === 8 && clean(state), actions);
const afterPlacement = structuredClone(game);
reserveBoard(game); place(game, holder, kind, 1);
const other = kind === 'carthag' ? 'sietch_tabr' : 'carthag'; place(game, holder === moritani ? opponent : moritani, other, 1);
staging.push('Before actual first END Mentat: conserve original counters through reserves, one controlled Stronghold per combatant, no three-hold victory or assigned custody.');
let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined;
for (let count = 0; game.turn === 1 && count < 200; count++) {
const next = nextMoritaniStrongholdsStep(game); assert.ok(next);
const before = structuredClone(game); game = run(game, next, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep); assert.equal(game.turn, 2);
assert.equal(game.strongholdCards!.owners[kind], holder);
const afterFirstMentat = structuredClone(game);
game = advanceMoritaniStrongholds(game, state => state.phase === 5 && clean(state), actions);
stageMoritaniStrongholdsBattle(game, moritani, opponent, kind, staging);
const m = moritaniStrongholdsPlayer(game, moritani), guild = moritaniStrongholdsPlayer(game, opponent);
const ownLeader = m.leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
const enemyLeader = guild.leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
assert.ok(ownLeader && enemyLeader);
const target = guild.leaders.find(l => !l.dead && l.id !== enemyLeader.id)!; assert.ok(target);
holdMoritaniStrongholdsTraitor(game, moritani, options.normalCall ? enemyLeader.id : target.id, staging);
const moritaniCards: string[] = [], opponentCards: string[] = [];
if (kind === 'carthag') {
moritaniCards.push(heldCard(game, moritani, 'shield', staging));
opponentCards.push(heldCard(game, opponent, 'poison', staging));
} else if (kind === 'tueks_sietch') {
moritaniCards.push(heldCard(game, moritani, 'worthless', staging));
moritaniCards.push(heldCard(game, moritani, 'worthless', staging, moritaniCards));
opponentCards.push(heldCard(game, opponent, 'worthless', staging));
}
game = advanceMoritaniStrongholds(game, state => state.phase === 6 && clean(state), actions);
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok([moritani, opponent].includes(actor));
const battleAction: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === moritani ? opponent : moritani } };
game = run(game, battleAction, actions);
game = advanceMoritaniStrongholds(game, state => !!state.battle && !state.battle.revealed && !nextMoritaniStrongholdsStep(state), actions);
const planActions: StrongholdFactionsNativeStep[] = [
{ actor: moritani, action: { type: 'battlePlan', leader: ownLeader.id, dial: 1, support: 1, weapon: kind === 'tueks_sietch' ? moritaniCards[0] : null, defense: kind === 'tueks_sietch' ? moritaniCards[1] : kind === 'carthag' ? moritaniCards[0] : null } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: 3, support: 3, weapon: opponentCards[0] ?? null, defense: null } },
];
const prepared = structuredClone(game), revealed = reveal(game, planActions, actions);
const pending = resolveMoritaniStrongholdsBattle(structuredClone(revealed), moritani, options.normalCall, actions);
return { initial, afterSetup, beforePlacement, afterPlacement, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, game: prepared, revealed, pending,
moritani, opponent, holder, kind, target: target.id, moritaniLeader: ownLeader.id, opponentLeader: enemyLeader.id, moritaniCards, opponentCards, battleAction, planActions, sourceActions: actions, staging };
}
+6 -4
View File
@@ -78,13 +78,13 @@ function step(game: Game): 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 {
function advance(game: Game, predicate: (game: Game) => boolean, firstStormDial = 4): 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;
// Use legal original dials that leave the intended controlled battle outside the next Storm band.
if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = firstStormDial;
game = applyAction(game, next.actor, next.action);
}
throw Error('Native Richese continuation did not reach its requested boundary');
@@ -180,7 +180,9 @@ export function createRicheseStrongholdsFixture(options: RicheseStrongholdsOptio
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 firstStormDial = Math.min(4, Math.floor((territory(kind).sectors[0] - 7) / 2));
assert.ok(firstStormDial >= 0);
game = advance(game, g => g.decision?.kind === 'richeseCache', firstStormDial);
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);
+201
View File
@@ -0,0 +1,201 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { treacheryDeck } from '../game/cards';
import { STRONGHOLD_CARDS, strongholdControllers } from '../game/stronghold-cards';
import { assassinationPhysical } from './moritani-assassinate-fixture';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import {
advanceMoritaniStrongholds, createMoritaniStrongholdsFixture, holdMoritaniStrongholdsTraitor,
moritaniStrongholdsPlayer as player, moritaniStrongholdsPolicy, nextMoritaniStrongholdsStep,
resolveMoritaniStrongholdsBattle, stageMoritaniStrongholdsBattle,
} from './fixture-moritani-strongholds';
function custody(game: Game) {
const cards = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
assert.deepEqual(cards, treacheryDeck(['ecaz']).map(c => c.id).sort());
assert.equal(new Set(cards).size, cards.length);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'emperor' ? 5 : 3);
}
const traitors = assassinationPhysical(game).traitors;
assert.equal(new Set(traitors).size, traitors.length);
}
function revealAssassination(game: Game, card: string) {
const d = game.decision; assert.equal(d?.kind, 'moritaniAssassinate');
if (d?.kind !== 'moritaniAssassinate') throw Error('Original loss decision is required');
return applyAction(game, d.player, { type: 'decision', event: d.event, card });
}
function finishBattle(game: Game) {
return advanceMoritaniStrongholds(game, state => !state.battle && !state.decision && !state.response && !state.pendingTreacheryDiscard);
}
void test('standalone native Advanced Moritani preserves authenticated seats, exact ecaz deck and six real first-Mentat card claims', () => {
let lobby = createGame('AUTHMORITANI', newPlayer('authenticated-m', 'Original Moritani', 'moritani'), true, ['ecaz']);
joinGame(lobby, newPlayer('authenticated-g', 'Original Guild', 'guild'));
joinGame(lobby, newPlayer('authenticated-e', 'Original Emperor', 'emperor'));
for (const p of lobby.players) lobby = applyAction(lobby, p.id, { type: 'ready' });
const original = structuredClone(lobby), f = createMoritaniStrongholdsFixture({ initial: lobby });
assert.deepEqual(lobby, original);
assert.deepEqual(f.afterSetup.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })), original.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })));
for (const game of [f.initial, f.afterSetup, f.afterFirstMentat, f.pending]) custody(game);
assert.equal(f.afterSetup.leaderSkills, undefined);
assert.equal(f.afterSetup.players.some(p => p.faction === 'ecaz' || p.faction === 'harkonnen'), false);
assert.equal(f.initial.strongholdCards!.claimedTurn, 0);
assert.deepEqual(Object.keys(f.initial.strongholdCards!.owners), STRONGHOLD_CARDS.map(c => c.id));
assert.ok(Object.values(f.initial.strongholdCards!.owners).every(id => id === null));
assert.equal(f.beforeFirstMentat.phase, 8);
const claimed = applyAction(structuredClone(f.beforeFirstMentat), f.firstMentatStep.actor, f.firstMentatStep.action);
assert.equal(claimed.turn, 2);
assert.deepEqual(claimed.strongholdCards, f.afterFirstMentat.strongholdCards);
assert.deepEqual(claimed.strongholdCards!.owners, strongholdControllers(f.beforeFirstMentat.players, false));
assert.deepEqual(f.pending.strongholdCards, claimed.strongholdCards, 'A real loss retains recorded cards until the next end-Mentat checkpoint');
assert.equal(f.pending.strongholdCards!.owners.tueks_sietch, f.moritani);
assert.equal(strongholdControllers(f.pending.players, false).tueks_sietch, f.opponent);
});
void test('Tuek pays its actual losing holder for both played Worthless cards, then original assassination pays one printed bounty and one private next-Mentat replacement', () => {
const f = createMoritaniStrongholdsFixture(), pending = f.pending;
assert.equal(pending.decision?.kind, 'moritaniAssassinate');
assert.equal(pending.lastBattleContext?.winner, f.opponent);
const quote = quoteStrongholdFactionsBattle(f.revealed);
assert.deepEqual(quote.strongholdIncome, [{ player: f.moritani, amount: 4 }]);
assert.equal(quote.bounty?.amount ?? 0, 0);
const moritaniBefore = player(f.revealed, f.moritani).spice, opponentBefore = player(f.revealed, f.opponent).spice;
assert.equal(player(pending, f.moritani).spice, moritaniBefore - 1 + 4, 'Native wallet minus one actual support plus two printed two-spice Worthless payouts');
assert.equal(player(pending, f.opponent).spice, opponentBefore - 3, 'The unheld winner Worthless card earns no Tuek payout');
assert.equal(player(pending, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false);
assert.equal(player(pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, false);
const target = structuredClone(player(pending, f.opponent).leaders.find(l => l.id === f.target)!);
assert.notEqual(target.id, f.opponentLeader);
assert.equal(target.dead, false);
const physical = assassinationPhysical(pending), reserve = [...pending.traitorReserve!], held = [...player(pending, f.moritani).traitors];
const deaths = pending.players.flatMap(p => p.leaders).map(l => ({ id: l.id, deaths: l.deaths }));
const tanks = pending.players.map(p => ({ id: p.id, tanks: p.tanks, elites: p.elites?.tanks }));
let game = revealAssassination(structuredClone(pending), f.target);
assert.equal(player(game, f.moritani).spice, moritaniBefore - 1 + 4 + target.strength);
assert.equal(player(game, f.opponent).spice, opponentBefore - 3);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.deaths, target.deaths + 1);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.bounty, target.strength);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
assert.deepEqual(game.traitorReserve, reserve);
assert.deepEqual(player(game, f.moritani).traitors, held, 'Revealed physical card remains held until original Mentat replacement');
assert.equal(game.decision?.kind, 'battleCards', 'Original saved winner-card cleanup resumes after reveal');
assert.equal(game.decision?.player, f.opponent);
game = applyAction(game, f.opponent, { type: 'decision', discard: f.opponentCards });
game = finishBattle(game);
for (const card of [...f.moritaniCards, ...f.opponentCards]) assert.equal(game.discard.filter(c => c.id === card).length, 1);
assert.deepEqual(normalizeAutomaticGame(structuredClone(game)), game);
game = advanceMoritaniStrongholds(game, state => state.phase === 8);
const receipt = game.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.stage, 'replaced');
assert.equal(receipt.replacement, reserve[0]);
assert.deepEqual(game.traitorReserve, reserve.slice(1));
assert.deepEqual(player(game, f.moritani).traitors, [...held.filter(id => id !== f.target), reserve[0]]);
assert.equal(game.players.flatMap(p => p.traitors).includes(f.target), false);
assert.equal(game.traitorReserve!.includes(f.target), false);
assert.equal(player(game, f.moritani).spice, moritaniBefore - 1 + 4 + target.strength, 'Neither support debit, Stronghold income nor printed assassination bounty replays');
assert.deepEqual(game.players.map(p => ({ id: p.id, tanks: p.tanks, elites: p.elites?.tanks })),
tanks.map(entry => entry.id === f.opponent ? { ...entry, tanks: entry.tanks + 3 } : entry),
'Original winning Guild dial3 casualties finish after assassination and winner-card cleanup, once only');
for (const old of deaths) assert.equal(game.players.flatMap(p => p.leaders).find(l => l.id === old.id)!.deaths, old.deaths + (old.id === f.target ? 1 : 0));
assert.deepEqual(assassinationPhysical(game), physical);
const rivalReceipt = viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!;
assert.equal(rivalReceipt.card, f.target);
assert.equal('replacement' in rivalReceipt, false);
custody(game);
const suffix = advanceMoritaniStrongholds(structuredClone(game), state => state.turn === 3);
assert.equal(suffix.traitorReserve!.length, reserve.length - 1, 'End-Mentat card claim does not draw a second replacement');
assert.equal(player(suffix, f.moritani).spice, player(game, f.moritani).spice);
assert.equal(suffix.strongholdCards!.claimedTurn, 2);
assert.equal(suffix.strongholdCards!.owners.tueks_sietch, f.opponent);
assert.deepEqual(Object.keys(suffix.strongholdCards!.owners), STRONGHOLD_CARDS.map(c => c.id));
});
void test('Tuek follows the actual holder, including a winning Guild plan, rather than paying every native participant', () => {
const f = createMoritaniStrongholdsFixture({ holder: 'guild' });
const quote = quoteStrongholdFactionsBattle(f.revealed);
assert.deepEqual(quote.strongholdIncome, [{ player: f.opponent, amount: 2 }]);
assert.equal(player(f.pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1);
assert.equal(player(f.pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3 + 2);
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
assert.equal(player(f.pending, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false);
custody(f.pending);
});
void test('Carthag gives the native holder-plan Shield its printed Snooper property, preserving an ordinary losing Moritani leader with no invented death bounty', () => {
const f = createMoritaniStrongholdsFixture({ kind: 'carthag' });
assert.equal(f.pending.lastBattleContext?.winner, f.opponent);
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
assert.equal(player(f.pending, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false);
assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, false);
assert.equal(quoteStrongholdFactionsBattle(f.revealed).bounty?.amount ?? 0, 0);
assert.equal(player(f.pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1);
assert.equal(player(f.pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3);
const declared = f.pending.decision!;
let declined = applyAction(f.pending, f.moritani, { type: 'decision', event: declared.kind === 'moritaniAssassinate' ? declared.event : '', decline: true });
declined = finishBattle(declined);
assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
assert.equal(player(declined, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false);
custody(declined);
const unheld = createMoritaniStrongholdsFixture({ kind: 'carthag', holder: 'guild' });
const killed = player(unheld.pending, unheld.moritani).leaders.find(l => l.id === unheld.moritaniLeader)!;
assert.equal(killed.dead, true, 'Opponent card custody does not lend Snooper coverage to the losing Shield plan');
assert.equal(player(unheld.pending, unheld.opponent).spice, player(unheld.revealed, unheld.opponent).spice - 3 + killed.strength, 'An actual poison death, unlike mere defeat, pays its printed bounty');
custody(unheld.pending);
});
void test('a native normal Traitor reveal forfeits assassination across the actual next turn and a different classic opposing faction', () => {
const f = createMoritaniStrongholdsFixture({ normalCall: true });
assert.equal(f.pending.lastBattleContext?.winner, f.moritani);
assert.equal(f.pending.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(f.pending.moritaniAssassinate!.opportunities.length, 0);
assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, true);
let game = advanceMoritaniStrongholds(f.pending, state => state.turn === 3 && state.phase === 5 && !state.phaseOpening && !state.response && !state.decision);
const emperor = game.players.find(p => p.faction === 'emperor')!.id;
stageMoritaniStrongholdsBattle(game, f.moritani, emperor, 'tueks_sietch');
const loser = player(game, f.moritani).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
const winner = player(game, emperor).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const target = player(game, emperor).leaders.find(l => !l.dead && l.id !== winner.id)!;
holdMoritaniStrongholdsTraitor(game, f.moritani, target.id);
game = advanceMoritaniStrongholds(game, state => state.phase === 6 && !state.phaseOpening && !state.response && !state.decision);
const actor = game.active!; assert.ok([f.moritani, emperor].includes(actor));
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === f.moritani ? emperor : f.moritani });
game = advanceMoritaniStrongholds(game, state => !!state.battle && !state.battle.revealed && !nextMoritaniStrongholdsStep(state));
game = applyAction(game, f.moritani, { type: 'battlePlan', leader: loser.id, dial: 0, support: 0 });
game = applyAction(game, emperor, { type: 'battlePlan', leader: winner.id, dial: 2, support: 2 });
game = resolveMoritaniStrongholdsBattle(game, f.moritani);
assert.equal(game.lastBattleContext?.winner, emperor);
assert.equal(game.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(game.moritaniAssassinate!.opportunities.length, 0);
assert.notEqual(game.decision?.kind, 'moritaniAssassinate');
assert.equal(player(game, emperor).leaders.find(l => l.id === target.id)!.dead, false);
assert.equal(player(game, f.moritani).leaders.find(l => l.id === loser.id)!.dead, false);
custody(game);
});
void test('all four minimal native policies perform original Terror choices and loss reveal/decline without a Terror fee or premature card transfer', () => {
const f = createMoritaniStrongholdsFixture();
for (const difficulty of DIFFICULTIES) {
const placement = moritaniStrongholdsPolicy(f.beforePlacement, f.moritani, difficulty).find(a => a.type === 'decision'); assert.ok(placement);
const placed = applyAction(structuredClone(f.beforePlacement), f.moritani, placement);
assert.deepEqual(placed.players.map(p => ({ id: p.id, spice: p.spice })), f.beforePlacement.players.map(p => ({ id: p.id, spice: p.spice })), 'Printed Terror placement has no fee');
assert.deepEqual(placed.strongholdCards, f.beforePlacement.strongholdCards);
const action = moritaniStrongholdsPolicy(f.pending, f.moritani, difficulty)[0]; assert.ok(action);
const revealed = applyAction(structuredClone(f.pending), f.moritani, action);
assert.equal(revealed.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
assert.equal(revealed.moritaniAssassinate!.opportunities.at(-1)!.card, f.target);
// Private eligibility is varied by a conserved swap before actual plan
// submission, never by modifying an original assassination receipt.
let empty = structuredClone(f.game);
holdMoritaniStrongholdsTraitor(empty, f.moritani, player(empty, f.moritani).leaders[0].id);
for (const plan of f.planActions) empty = applyAction(empty, plan.actor, plan.action);
empty = resolveMoritaniStrongholdsBattle(empty, f.moritani);
const decline = moritaniStrongholdsPolicy(empty, f.moritani, difficulty)[0]; assert.ok(decline);
const declined = applyAction(empty, f.moritani, decline);
assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
custody(placed); custody(revealed); custody(declined);
}
});
@@ -133,7 +133,6 @@ void test('native marker-only plans use the reserve-limited private pool, reveal
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);
+16 -3
View File
@@ -312,6 +312,16 @@ 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): Scenario => ({
ordinal: 298 + players - 2, profile: 'stronghold-factions', rules: 'advanced',
expansions: ['ecaz'],
roster: (['ecaz', 'guild', 'emperor', 'fremen', 'beneGesserit', 'harkonnen'] as FactionId[]).slice(0, players),
})),
...[2, 3, 4, 5, 6].map((players): Scenario => ({
ordinal: 303 + players - 2, profile: 'stronghold-factions', rules: 'advanced',
expansions: ['ecaz'],
roster: (['moritani', 'guild', 'emperor', 'fremen', 'beneGesserit', 'atreides'] as FactionId[]).slice(0, players),
})),
...[2, 3, 4, 5, 6].map(players => ({
ordinal: 151 + players - 2,
profile: 'stronghold-factions' as const,
@@ -386,6 +396,7 @@ function usage() {
'\nEcaz-occupy supports Basic even-force and Advanced: native Ecaz plus classic/Ixian/Tleilaxu/CHOAM/Richese paired or mixed rosters, two through six seats; selected E3/E1/E2 sources determine ordinary33/47/35 cards. Basic uses Ecaz/classic/optional Moritani and the exact ecaz deck, with E/2 mandatory contribution and losses. An uncanceled odd Basic count remains blocked by the preserved publisher casualty-rounding conflict. Explicit independent three-card samples additionally use36/50/38. Richese marker-only or ordinary pools retain original reveal timing; own mixed No-Field dialing, whole-plan inspection and optional overlays remain guarded.' +
'\nThe four earlier native skill profiles retain their Basic samples. Advanced Ix-skills/choam-skills/tleilaxu-skills use documented native plus classic families; Advanced moritani-skills composes its original non-Harkonnen classic assassination roster. Richese-skills adds Basic/Advanced Richese or paired CHOAM tables, Advanced Richese/Tleilaxu/CHOAM and optional Ix-deck tables without native Ixians. All retain fourteen skills and original cache/native inventories. Native Ixian Technology on Richese lots, own mixed No-Field, other roster/modules and public starts retain their separate guards.'
+ '\nEcaz-skills uses native five-disc Ecaz with classic opponents, the ecaz deck and all fourteen skills in Basic/Advanced; Advanced Harkonnen is excluded by the existing shared-Duke capture boundary. Ambassador and ordinary skill paths retain native custody. Shared-Duke assignment and combined Occupy skills remain separate boundaries; no public starts or save retrofit.'
+ '\nStandalone E3 Stronghold samples additionally use native Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards only. Ecaz holder-only coalition effects and original Moritani assassination remain; Moritani excludes Harkonnen. E3 pairs, E1/E2 mixtures and other overlays remain guarded.'
);
}
@@ -418,7 +429,7 @@ 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));
['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].includes(faction));
return `${scenario.profile}-${native.join('-')}-${scenario.roster.length}-${scenario.rules}`;
}
if (scenario.profile === 'ecaz-occupy') {
@@ -564,9 +575,11 @@ function resumedGame(path: string) {
skillProfile(candidate.profile) === !!game.leaderSkills &&
(candidate.profile === 'stronghold-factions') === !!game.strongholdCards &&
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
(candidate.profile === 'moritani-assassinate' || (candidate.profile === 'moritani-skills' && candidate.rules === 'advanced')) === !!game.moritaniAssassinatePreview &&
(candidate.profile === 'moritani-assassinate' || (candidate.profile === 'moritani-skills' && candidate.rules === 'advanced') ||
(candidate.profile === 'stronghold-factions' && candidate.roster.includes('moritani'))) === !!game.moritaniAssassinatePreview &&
(candidate.profile === 'ecaz-occupy') === !!game.ecazOccupyPreview &&
(candidate.profile === 'moritani-assassinate' || (candidate.profile === 'moritani-skills' && candidate.rules === 'advanced')) === !!game.moritaniAssassinate &&
(candidate.profile === 'moritani-assassinate' || (candidate.profile === 'moritani-skills' && candidate.rules === 'advanced') ||
(candidate.profile === 'stronghold-factions' && candidate.roster.includes('moritani'))) === !!game.moritaniAssassinate &&
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
JSON.stringify(candidate.expansions) ===
JSON.stringify(game.expansions) &&
+1
View File
@@ -25,6 +25,7 @@ async function main() {
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|ecaz-occupy|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced roster with at least one selected native Ixians, Tleilaxu, CHOAM or Richese faction and otherwise classic opponents into Stronghold Cards alone. Select one or both distinct Ix/CHOAM decks; each native faction requires its family deck. The selected decks supply 47 Ix or combined, or 35 CHOAM canonical physical Treachery Cards, with a separate ten-card Richese cache when Richese plays. Native setup choices, HMS placement, Face Dancers, Richese cache and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced base-deck roster, supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill roster with its single required deck, or Advanced native Ixians, Tleilaxu and/or CHOAM plus classic roster with distinct required family decks, into automatic deferred normal income and native Mentat collection with all fourteen Leader Skills. Original bank payer costs exclude the separate Tleilaxu free-revival award; already-earned spice stays with its original faction under the existing provisional policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced base-deck roster, or Basic Tleilaxu plus classic opponents with the original Ix47 deck and no optional Sandtrout, into the exact normal-auction purchased-card replacement with Nexus alone. Native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
'\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Advanced uses distinct selected ecaz/ix/choam decks; Basic uses the exact ecaz deck with classic/optional Moritani opponents. Ordinary33/47/35 cards retain real original offers/HMS/Auditor/Face Dancers and Richese cache. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38 (Advanced only). Basic combined battles require an even Ecaz force count; the preserved publisher odd-force casualty-rounding conflict rejects before any lead choice. Native Richese ordinary and marker-only pools compose with selected lead plans; original own mixed No-Field dialing, whole-plan disclosure and optional overlays remain guarded.' +
'\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Explicit --mentat-question stays private; banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.',
'\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards only. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.',
);
return;
}