Compose Discovery Strongholds and repair mandatory Ecaz battles
This commit is contained in:
1 parent
e798914aab
commit
b0267827b5
19 files changed
+1243
-39
No files matched your search
@@ -17,6 +17,32 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Discovery / original Strongholds — bounded Development Verified:**
|
||||
fresh original Discovery and separate classic Skills/Discovery preserve
|
||||
Advanced Strongholds2–6, optional Tech3–6, original setup/held claims,
|
||||
physical entry, paid nested routes and source-local battle effects.
|
||||
Source-clear native HMS/CHOAM invoices, Richese nested marker/defense,
|
||||
trained support/rescue and mandatory Ecaz coalition retain original outcomes.
|
||||
Skills-native/Nexus/HW/variants/previews, pending rulings and public starts
|
||||
are not opened.
|
||||
|
||||
Affected77 plus post-repair coalition39, final types/lint/build and actual
|
||||
programmes pass. Five genuine six-seat samples finish3,096 accepted/
|
||||
3,097 attempts/81 JSON across original and repaired trees. Original Ecaz
|
||||
seed20265041's mandatory battle was reproduced, repaired and resumed unchanged
|
||||
for557 more/no rejection/15 JSON. Richese seed20265033 remains captured after
|
||||
1,639 accepted atturn10 Bidding on its existing exhausted-cache count ruling.
|
||||
No skipped battle, invented count, reset or natural-every-effect claim.
|
||||
|
||||
Actual UWEH7WNBv14 retains natural Smuggler, earned Arrakeen, free Cistern3
|
||||
then paid4, spice3→2/reserves16→15. Native4LBU44PTv21 records human typed
|
||||
Shrine free/paid entry, earned HMS copy and real support3/bank2/own1,
|
||||
CHOAM income1, three physical Cyborg losses, protected Shrine4/two Cyborgs
|
||||
and original Production transferred Guild→Ixians. Both refresh at390px.
|
||||
Original staging and source trees are explicit; tabs released.
|
||||
[Canonical rule/evidence contract](DISCOVERY_PROTOTYPE.md#original-advanced-stronghold-composition--5-october-2026).
|
||||
No production deployment or complete-mode acceptance is claimed.
|
||||
|
||||
**Classic Nexus / Discovery — bounded Development Verified:** fresh explicit
|
||||
`nexus --discoveries` composes classic/base33/all12/Discovery7/8,
|
||||
Basic/Advanced2–6, original Tech3+ and/or Advanced Strongholds2+.
|
||||
|
||||
@@ -17,14 +17,14 @@ uses those identities and effects; it does not treat test outcomes as rulings.
|
||||
ready seats in Basic or Advanced. Fresh classic, selected E1/E2 native families,
|
||||
and standalone Ecaz **or** Moritani with classic opponents use their original
|
||||
decks and native setup. Canonical unused Tech Tokens may be preserved at three
|
||||
through six seats. Advanced Moritani retains its non-Harkonnen assassination
|
||||
profile; paired/mixed E3, Skills, Strongholds, Homeworlds, Nexus and unrelated
|
||||
previews stay outside this entry.
|
||||
through six seats; original Stronghold Cards require Advanced. Advanced Moritani
|
||||
retains its non-Harkonnen assassination profile; paired/mixed E3, Skills,
|
||||
Homeworlds, Nexus and unrelated previews stay outside this entry.
|
||||
|
||||
Discoveries are enabled before components are dealt. The initializer adds the
|
||||
seven cards to the selected original Spice Deck, retaining Ix Sandtrout, and
|
||||
creates each token behind shuffled opaque physical identities. Started games,
|
||||
nonempty native inventories and already-used Tech Tokens cannot be redealt.
|
||||
nonempty native inventories and already-used Tech or Stronghold components cannot be redealt.
|
||||
No ordinary player action or room endpoint bypasses the normal start gate.
|
||||
The existing local starter accepts `--profile discovery` for these fresh lobbies:
|
||||
|
||||
@@ -92,6 +92,83 @@ passes. Preserved3D7SAG45v27/Charity retains physical Richese/deck11 and shows
|
||||
the **a2a92bb** local marker at390px after the isolated QA worker reload.
|
||||
Tab released. Publication is not protected NAS deployment or live acceptance.
|
||||
|
||||
## Original Advanced Stronghold composition — 5 October 2026
|
||||
|
||||
The original fresh Discovery entry admits unused original Stronghold Cards in
|
||||
Advanced2–6 with classic, supported selected E1/E2 or standalone Ecaz OR
|
||||
Moritani rosters. The separate fresh classic `leader-skills --discoveries`
|
||||
entry likewise preserves all14/base33/Discovery7/8/Strongholds6. Either may
|
||||
retain original Tech at3–6. Basic Strongholds, native Discovery skill families,
|
||||
Nexus/HW/independent variants/other previews and save conversion stay separate.
|
||||
The existing Nexus/Discovery composition above is unchanged.
|
||||
|
||||
No new Stronghold Card is created for Jacurutu. Original actual end-Mentat
|
||||
custody supplies held benefits in their named territory or a declared HMS
|
||||
copy, never an inherited defense or subsidy inside a different nested site.
|
||||
Original free entry and ordinary nested invoices retain physical/trained
|
||||
groups; named support reductions change actual debit, not pledged rescue
|
||||
eligibility. Typed losses, winner cleanup and Tech/Face Dance retain their
|
||||
existing order. Contested Discovery and other pending sources are not decided.
|
||||
|
||||
The actual CLI entries remain `--profile discovery` or
|
||||
`--profile leader-skills --discoveries` with selected original Advanced
|
||||
Strongholds in the fresh lobby. The genuine runner additionally offers
|
||||
`discovery-stronghold`, `discovery-stronghold-tech`,
|
||||
`discovery-skills-stronghold`, `discovery-skills-stronghold-tech`;
|
||||
new4000–4300 ordinal bands preserve earlier defaults and scenario seeds.
|
||||
|
||||
### Bounded held-card and mandatory-coalition evidence
|
||||
|
||||
Affected77 cases pass; after the reproduced mandatory Ecaz coalition repair,
|
||||
the related39 cases, final types/lint and build pass. Original rule programmes
|
||||
produce free Cistern3 then paid4, Arrakeen bank2/own1 followed by physical
|
||||
Suk rescue and original winner Tech. Native HMS copying pays bank2/own1,
|
||||
actual CHOAM income1 and three physical Cyborg losses. Real nested Richese
|
||||
marker5 costs1 then materializes five reserves without a second fee; retained
|
||||
Carthag does not protect its Shield from poison in Shrine. Original second
|
||||
end-Mentat accepts the nested counters; no new location-validation shim is needed.
|
||||
|
||||
Genuine six-seat classic Skills and classic/Ixian-Tleilaxu/standalone Moritani
|
||||
Stronghold+Tech samples complete on initial tree
|
||||
`b2d642151709534ecc54eb8b7538d764b1da28ac0fe185e9ba6a26a09c1c6698`.
|
||||
The original Ecaz sample seed20265041 stops after616 accepted actions on its
|
||||
mandatory allied battle, not an optional AI action. The supported live
|
||||
Discovery composition now feeds the original Occupy lead/owner-labelled
|
||||
calculation. Its original captured state resumes unchanged and completes
|
||||
557 additional actions/no rejection/15 JSON on corrected tree
|
||||
`9ded08476837f230184f6a606209cd88c50444cd934de9975242e8dc1ee81157`.
|
||||
The five completed samples total3,096 accepted/3,097 attempts/81 JSON across
|
||||
those two trees. Resume does not reconstruct the earlier random stream.
|
||||
The sixth CHOAM/Richese sample seed20265033 remains captured atturn10 Bidding,
|
||||
1,639 accepted/1,640 attempts/44 JSON: declining Black Market reaches the
|
||||
existing exhausted-cache normal-count ruling. No count, card or outcome is
|
||||
invented. Original reports and captures remain outside Git.
|
||||
|
||||
Actual **UWEH7WNB** CLIv8 preserves its natural Smuggler offer. Forty-one
|
||||
original controls plus explicit conserved source/claim positions reachv9
|
||||
before actual first end-Mentat. Human readyv10 claims Arrakeen; human free
|
||||
entryv11 moves three trained counters without price/reserve/movement cost.
|
||||
Twenty-three original continuations, including conserved physical Weather
|
||||
Control played for zero movement, reachv12. Human paid Cistern shipment and
|
||||
Guild allowancev13/v14 debit3→2 spice/16→15 reserves, leave four nested
|
||||
counters and retain the original training/card. Refresh fits390px.
|
||||
|
||||
Actual **4LBU44PT** CLIv8 preserves native setup/actors. Printed Hagga blow,
|
||||
real Shrine reveal, conserved claimant and original end-Mentat producev9
|
||||
entry. Human one-ordinary/one-Cyborg entryv10 costs nothing and keeps one
|
||||
ordinary parent counter. Fifty-one original controls reachv11; human typed
|
||||
shipment/Guild allowance/CHOAM income passv12–14 debit5→3 spice/8→6 reserves,
|
||||
leave Shrine4/two Cyborgs and unchanged HMS6/three Cyborgs, with deferred
|
||||
Heighliners1. Six original actions after explicit conserved opposing/current
|
||||
control positions reachv15 HMS copy; actual phase-end Tech raises own3→4.
|
||||
Human Arrakeen copyv16 and real sealed plansv17/v18 show support3/bank2/own1.
|
||||
Human no-Traitor callsv19/v20 and Keep these casualtiesv21 leave HMS3/no
|
||||
Cyborg, Shrine4/two Cyborgs, own spice3, CHOAM income1 and original
|
||||
Guild-held Production transferred to Ixians. Another real conflict keeps
|
||||
Battle open; no unrelated Collection contaminates the invoice. Refresh fits
|
||||
390px. Tabs released; no production acceptance is claimed.
|
||||
|
||||
|
||||
## Native setup and original consumers — 5 October 2026
|
||||
|
||||
Original Ixian setup keeps the six-counter HMS garrison and seven physical
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
Historical primary-source audit, 2026-09-07, corrected after independent verification. The old publisher/designer casualty-rounding conflict remains unresolved as a publisher correction and for odd Basic counts. Its earlier false attribution to Jack Reda is still withdrawn. The later user-authorized Advanced source below selects explicit Advanced arithmetic, and the Basic even-force composition selects E/2 for even counts; neither retroactively certifies that attribution.
|
||||
|
||||
## Original live Discovery coalition — 5 October 2026
|
||||
|
||||
The original standalone Ecaz Discovery profile marks its native Occupy path,
|
||||
but its former combined-battle gate still rejected the mandatory encounter.
|
||||
The live supported Discovery configuration now feeds original lead selection,
|
||||
owner-labelled fixed/variable armies, counters, payments and aftermath,
|
||||
including optional original Tech/Advanced Strongholds. No battle is skipped,
|
||||
no alliance/plan is synthesized and no extra coalition source is inferred.
|
||||
Unsupported rosters, Skills/Nexus/HW/variants/previews and Basic odd-force
|
||||
rounding remain separate. A fresh Occupy initializer does not gain a Discovery
|
||||
conversion path merely from this live composition.
|
||||
|
||||
Meaningful original coalition cases fail before the fix and pass afterward:
|
||||
Ecaz-led support3 pays bank2/own1; allied Guild lead pays its own3 and does not
|
||||
borrow Ecaz's held card. Actual Ecaz5/Guild4 loses3 per owner and leaves2/1.
|
||||
The original genuine six-seat seed20265041 capture after616 accepted actions
|
||||
resumes without changing its saved position, completes557 more/no rejection/
|
||||
15 JSON and retains genuine Discovery decisions. Related39, types/lint/build
|
||||
pass. [Discovery evidence and limits](DISCOVERY_PROTOTYPE.md#bounded-held-card-and-mandatory-coalition-evidence).
|
||||
|
||||
## Standalone Nexus / Skills coalition — 5 October 2026
|
||||
|
||||
The fresh standalone Ecaz/classic skill-Nexus profile now connects mandatory
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 5 October 2026 — Original Discovery Strongholds and mandatory Ecaz repair
|
||||
|
||||
Fresh original Discovery classic/E1/E2/standalone E3 and separate classic
|
||||
Skills/Discovery entries retain canonical unused Advanced Strongholds2–6,
|
||||
optional Tech3–6 and actual end-Mentat claims. Source-local benefits compose
|
||||
original entry/tariffs, trained rescue, typed Cyborg/CHOAM invoice and nested
|
||||
No-Field materialization/poison defeat. No nested defense/subsidy inheritance
|
||||
or seventh physical Jacurutu card is created. Other profiles/rulings/public
|
||||
starts and save conversion stay separate.
|
||||
|
||||
Affected77 pass. The genuine Ecaz sample's mandatory combined battle exposed
|
||||
the old module guard: two meaningful original cases fail before repair and
|
||||
pass after. Related39, final types/lint/build and actual rule programmes pass.
|
||||
Original Ecaz5/Guild4 loses3 per owner; Ecaz's bank2/own1 is not lent to a
|
||||
Guild-led own3 plan. The existing room-local board primitive already admits
|
||||
nested structural keys; an inferred extra validator repair was withdrawn.
|
||||
Named SukRescueOption fixes a TypeScript recursive-inference boundary without
|
||||
concrete ReturnType coupling. Removed incidental action-trace/default pins.
|
||||
|
||||
Four initial genuine six-seat samples complete on b2d64215 tree. Original
|
||||
Ecaz seed20265041 after616 accepted is preserved and resumes557/no rejection/
|
||||
15 JSON on9ded0847. Five completed samples total3,096/3,097/81 JSON across
|
||||
two trees; resume does not reconstruct the prior stream. Sixth Richese sample
|
||||
seed20265033 remains captured atturn10/richeseBlackMarket after1,639 accepted/
|
||||
1,640 attempts/44 JSON on existing exhausted-cache normal-count ruling.
|
||||
No fake source remedy or rerun to confirm.
|
||||
|
||||
Actual UWEH7WNB original CLIv8, natural Smuggler, human held claimv10,
|
||||
free-entryv11 and paid shipment/Guild allowancev13/v14 retain Arrakeen,
|
||||
Cistern4/spice2/reserves15. Native4LBU44PT CLIv8, typed free-entryv10,
|
||||
paid shipment/income allowancev12–14, original HMS copyv16/plansv17–18,
|
||||
Traitor declinesv19–20 and casualty keepv21 leave HMS3/no Cyborg,
|
||||
Shrine4/two Cyborgs/spice3, CHOAM income1 and original Production winner
|
||||
transfer. Both390px refresh; explicit conserved staging/private evidence,
|
||||
tabs closed. [Canonical evidence](DISCOVERY_PROTOTYPE.md#bounded-held-card-and-mandatory-coalition-evidence).
|
||||
No production or full-module acceptance is claimed.
|
||||
|
||||
## 5 October 2026 — Classic Nexus and original Discovery composition
|
||||
|
||||
Fresh explicit `nexus --discoveries` admits classic/base33/all12/Discovery7/8
|
||||
|
||||
@@ -237,6 +237,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
||||
| Classic Nexus / Leader Skills | Fresh classic/base33/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit `leader-skills --nexus-cards`, no other overlays/previews. Original starting hands/offers/skill-before-Traitor setup and actual closing Nexus draw remain. Exact extra/ordinary free-return ledgers, real bank purchase and committed Traitor declarations preserve trained-disc custody. Temporary ordinary-as-Sardaukar roles compose actual skill posture/bonuses/support; matching Cunning casualty receipt stays pending through original Suk rescue and completes only after physical settlement. Source-guarded borrowed Smuggler arithmetic, Atreides Suk/KH and unresolved effects remain; no public start, retrofit or new ruling. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#classic-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#classic-nexus-composition--4-october-2026) |
|
||||
| Paired E1/E2 Nexus / Leader Skills | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family deck/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit original skill entry. Actual native setup/offers/closing draws remain. Full unsupported Suboids compose typed Suk rescue before equal actual Tank substitution; Face Dance follows original winner outcomes, kills/returns the winning trainer skill once without another bounty, and partial Cunning retains ordinary Mentat rights. Richese pair uses its original signed two-token frame/one invoice/native response/Guild continuation, not single-marker skill proof; CHOAM printed fuel/Planetologist physical route remain separate. No other overlays, companion/mixed-plan/new ruling/public starts or retrofit. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#paired-e1e2-nexus-composition--4-october-2026) |
|
||||
| Classic Nexus / Discovery / original components | Fresh classic/base33/all12/Discovery7/8 Basic/Advanced2–6, original Tech3+/Advanced Strongholds2+, explicit `nexus --discoveries`. Original worm/vote/typed ride, next-turn entry, end-Mentat held custody and end-Spice closing draw after a Nexus and settled alliance remain. Extra/ordinary revival ledgers and borrowed Guild nested payment preserve native identity and physical outcomes. Skills/native/HW/variants/previews and unresolved mixed-module Fremen Betrayal remain gated; no new universal interruption priority. | [Composition contract and bounded evidence](DISCOVERY_PROTOTYPE.md#classic-nexus-composition--5-october-2026), [Nexus source contract](NEXUS_CARD_RULES.md#classic-discovery--original-tech-and-strongholds--5-october-2026) |
|
||||
| Discovery / original Advanced Strongholds | Fresh original Discovery classic/selected E1/E2/standalone E3 and separate classic all14 Skills entry retain Strongholds6 Advanced2–6, optional Tech3–6 and original end-Mentat claims. Named held benefits or actual HMS copy do not inherit into nested locations; Jacurutu creates no seventh card. Live supported Ecaz Discovery feeds mandatory original coalition lead/armies/payment/aftermath rather than skipping legal battle. Native typed/marker, trained rescue and winner Tech retain their sources. Basic Strongholds, native Discovery Skills, HW/variants/previews/pending rules/public starts/save conversion remain separate. | [Bounded contract and original programmes](DISCOVERY_PROTOTYPE.md#original-advanced-stronghold-composition--5-october-2026), [mandatory Ecaz repair](ECAZ_OCCUPY_RULES.md#original-live-discovery-coalition--5-october-2026) |
|
||||
| Discoveries | Preserve seven sourced Spice Cards/eight tokens, faction peeks and Collection choices. Fresh classic/native E1/E2/standalone E3 may retain original Tech3..6; separate fresh classic all14 Skills/Discovery/Tech uses original training, entry, movement, stash, rescue/rewards and Bureaucrat invoices. Native setup, revealed-only No-Fields and Moritani source histories remain. Advanced Orgiz uses the supplied p24 territory-Collection trigger; Basic stays provisional per deposit. Only Jacurutu adds a stronghold. Contested/shared/mixed cases, other combinations and public starts remain unfinished. | [Component authority](DISCOVERY_COMPONENTS.md), [native composition](DISCOVERY_PROTOTYPE.md#native-setup-and-original-consumers--5-october-2026), [classic Skills/Advanced source cutover](DISCOVERY_PROTOTYPE.md#classic-leader-skills-and-advanced-orgiz--5-october-2026) |
|
||||
| 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) |
|
||||
|
||||
@@ -2,6 +2,39 @@
|
||||
|
||||
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.
|
||||
|
||||
## Original Discovery composition — 5 October 2026
|
||||
|
||||
Fresh original `discovery` entry preserves selected unused Strongholds6 in
|
||||
Advanced2–6 classic/supported E1/E2/standalone E3, with optional Tech3–6.
|
||||
The separate classic `leader-skills --discoveries` entry preserves
|
||||
base33/all14/Discovery7/8 and the same original Advanced Strongholds/Tech.
|
||||
The supplied authorized source physical21 permits variants together; this
|
||||
bounded entry does not open every unresolved interaction or public mode.
|
||||
|
||||
Original first end-Mentat ownership is required. Benefits stay in the held
|
||||
card's named territory or the actual HMS copy; Cistern/Shrine and other
|
||||
revealed nested sites do not inherit another site's defense or support.
|
||||
Jacurutu adds a stronghold victory location, not a seventh physical card.
|
||||
Free entry, paid nested forces/markers, native typed casualties, trained
|
||||
rescue and original cleanup/Tech/Face Dance retain their existing source
|
||||
identities. No held receipt or paid result is injected to grant an advantage.
|
||||
|
||||
Basic Strongholds, native Discovery skill families, Homeworlds, independent
|
||||
variants/previews, pending rules, public starts and save conversion remain
|
||||
separate. Existing Nexus/Discovery/Strongholds is unchanged. The
|
||||
[Discovery contract](DISCOVERY_PROTOTYPE.md#original-advanced-stronghold-composition--5-october-2026)
|
||||
records the original CLI and genuine-runner entries and bounded evidence.
|
||||
|
||||
Affected77 and post-repair coalition39, types/lint/build and actual programmes
|
||||
pass. Phone UWEH7WNBv14 retains natural Smuggler/held Arrakeen/four paid/free
|
||||
Cistern counters. Native4LBU44PTv21 records human HMS copy/support3/bank2/
|
||||
own1, actual CHOAM income1, three physical Cyborg losses, protected Shrine4/
|
||||
two Cyborgs and original Production winner transfer. The five completed
|
||||
genuine six-seat samples include one actual mandatory Ecaz repair/resume;
|
||||
the sixth Richese sample remains at the existing exhausted-cache ruling.
|
||||
Counts, source trees, conserved staging and390px refresh are recorded in the
|
||||
linked contract; these proofs do not certify complete modules or deployment.
|
||||
|
||||
## Native Tech composition without Skills — 4 October 2026
|
||||
|
||||
Fresh Advanced `stronghold-factions` may preserve original unused Tech Tokens
|
||||
|
||||
@@ -14,7 +14,7 @@ export function discoveryModeSupported(game: FactionModuleProfile): boolean {
|
||||
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
|
||||
game.players.length < 2 || game.players.length > 6 ||
|
||||
(game.techTokens && game.players.length < 3) || game.expansions.length > 2 ||
|
||||
game.strongholdCards || game.leaderSkills || game.homeworlds || game.nexusCards ||
|
||||
(game.strongholdCards && !game.advanced) || game.leaderSkills || game.homeworlds || game.nexusCards ||
|
||||
game.ecazTreachery ||
|
||||
game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
|
||||
game.guildBetrayalPreview || game.richeseBetrayalPreview ||
|
||||
@@ -45,13 +45,13 @@ export function discoveryModeSupported(game: FactionModuleProfile): boolean {
|
||||
return (!e3 || native === 1) && !(game.advanced && moritani && harkonnen);
|
||||
}
|
||||
|
||||
/** Fresh or live classic all-fourteen Skills/Discovery, optionally original Tech.
|
||||
/** Fresh or live classic all-fourteen Skills/Discovery, optionally Tech/Advanced Strongholds.
|
||||
* Physical undealt components and existing inventories belong to the initializer. */
|
||||
export function classicDiscoveryLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
const players = game.players;
|
||||
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
|
||||
game.expansions.length || !players || players.length < 2 || players.length > 6 ||
|
||||
(game.techTokens && players.length < 3) || game.strongholdCards ||
|
||||
(game.techTokens && players.length < 3) || (game.strongholdCards && !game.advanced) ||
|
||||
game.homeworlds || game.nexusCards || game.ecazTreachery || game.semutaPreview ||
|
||||
game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview ||
|
||||
game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview ||
|
||||
|
||||
+6
-4
@@ -9383,6 +9383,8 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
|
||||
function ecazOccupyCompositionSupported(g: Game): boolean {
|
||||
if (g.leaderSkills && standaloneE3NexusLeaderSkillsProfile(g) &&
|
||||
g.players.some(p => p.faction === 'ecaz')) return true;
|
||||
if (g.ecazOccupyPreview === true && g.discoveries && discoveryModeSupported(g) &&
|
||||
g.players.some(p => p.faction === 'ecaz')) return true;
|
||||
return typeof g.advanced === 'boolean' && Array.isArray(g.expansions) &&
|
||||
g.expansions.includes('ecaz') &&
|
||||
g.expansions.every(id => ['ecaz', 'ix', 'choam'].includes(id)) &&
|
||||
@@ -9522,7 +9524,7 @@ export function initializeDiscoveryGameForAudit(state: Game): Game {
|
||||
export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
||||
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
|
||||
if (state.discoveryEnabled) {
|
||||
requireRule(classicDiscoveryLeaderSkillsProfile(state), 'Discovery Leader Skills require a fresh classic lobby with optional original Tech and no unrelated overlays.');
|
||||
requireRule(classicDiscoveryLeaderSkillsProfile(state), 'Discovery Leader Skills require a fresh classic lobby with optional original Tech or Advanced Strongholds and no unrelated overlays.');
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
}
|
||||
@@ -9574,7 +9576,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) &&
|
||||
JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())),
|
||||
'Tech Tokens require a fresh supported three-through-six-seat lobby with unused tokens.');
|
||||
requireRule(!g.strongholdCards || strongholdFactions || ((skillStronghold || nexusModules) &&
|
||||
requireRule(!g.strongholdCards || strongholdFactions || ((skillStronghold || nexusModules || discoveryComposition) &&
|
||||
JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards())),
|
||||
'Stronghold Cards require a fresh supported Advanced lobby with unused cards.');
|
||||
requireRule(
|
||||
@@ -9586,7 +9588,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
(nexus || !g.nexusCards) &&
|
||||
(ecazTreachery || !g.ecazTreachery) &&
|
||||
(!g.techTokens || skillTech || nativeTech || nexusModules || discoveryComposition) &&
|
||||
(strongholdFactions || skillStronghold || nexusModules || !g.strongholdCards) &&
|
||||
(strongholdFactions || skillStronghold || nexusModules || discoveryComposition || !g.strongholdCards) &&
|
||||
(homeworlds || !g.homeworlds) &&
|
||||
g.players.every((p) =>
|
||||
FACTIONS.some(
|
||||
@@ -9606,7 +9608,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
: homeworlds
|
||||
? 'The Homeworld setup audit supports implemented deck sets without Tech Tokens or Stronghold Cards.'
|
||||
: discoveryComposition
|
||||
? 'Discovery supports fresh classic, selected E1/E2 or standalone E3 factions with optional original Tech and no other overlays.'
|
||||
? 'Discovery supports fresh classic, selected E1/E2 or standalone E3 factions with optional original Tech or Advanced Strongholds and no other overlays.'
|
||||
: 'The audit initializer supports base factions without expansions or optional modules.',
|
||||
);
|
||||
initializeSetup(g, strongholdFactions || !!g.strongholdCards);
|
||||
|
||||
+6
-5
@@ -4079,9 +4079,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'A development prototype connects Great Maker, six Discovery spice blows, private token inspection, stash rewards, revealed locations, next-turn entry, Cistern income, bounded Orgiz transfers, Ornithopter movement, Jacurutu income, Testing Station and Shrine. Remaining interpretations and normal public starts stay gated.',
|
||||
searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter',
|
||||
steps: [
|
||||
'Fresh Discovery development setup supports classic, selected Ixian/Tleilaxu/CHOAM/Richese families, or standalone Ecaz or Moritani with classic opponents in Basic or Advanced, with seven extra Spice Cards and eight tokens. Original unused Tech Tokens may be selected with three through six seats. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.',
|
||||
'A separate fresh classic Leader Skills entry may explicitly add Discoveries, with the original base deck, all fourteen skill components and optional original Tech at three or more seats. Existing trained posture, physical free entry, paid nested routes, stash capacity and skill battle outcomes remain. Native skill families, Nexus, Strongholds, Homeworlds and separate Banker/Mentat previews are outside this composition.',
|
||||
'Fresh Discovery development setup supports classic, selected Ixian/Tleilaxu/CHOAM/Richese families, or standalone Ecaz or Moritani with classic opponents in Basic or Advanced, with seven extra Spice Cards and eight tokens. Original unused Tech Tokens may be selected with three through six seats; original Stronghold Cards require Advanced. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.',
|
||||
'A separate fresh classic Leader Skills entry may explicitly add Discoveries, with the original base deck, all fourteen skill components, optional original Tech at three or more seats and Advanced Stronghold Cards. Existing trained posture, physical free entry, paid nested routes, stash capacity and skill battle outcomes remain. Native skill families, Nexus, Homeworlds and separate Banker/Mentat previews are outside this composition.',
|
||||
'A separate fresh classic Nexus entry may explicitly add Discoveries, original Tech at three or more seats and Advanced Stronghold Cards. Original end-Mentat ownership and next-turn physical entry remain. Great Maker finishes its worm, vote and typed reserve ride before the ordinary Nexus settlement; cards are offered only at the end of the entire Spice Blow phase after a Nexus and settled alliance, not immediately or at Mentat. Source-clear extra or ordinary free revival and Guild-priced nested shipment retain native pieces, ledgers and actual battle rewards. Skills, native families, Homeworlds, unrelated previews and unresolved mixed-module Fremen Betrayal stay separate.',
|
||||
'Original Stronghold Cards are claimed only at end-Mentat. A held benefit applies in its named territory or the mobile stronghold through its actual declared copy; revealed Discovery locations do not inherit another territory’s defense or bank support. Jacurutu counts toward stronghold victory but creates no seventh Stronghold Card. Physical free entry, nested tariffs, trained or native force casualties, card cleanup and optional Tech reward retain their original sources.',
|
||||
'Great Maker first resolves the ordinary worm consequences. All players then vote in storm order on creating a Nexus. A strict majority is required; a tie adds no Nexus and an existing Nexus in the phase stays recorded. Fremen may then ride with actual reserve forces to one legal destination for free, without using ordinary shipment or movement. Storm and occupancy restrictions still apply.',
|
||||
'Each of the six Discovery territory cards destroys prior spice and forces in the named blow territory, including Fremen forces, before placing six new spice subject to the storm. It also places a random available face-down token of the printed type at the card’s separate token destination.',
|
||||
'Fremen privately see placed Hiereg tokens; Guild privately sees placed Smuggler tokens. During Spice Collection, non-advisor forces in a token’s surrounding territory may inspect it privately, then choose whether to reveal it. The Discovery panel shows only authorized faces. Reading an already visible token’s rules does not reveal or use it.',
|
||||
@@ -4100,7 +4101,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
checklist: [
|
||||
{
|
||||
area: 'Implementation', status: 'Partial',
|
||||
detail: 'Original classic/E1/E2/standalone E3 Discovery with optional Tech, plus separate classic Skills/Discovery/Tech, connect physical entry, nested marker and paid routes, native/Harkonnen stash limits, Face Dance and Moritani outcomes, trained posture, skill movement/rescue and distinct-party Bureaucrat invoices. Advanced Orgiz aggregates one actual territory Collection; Basic stays provisional per deposit. Existing bounded effects remain, with contested/shared/mixed cases pending.',
|
||||
detail: 'Original classic/E1/E2/standalone E3 Discovery, separate classic Skills and classic Nexus compositions retain selected original Tech/Advanced Strongholds. Physical entry, nested marker/tariffs, stash limits, native outcomes, training, rescue and invoices use original consumers. Held benefits stay source-local; actual HMS copying and mandatory Ecaz coalition retain original payment/casualty/reward ordering. Advanced Orgiz aggregates one actual territory Collection; Basic stays provisional per deposit. Contested/shared/mixed cases remain pending.',
|
||||
evidence: ['game/discovery-module-profile.ts', 'game/richese-no-field.ts', 'game/discoveries.ts', 'game/discovery-actions.ts', 'game/discovery-entry.ts', 'game/discovery-collection.ts', 'game/discovery-flight.ts', 'game/discovery-battle.ts', 'game/discovery-storm.ts', 'game/shrine.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'],
|
||||
},
|
||||
{
|
||||
@@ -4120,8 +4121,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
},
|
||||
{
|
||||
area: 'Verification', status: 'Partial',
|
||||
detail: 'Native, classic-skill and classic Nexus original programmes/phone controls cover setup, physical entry, nested tariff/No-Field arrival, stash capacity, skill movement/rescue, reward ordering and Moritani assassination. Classic Nexus proof includes Great Maker typed reserve ride, actual alliance settlement and one closing end-Spice draw releasing Charity, separate revival ledgers and nested battle/Tech. Two genuine six-seat Nexus Discovery games finish 1,150 accepted actions without rejection; Skills and native repair/resume evidence remain separate. These are targeted proofs, not natural-every-effect, full combinations, assurance or deployed acceptance.',
|
||||
evidence: ['tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts'],
|
||||
detail: 'Original programmes and phone controls cover native/skill/Nexus setup, physical entry, paid nested tariffs/No-Field, stash, trained and typed battles, HMS support, CHOAM invoice, source-local defense and original rewards. Five genuine six-seat Stronghold samples complete including one reproduced/repaired/resumed mandatory Ecaz battle; a sixth Richese sample retains its exhausted-cache ruling guard. Original two-game Nexus/Discovery proof and native repair/resume evidence remain separate. These are targeted proofs, not natural-every-effect, full combinations, assurance or deployed acceptance.',
|
||||
evidence: ['tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -48,7 +48,7 @@ void test('Discovery cannot redeal started components or silently remove another
|
||||
const overlays: Game[] = [structuredClone(initial), structuredClone(initial), structuredClone(initial)];
|
||||
overlays[0].homeworlds = { custody: null };
|
||||
overlays[1].nexusCards = { cards: null, phase: null };
|
||||
overlays[2].strongholdCards = createStrongholdCards();
|
||||
overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards();
|
||||
for (const game of overlays) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError);
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ void test('classic Discovery Skills does not silently drop native families or un
|
||||
const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
|
||||
overlays[0].homeworlds = { custody: null };
|
||||
overlays[1].nexusCards = { cards: null, phase: null };
|
||||
overlays[2].strongholdCards = createStrongholdCards();
|
||||
overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards();
|
||||
overlays[3].mentatQuestionPreview = true;
|
||||
overlays[4].spiceBankerIncomePreview = true;
|
||||
for (const game of overlays) assert.throws(() => initializeLeaderSkillsGameForAudit(game), RuleError);
|
||||
@@ -89,3 +89,18 @@ void test('Discovery Nexus rejects native families, used components and incompat
|
||||
started.nexusCards = { cards: null, phase: null };
|
||||
assert.throws(() => initializeNexusGameForAudit(started), RuleError);
|
||||
});
|
||||
|
||||
void test('Discovery original Stronghold composition rejects already claimed cards instead of granting or redealing them', () => {
|
||||
for (const initialize of [initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit]) {
|
||||
const owner = lobby(['guild', 'emperor', 'fremen'], []);
|
||||
owner.strongholdCards = createStrongholdCards();
|
||||
owner.strongholdCards.owners.arrakeen = 'guild';
|
||||
const before = JSON.stringify(owner);
|
||||
assert.throws(() => initialize(owner), RuleError);
|
||||
assert.equal(JSON.stringify(owner), before);
|
||||
const settled = lobby(['guild', 'emperor', 'fremen'], []);
|
||||
settled.strongholdCards = createStrongholdCards();
|
||||
settled.strongholdCards.claimedTurn = 1;
|
||||
assert.throws(() => initialize(settled), RuleError);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, gameDistance, gameTerritories } from '../game/board';
|
||||
import { strongholdControllers } from '../game/stronghold-cards';
|
||||
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
|
||||
import {
|
||||
assertDiscoveryNativeStrongholdCustody,
|
||||
createDiscoveryNativeStrongholdFixture, discoveryNativeStrongholdPlayer,
|
||||
discoveryNativeStrongholdReload, finishDiscoveryNativeStrongholdBattle,
|
||||
prepareDiscoveryNativeStrongholdBattle, revealDiscoveryNativeStrongholdBattle,
|
||||
} from './fixture-discovery-native-strongholds';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected native controls cannot debit wallets, move counters or change retained card custody');
|
||||
}
|
||||
function legalPolicies(game: Game, actor: string): void {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, actor);
|
||||
view.players.find(p => p.id === actor)!.bot = difficulty;
|
||||
const choices = botActions(view);
|
||||
assert.ok(choices.length, `${difficulty} supplies an actual native continuation`);
|
||||
for (const action of choices) {
|
||||
const next = applyAction(discoveryNativeStrongholdReload(game), actor, action);
|
||||
assertDiscoveryNativeStrongholdCustody(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void test('native Advanced Discovery blow/reveal and original final Mentat produce retained cards without redealing authenticated setup', () => {
|
||||
for (const kind of ['ix-hms-invoice', 'richese-nested-defense'] as const) {
|
||||
const fixture = createDiscoveryNativeStrongholdFixture({ kind, tech: true });
|
||||
const { owner } = fixture;
|
||||
const original = discoveryNativeStrongholdReload(fixture.setup);
|
||||
const fromSetup = createDiscoveryNativeStrongholdFixture({ kind, initial: fixture.setup });
|
||||
assert.deepEqual(fixture.setup, original);
|
||||
assert.deepEqual(fromSetup.setup, original, 'An original undealt native setup is continued, never initialized twice');
|
||||
assert.equal(fixture.setup.strongholdCards!.claimedTurn, 0);
|
||||
assert.ok(Object.values(fixture.setup.strongholdCards!.owners).every(id => id === null));
|
||||
const old = discoveryNativeStrongholdPlayer(fixture.beforeBlow, owner);
|
||||
const blown = discoveryNativeStrongholdPlayer(fixture.afterBlow, owner);
|
||||
assert.equal(old.forces['hagga_basin:12'], 2);
|
||||
assert.equal(blown.forces['hagga_basin:12'], undefined);
|
||||
assert.equal(blown.tanks, old.tanks + 2);
|
||||
if (kind === 'ix-hms-invoice') {
|
||||
assert.equal(old.elites!.forces['hagga_basin:12'], 1);
|
||||
assert.equal(blown.elites!.forces['hagga_basin:12'], undefined);
|
||||
assert.equal(blown.elites!.tanks, old.elites!.tanks + 1);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(fixture.afterSetup, owner).forces[MOBILE_LOCATION], 6);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(fixture.afterSetup, owner).elites!.forces[MOBILE_LOCATION], 3);
|
||||
}
|
||||
const token = fixture.afterBlow.discoveries!.tokens.find(t => t.id === fixture.token)!;
|
||||
assert.equal(token.status, 'placed');
|
||||
assert.equal(token.territory, 'gara_kulon');
|
||||
assert.equal(token.sector, 8);
|
||||
assert.ok(fixture.afterBlow.spiceDiscard.flat().some(c => 'territory' in c && c.discovery === 'discovery-hagga-basin'));
|
||||
assert.equal(gameTerritories(fixture.beforeReveal).some(t => t.id === 'shrine'), false);
|
||||
assert.equal(gameTerritories(fixture.afterReveal).some(t => t.id === 'shrine'), true);
|
||||
assert.equal(fixture.beforeFirstMentat.phase, 8);
|
||||
assert.equal(fixture.beforeFirstMentat.turn, 1);
|
||||
assert.equal(fixture.beforeFirstMentat.strongholdCards!.claimedTurn, 0);
|
||||
const claimed = applyAction(discoveryNativeStrongholdReload(fixture.beforeFirstMentat),
|
||||
fixture.firstMentatStep.actor, fixture.firstMentatStep.action);
|
||||
assert.equal(claimed.turn, 2);
|
||||
assert.deepEqual(claimed.strongholdCards, fixture.afterFirstMentat.strongholdCards);
|
||||
assert.deepEqual(claimed.strongholdCards!.owners,
|
||||
strongholdControllers(fixture.beforeFirstMentat.players, kind === 'ix-hms-invoice'));
|
||||
assert.equal(claimed.strongholdCards!.owners[kind === 'ix-hms-invoice' ? MOBILE_STRONGHOLD : 'carthag'], owner);
|
||||
assert.deepEqual(fixture.beforeBattle.strongholdCards, claimed.strongholdCards);
|
||||
for (const snapshot of [fixture.setup, fixture.afterSetup, fixture.beforeReveal, claimed, fixture.beforeBattle])
|
||||
assertDiscoveryNativeStrongholdCustody(snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
void test('earned HMS card composes with actual typed Discovery free entry, paid nested shipment and optional original industry', () => {
|
||||
for (const tech of [false, true]) {
|
||||
const f = createDiscoveryNativeStrongholdFixture({ tech });
|
||||
const before = discoveryNativeStrongholdPlayer(f.entry.before, f.owner);
|
||||
const entered = discoveryNativeStrongholdPlayer(f.entry.after, f.owner);
|
||||
const offer = viewGame(f.entry.before, f.owner).discoveryEntry!;
|
||||
assert.deepEqual(offer.sources, [{ source: f.source, normal: 2, elite: 1 }]);
|
||||
legalPolicies(f.entry.before, f.owner);
|
||||
assert.equal(entered.forces['shrine:0'], 2);
|
||||
assert.equal(entered.elites!.forces['shrine:0'], 1);
|
||||
assert.equal(entered.forces[f.source], 1);
|
||||
assert.equal(entered.elites!.forces[f.source] ?? 0, 0);
|
||||
assert.equal(entered.reserves, before.reserves);
|
||||
assert.equal(entered.elites!.reserves, before.elites!.reserves);
|
||||
assert.equal(entered.spice, before.spice);
|
||||
assert.equal(entered.shipped, before.shipped);
|
||||
assert.equal(entered.moved, before.moved);
|
||||
assert.equal(f.entry.after.discoveryEntry, undefined);
|
||||
reject(f.entry.after, f.owner, f.entry.step.action);
|
||||
const shipping = discoveryNativeStrongholdPlayer(f.shipment.before, f.owner);
|
||||
const shipped = discoveryNativeStrongholdPlayer(f.shipment.after, f.owner);
|
||||
assert.equal(gameDistance(f.shipment.before, f.source, 'shrine:0'), 1);
|
||||
assert.equal(shipped.forces['shrine:0'], 4);
|
||||
assert.equal(shipped.elites!.forces['shrine:0'], 2);
|
||||
assert.equal(shipped.reserves, shipping.reserves - 2);
|
||||
assert.equal(shipped.elites!.reserves, shipping.elites!.reserves - 1);
|
||||
assert.equal(shipped.spice, shipping.spice - 2);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(f.shipment.after, f.opponent).spice,
|
||||
discoveryNativeStrongholdPlayer(f.shipment.before, f.opponent).spice + 2);
|
||||
assert.equal(shipped.forces[MOBILE_LOCATION], 6);
|
||||
assert.equal(shipped.elites!.forces[MOBILE_LOCATION], 3);
|
||||
assert.equal(shipped.shipped, true);
|
||||
assert.deepEqual(f.shipment.after.strongholdCards, f.afterFirstMentat.strongholdCards);
|
||||
reject(f.shipment.after, f.owner, f.shipment.step.action);
|
||||
if (tech) {
|
||||
assert.equal(f.shipment.after.techTokens!.heighliners.owner, f.owner);
|
||||
assert.equal(f.shipment.after.techTokens!.heighliners.triggeredTurn, 2);
|
||||
assert.equal(f.shipment.after.techTokens!.heighliners.spice, 1);
|
||||
assert.equal(f.beforeBattle.techTokens!.heighliners.spice, 0);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(f.beforeBattle, f.owner).spice, shipped.spice + 1);
|
||||
} else assert.equal(f.beforeBattle.techTokens, undefined);
|
||||
assertDiscoveryNativeStrongholdCustody(f.shipment.after);
|
||||
}
|
||||
});
|
||||
|
||||
void test('original HMS declaration copies CURRENT Arrakeen control before plans; native CHOAM invoice includes bank support but pays no cash subsidy', () => {
|
||||
const f = createDiscoveryNativeStrongholdFixture({ tech: true });
|
||||
assert.equal(f.game.decision?.kind, 'strongholdCopy');
|
||||
assert.equal(strongholdControllers(f.game.players, true).arrakeen, f.owner);
|
||||
assert.ok(f.game.decision?.kind === 'strongholdCopy');
|
||||
assert.deepEqual(f.game.decision.choices, ['arrakeen', 'sietch_tabr']);
|
||||
assert.equal(f.game.strongholdCards!.owners.sietch_tabr, null, 'Current control offers a choice without fabricating retained card custody');
|
||||
assert.equal(f.game.strongholdCards!.owners[MOBILE_STRONGHOLD], f.owner);
|
||||
legalPolicies(f.game, f.owner);
|
||||
reject(f.game, f.opponent, f.copyStep!.action);
|
||||
reject(f.game, f.owner, { ...f.copyStep!.action, event: 'stale-original-declaration' });
|
||||
reject(f.game, f.owner, { ...f.copyStep!.action, stronghold: 'carthag' });
|
||||
const ready = prepareDiscoveryNativeStrongholdBattle(f);
|
||||
assert.equal(viewGame(ready, f.owner).battle!.strongholdEffects[f.owner], 'arrakeen');
|
||||
assert.equal(viewGame(ready, f.owner).battle!.ownForces!.normalFixedHalf, true);
|
||||
const revealed = revealDiscoveryNativeStrongholdBattle(f);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(revealed, f.owner).spice,
|
||||
discoveryNativeStrongholdPlayer(ready, f.owner).spice, 'Sealing the plan never credits spendable bank support');
|
||||
const quote = quoteStrongholdFactionsBattle(revealed);
|
||||
assert.equal(quote.winner, f.owner);
|
||||
assert.deepEqual(quote.payments.find(p => p.player === f.owner), {
|
||||
player: f.owner, cost: 1, ownPayment: 1, allyPayment: 0, donor: null, bankSupport: 2, freeByTraitor: false,
|
||||
});
|
||||
assert.deepEqual(quote.choamIncome, { owner: f.choam, amount: 1 });
|
||||
assert.deepEqual(quote.casualties!.options, [{ normal: 0, elite: 3, paidNormal: 0, paidElite: 3 }]);
|
||||
const done = finishDiscoveryNativeStrongholdBattle(revealed);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).tanks,
|
||||
discoveryNativeStrongholdPlayer(revealed, f.owner).tanks + 3);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).elites!.tanks,
|
||||
discoveryNativeStrongholdPlayer(revealed, f.owner).elites!.tanks + 3);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).forces[MOBILE_LOCATION], 3);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).elites!.forces[MOBILE_LOCATION] ?? 0, 0);
|
||||
assert.equal(done.phase, 6, 'A real second conflict prevents unrelated Collection from contaminating this invoice');
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).spice,
|
||||
discoveryNativeStrongholdPlayer(revealed, f.owner).spice - 1);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(done, f.choam).spice,
|
||||
discoveryNativeStrongholdPlayer(revealed, f.choam).spice + 1);
|
||||
assert.deepEqual(done.strongholdCards, f.afterFirstMentat.strongholdCards);
|
||||
const expectedTech = structuredClone(f.beforeBattle.techTokens!);
|
||||
assert.equal(Object.values(expectedTech).filter(t => t.owner === f.opponent).length, 1);
|
||||
for (const token of Object.values(expectedTech)) if (token.owner === f.opponent) token.owner = f.owner;
|
||||
assert.deepEqual(done.techTokens, expectedTech, 'Original winner takes the defeated seat’s one actual token only after battle cleanup');
|
||||
assertDiscoveryNativeStrongholdCustody(done);
|
||||
assert.deepEqual(normalizeAutomaticGame(discoveryNativeStrongholdReload(done)), done);
|
||||
});
|
||||
|
||||
void test('physical E2 No-Field shipment/reveal at native nested Shrine does not import retained Carthag poison protection or parent armies', () => {
|
||||
const f = createDiscoveryNativeStrongholdFixture({ kind: 'richese-nested-defense', tech: true });
|
||||
assert.equal(f.parent, 'gara_kulon', 'Printed native placement is a sand parent, never an invented Carthag token');
|
||||
const before = discoveryNativeStrongholdPlayer(f.shipment.before, f.owner);
|
||||
const shipped = discoveryNativeStrongholdPlayer(f.shipment.after, f.owner);
|
||||
const token = before.noField!.tokens.find(t => t.value === 5)!;
|
||||
const originalTokens = structuredClone(before.noField!.tokens);
|
||||
legalPolicies(f.shipment.before, f.owner);
|
||||
assert.equal(shipped.spice, before.spice - 1);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(f.shipment.after, f.opponent).spice,
|
||||
discoveryNativeStrongholdPlayer(f.shipment.before, f.opponent).spice + 1);
|
||||
assert.deepEqual(shipped.forces, before.forces);
|
||||
assert.equal(shipped.reserves, before.reserves);
|
||||
assert.deepEqual(shipped.noField!.deployed!.location, { territory: 'shrine', sector: 0 });
|
||||
assert.deepEqual(shipped.noField!.tokens, originalTokens);
|
||||
const reveal = f.markerReveal!;
|
||||
reject(reveal.before, f.opponent, reveal.step.action);
|
||||
reject(reveal.before, f.owner, { ...reveal.step.action, event: before.noFieldEvent });
|
||||
const materialized = discoveryNativeStrongholdPlayer(reveal.after, f.owner);
|
||||
assert.equal(materialized.forces['shrine:0'], 5);
|
||||
assert.equal(materialized.reserves, shipped.reserves - 5);
|
||||
assert.equal(materialized.spice, shipped.spice);
|
||||
assert.equal(materialized.noField!.deployed, null);
|
||||
assert.equal(materialized.noField!.lastShipped, token.id);
|
||||
assert.deepEqual(materialized.noField!.tokens, originalTokens);
|
||||
reject(reveal.after, f.owner, reveal.step.action);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(f.beforeBattle, f.opponent).forces[f.source], 1);
|
||||
assert.equal(f.beforeBattle.strongholdCards!.owners.carthag, f.owner);
|
||||
const ready = prepareDiscoveryNativeStrongholdBattle(f);
|
||||
assert.equal(viewGame(ready, f.owner).battle!.strongholdEffects[f.owner], null);
|
||||
assert.equal(viewGame(ready, f.owner).battle!.ownForces!.normal, 5);
|
||||
assert.equal(viewGame(ready, f.opponent).battle!.ownForces!.normal, 6, 'Parent-sector counter is not a nested fighter');
|
||||
const revealed = revealDiscoveryNativeStrongholdBattle(f), quote = quoteStrongholdFactionsBattle(revealed);
|
||||
assert.equal(quote.winner, f.opponent);
|
||||
assert.equal(quote.strongholdIncome.length, 0);
|
||||
const done = finishDiscoveryNativeStrongholdBattle(revealed);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).leaders.find(l => l.id === f.leader)!.dead, true,
|
||||
'A real Shield in Shrine does not gain the poison coverage of a retained Carthag card elsewhere');
|
||||
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).tanks,
|
||||
discoveryNativeStrongholdPlayer(revealed, f.owner).tanks + 5);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).forces['shrine:0'], undefined);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(done, f.opponent).forces['shrine:0'], 6);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(done, f.opponent).forces[f.source], 1);
|
||||
assert.equal(discoveryNativeStrongholdPlayer(done, f.opponent).spice,
|
||||
discoveryNativeStrongholdPlayer(revealed, f.opponent).spice + quote.bounty!.amount);
|
||||
assert.ok(done.discard.some(c => c.id === f.defense));
|
||||
assert.ok(discoveryNativeStrongholdPlayer(done, f.opponent).hand.some(c => c.id === f.weapon));
|
||||
const expectedTech = structuredClone(f.beforeBattle.techTokens!);
|
||||
assert.equal(Object.values(expectedTech).filter(t => t.owner === f.owner).length, 1);
|
||||
for (const token of Object.values(expectedTech)) if (token.owner === f.owner) token.owner = f.opponent;
|
||||
assert.deepEqual(done.techTokens, expectedTech, 'Retained Carthag custody cannot divert the native loser’s Tech reward');
|
||||
assert.deepEqual(done.strongholdCards, f.afterFirstMentat.strongholdCards);
|
||||
assert.equal(done.phase, 6);
|
||||
assertDiscoveryNativeStrongholdCustody(done);
|
||||
assert.deepEqual(normalizeAutomaticGame(discoveryNativeStrongholdReload(done)), done);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createStrongholdCards } from '../game/stronghold-cards';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import type { SukRescueOption } from '../game/suk-graduate';
|
||||
import { quoteClassicSkillsStrongholdBattle } from './fixture-classic-skills-stronghold';
|
||||
import { advanceClassicDiscoverySkills, classicDiscoverySkillsClean, createClassicDiscoverySkillsFixture,
|
||||
settleClassicDiscoverySkillsArrival } from './fixture-discovery-classic-skills';
|
||||
import { createDiscoverySkillsStrongholdsFixture, discoverySkillsStrongholdsPlayer as player,
|
||||
finishDiscoverySkillsStrongholdsBattle, prepareDiscoverySkillsStrongholdsBattle,
|
||||
revealDiscoverySkillsStrongholdsBattle } from './fixture-discovery-skills-strongholds';
|
||||
|
||||
function legalPolicies(game: Game, actor: string): void {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, actor); view.players.find(seat => seat.id === actor)!.bot = difficulty;
|
||||
const actions = botActions(view);
|
||||
assert.ok(actions.length, `${difficulty} has an original owned continuation.`);
|
||||
for (const action of actions) {
|
||||
const after = applyAction(structuredClone(game), actor, action);
|
||||
assert.equal(ordinaryTotal(after, actor), ordinaryTotal(game, actor),
|
||||
`${difficulty}'s actual entry, paid shipment or plan preserves its physical force inventory.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function ordinaryTotal(game: Game, actor: string): number {
|
||||
const seat = player(game, actor);
|
||||
return seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, count) => sum + count, 0);
|
||||
}
|
||||
|
||||
void test('authenticated Advanced original lobby and undealt offers retain identities, unused cards, real training and the first end-Mentat claim before free entry', () => {
|
||||
const lobby = createGame('HUMANDISCOVERYSKILLS', newPlayer('human-guild', 'Guild', 'guild'), true);
|
||||
joinGame(lobby, newPlayer('human-emperor', 'Emperor', 'emperor'));
|
||||
joinGame(lobby, newPlayer('human-harkonnen', 'Harkonnen', 'harkonnen'));
|
||||
const unchanged = structuredClone(lobby);
|
||||
const fixture = createDiscoverySkillsStrongholdsFixture({ initial: lobby, tech: true });
|
||||
assert.deepEqual(lobby, unchanged);
|
||||
assert.deepEqual(fixture.setup.players.map(seat => seat.id), lobby.players.map(seat => seat.id));
|
||||
assert.deepEqual(fixture.setup.strongholdCards, createStrongholdCards());
|
||||
assert.equal(fixture.setup.leaderSkills!.deck.length + Object.values(fixture.setup.leaderSkills!.offers)
|
||||
.reduce((sum, offer) => sum + offer.cards.length, 0), 14);
|
||||
const claimed = applyAction(structuredClone(fixture.beforeFirstMentat), fixture.firstMentatStep.actor, fixture.firstMentatStep.action);
|
||||
assert.equal(claimed.turn, 2);
|
||||
assert.equal(claimed.strongholdCards!.owners.arrakeen, fixture.owner);
|
||||
assert.equal(claimed.strongholdCards!.claimedTurn, 1);
|
||||
const before = player(fixture.entryWindow, fixture.owner);
|
||||
legalPolicies(fixture.entryWindow, fixture.owner);
|
||||
reject(fixture.entryWindow, fixture.opponent, fixture.entryStep.action);
|
||||
const entered = applyAction(structuredClone(fixture.entryWindow), fixture.entryStep.actor, fixture.entryStep.action);
|
||||
assert.equal(player(entered, fixture.owner).forces['cistern:0'], 3);
|
||||
assert.equal(player(entered, fixture.owner).forces[`${fixture.discovery.parent}:${fixture.discovery.parentSector}`] ?? 0, 0);
|
||||
assert.equal(player(entered, fixture.owner).spice, before.spice);
|
||||
assert.equal(player(entered, fixture.owner).reserves, before.reserves);
|
||||
assert.equal(player(entered, fixture.owner).moved, before.moved);
|
||||
assert.equal(player(entered, fixture.owner).shipped, before.shipped);
|
||||
assert.equal(viewGame(entered, fixture.owner).leaderSkills!.assignments.find(assignment => assignment.owner === fixture.owner)!.leader, fixture.discovery.leader);
|
||||
const admitted = createDiscoverySkillsStrongholdsFixture({ initial: fixture.setup, tech: true });
|
||||
assert.deepEqual(admitted.setup, fixture.setup, 'An admitted original setup is never redealt.');
|
||||
assert.equal(admitted.game.strongholdCards!.owners.arrakeen, fixture.owner);
|
||||
});
|
||||
|
||||
void test('held Arrakeen support pays only the actual subsidy while trained physical Suk rescue precedes original winner cleanup and Tech reward for all four policies', () => {
|
||||
const fixture = createDiscoverySkillsStrongholdsFixture({ tech: true, support: 3, ownerDial: 4,
|
||||
ownerWeapon: 'projectile', ownerDefense: 'shield', opponentWeapon: 'projectile' });
|
||||
const revealed = revealDiscoverySkillsStrongholdsBattle(fixture);
|
||||
const quote = quoteClassicSkillsStrongholdBattle(revealed);
|
||||
const payment = quote.payments.find(receipt => receipt.player === fixture.owner)!;
|
||||
assert.equal(payment.bankSupport, 2); assert.equal(payment.ownPayment, 1);
|
||||
assert.equal(quote.casualties!.options[0].normal, 5);
|
||||
assert.equal(quote.sukGraduate?.mode, 'skilled');
|
||||
const original = player(revealed, fixture.owner), enemy = player(revealed, fixture.opponent);
|
||||
const token = ownedTech(revealed.techTokens, fixture.opponent); assert.equal(token.length, 1);
|
||||
const rescue = finishDiscoverySkillsStrongholdsBattle(revealed, { stop: 'skill' });
|
||||
assert.ok(rescue.decision?.kind === 'sukRescue');
|
||||
assert.equal(player(rescue, fixture.owner).spice, original.spice - 1 + 3, 'Only actual own support is debited; the dead printed enemy disc pays its real bounty.');
|
||||
assert.equal(player(rescue, fixture.opponent).tanks, enemy.tanks + 8);
|
||||
assert.deepEqual(rescue.techTokens, revealed.techTokens);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(rescue, fixture.owner); view.players.find(seat => seat.id === fixture.owner)!.bot = difficulty;
|
||||
const action = botActions(view).find(candidate => candidate.type === 'decision');
|
||||
assert.ok(action && typeof action.choice === 'number');
|
||||
const selected: SukRescueOption = rescue.decision.options[action.choice];
|
||||
assert.equal(selected.normal, 3); assert.equal(selected.elite, 0); assert.equal(selected.kept?.key, fixture.key);
|
||||
reject(rescue, fixture.opponent, action);
|
||||
const cleanup = applyAction(structuredClone(rescue), fixture.owner, action);
|
||||
assert.equal(player(cleanup, fixture.owner).forces[fixture.key], 4);
|
||||
assert.equal(player(cleanup, fixture.owner).reserves, original.reserves + 2);
|
||||
assert.equal(player(cleanup, fixture.owner).tanks, original.tanks + 2);
|
||||
assert.equal(player(cleanup, fixture.owner).battleLosses, original.battleLosses + 2);
|
||||
assert.equal(ordinaryTotal(cleanup, fixture.owner), ordinaryTotal(revealed, fixture.owner));
|
||||
assert.equal(cleanup.decision?.kind, 'battleCards');
|
||||
assert.deepEqual(cleanup.techTokens, revealed.techTokens);
|
||||
const done = finishDiscoverySkillsStrongholdsBattle(cleanup);
|
||||
assert.equal(done.techTokens![token[0]].owner, fixture.owner);
|
||||
assert.deepEqual(ownedTech(done.techTokens, fixture.opponent), []);
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
||||
assert.equal(done.lastBattleContext!.sukRescue!.completed, true);
|
||||
assert.equal(viewGame(done, fixture.owner).leaderSkills!.assignments.find(assignment => assignment.owner === fixture.owner)!.faceUp, true);
|
||||
}
|
||||
});
|
||||
|
||||
void test('unheld Arrakeen supplies no subsidy; a held Arrakeen card never donates its territory-only bank benefit to the actual nested Cistern battle', () => {
|
||||
for (const options of [{ holder: 'unclaimed' as const }, { nestedBattle: true }]) {
|
||||
const fixture = createDiscoverySkillsStrongholdsFixture({ ...options, support: 2, ownerDial: 4,
|
||||
ownerWeapon: 'projectile', ownerDefense: 'shield', opponentWeapon: 'projectile' });
|
||||
const revealed = revealDiscoverySkillsStrongholdsBattle(fixture);
|
||||
assert.equal(viewGame(revealed, fixture.owner).battle!.strongholdEffects[fixture.owner], null);
|
||||
const quote = quoteClassicSkillsStrongholdBattle(revealed);
|
||||
const payment = quote.payments.find(receipt => receipt.player === fixture.owner)!;
|
||||
assert.equal(payment.bankSupport, 0); assert.equal(payment.ownPayment, 2);
|
||||
const rescue = finishDiscoverySkillsStrongholdsBattle(revealed, { stop: 'skill' });
|
||||
assert.equal(player(rescue, fixture.owner).spice, player(revealed, fixture.owner).spice - 2 + 3);
|
||||
assert.equal(rescue.lastBattleContext!.territory, options.nestedBattle ? 'cistern' : 'arrakeen');
|
||||
assert.equal(rescue.strongholdCards!.owners.arrakeen, options.nestedBattle ? fixture.owner : null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('held Carthag actual Shield protects the normal Prana Bindu-trained disc and its matching physical defense bonus wins; unheld poison still kills', () => {
|
||||
for (const holder of ['owner', 'unclaimed'] as const) {
|
||||
const fixture = createDiscoverySkillsStrongholdsFixture({ kind: 'carthag', holder, skill: 'prana-bindu-adept', hide: false,
|
||||
ownerDial: 0.5, support: 0, opponentDial: 1, opponentSupport: 1, ownerDefense: 'shield', opponentWeapon: 'poison' });
|
||||
const revealed = revealDiscoverySkillsStrongholdsBattle(fixture);
|
||||
const quote = quoteClassicSkillsStrongholdBattle(revealed);
|
||||
assert.equal(quote.winner, holder === 'owner' ? fixture.owner : fixture.opponent);
|
||||
const cleanup = finishDiscoverySkillsStrongholdsBattle(revealed, { stop: 'cleanup' });
|
||||
assert.equal(player(cleanup, fixture.owner).leaders.find(leader => leader.id === fixture.leader)!.dead, holder !== 'owner');
|
||||
if (holder === 'owner') {
|
||||
const bonus = quote.leaderSkillBonuses[revealed.battle!.attacker === fixture.owner ? 'attacker' : 'defender'];
|
||||
assert.deepEqual(bonus.applied, [{ skill: 'prana-bindu-adept', amount: 1, mode: 'normal' }]);
|
||||
assert.equal(player(cleanup, fixture.owner).spice, player(revealed, fixture.owner).spice);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('held Sietch Tabr victory income and sole Cistern Collection remain separate; Tabr creates no city Collection payment', () => {
|
||||
const fixture = createDiscoverySkillsStrongholdsFixture({ kind: 'sietch_tabr', skill: 'planetologist', ownerDial: 2, support: 2,
|
||||
opponentDial: 1, opponentSupport: 1, ownerDefense: 'shield' });
|
||||
const revealed = revealDiscoverySkillsStrongholdsBattle(fixture), quote = quoteClassicSkillsStrongholdBattle(revealed);
|
||||
assert.equal(quote.winner, fixture.owner);
|
||||
assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: 1 }]);
|
||||
const cleanup = finishDiscoverySkillsStrongholdsBattle(revealed, { stop: 'cleanup' });
|
||||
const before = player(revealed, fixture.owner).spice;
|
||||
assert.equal(player(cleanup, fixture.owner).spice, before - 2 + 1);
|
||||
const collected = advanceClassicDiscoverySkills(finishDiscoverySkillsStrongholdsBattle(cleanup), state => state.phase === 7 && classicDiscoverySkillsClean(state));
|
||||
assert.equal(player(collected, fixture.owner).forces['cistern:0'], 3);
|
||||
assert.equal(player(collected, fixture.owner).forces[fixture.key], 6);
|
||||
assert.equal(player(collected, fixture.owner).spice, before - 2 + 1 + 2,
|
||||
'Only actual Tabr battle income and sole Cistern Collection pay; Tabr is not an income-paying city.');
|
||||
const nextTurn = advanceClassicDiscoverySkills(collected, state => state.turn === 3);
|
||||
assert.equal(nextTurn.strongholdCards!.claimedTurn, 2, 'The next real end-Mentat must accept the actually entered nested force keys.');
|
||||
assert.equal(nextTurn.strongholdCards!.owners.sietch_tabr, fixture.owner);
|
||||
assert.equal(player(nextTurn, fixture.owner).forces['cistern:0'], 3);
|
||||
});
|
||||
|
||||
void test('real held-card nested shipment charges one spice, does not consume free-entry movement, and has four legal native policies', () => {
|
||||
const fixture = createDiscoverySkillsStrongholdsFixture();
|
||||
const window = fixture.shipmentWindow, before = player(window, fixture.owner);
|
||||
legalPolicies(window, fixture.owner);
|
||||
let shipped = applyAction(structuredClone(window), fixture.paidShipmentStep.actor, fixture.paidShipmentStep.action);
|
||||
shipped = settleClassicDiscoverySkillsArrival(shipped);
|
||||
assert.equal(player(shipped, fixture.owner).spice, before.spice - 1);
|
||||
assert.equal(player(shipped, fixture.owner).reserves, before.reserves - 1);
|
||||
assert.equal(player(shipped, fixture.owner).forces['cistern:0'], 4);
|
||||
assert.equal(player(shipped, fixture.owner).moved, before.moved);
|
||||
assert.equal(player(shipped, fixture.owner).shipped, true);
|
||||
assert.equal(shipped.strongholdCards!.owners.arrakeen, fixture.owner);
|
||||
const prepared = prepareDiscoverySkillsStrongholdsBattle(fixture);
|
||||
legalPolicies(prepared, fixture.owner);
|
||||
});
|
||||
|
||||
void test('selected canonical unused cards are retained while Basic admission and injected held receipts remain outside original controls', () => {
|
||||
const original = createClassicDiscoverySkillsFixture({ advanced: true, strongholdCards: true });
|
||||
assert.deepEqual(original.setup.strongholdCards, createStrongholdCards());
|
||||
const actualOffers = structuredClone(original.setup);
|
||||
const resumed = createDiscoverySkillsStrongholdsFixture({ initial: actualOffers });
|
||||
assert.deepEqual(actualOffers, original.setup, 'The authenticated original offers remain untouched.');
|
||||
assert.deepEqual(resumed.setup, original.setup);
|
||||
assert.equal(resumed.game.leaderSkills!.assignments.find(assignment => assignment.owner === resumed.owner)!.skill, 'planetologist',
|
||||
'Human setup continues its actual available offer, not a demanded Suk replacement.');
|
||||
assert.throws(() => createClassicDiscoverySkillsFixture({ strongholdCards: true }));
|
||||
const forged = structuredClone(original.setup); forged.strongholdCards!.owners.arrakeen = original.collector;
|
||||
assert.throws(() => createClassicDiscoverySkillsFixture({ initial: forged }));
|
||||
});
|
||||
@@ -212,3 +212,20 @@ void test('Actual Stronghold initializer excludes Basic, E1/E2 mixtures, native
|
||||
for (const player of pair.players) pair = applyAction(pair, player.id, { type: 'ready' });
|
||||
assert.throws(() => initializeStrongholdFactionsGameForAudit(pair));
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`live original Discovery coalition preserves ${lead} lead support, physical allied losses and held-card custody`, () => {
|
||||
const fixture = ecazStrongholdFixture({ discoveries: true, tech: true });
|
||||
const { game, afterBattle, before, actor, quote } = reveal(fixture, lead, { variable: 3 });
|
||||
const payment = quote.payments.find(receipt => receipt.player === actor)!;
|
||||
assert.equal(payment.bankSupport, lead === 'ecaz' ? 2 : 0);
|
||||
assert.equal(payment.ownPayment, lead === 'ecaz' ? 1 : 3);
|
||||
assert.equal(seat(before, actor).spice - seat(afterBattle, actor).spice, payment.ownPayment);
|
||||
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], 1);
|
||||
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, 3);
|
||||
assert.equal(game.lastBattleContext!.winner, actor);
|
||||
assert.equal(game.strongholdCards!.owners.arrakeen, fixture.ecaz);
|
||||
assert.deepEqual(game.discoveries, before.discoveries);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-car
|
||||
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
|
||||
import { createStrongholdCards } from '../game/stronghold-cards';
|
||||
|
||||
export type ClassicDiscoverySkillsStep = { actor: string; action: Action };
|
||||
export type ClassicDiscoverySkillsOptions = {
|
||||
@@ -15,6 +16,8 @@ export type ClassicDiscoverySkillsOptions = {
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
/** Preserve the selected, still-unused original Advanced module at setup. */
|
||||
strongholdCards?: boolean;
|
||||
seats?: 2 | 3 | 4 | 5 | 6;
|
||||
collector?: 'guild' | 'harkonnen';
|
||||
skill?: LeaderSkillId;
|
||||
@@ -118,7 +121,15 @@ export function createClassicDiscoverySkillsFixture(options: ClassicDiscoverySki
|
||||
assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0));
|
||||
const controlledOffers = game.status === 'lobby';
|
||||
assert.deepEqual(game.expansions, []);
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.strongholdCards && !game.spiceBankerIncomePreview);
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.spiceBankerIncomePreview);
|
||||
if (options.strongholdCards || game.strongholdCards) {
|
||||
assert.equal(game.advanced, true);
|
||||
if (!game.strongholdCards) {
|
||||
assert.equal(game.status, 'lobby', 'Strongholds must be selected before the original deal.');
|
||||
game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
|
||||
}
|
||||
assert.deepEqual(game.strongholdCards, createStrongholdCards(), 'Never accept an earned or injected held-card receipt.');
|
||||
}
|
||||
const collector = game.players.find(player => player.faction === collectorFaction)!.id;
|
||||
const opponent = game.players.find(player => player.faction === 'emperor')!.id;
|
||||
const skillOwner = game.players.find(player => player.faction === (options.skillOwner ?? collectorFaction))!.id;
|
||||
@@ -181,11 +192,14 @@ export function createClassicDiscoverySkillsFixture(options: ClassicDiscoverySki
|
||||
return { initial, setup, game, actions, staging, collector, opponent, leader,
|
||||
token: token.id, face, parent: placement.territory, parentSector: placement.sector };
|
||||
}
|
||||
export function revealClassicDiscoverySkills(fixture: ClassicDiscoverySkillsFixture): Game {
|
||||
export function revealClassicDiscoverySkills(fixture: ClassicDiscoverySkillsFixture, actions?: ClassicDiscoverySkillsStep[]): Game {
|
||||
let game = fixture.game;
|
||||
if (viewGame(game, fixture.collector).discoveries!.canInspect.some(token => token === fixture.token))
|
||||
game = applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: false });
|
||||
return applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: true });
|
||||
if (viewGame(game, fixture.collector).discoveries!.canInspect.some(token => token === fixture.token)) {
|
||||
const inspect: ClassicDiscoverySkillsStep = { actor: fixture.collector, action: { type: 'discovery', token: fixture.token, reveal: false } };
|
||||
actions?.push(inspect); game = applyAction(game, inspect.actor, inspect.action);
|
||||
}
|
||||
const reveal: ClassicDiscoverySkillsStep = { actor: fixture.collector, action: { type: 'discovery', token: fixture.token, reveal: true } };
|
||||
actions?.push(reveal); return applyAction(game, reveal.actor, reveal.action);
|
||||
}
|
||||
export function classicDiscoverySkillsEntryWindow(fixture: ClassicDiscoverySkillsFixture): Game {
|
||||
return advanceClassicDiscoverySkills(revealClassicDiscoverySkills(fixture), game =>
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, handLimit, initializeDiscoveryGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, gameTerritories, territory } from '../game/board';
|
||||
import { isAuditorLeader, treacheryDeck, type Card } from '../game/cards';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { validateRicheseNoField } from '../game/richese-no-field';
|
||||
import { createStrongholdCards, strongholdControllers } from '../game/stronghold-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { nextDiscoveryNativeMarkerStep } from './fixture-discovery-native-marker';
|
||||
|
||||
export type DiscoveryNativeStrongholdStep = { actor: string; action: Action };
|
||||
export type DiscoveryNativeStrongholdKind = 'ix-hms-invoice' | 'richese-nested-defense';
|
||||
export type DiscoveryNativeStrongholdOptions = {
|
||||
/** Authenticated fresh lobby or original undealt Discovery/Stronghold audit setup. */
|
||||
initial?: Game;
|
||||
kind?: DiscoveryNativeStrongholdKind;
|
||||
tech?: boolean;
|
||||
};
|
||||
export type DiscoveryNativeStrongholdFixture = {
|
||||
initial: Game;
|
||||
setup: Game;
|
||||
afterSetup: Game;
|
||||
beforeBlow: Game;
|
||||
afterBlow: Game;
|
||||
beforeReveal: Game;
|
||||
afterReveal: Game;
|
||||
beforeFirstMentat: Game;
|
||||
firstMentatStep: DiscoveryNativeStrongholdStep;
|
||||
afterFirstMentat: Game;
|
||||
entry: { before: Game; step: DiscoveryNativeStrongholdStep; after: Game };
|
||||
shipment: { before: Game; step: DiscoveryNativeStrongholdStep; after: Game };
|
||||
markerReveal: { before: Game; step: DiscoveryNativeStrongholdStep; after: Game } | null;
|
||||
beforeBattle: Game;
|
||||
battleStep: DiscoveryNativeStrongholdStep;
|
||||
copyStep: DiscoveryNativeStrongholdStep | null;
|
||||
game: Game;
|
||||
kind: DiscoveryNativeStrongholdKind;
|
||||
owner: string;
|
||||
collector: string;
|
||||
opponent: string;
|
||||
choam: string;
|
||||
token: string;
|
||||
parent: string;
|
||||
source: string;
|
||||
location: string;
|
||||
leader: string;
|
||||
opponentLeader: string;
|
||||
defense: string | null;
|
||||
weapon: string | null;
|
||||
plans: DiscoveryNativeStrongholdStep[];
|
||||
actions: DiscoveryNativeStrongholdStep[];
|
||||
staging: string[];
|
||||
};
|
||||
export const discoveryNativeStrongholdReload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
export function discoveryNativeStrongholdPlayer(game: Game, actor: string): Player {
|
||||
const p = game.players.find(player => player.id === actor); assert.ok(p); return p;
|
||||
}
|
||||
export function assertDiscoveryNativeStrongholdCustody(game: Game): void {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? []),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
|
||||
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(game.expansions),
|
||||
...(game.players.some(p => p.faction === 'richese') ? richeseCards() : [])].map(c => c.id).sort());
|
||||
assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
|
||||
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(p => [
|
||||
...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(c => c.leader),
|
||||
])].sort(), traitorDeck(game.players, game.expansions.includes('ix')).sort());
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction);
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
|
||||
p.faction === 'ixians' ? 7 : p.faction === 'fremen' ? 3 : 5);
|
||||
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
|
||||
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count >= 0 && count <= (p.forces[key] ?? 0));
|
||||
}
|
||||
if (p.noField) validateRicheseNoField(p.noField);
|
||||
}
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
}
|
||||
export const discoveryNativeStrongholdClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
/** Existing native policies; leave declarations and unsealed plans to the caller. */
|
||||
export function nextDiscoveryNativeStrongholdStep(game: Game): DiscoveryNativeStrongholdStep | null {
|
||||
if (game.decision?.kind === 'discoveryEntry') return { actor: game.decision.player,
|
||||
action: { type: 'decision', event: game.decision.event, accept: false } };
|
||||
if (game.decision?.kind === 'techToken') return { actor: game.decision.player,
|
||||
action: { type: 'decision', token: game.decision.choices[0] } };
|
||||
if (game.decision?.kind === 'strongholdCopy' || game.decision?.kind === 'faceDance') return null;
|
||||
if (game.battle) return nextStrongholdFactionsNativeStep(game);
|
||||
return game.players.some(p => p.faction === 'richese')
|
||||
? nextDiscoveryNativeMarkerStep(game) : nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
function entropy<T>(operation: () => T, scalar?: number): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array) array.fill(scalar ?? 0xffffffff);
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
function act(game: Game, step: DiscoveryNativeStrongholdStep, actions?: DiscoveryNativeStrongholdStep[], scalar?: number): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return entropy(() => applyAction(game, step.actor, step.action), scalar);
|
||||
}
|
||||
export function advanceDiscoveryNativeStronghold(state: Game, until: (g: Game) => boolean,
|
||||
actions?: DiscoveryNativeStrongholdStep[], scalar?: number): Game {
|
||||
let game = state;
|
||||
for (let n = 0; n < 1800; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextDiscoveryNativeStrongholdStep(game); assert.ok(step, 'A real human declaration or plan is required');
|
||||
game = act(game, step, actions, scalar);
|
||||
}
|
||||
throw Error('Native Discovery/Stronghold policy did not reach its requested original window');
|
||||
}
|
||||
function place(game: Game, actor: string, location: string, amount: number, elite = 0): void {
|
||||
const p = discoveryNativeStrongholdPlayer(game, actor);
|
||||
assert.ok(p.reserves - (p.elites?.reserves ?? 0) >= amount - elite && amount >= elite);
|
||||
assert.ok((p.elites?.reserves ?? 0) >= elite);
|
||||
p.reserves -= amount; p.forces[location] = (p.forces[location] ?? 0) + amount;
|
||||
if (elite) { p.elites!.reserves -= elite; p.elites!.forces[location] = (p.elites!.forces[location] ?? 0) + elite; }
|
||||
}
|
||||
function reserveOtherLocations(game: Game, kept: string[]): void {
|
||||
for (const p of game.players) for (const [key, amount] of Object.entries(p.forces)) {
|
||||
if (kept.includes(key)) continue;
|
||||
p.reserves += amount; delete p.forces[key];
|
||||
if (p.elites) { p.elites.reserves += p.elites.forces[key] ?? 0; delete p.elites.forces[key]; }
|
||||
}
|
||||
}
|
||||
function held(game: Game, actor: string, kind: Card['kind'], staging: string[]): string {
|
||||
const p = discoveryNativeStrongholdPlayer(game, actor), owned = p.hand.find(c => c.kind === kind);
|
||||
if (owned) return owned.id;
|
||||
const index = game.deck.findIndex(c => c.kind === kind);
|
||||
assert.ok(index >= 0 && p.hand.length < handLimit(p));
|
||||
const card = game.deck.splice(index, 1)[0]; p.hand.push(card);
|
||||
staging.push(`Conserved ${card.id}: original undealt native deck → ${actor}'s hand.`);
|
||||
return card.id;
|
||||
}
|
||||
/** Original Advanced E1+E2 or E2 setup, printed Hagga Basin blow, actual
|
||||
* inspection/reveal, first END Mentat claim, original free entry and paid shipment.
|
||||
* Entropy controls only original deals/remainder shuffles and the physical token
|
||||
* lottery. No held-card owner, paid continuation, phase or timestamp is forged. */
|
||||
export function createDiscoveryNativeStrongholdFixture(options: DiscoveryNativeStrongholdOptions = {}): DiscoveryNativeStrongholdFixture {
|
||||
const kind = options.kind ?? 'ix-hms-invoice', ix = kind === 'ix-hms-invoice';
|
||||
const factions = ix ? ['ixians', 'choam', 'guild'] as const : ['richese', 'choam', 'guild'] as const;
|
||||
const expansions = ix ? ['ix', 'choam'] : ['choam'];
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('DISCOVERYNATIVEHELD', newPlayer(factions[0], factions[0], factions[0]), true, expansions);
|
||||
if (!options.initial) for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
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].sort(), [...expansions].sort());
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), [...factions].sort());
|
||||
assert.ok(!game.leaderSkills && !game.nexusCards && !game.homeworlds);
|
||||
if (game.status === 'lobby') {
|
||||
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
game.discoveryEnabled = true;
|
||||
if (!game.strongholdCards) game.strongholdCards = createStrongholdCards();
|
||||
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
|
||||
}
|
||||
const initial = structuredClone(game);
|
||||
if (game.status === 'lobby') game = entropy(() => initializeDiscoveryGameForAudit(game));
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.strongholdCards!.claimedTurn, 0);
|
||||
assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
|
||||
const actions: DiscoveryNativeStrongholdStep[] = [], staging = [
|
||||
'Controlled only original native deal/remainder entropy; selected the actual Shrine token placement lottery.',
|
||||
'Reordered four original unplayed Spice Cards: printed Hagga Basin Discovery, then three ordinary blows.',
|
||||
];
|
||||
game = advanceDiscoveryNativeStronghold(game, g => g.status === 'playing', actions);
|
||||
const afterSetup = structuredClone(game);
|
||||
const owner = game.players.find(p => p.faction === factions[0])!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'guild')!.id;
|
||||
const choam = game.players.find(p => p.faction === 'choam')!.id, collector = ix ? owner : opponent;
|
||||
const printed = 'discovery-hagga-basin', placement = DISCOVERY_CARD_PLACEMENTS[printed];
|
||||
const source = `${placement.territory}:${placement.sector}`;
|
||||
const cardIndex = game.spiceDeck.findIndex(c => 'territory' in c && c.discovery === printed); assert.ok(cardIndex >= 0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(cardIndex, 1)[0]);
|
||||
for (let position = 1; position < 4; position++) {
|
||||
const index = game.spiceDeck.findIndex((c, i) => i >= position && 'territory' in c && !c.discovery);
|
||||
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game = advanceDiscoveryNativeStronghold(game, g => g.phase === 1 && discoveryNativeStrongholdClean(g), actions);
|
||||
// Actual blow destroys conserved typed counters in its PRINTED territory;
|
||||
// placement is the separate printed Gara Kulon destination, not the blow site.
|
||||
const blowLocation = 'hagga_basin:12';
|
||||
place(game, owner, blowLocation, 2, ix ? 1 : 0);
|
||||
staging.push(`Conserved original reserves → ${blowLocation}: two ${factions[0]} counters${ix ? ', including one Cyborg' : ''} before the actual blow.`);
|
||||
const beforeBlow = structuredClone(game);
|
||||
const eligible = game.discoveries!.tokens.filter(t => t.status === 'supply' && t.type === 'hiereg');
|
||||
const selected = eligible.findIndex(t => t.face === 'shrine'); assert.ok(selected >= 0);
|
||||
game = advanceDiscoveryNativeStronghold(game,
|
||||
g => g.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed'), actions,
|
||||
Math.floor((selected + 0.5) / eligible.length * 0x100000000));
|
||||
const afterBlow = structuredClone(game), token = game.discoveries!.tokens.find(t => t.face === 'shrine')!;
|
||||
game = advanceDiscoveryNativeStronghold(game, g => g.phase === 5 && g.active === collector && discoveryNativeStrongholdClean(g), actions);
|
||||
game = act(game, { actor: collector, action: { type: 'ship', territory: placement.territory,
|
||||
sector: placement.sector, amount: ix ? 3 : 1, elite: ix ? 1 : 0, allyPayment: 0 } }, actions);
|
||||
game = advanceDiscoveryNativeStronghold(game, g => g.phase === 7 && discoveryNativeStrongholdClean(g), actions);
|
||||
const beforeReveal = structuredClone(game);
|
||||
assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id));
|
||||
game = act(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
|
||||
game = act(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: true } }, actions);
|
||||
const afterReveal = structuredClone(game);
|
||||
game = advanceDiscoveryNativeStronghold(game, g => g.phase === 8 && discoveryNativeStrongholdClean(g), actions);
|
||||
reserveOtherLocations(game, [source, ...(ix ? [MOBILE_LOCATION] : [])]);
|
||||
const claim = ix ? 'arrakeen' : 'carthag';
|
||||
place(game, owner, `${claim}:${territory(claim).sectors[0]}`, 1);
|
||||
staging.push('Before actual first END Mentat: return unrelated original board counters to their own reserves, preserve native HMS/Discovery garrisons, and place one conserved claim counter.');
|
||||
let beforeFirstMentat: Game | undefined, firstMentatStep: DiscoveryNativeStrongholdStep | undefined;
|
||||
for (let n = 0; game.turn === 1 && n < 100; n++) {
|
||||
const step = nextDiscoveryNativeStrongholdStep(game); assert.ok(step);
|
||||
const before = structuredClone(game); game = act(game, step, actions);
|
||||
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; }
|
||||
}
|
||||
assert.ok(beforeFirstMentat && firstMentatStep);
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
assert.equal(game.strongholdCards!.owners[claim], owner);
|
||||
assert.deepEqual(game.strongholdCards!.owners, strongholdControllers(beforeFirstMentat.players, ix));
|
||||
game = advanceDiscoveryNativeStronghold(game, g => g.decision?.kind === 'discoveryEntry', actions);
|
||||
assert.equal(game.decision!.player, collector);
|
||||
const entryBefore = structuredClone(game), offer = viewGame(game, collector).discoveryEntry!;
|
||||
const entryStep: DiscoveryNativeStrongholdStep = { actor: collector, action: ix
|
||||
? { type: 'decision', event: offer.event, accept: true, groups: [{ source, normal: 1, elite: 1 }] }
|
||||
: { type: 'decision', event: offer.event, accept: false } };
|
||||
game = act(game, entryStep, actions);
|
||||
game = advanceDiscoveryNativeStronghold(game, g => !g.discoveryEntry && !g.decision && !g.response, actions);
|
||||
const entry = { before: entryBefore, step: entryStep, after: structuredClone(game) };
|
||||
game = advanceDiscoveryNativeStronghold(game, g => g.phase === 5 && g.active === owner && discoveryNativeStrongholdClean(g), actions);
|
||||
const shipmentBefore = structuredClone(game), player = discoveryNativeStrongholdPlayer(game, owner);
|
||||
const noField = player.noField?.tokens.find(t => t.value === 5);
|
||||
const shipmentStep: DiscoveryNativeStrongholdStep = { actor: owner, action: ix
|
||||
? { type: 'ship', territory: 'shrine', sector: 0, amount: 2, elite: 1, allyPayment: 0 }
|
||||
: { type: 'ship', territory: 'shrine', sector: 0, noField: noField!.id, event: player.noFieldEvent, allyPayment: 0 } };
|
||||
game = act(game, shipmentStep, actions);
|
||||
game = advanceDiscoveryNativeStronghold(game, g => !g.pendingShipment && discoveryNativeStrongholdClean(g), actions);
|
||||
const shipment = { before: shipmentBefore, step: shipmentStep, after: structuredClone(game) };
|
||||
let markerReveal: DiscoveryNativeStrongholdFixture['markerReveal'] = null;
|
||||
if (!ix) {
|
||||
const before = structuredClone(game);
|
||||
const step: DiscoveryNativeStrongholdStep = { actor: owner, action: { type: 'revealNoField',
|
||||
token: noField!.id, event: discoveryNativeStrongholdPlayer(game, owner).noFieldEvent } };
|
||||
game = act(game, step, actions); markerReveal = { before, step, after: structuredClone(game) };
|
||||
}
|
||||
if (ix) {
|
||||
place(game, owner, `sietch_tabr:${territory('sietch_tabr').sectors[0]}`, 1);
|
||||
staging.push('After earned first-Mentat custody: one conserved Ixian reserve counter → Sietch Tabr, giving a second CURRENT copy choice without claiming its unheld card.');
|
||||
}
|
||||
const location = ix ? MOBILE_LOCATION : 'shrine:0', battleTerritory = ix ? MOBILE_STRONGHOLD : 'shrine';
|
||||
place(game, opponent, location, 6);
|
||||
// A second actual conflict keeps Battle open so bank/Tech custody can be
|
||||
// inspected before unrelated Collection or the next Mentat checkpoint.
|
||||
const other = gameTerritories(game).find(t => t.type === 'sand' && t.id !== placement.territory && t.sectors.some(s => s >= 9));
|
||||
assert.ok(other); const otherLocation = `${other.id}:${other.sectors.find(s => s >= 9)!}`;
|
||||
place(game, owner, otherLocation, 1); place(game, opponent, otherLocation, 1);
|
||||
staging.push(`Conserved opponent reserve counters → ${location} (six); one counter per combatant → ${otherLocation} for a second original conflict. Retain earned first-Mentat cards and all paid/free arrivals.`);
|
||||
const defense = !ix ? held(game, owner, 'shield', staging) : null;
|
||||
const weapon = !ix ? held(game, opponent, 'poison', staging) : null;
|
||||
const leader = discoveryNativeStrongholdPlayer(game, owner).leaders.filter(l => !l.dead && !isAuditorLeader(l)).sort((a, b) => b.strength - a.strength)[0];
|
||||
const opponentLeader = discoveryNativeStrongholdPlayer(game, opponent).leaders.filter(l => !l.dead && !isAuditorLeader(l)).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(leader && opponentLeader);
|
||||
game = advanceDiscoveryNativeStronghold(game, g => g.phase === 6 && discoveryNativeStrongholdClean(g), actions);
|
||||
const beforeBattle = structuredClone(game), actor = game.active!;
|
||||
assert.ok(actor === owner || actor === opponent);
|
||||
const battleStep: DiscoveryNativeStrongholdStep = { actor, action: { type: 'chooseBattle', territory: battleTerritory, target: actor === owner ? opponent : owner } };
|
||||
game = act(game, battleStep, actions);
|
||||
for (let n = 0; n < 120; n++) {
|
||||
if (game.decision?.kind === 'strongholdCopy') break;
|
||||
const step = nextDiscoveryNativeStrongholdStep(game); if (!step) break;
|
||||
game = act(game, step, actions);
|
||||
}
|
||||
const copyStep: DiscoveryNativeStrongholdStep | null = game.decision?.kind === 'strongholdCopy'
|
||||
? { actor: owner, action: { type: 'decision', event: game.decision.event, stronghold: 'arrakeen' } } : null;
|
||||
assert.equal(Boolean(copyStep), ix);
|
||||
const plans: DiscoveryNativeStrongholdStep[] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', leader: leader.id, dial: ix ? 6 : 0, support: ix ? 3 : 0, weapon: null, defense } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: opponentLeader.id, dial: 0, support: 0, weapon, defense: null } },
|
||||
];
|
||||
assertDiscoveryNativeStrongholdCustody(game);
|
||||
return { initial, setup, afterSetup, beforeBlow, afterBlow, beforeReveal, afterReveal,
|
||||
beforeFirstMentat, firstMentatStep, afterFirstMentat, entry, shipment, markerReveal,
|
||||
beforeBattle, battleStep, copyStep, game, kind, owner, collector, opponent, choam,
|
||||
token: token.id, parent: placement.territory, source, location, leader: leader.id,
|
||||
opponentLeader: opponentLeader.id, defense, weapon, plans, actions, staging };
|
||||
}
|
||||
export function prepareDiscoveryNativeStrongholdBattle(fixture: DiscoveryNativeStrongholdFixture): Game {
|
||||
let game = discoveryNativeStrongholdReload(fixture.game);
|
||||
if (fixture.copyStep) game = act(game, fixture.copyStep);
|
||||
for (let n = 0; n < 120; n++) {
|
||||
const step = nextDiscoveryNativeStrongholdStep(game);
|
||||
if (!step) { assert.ok(game.battle && !game.battle.revealed); return game; }
|
||||
game = act(game, step);
|
||||
}
|
||||
throw Error('Original native pre-plan window did not close');
|
||||
}
|
||||
export function revealDiscoveryNativeStrongholdBattle(fixture: DiscoveryNativeStrongholdFixture): Game {
|
||||
let game = prepareDiscoveryNativeStrongholdBattle(fixture);
|
||||
for (const plan of fixture.plans) {
|
||||
game = act(game, plan);
|
||||
while (game.response || game.decision?.kind === 'fullPlanOffer') {
|
||||
const next = nextDiscoveryNativeStrongholdStep(game); assert.ok(next); game = act(game, next);
|
||||
}
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
export function finishDiscoveryNativeStrongholdBattle(state: Game): Game {
|
||||
return advanceDiscoveryNativeStronghold(state, g => !g.battle && !g.decision && !g.response && !g.pendingTreacheryDiscard);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, handLimit, viewGame, type Action, type Game, type Player } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import type { Card } from '../game/cards';
|
||||
import type { LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { createStrongholdCards, strongholdControllers, type StrongholdId } from '../game/stronghold-cards';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import {
|
||||
advanceClassicDiscoverySkills, classicDiscoverySkillsClean, createClassicDiscoverySkillsFixture,
|
||||
nextClassicDiscoverySkillsStep, revealClassicDiscoverySkills,
|
||||
type ClassicDiscoverySkillsFixture, type ClassicDiscoverySkillsOptions,
|
||||
} from './fixture-discovery-classic-skills';
|
||||
import { finishClassicSkillsStrongholdBattle, nextClassicSkillsStrongholdStep } from './fixture-classic-skills-stronghold';
|
||||
|
||||
export type DiscoverySkillsStrongholdsStep = { actor: string; action: Action };
|
||||
export type DiscoverySkillsStrongholdsOptions = {
|
||||
/** Fresh authenticated lobby or original undealt setup, never a played save. */
|
||||
initial?: Game;
|
||||
tech?: boolean;
|
||||
kind?: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
|
||||
holder?: 'owner' | 'unclaimed';
|
||||
skill?: LeaderSkillId;
|
||||
hide?: boolean;
|
||||
nestedBattle?: boolean;
|
||||
ownerDial?: number;
|
||||
support?: number;
|
||||
opponentDial?: number;
|
||||
opponentSupport?: number;
|
||||
ownerWeapon?: Card['kind'];
|
||||
ownerDefense?: Card['kind'];
|
||||
opponentWeapon?: Card['kind'];
|
||||
opponentDefense?: Card['kind'];
|
||||
skillOwner?: ClassicDiscoverySkillsOptions['skillOwner'];
|
||||
};
|
||||
export type DiscoverySkillsStrongholdsFixture = {
|
||||
discovery: ClassicDiscoverySkillsFixture;
|
||||
initial: Game;
|
||||
setup: Game;
|
||||
beforeFirstMentat: Game;
|
||||
firstMentatStep: DiscoverySkillsStrongholdsStep;
|
||||
entryWindow: Game;
|
||||
entryStep: DiscoverySkillsStrongholdsStep;
|
||||
entered: Game;
|
||||
shipmentWindow: Game;
|
||||
paidShipmentStep: DiscoverySkillsStrongholdsStep;
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
leader: string;
|
||||
kind: StrongholdId;
|
||||
key: string;
|
||||
hide: boolean;
|
||||
actions: DiscoverySkillsStrongholdsStep[];
|
||||
planActions: DiscoverySkillsStrongholdsStep[];
|
||||
staging: string[];
|
||||
};
|
||||
export function discoverySkillsStrongholdsPlayer(game: Game, actor: string): Player {
|
||||
const player = game.players.find(candidate => candidate.id === actor);
|
||||
assert.ok(player); return player;
|
||||
}
|
||||
function reserveExcept(game: Game, keep: string[]): void {
|
||||
for (const player of game.players) for (const key of Object.keys(player.forces)) {
|
||||
if (keep.includes(key)) continue;
|
||||
const elite = player.elites?.forces[key] ?? 0;
|
||||
player.reserves += player.forces[key];
|
||||
if (player.elites) { player.elites.reserves += elite; delete player.elites.forces[key]; }
|
||||
delete player.forces[key];
|
||||
if (player.advisors) delete player.advisors[key.split(':')[0]];
|
||||
}
|
||||
}
|
||||
function place(game: Game, actor: string, key: string, amount: number): void {
|
||||
const player = discoverySkillsStrongholdsPlayer(game, actor);
|
||||
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= amount);
|
||||
player.reserves -= amount; player.forces[key] = (player.forces[key] ?? 0) + amount;
|
||||
}
|
||||
function held(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], used: string[] = []): Card {
|
||||
const player = discoverySkillsStrongholdsPlayer(game, actor);
|
||||
const match = (card: Card) => matches(card) && !used.includes(card.id);
|
||||
const existing = player.hand.find(match); if (existing) return existing;
|
||||
assert.ok(player.hand.length < handLimit(player));
|
||||
const index = game.deck.findIndex(match);
|
||||
const donor = index < 0 ? game.players.find(candidate => candidate.id !== actor && candidate.hand.some(match)) : undefined;
|
||||
assert.ok(index >= 0 || donor, 'Original base33 card custody must supply the staged card.');
|
||||
const card = index >= 0 ? game.deck.splice(index, 1)[0] : donor!.hand.splice(donor!.hand.findIndex(match), 1)[0];
|
||||
player.hand.push(card);
|
||||
staging.push(`Conserved ${card.id}: ${donor?.id ?? 'original deck'} custody to ${actor}'s hand.`);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Exact human sequence: create -> entryStep in entryWindow -> shipping controls
|
||||
* in shipmentWindow -> prepare -> planActions -> finish. All phases, training,
|
||||
* reveal, first end-Mentat custody and free entry use original actions. Explicit
|
||||
* force/card staging conserves counters and base33 custody, not earned receipts. */
|
||||
export function createDiscoverySkillsStrongholdsFixture(options: DiscoverySkillsStrongholdsOptions = {}): DiscoverySkillsStrongholdsFixture {
|
||||
let skill = options.skill;
|
||||
if (!skill && options.initial?.status === 'setup') {
|
||||
const initial = options.initial;
|
||||
const actor = initial.players.find(seat => seat.faction === (options.skillOwner ?? 'guild'))?.id;
|
||||
assert.ok(actor && initial.leaderSkills);
|
||||
const assignment = initial.leaderSkills.assignments.find(candidate => candidate.owner === actor);
|
||||
const view = viewGame(initial, actor).leaderSkills!;
|
||||
skill = assignment?.skill ?? initial.leaderSkills.offers[actor]?.cards.find(candidate =>
|
||||
!['spice-banker', 'mentat', 'diplomat'].includes(candidate) && !view.unavailableSkills?.[candidate]);
|
||||
assert.ok(skill, 'Actual undealt offers need an available source-clear original skill; never substitute an injected offer.');
|
||||
}
|
||||
const discovery = createClassicDiscoverySkillsFixture({ initial: options.initial, advanced: true,
|
||||
strongholdCards: true, tech: options.tech, skill: skill ?? 'suk-graduate', skillOwner: options.skillOwner,
|
||||
face: 'cistern', amount: 3 });
|
||||
const owner = discovery.collector, opponent = discovery.opponent;
|
||||
const kind = options.kind ?? 'arrakeen', hide = options.hide ?? true;
|
||||
const strongholdKey = `${kind}:${territory(kind).sectors[0]}`;
|
||||
const actions: DiscoverySkillsStrongholdsStep[] = [...discovery.actions];
|
||||
const staging = [...discovery.staging];
|
||||
let game = revealClassicDiscoverySkills(discovery, actions);
|
||||
game = advanceClassicDiscoverySkills(game, state => state.phase === 8 && classicDiscoverySkillsClean(state), actions);
|
||||
assert.deepEqual(game.strongholdCards, createStrongholdCards());
|
||||
reserveExcept(game, [`${discovery.parent}:${discovery.parentSector}`]);
|
||||
if (options.holder !== 'unclaimed') place(game, owner, strongholdKey, 1);
|
||||
staging.push('Before original first end-Mentat, conserved all non-source board counters through their own reserves; preserved the three Discovery source forces and placed one ordinary claimant in the named stronghold.');
|
||||
let beforeFirstMentat: Game | undefined, firstMentatStep: DiscoverySkillsStrongholdsStep | undefined;
|
||||
for (let count = 0; game.turn === 1 && count < 200; count++) {
|
||||
const next = nextClassicDiscoverySkillsStep(game), before = structuredClone(game);
|
||||
actions.push(next); game = applyAction(game, next.actor, next.action);
|
||||
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
|
||||
}
|
||||
assert.ok(beforeFirstMentat && firstMentatStep);
|
||||
assert.equal(game.strongholdCards!.claimedTurn, 1);
|
||||
assert.deepEqual(game.strongholdCards!.owners, strongholdControllers(beforeFirstMentat.players, false));
|
||||
assert.equal(game.strongholdCards!.owners[kind], options.holder === 'unclaimed' ? null : owner);
|
||||
game = advanceClassicDiscoverySkills(game, state => state.turn === 2 && state.decision?.kind === 'discoveryEntry' && state.decision.player === owner, actions);
|
||||
const entryWindow = structuredClone(game), entryView = viewGame(game, owner);
|
||||
const entryAction = discoveryEntryMoveAction(entryView, entryView.discoveryEntry!.sources); assert.ok(entryAction);
|
||||
const entryStep: DiscoverySkillsStrongholdsStep = { actor: owner, action: entryAction };
|
||||
actions.push(entryStep); game = applyAction(game, owner, entryAction);
|
||||
const entered = structuredClone(game);
|
||||
// Original Weather Control owns a deterministic safe storm, not a staged dial.
|
||||
const weather = held(game, owner, card => card.effect === 'weather', staging);
|
||||
game = advanceClassicDiscoverySkills(game, state => state.phase === 0 && classicDiscoverySkillsClean(state), actions);
|
||||
const weatherStep: DiscoverySkillsStrongholdsStep = { actor: owner, action: { type: 'card', card: weather.id, amount: 0 } };
|
||||
actions.push(weatherStep); game = applyAction(game, owner, weatherStep.action);
|
||||
game = advanceClassicDiscoverySkills(game, state => state.phase === 5 && state.active === owner && classicDiscoverySkillsClean(state), actions);
|
||||
const shipmentWindow = structuredClone(game);
|
||||
reserveExcept(game, ['cistern:0']);
|
||||
const key = options.nestedBattle ? 'cistern:0' : strongholdKey;
|
||||
const ownerForces = discoverySkillsStrongholdsPlayer(game, owner).forces[key] ?? 0;
|
||||
place(game, owner, key, 8 - ownerForces); place(game, opponent, key, 8);
|
||||
staging.push(`Before original turn-two movement completion, conserved eight ordinary counters per combatant at ${key}; retained three actually entered Cistern forces and original first-Mentat custody.`);
|
||||
const used: string[] = [];
|
||||
const card = (actor: string, kind: Card['kind'] | undefined): string | null => {
|
||||
if (!kind) return null;
|
||||
const physical = held(game, actor, candidate => candidate.kind === kind, staging, used);
|
||||
used.push(physical.id); return physical.id;
|
||||
};
|
||||
const ownerWeapon = card(owner, options.ownerWeapon), ownerDefense = card(owner, options.ownerDefense);
|
||||
const opponentWeapon = card(opponent, options.opponentWeapon), opponentDefense = card(opponent, options.opponentDefense);
|
||||
const untrained = (actor: string) => {
|
||||
const eligible = discoverySkillsStrongholdsPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
|
||||
!game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id));
|
||||
const leader = eligible.find(candidate => candidate.strength === 3) ?? eligible[0]; assert.ok(leader); return leader.id;
|
||||
};
|
||||
const leader = hide ? discovery.leader : untrained(owner);
|
||||
const enemyLeader = untrained(opponent);
|
||||
game = advanceClassicDiscoverySkills(game, state => state.phase === 6 && !!state.active && classicDiscoverySkillsClean(state), actions);
|
||||
const actor = game.active!; assert.ok(actor === owner || actor === opponent);
|
||||
const battleStep: DiscoverySkillsStrongholdsStep = { actor, action: { type: 'chooseBattle', territory: options.nestedBattle ? 'cistern' : kind, target: actor === owner ? opponent : owner } };
|
||||
actions.push(battleStep); game = applyAction(game, actor, battleStep.action);
|
||||
const planActions: DiscoverySkillsStrongholdsStep[] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', dial: options.ownerDial ?? 4, support: options.support ?? 2, leader, weapon: ownerWeapon, defense: ownerDefense } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0, support: options.opponentSupport ?? 0, leader: enemyLeader, weapon: opponentWeapon, defense: opponentDefense } },
|
||||
];
|
||||
const paidShipmentStep: DiscoverySkillsStrongholdsStep = { actor: owner,
|
||||
action: { type: 'ship', territory: 'cistern', sector: 0, amount: 1, elite: 0, allyPayment: 0 } };
|
||||
return { discovery, initial: discovery.initial, setup: discovery.setup, beforeFirstMentat, firstMentatStep,
|
||||
entryWindow, entryStep, entered, shipmentWindow, paidShipmentStep, game, owner, opponent, leader, kind, key, hide, actions, planActions, staging };
|
||||
}
|
||||
export function prepareDiscoverySkillsStrongholdsBattle(fixture: DiscoverySkillsStrongholdsFixture): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
for (let count = 0; count < 160; count++) {
|
||||
let next = nextClassicSkillsStrongholdStep(game);
|
||||
if (!next && game.battle?.preLeader?.closed === false) next = nextClassicDiscoverySkillsStep(game);
|
||||
if (!next) { assert.ok(game.battle && !game.battle.revealed); return game; }
|
||||
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === fixture.owner && fixture.hide;
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
throw Error('Original Discovery skills/Strongholds pre-plan continuation did not open.');
|
||||
}
|
||||
export function revealDiscoverySkillsStrongholdsBattle(fixture: DiscoverySkillsStrongholdsFixture): Game {
|
||||
let game = prepareDiscoverySkillsStrongholdsBattle(fixture);
|
||||
for (const next of fixture.planActions) {
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
|
||||
const continuation = nextClassicDiscoverySkillsStep(game);
|
||||
game = applyAction(game, continuation.actor, continuation.action);
|
||||
}
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
export const finishDiscoverySkillsStrongholdsBattle = finishClassicSkillsStrongholdBattle;
|
||||
@@ -1,10 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer,
|
||||
import { applyAction, createGame, initializeDiscoveryGameForAudit, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { STRONGHOLD_CARDS, type StrongholdId } from '../game/stronghold-cards';
|
||||
import { createStrongholdCards, STRONGHOLD_CARDS, type StrongholdId } from '../game/stronghold-cards';
|
||||
import { finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy';
|
||||
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
|
||||
export type EcazStrongholdOptions = {
|
||||
/** Ready authenticated lobby, or its original fresh native setup. Seats are never replaced. */
|
||||
@@ -13,6 +14,8 @@ export type EcazStrongholdOptions = {
|
||||
ecazForces?: number;
|
||||
allyForces?: number;
|
||||
opponentForces?: number;
|
||||
discoveries?: boolean;
|
||||
tech?: boolean;
|
||||
};
|
||||
export type EcazStrongholdFixture = {
|
||||
initial: Game; afterSetup: Game; beforeMentat: Game; mentatStep: StrongholdFactionsNativeStep;
|
||||
@@ -64,14 +67,21 @@ function orderBlow(game: Game, worm: boolean, position: number): void {
|
||||
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 {
|
||||
function initialize(game: Game, discoveries: boolean): 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); }
|
||||
try {
|
||||
if (discoveries) {
|
||||
game.discoveryEnabled = true;
|
||||
game.strongholdCards ??= createStrongholdCards();
|
||||
return initializeDiscoveryGameForAudit(game);
|
||||
}
|
||||
return initializeStrongholdFactionsGameForAudit(game);
|
||||
}
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
@@ -108,7 +118,8 @@ export function ecazStrongholdFixture(options: EcazStrongholdOptions = {}): Ecaz
|
||||
'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);
|
||||
if (options.tech && !game.techTokens) game.techTokens = createTechTokens();
|
||||
game = initialize(game, options.discoveries === true);
|
||||
}
|
||||
const initial = structuredClone(game);
|
||||
assert.equal(game.strongholdCards!.claimedTurn, 0);
|
||||
|
||||
+27
-9
@@ -50,7 +50,14 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm
|
||||
| 'paired-nexus-skills-tech' | 'paired-nexus-skills-stronghold' | 'paired-nexus-skills-stronghold-tech'
|
||||
| 'e3-nexus-skills' | 'e3-nexus-skills-tech' | 'e3-nexus-skills-stronghold' | 'e3-nexus-skills-stronghold-tech' | 'homeworld-occupation'
|
||||
| 'discovery' | 'discovery-tech' | 'discovery-skills' | 'discovery-skills-tech'
|
||||
| 'discovery-stronghold' | 'discovery-stronghold-tech' | 'discovery-skills-stronghold' | 'discovery-skills-stronghold-tech'
|
||||
| 'discovery-nexus' | 'discovery-nexus-tech' | 'discovery-nexus-stronghold' | 'discovery-nexus-stronghold-tech';
|
||||
const discoveryStrongholdProfile = (profile: string) => profile === 'discovery-stronghold' ||
|
||||
profile === 'discovery-stronghold-tech' || profile === 'discovery-skills-stronghold' ||
|
||||
profile === 'discovery-skills-stronghold-tech';
|
||||
const discoveryTechProfile = (profile: string) => profile === 'discovery-tech' ||
|
||||
profile === 'discovery-skills-tech' || profile === 'discovery-stronghold-tech' ||
|
||||
profile === 'discovery-skills-stronghold-tech';
|
||||
type Rules = 'basic' | 'advanced';
|
||||
|
||||
type Scenario = {
|
||||
@@ -408,6 +415,13 @@ const DISCOVERY_NEXUS_SCENARIOS: readonly Scenario[] =
|
||||
.filter(scenario => (!profile.includes('stronghold') || scenario.rules === 'advanced') &&
|
||||
(!profile.endsWith('tech') || scenario.roster.length >= 3))
|
||||
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 3600 + band * 100 + index })));
|
||||
const DISCOVERY_STRONGHOLD_SCENARIOS: readonly Scenario[] =
|
||||
(['discovery-stronghold', 'discovery-stronghold-tech', 'discovery-skills-stronghold', 'discovery-skills-stronghold-tech'] as const)
|
||||
.flatMap((profile, band) => (profile.includes('skills') ? BASE_SCENARIOS
|
||||
: DISCOVERY_SCENARIOS.filter(scenario => scenario.profile === 'discovery'))
|
||||
.filter(scenario => scenario.rules === 'advanced' &&
|
||||
(!profile.endsWith('tech') || scenario.roster.length >= 3))
|
||||
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 4000 + band * 100 + index })));
|
||||
const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [
|
||||
'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit',
|
||||
];
|
||||
@@ -484,9 +498,11 @@ const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] =
|
||||
].filter(scenario => profile !== 'skills-stronghold-tech' || scenario.roster.length >= 3)
|
||||
.map((scenario, index): Scenario => ({ ...scenario, ordinal: 376 + band * 100 + index, profile })));
|
||||
const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech';
|
||||
const discoverySkillsProfile = (profile: string) => profile === 'discovery-skills' || profile === 'discovery-skills-tech';
|
||||
const discoverySkillsProfile = (profile: string) => profile === 'discovery-skills' || profile === 'discovery-skills-tech' ||
|
||||
profile === 'discovery-skills-stronghold' || profile === 'discovery-skills-stronghold-tech';
|
||||
const skillProfile = (profile: string) => discoverySkillsProfile(profile) || e3NexusSkillsProfile(profile) || pairedNexusSkillsModuleProfile(profile) || nexusSkillsModuleProfile(profile) || profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile);
|
||||
const discoveryProfile = (profile: string) => profile === 'discovery' || profile === 'discovery-tech';
|
||||
const discoveryProfile = (profile: string) => profile === 'discovery' || profile === 'discovery-tech' ||
|
||||
profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech';
|
||||
const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' &&
|
||||
scenario.roster.includes('moritani') &&
|
||||
(scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile));
|
||||
@@ -530,6 +546,7 @@ function usage() {
|
||||
'[--seed UINT32] [--profile all|base|discovery|discovery-tech|discovery-skills|discovery-skills-tech|discovery-nexus|discovery-nexus-tech|discovery-nexus-stronghold|discovery-nexus-stronghold-tech|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|homeworld-occupation|nexus|nexus-skills|paired-nexus-skills|nexus-skills-tech|nexus-skills-stronghold|nexus-skills-stronghold-tech|paired-nexus-skills-tech|paired-nexus-skills-stronghold|paired-nexus-skills-stronghold-tech|e3-nexus-skills|e3-nexus-skills-tech|e3-nexus-skills-stronghold|e3-nexus-skills-stronghold-tech|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-nexus-stronghold-tech|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' +
|
||||
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
|
||||
'[--resume FAILED_GAME.json]\n' +
|
||||
'Advanced Discovery Stronghold profiles: discovery-stronghold, discovery-stronghold-tech, discovery-skills-stronghold, discovery-skills-stronghold-tech; native or classic-skill original setup, optional Tech3+, original held claims, no Nexus/Homeworlds/variants/previews. Earlier defaults and seeds are unchanged.\n' +
|
||||
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' +
|
||||
'\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.'
|
||||
@@ -669,8 +686,8 @@ function freshGame(scenario: Scenario) {
|
||||
player.ready = true;
|
||||
}
|
||||
if (scenario.ecazTreachery) game.ecazTreachery = true;
|
||||
if (scenario.profile === 'discovery-tech' || scenario.profile === 'discovery-skills-tech' || scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile) || nexusTechProfile(scenario.profile)) game.techTokens = createTechTokens();
|
||||
if (strongholdSkillProfile(scenario.profile) || nexusStrongholdProfile(scenario.profile)) game.strongholdCards = createStrongholdCards();
|
||||
if (discoveryTechProfile(scenario.profile) || scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile) || nexusTechProfile(scenario.profile)) game.techTokens = createTechTokens();
|
||||
if (discoveryStrongholdProfile(scenario.profile) || strongholdSkillProfile(scenario.profile) || nexusStrongholdProfile(scenario.profile)) game.strongholdCards = createStrongholdCards();
|
||||
if (scenario.profile === 'homeworld' || scenario.profile === 'homeworld-occupation' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus')
|
||||
game.homeworlds = { custody: null };
|
||||
if (scenario.profile === 'paired-nexus-skills' || scenario.profile === 'nexus-skills' || scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
|
||||
@@ -745,15 +762,15 @@ function resumedGame(path: string) {
|
||||
game.mentatQuestionPreview
|
||||
)
|
||||
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
|
||||
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS].find(
|
||||
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS].find(
|
||||
(candidate) =>
|
||||
(discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery &&
|
||||
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
|
||||
(candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
|
||||
(candidate.profile === 'homeworld-occupation') === !!game.homeworldOccupationPreview &&
|
||||
skillProfile(candidate.profile) === !!game.leaderSkills &&
|
||||
(candidate.profile === 'discovery-tech' || candidate.profile === 'discovery-skills-tech' || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
|
||||
(nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
|
||||
(discoveryTechProfile(candidate.profile) || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
|
||||
(discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
|
||||
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
|
||||
assassinationScenario(candidate) === !!game.moritaniAssassinatePreview &&
|
||||
(candidate.profile === 'ecaz-occupy' || ((nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview &&
|
||||
@@ -973,10 +990,11 @@ async function main() {
|
||||
throw new Error('Stronghold samples require Advanced rules.');
|
||||
if ((profile === 'skills-tech' || profile === 'skills-stronghold-tech') && players === 2)
|
||||
throw new Error('Leader Skills with Tech Tokens samples require three through six players.');
|
||||
if ((profile === 'discovery-tech' || profile === 'discovery-skills-tech' || profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold-tech') && players === 2)
|
||||
if ((discoveryTechProfile(profile) || profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold-tech') && players === 2)
|
||||
throw new Error('Discovery with Tech Tokens requires three through six players.');
|
||||
const resume = values.resume ? resumedGame(values.resume) : null;
|
||||
const requestedSamples = discoveryNexusProfile(profile) ? DISCOVERY_NEXUS_SCENARIOS
|
||||
const requestedSamples = discoveryStrongholdProfile(profile) ? DISCOVERY_STRONGHOLD_SCENARIOS
|
||||
: discoveryNexusProfile(profile) ? DISCOVERY_NEXUS_SCENARIOS
|
||||
: discoverySkillsProfile(profile) ? DISCOVERY_SKILLS_SCENARIOS
|
||||
: discoveryProfile(profile) ? DISCOVERY_SCENARIOS
|
||||
: profile === 'homeworld-occupation' ? HOMEWORLD_OCCUPATION_SCENARIOS
|
||||
|
||||
@@ -41,8 +41,8 @@ async function main() {
|
||||
'\nHomeworld-occupation opts a fresh ready 2–6-seat Basic/Advanced lobby with original selected faction decks into occupied bank Collection/immediate ally sharing, Kaitain/Junction/Richese percentage receipts, completed total Southern Hemisphere Collection, Caladan shared inspection, displaced Ix pool control, Richese card-only selection with native seller terms, Giedi receiver choice and occupied native defenses. Advanced follows the authorized supplied rulebook p22: sole foreign qualification, retained until its last force leaves; later contests/native repopulation do not expire it, and a new sole arrival after departure has a new source epoch. Basic retains unresolved lifecycle guards. Native income/peek cancellation remains distinct from printed occupied effects. Other borrowed/exceptional powers, overlays, public starts and old-game conversion remain separate.',
|
||||
'\nWhen native CHOAM is seated, this fresh entry additionally marks original occupied Tupile hand slots and owner-selected normal-limit cleanup. The occupier/reciprocal ally gain one validated slot through existing capacity consumers; Advanced departure and ally removal queue the original lost-holder cleanup. Private selected-ID discards preserve current phase/opening and remaining movement, with no new card or fee. Known occupation suppresses new native low-intelligence queries; proved Advanced expiry restores eligibility without resetting historical answers/usage. Basic unknown cases retain source guards. Unmarked played profiles are not upgraded.',
|
||||
'\nWhen native Ecaz is seated, this original fresh entry separately marks its printed high Homeworld alliance victory: current native seven-plus, one actual joint stronghold, two distinct other native factions with proved current occupied holdings. It adds no ordinary stronghold/Tech points, pays no reward and changes no Duke authority. Public progress remains prospective until original Mentat; qualifying members join original winners before BG prediction/final fallback. Unmarked played/public profiles remain unchanged.',
|
||||
'\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining unused original Tech3–6. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/HW/Nexus/Strongholds, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.',
|
||||
'\nLeader-skills --discoveries explicitly composes fresh classic/base33/all14 Skills with original Discovery7/8 at2–6 seats Basic/Advanced. Unused original Tech requires3–6. Original setup, typed free entry, paid nested routes, carried fixed-three movement, stash limits, trained battles and Bureaucrat invoices use existing controls. Native families, Nexus/Strongholds/HW, Banker/Mentat previews, pending rulings, public starts and save conversion are not opened.',
|
||||
'\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining unused original Tech3–6 and Advanced Strongholds2–6. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits, end-Mentat held claims and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/HW/Nexus, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.',
|
||||
'\nLeader-skills --discoveries explicitly composes fresh classic/base33/all14 Skills with original Discovery7/8 at2–6 seats Basic/Advanced. Unused original Tech requires3–6; original Strongholds requires Advanced2–6. Original setup, end-Mentat held claims, typed free entry, paid nested routes, carried fixed-three movement, stash limits, trained battles and Bureaucrat invoices use existing controls. Native families, Nexus/HW, Banker/Mentat previews, pending rulings, public starts and save conversion are not opened.',
|
||||
'\nNexus --discoveries explicitly composes fresh classic/base33/all12 Nexus with original Discovery7/8 at2–6 seats Basic/Advanced. Unused Tech requires3+; optional Strongholds require Advanced; both3+. Actual Great Maker/worm/vote/reserve ride, original settled-alliance end-Spice closing card deal and end-Mentat card ownership/next entry remain. Source-clear revival/tariff/battle effects reuse original handlers. Skills/HW/native families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.',
|
||||
);
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user