Compose classic Discovery skills and Advanced Orgiz collection

This commit is contained in:
Quintine committed 2026-10-05 17:35:51 +11:00
1 parent 1947e354ce
commit c12b463439
21 files changed
+1107 -83

No files matched your search

+8 -4
View File
@@ -22,7 +22,7 @@ const tokenRules: Record<DiscoveryTokenFace, string> = {
ornithopter: 'Take this token. On a later turn, you may spend it to move up to three territories instead of your usual allowance for one movement action. Remove the token after that action.',
};
function DiscoveryRules({ face }: { face: DiscoveryTokenFace }) {
function DiscoveryRules({ face, advanced }: { face: DiscoveryTokenFace; advanced: boolean }) {
const definition = DISCOVERY_TOKEN_BY_ID[face];
return (
<details className="rounded-lg border border-[#a88b60]/40 bg-[#171d18]">
@@ -30,7 +30,9 @@ function DiscoveryRules({ face }: { face: DiscoveryTokenFace }) {
Read {definition.name} rules
</summary>
<div className="space-y-3 border-t border-[#a88b60]/40 px-3 py-4 text-base leading-7 text-[#eee5d2]">
<p>{tokenRules[face]}</p>
<p>{face === 'orgiz-processing-station' && advanced
? 'During Spice Collection, an occupant takes one of the collected spice each time collection occurs in a territory containing spice.'
: tokenRules[face]}</p>
{definition.kind === 'location' && (
<>
<p>This location is a separate territory inside the surrounding territory. Revealing it leaves it empty. Normal ground movement enters the surrounding territory before entering the location; shipment uses stronghold prices.</p>
@@ -79,7 +81,7 @@ export function DiscoveryPanel({ game, act, busy }: Props) {
</div>
</div>
{token.territory && <p>In {territory(token.territory).name} · sector {token.sector}</p>}
{token.face && <DiscoveryRules face={token.face} />}
{token.face && <DiscoveryRules face={token.face} advanced={game.advanced} />}
{(inspect || reveal) && (
<div className="flex flex-wrap gap-2">
{inspect && <Button className="game-action min-h-11 whitespace-normal" disabled={disabled}
@@ -92,7 +94,9 @@ export function DiscoveryPanel({ game, act, busy }: Props) {
);
})}
</div>
<p className="fine">Inspection and reveal are optional. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype. Jacurutu income, Testing Station storm choices and Shrine card conversion are prototyped. A sole Orgiz occupant takes one spice per board deposit collected by a rival. Contested Orgiz, unresolved Ecaz shared lots, mixed-force Jacurutu rewards and contested Cistern/Testing Station benefits remain pending.</p>
<p className="fine">Inspection and reveal are optional. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype. Jacurutu income, Testing Station storm choices and Shrine card conversion are prototyped. {game.advanced
? 'A sole Orgiz occupant takes one spice for each territory where one rival collected board spice.'
: 'Basic keeps the provisional one-spice transfer per positive board deposit collected by one rival.'} Contested Orgiz, different or shared collectors, mixed-force Jacurutu rewards and contested Cistern/Testing Station benefits remain pending; uncertain transfers do not block ordinary Collection.</p>
</section>
);
}
+36
View File
@@ -17,6 +17,42 @@ open mode gates.
## Current checkpoint and work
**Advanced Orgiz / classic Discovery Skills — bounded Development Verified:**
the supplied Advanced rulebook p24 uses a territory-Collection trigger.
Advanced transfers one collected spice per territory with a unique rival payer;
Basic keeps the provisional per-positive-deposit interpretation. Shared,
contested or inconsistent payers withhold only uncertain transfers. Original
bank income, collected/desert facts and paid continuations remain.
Fresh explicit `leader-skills --discoveries` composes classic/base33/all14
Skills with Discovery7/8, optional original Tech3–6, Basic/Advanced2–6.
Trained physical entry, original nested invoices, separate Planetologist/fixed3
movement, Harkonnen8-card stash, Suk rescue/Tech reward and distinct-party
Bureaucrat diversion use original controls and four minimal legal policies.
Native skill families, Nexus/Strongholds/HW and Banker/Mentat previews stay
outside this entry; no public start or played-game conversion.
Affected rule/control/reference **75 cases**, types/lint/build and actual
programmes pass. Two genuine six-seat Basic/Advanced Tech games finish
**1,578 accepted / 1,578 attempts / 42 JSON** on unchanged946335ef tree.
Counts prove continuation, not natural activation of every new effect.
Actual **PCDX5J8M** CLI starts v7; human natural Warmaster/Staban assignment v8.
Human Cistern inspect/reveal v10/v11 keeps it empty; original turn-two entry v12,
human three-counter entry **v13** costs no spice/reserves/movement. Atv14 the
protected counters survive and ordinary shipment is available. Original human
one-force shipment/Guild allowance reach **v16**, spice3→2/reserves12→11,
four Cistern counters and unchanged Warmaster; refresh fits390px.
Actual **FCVCEA3R** final movement ending opens **v8/Collection** automatically.
Controlled conserved positions collect5 across three deposits/two territories:
owner11→13/payer's own view10→8; two named-territory notices preserve bank
income and ordinary facts. Expanded Advanced guidance/refresh fit390px.
Tabs released; no unrelated record was written.
[Source, prototype bounds and evidence](DISCOVERY_PROTOTYPE.md#classic-leader-skills-and-advanced-orgiz--5-october-2026).
Full combinations, pending rulings, assurance and deployed acceptance remain.
**Native Discovery / optional original Tech — bounded Development Verified:**
fresh classic, selected E1/E2 or standalone Ecaz OR Moritani Basic/Advanced
profiles retain original setup, decks/cache, seven Discovery Spice Cards and
+111 -8
View File
@@ -118,6 +118,108 @@ not written; no reset, public activation, save conversion or deployed claim.
## Classic Leader Skills and Advanced Orgiz — 5 October 2026
An explicit fresh `leader-skills --discoveries` entry composes classic factions,
the original base 33-card deck, all fourteen physical Leader Skills, seven
Discovery Spice Cards and eight tokens. Basic/Advanced supports two through six
seats; optional unused original Tech requires at least three. The existing
skill-first choices, trained-disc posture and original faction setup remain.
Native families, Nexus, Strongholds, Homeworlds and separate Banker/Mentat
previews are not admitted by this particular entry. It does not redeal a
previously initialized Discovery or upgrade a played game.
```sh
node --import tsx tools/start-prototype.ts --profile leader-skills --discoveries --db PATH --room CODE --version NUMBER --out /private/new-directory
node --import tsx tools/faction-games.ts --profile discovery-skills-tech --rules both --players 6 --out /private/new-directory
```
Physical next-turn entry spends neither the ordinary movement allowance nor
spice and does not change the trained disc. Paid nested shipment retains its
original invoice. Planetologist movement and the carried Discovery Ornithopter
are separate alternatives: the latter replaces one movement with fixed range
three rather than adding a skill modifier or action. Original Harkonnen
eight-card capacity, owner-selected stash discard, normal/skilled Suk rescue
and subsequent original Tech reward use existing producers. A distinct
Bureaucrat holder may divert the printed two spice from an eligible five-spice
single-payer shipment without changing the payer's invoice or physical arrival.
Existing modified-defense, Atreides Suk/KH and private split-source guards remain.
### Authorized Advanced Collection wording
The supplied `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`, **physical page 24**,
says Orgiz steals one collected spice **each time collection occurs in a
territory containing spice**, rather than the publisher's older per-blow text.
For Advanced, the quote maps an original territory's positive Collection to
one transfer, even when multiple collected source keys identify the same
unique rival payer. A different, missing, contested or shared payer withholds
that territory's transfer; other territories and ordinary Collection proceed.
An owner's own Collection is not theft. This is the explicit application of
the authorized Advanced trigger, not a newly discovered publisher ruling on
contested ownership or shared allocation.
Basic retains its recorded provisional one-transfer-per-positive-deposit
interpretation. The context requires its existing public `advanced` boolean:
missing mode is not silently treated as either source. Effect `location`
remains one actual deterministic collected key; Advanced notices name the
territory. Original collected/desert facts, remaining map spice, bank-income
prefix and already-paid phase continuations are not rewritten or replayed.
Original programs with explicitly controlled, conserved counter/spice positions
show one rival collecting five spice across three deposits in two territories:
**Advanced transfers two; Basic transfers three**. These are controlled board
positions, not naturally produced card history. The trained programme moves
three actual Guild counters into Cistern free, then ships one additional
reserve there for one spice, reaching four physical counters.
Two genuine six-seat Basic/Advanced Skills+Discovery+Tech games finish
**1,578 accepted / 1,578 attempted / 42 JSON continuations** on unchanged tree
`946335ef77745f086cd9bd07bfa405c46c06785a3aae7a684a8e244ce2da4ee5`.
That proves original continuation, not natural activation of every new effect,
complete Skills/Discovery combinations or strategic calibration. Focused
consumers live in `tests/discovery-orgiz-advanced-runtime.test.ts` and
`tests/discovery-classic-skills-runtime.test.ts`; source cases remain in
`tests/discovery-collection.test.ts`.
### Actual human controls and targeted checks
The frozen affected rule/control/reference union passes **75 cases**, with
TypeScript/lint and the actual build. Named owner-defined quote/driver contracts
replace newly encountered concrete-function return-type coupling.
Actual fresh **PCDX5J8M v6** starts through the existing backed-up
`leader-skills --discoveries` CLI at **v7/setup**. Guild's natural physical
offers are Warmaster and Prana Bindu Adept; human assignment chooses Warmaster
on Staban Tuek at **v8**. The subsequent original programme retains those
already-dealt choices, controlling only four unplayed Spice Cards and the
actual token lottery. Human Cistern inspection/reveal reaches **v10/v11** while
the location remains empty.
Six original phase actions expose turn-two entry at **v12**, before the storm.
Human **Move 3 forces inside** reaches **v13**, preserves spice3/reserves12,
movement0 and the existing shipment flag, and keeps Warmaster. Twenty-two
original controls reach movement at **v14**: the three Cistern counters survive
the storm and ordinary shipment is available. Human reserve shipment plus
original Guild **Allow shipment** reach **v16**, spice **3→2**, reserves
**12→11**, four Cistern counters and unchanged training. Refresh fits **390px**.
Actual **FCVCEA3R v6** retains its authenticated Atreides/Guild/Fremen identities.
Original setup/inspection/reveal and phase controls plus explicitly conserved
counter/spice positions stop before the final movement ending at **v7**.
Human **Finish shipment & movement** opens automatic Collection at **v8**:
Guild's ordinary five collected spice remain factual; Atreides receives
**11→13** and Guild's own view shows **10→8**, with two territory-labelled Orgiz
notices. Ordinary Fremen stronghold income is still logged. Rival budgets remain
masked in other seats; each balance was read from its own controlled QA seat.
The expanded rule panel and footer show Advanced territory counting and pending
mixed/contested limits. Refresh retains v8 at390px. Managed tabs released.
Only these dedicated QA records were written. The failed throwaway attempt to
wait in an empty Battle phase was corrected to stop before the original final
movement ending: ordinary automation skips empty battles rather than adding a
new confirmation. No clock, wallet, paid quote, public gate or existing-game
conversion was fabricated.
## Connected behavior
- **Great Maker:** ordinary worm consequences resolve first. Every faction then
@@ -221,12 +323,13 @@ the contested bonus. The publisher's [Ecaz & Moritani rulebook, page 13](https:/
says an Orgiz occupant steals one spice of each collected blow. Its bundled
Q&A does not define a stacked pile as one or several blows; searches of the
publisher/designer and community rules pages found no explicit clarification.
The bounded implementation treats each positive board deposit collected as one
observable blow, transfers one spice from its unique collector, and does not
take spice from the occupant itself. This is an inference, not an official
ruling. Two fighters occupying Orgiz withhold all theft. An Ecaz shared lot
with no assigned collector withholds theft from its entire territory; independent
deposits elsewhere and ordinary collection proceed.
Basic's bounded implementation treats each positive board deposit as one
observable blow, transfers one spice from its unique collector and does not
take spice from the occupant itself. That remains an inference, not an official
ruling. Advanced now follows the supplied rulebook's territory-Collection
trigger described above. Two fighters occupying Orgiz still withhold theft.
An Ecaz shared lot or inconsistent collected payers withholds that territory's
transfer; independent territories and ordinary Collection proceed.
The Testing Station's two-occupant case leaves ordering and cumulative adjustment
unresolved. The prototype opens only the sole-occupant path and continues an
@@ -316,8 +419,8 @@ The 187 opening saved rooms remain preserved; no server restart was needed.
1. Resolve contested Cistern/Orgiz/Testing Station benefits, Ecaz shared-lot
Orgiz allocation and Jacurutu's mixed physical dial allocation; complete
remaining controls and effects when settled. The per-collected-deposit
Orgiz interpretation remains provisional.
remaining controls and effects when settled. Basic's per-collected-deposit
Orgiz interpretation remains provisional; the Advanced count is source-cut over.
2. Integrate combined modules and complete games, then refine interaction,
strategy and presentation coverage before opening normal start gates.
3. Use the existing prototype starter and saved-room verification for further
+37
View File
@@ -1,5 +1,42 @@
# Dune implementation status
## 5 October 2026 — Advanced Orgiz and classic Discovery Skills
Authorized supplied Advanced p24 territory-Collection wording now groups actual
positive source keys into one transfer per territory and unique foreign payer.
Basic keeps the existing provisional deposit mapping. Mixed/shared/contested
sources leave only their transfer unpaid; ordinary facts, bank prefix and
already-paid continuation remain unchanged. No new publisher ruling is claimed.
Explicit fresh classic `leader-skills --discoveries` preserves base33/all14
Skills/Discovery7/8 and optional untouched Tech3–6, Basic/Advanced2–6.
Original trained entry, nested tariff, independent Planetologist/fixed-three
routes, Harkonnen stash capacity, Suk/Tech ordering and Bureaucrat5→bank2
diversion reuse original consumers. Other overlays/native skill profiles and
Banker/Mentat previews remain separate. No public activation or save conversion.
Targeted rule/control/reference75, TypeScript/lint/build and actual programmes
pass. Basic3 versus Advanced2 transfers preserve ordinary five-spice collection
in controlled conserved positions. Two genuine six-seat games finish
1,578/1,578/42 JSON on unchanged tree
`946335ef77745f086cd9bd07bfa405c46c06785a3aae7a684a8e244ce2da4ee5`;
no natural-every-effect claim. Newly encountered concrete-function return-type
coupling uses named owner-defined CollectionQuote/DiscoveryCollectionQuote/
SkillsTechBattleStep contracts.
Actual PCDX5J8M CLIv7, natural human Warmaster/Stabanv8, Cistern inspect/reveal
v10/v11 and three-counter free entryv13 preserve training/resources. Original
protected turn-two continuationv14 permits a one-force paid shipment; human
Guild allowancev16 gives spice3→2/reserves12→11/four Cistern counters. Refresh390px.
Actual FCVCEA3R final movement endingv8 automatically collects five and transfers
two: owner11→13/payer-own-view10→8, two territorial notices and bank prefix intact.
The failed throwaway empty-Battle wait was corrected to the original automatic
phase path, not a new confirmation. Tabs released; no unrelated game write.
[Canonical source/control evidence](DISCOVERY_PROTOTYPE.md#classic-leader-skills-and-advanced-orgiz--5-october-2026).
Complete combinations, pending contested/shared/mixed rulings, assurance and
deployed acceptance remain open.
## 5 October 2026 — native Discovery with original Tech
Fresh classic, selected E1/E2 and standalone Ecaz OR Moritani Discovery entry
+2 -2
View File
@@ -236,7 +236,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Paired Nexus / Skills / Tech / Strongholds | Both Ixians+Tleilaxu OR CHOAM+Richese plus classics and one original family deck/all14/all12 preserve selected unused Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced through the original fresh skill/Nexus entry. Typed rescue precedes equal actual Cyborg Tank substitution, original card cleanup/mandatory winner Tech precedes Face Dance; held subsidy changes debit, not committed support, and reward/custody stay original winner. Signed marker pair/one invoice/native industry retains original timing. Bounded runtime/program/phone/genuine-game evidence does not certify full modules; mixed families/E3/HW/previews/companions/native Nexus free-return rulings/public start/save conversion stay guarded. | [Paired combined contract and evidence](NEXUS_CARD_RULES.md#paired-nexus-skills-and-original-techstrongholds--5-october-2026) |
| 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) |
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Fresh classic, selected E1/E2 or standalone Ecaz OR Moritani profiles may retain original Tech3..6. Native setup, physical typed entry, revealed-only nested No-Field custody/materialization, CHOAM stash capacity and original Moritani assassination use existing source handlers. Revealed locations are separate; only Jacurutu counts toward stronghold victory. Existing bounded Cistern/Orgiz/Ornithopter/Jacurutu/Testing Station/Shrine paths remain prototypes; contested/shared/mixed cases and public starts are not resolved. | [Component authority](DISCOVERY_COMPONENTS.md), [native source composition](DISCOVERY_PROTOTYPE.md#native-setup-and-original-consumers--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) |
| Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) |
@@ -287,7 +287,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Occupied Homeworlds | Entitlement/expiry after departure, replacement or contest; unique powers and shared income. The first-occupier/current-occupier question was already sent. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md) |
| Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. Tleilaxu Secret Ally force accounting, leader slot/eligibility and force-only use remain unresolved after requested publisher/designer source searches; do not infer answers from native Tleilaxu revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries), [Tleilaxu source search](NEXUS_TLEILAXU_RULES.md#source-search-follow-up) and individual faction source/runtime documents |
| Moritani Atomics after alliance change | The publisher's persistent −1 hand-limit text names Moritani and “your ally,” but not whether the penalty follows a later ally, remains on the original ally or affects both. Requested online source searches found no clarification; the alliance-change case remains gated. | [Terror source boundary](MORITANI_TERROR_RULES.md#material-gaps-and-ordinary-compositions) |
| Orgiz collection | The [publisher rulebook, page 13](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf) says an occupant steals one spice of each collected blow, but its Q&A and searched designer/community sources do not define stacked board piles. Choose one transfer per positive collected board deposit as the observable first version, from its unique rival collector; this is an inference, not a publisher ruling. Contested Orgiz occupancy and Ecaz shared lots withhold only the uncertain transfer while ordinary collection continues. | [Discovery interpretation](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Orgiz collection | Basic retains the publisher-p13 per-blow ambiguity and provisional one-transfer-per-positive-deposit mapping. Authorized supplied Advanced rulebook physical24 instead says each time Collection occurs in a territory containing spice: Advanced applies one transfer per actual positive territory Collection, only to a unique rival payer. Multiple source keys do not add transfers. Contested ownership, inconsistent/different payers and Ecaz shared lots still withhold only uncertain transfers; ordinary Collection proceeds. No new publisher ruling on those pending cases is claimed. | [Advanced source cutover and Basic boundary](DISCOVERY_PROTOTYPE.md#authorized-advanced-collection-wording) |
| Jacurutu physical dial | The user question asks whether the losing player should choose the physical normal/elite forces represented by its dial. When legal Advanced allocations produce different rewards, income remains explicitly unpaid; numeric wheel subtraction is not a ruling. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Testing Station contest/timing | Only sole non-advisor occupation is prototyped. Two-occupant priority/stacking remains unresolved; opening after the ordinary storm-card window is an explicit source-timing inference. Weather Control is excluded by the printed text. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| CHOAM Secret Ally battle timing | The printed after-victory inspection is optional and the Nexus identity stays secret. The connected prototype chooses a uniform use-or-continue window for every winner when Nexus is enabled and CHOAM absent; nonholders pass, while a holder may decide after the result. This privacy-preserving prompt is a product inference, not publisher text. A holder-only pause would reveal custody and a precommitment would remove the printed choice. Ordinary Karama cancellation remains unestablished by retrieved sources; no response is invented. | [After-victory inspection boundary](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) |
+2 -1
View File
@@ -1,4 +1,5 @@
import { classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile';
import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile';
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival';
export type BureaucratPaymentSource = {
event: string;
@@ -38,7 +39,7 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea
return (noOtherLeaderSkillModules(game) &&
(!game.expansions.length ||
(game.expansions.length === 1 && game.expansions[0] === 'choam') ||
nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game);
nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game);
}
export function bureaucratUsed(
used: readonly BureaucratPaymentUse[],
+64 -38
View File
@@ -18,6 +18,7 @@ type DiscoveryCollectionSeat = Pick<Player, 'id' | 'faction' | 'advisors'> &
ForcePresence;
export type DiscoveryCollectionContext = MobileBoard & {
advanced: boolean;
order: readonly string[];
players: readonly DiscoveryCollectionSeat[];
/** Deposits before ordinary collection. */
@@ -101,15 +102,58 @@ function occupants(
);
}
/** Public effective presence only; an ambiguous source cannot identify a payer. */
function collectorAt(
context: DiscoveryCollectionContext,
key: string,
released: readonly AdvisorRelease[],
): DiscoveryCollectionSeat | undefined {
const { territory, sector } = splitLocation(key);
let collector: DiscoveryCollectionSeat | undefined;
for (const player of context.players) {
if (!isCollectingFighter(player, territory, released)) continue;
const marker = player.noField?.deployed?.location;
if (
(player.forces[key] ?? 0) <= 0 &&
!(marker?.territory === territory && marker.sector === sector)
) continue;
if (collector) return undefined;
collector = player;
}
return collector;
}
function transferOrgiz(
key: string,
collector: DiscoveryCollectionSeat | undefined,
ownerReceipt: DiscoveryBaseCollectionReceipt,
receipts: DiscoveryBaseCollectionReceipt[],
effects: DiscoveryCollectionQuote['effects'],
) {
if (!collector || collector.id === ownerReceipt.player) return;
const collectorReceipt = receipts.find(entry => entry.player === collector.id)!;
requireCollection(
collectorReceipt.balance >= 1 &&
Number.isSafeInteger(ownerReceipt.balance + 1),
'Orgiz theft would invalidate a player spice balance.',
);
collectorReceipt.balance -= 1;
ownerReceipt.balance += 1;
effects.push({
kind: 'orgiz', player: ownerReceipt.player, from: collector.id,
location: key, amount: 1, source: 'player',
});
}
/**
* Compose sole-occupant Cistern income and a bounded Orgiz transfer over the
* canonical ordinary collection quote. The ordinary `collected` and `desert`
* fields remain factual: Cistern is bank income; Orgiz transfers player spice.
*
* The publisher does not specify whether a stacked physical pile is one or
* several blows. One positive collected board deposit is treated as one
* observable blow. Contested location benefits and unresolved shared lots
* withhold only their uncertain effect; they never block ordinary collection.
* Advanced uses the authorized revised rulebook's territory Collection event;
* Basic retains the provisional one-positive-deposit interpretation. Contested
* sources, different territory payers and shared lots withhold only their
* uncertain effect; they never block ordinary collection.
*/
export function quoteDiscoveryCollection(
context: DiscoveryCollectionContext,
@@ -117,6 +161,7 @@ export function quoteDiscoveryCollection(
): DiscoveryCollectionQuote {
requireCollection(
record(context) &&
typeof context.advanced === 'boolean' &&
Array.isArray(context.players) &&
context.players.length > 0 &&
Array.isArray(context.order) &&
@@ -225,49 +270,30 @@ export function quoteDiscoveryCollection(
if (orgiz.length === 1) {
const owner = orgiz[0];
const ownerReceipt = receipts.find((entry) => entry.player === owner.id)!;
const transfers = context.advanced
? new Map<string, { location: string; collector: DiscoveryCollectionSeat | undefined }>()
: undefined;
for (const key of sourceKeys) {
if (context.spice[key] <= collection.spice[key]) continue;
const { territory, sector } = splitLocation(key);
const { territory } = splitLocation(key);
if (
collection.shared.some(
(lot) => lot.amount > 0 && lot.territory === territory,
)
) continue;
let collector: DiscoveryCollectionSeat | undefined;
let contested = false;
for (const player of context.players) {
if (!isCollectingFighter(player, territory, collection.released)) continue;
const marker = player.noField?.deployed?.location;
if (
(player.forces[key] ?? 0) <= 0 &&
!(marker?.territory === territory && marker.sector === sector)
) continue;
if (collector) {
contested = true;
break;
}
collector = player;
const collector = collectorAt(context, key, collection.released);
if (!transfers) {
transferOrgiz(key, collector, ownerReceipt, receipts, effects);
continue;
}
if (contested || !collector || collector.id === owner.id) continue;
const collectorReceipt = receipts.find(
(entry) => entry.player === collector.id,
)!;
requireCollection(
collectorReceipt.balance >= 1 &&
Number.isSafeInteger(ownerReceipt.balance + 1),
'Orgiz theft would invalidate a player spice balance.',
);
collectorReceipt.balance -= 1;
ownerReceipt.balance += 1;
effects.push({
kind: 'orgiz',
player: owner.id,
from: collector.id,
location: key,
amount: 1,
source: 'player',
});
const pending = transfers.get(territory);
if (!pending) transfers.set(territory, { location: key, collector });
else if (pending.collector?.id !== collector?.id)
pending.collector = undefined;
}
if (transfers)
for (const pending of transfers.values())
transferOrgiz(pending.location, pending.collector, ownerReceipt, receipts, effects);
}
return { receipts, effects };
+24
View File
@@ -1,5 +1,6 @@
import { FACTIONS } from './catalog';
import type { FactionModuleProfile } from './faction-module-profile';
import type { LeaderSkillProfile } from './leader-skill-profile';
/** Public unused-lobby admission only. The original initializer owns physical
* decks, tokens, leader/force inventories and all mandatory setup choices. */
@@ -43,3 +44,26 @@ 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.
* 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.homeworlds || game.nexusCards || game.ecazTreachery || game.semutaPreview ||
game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview ||
game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview ||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
game.nexusHarkonnenBetrayalPreview || game.mentatQuestionPreview ||
game.spiceBankerIncomePreview) return false;
for (let i = 0; i < players.length; i++) {
for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
let classic = false;
for (const faction of FACTIONS)
if (faction.id === players[i].faction) { classic = faction.expansion === 'base'; break; }
if (!classic) return false;
}
return true;
}
+15 -7
View File
@@ -1,6 +1,6 @@
import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile';
import { nativeFactionTechProfile } from './faction-module-profile';
import { discoveryModeSupported, discoveryModuleProfile } from './discovery-module-profile';
import { classicDiscoveryLeaderSkillsProfile, discoveryModeSupported, discoveryModuleProfile } from './discovery-module-profile';
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
@@ -299,10 +299,12 @@ import {
quoteBattlePhaseAdvance,
BoardResolutionError,
type AdvisorRelease,
type CollectionQuote,
} from './board-resolution-quote';
import {
quoteDiscoveryCollection,
DiscoveryCollectionError,
type DiscoveryCollectionQuote,
} from './discovery-collection';
import {
quoteMovementCancellation,
@@ -9514,9 +9516,14 @@ export function initializeDiscoveryGameForAudit(state: Game): Game {
/** Gated development setup; never dispatched by a player action or room route. */
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.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
}
const g = structuredClone(state);
g.leaderSkills = createLeaderSkills(random);
const initialized = initializeSetupGameForAudit(g, false, classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false, false, true,
const initialized = initializeSetupGameForAudit(g, false, classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false, classicDiscoveryLeaderSkillsProfile(g), true,
g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g));
if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized);
return initialized;
@@ -9553,9 +9560,10 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
const g = structuredClone(state);
requireFreshSetup(g, homeworlds || nexus || ix || factions);
const skillStronghold = leaderSkills && strongholdLeaderSkillsProfile(g);
const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold);
const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold || classicDiscoveryLeaderSkillsProfile(g));
const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g);
const discoveryComposition = discovery && discoveryModuleProfile(g);
const discoveryComposition = discovery && (discoveryModuleProfile(g) ||
(leaderSkills && classicDiscoveryLeaderSkillsProfile(g)));
const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) ||
(leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g))));
requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) &&
@@ -18704,10 +18712,10 @@ function projectedGrummanCollection(g: Game, player: string) {
}
function commitCollection(
g: Game,
quote: ReturnType<typeof quoteSpiceCollection>,
quote: CollectionQuote,
canceled = false,
) {
let discovery: ReturnType<typeof quoteDiscoveryCollection>;
let discovery: DiscoveryCollectionQuote;
try {
discovery = quoteDiscoveryCollection(g, quote);
} catch (error) {
@@ -18746,7 +18754,7 @@ function commitCollection(
else
log(
g,
`${faction(p.faction).name} took ${effect.amount} spice from ${faction(getPlayer(g, effect.from).faction).name} through Orgiz Processing Station.`,
`${faction(p.faction).name} took ${effect.amount} spice from ${faction(getPlayer(g, effect.from).faction).name} through Orgiz Processing Station${g.advanced ? ' for collection in ' : ''}${g.advanced ? territory(splitLocation(effect.location).territory).name : ''}.`,
{ faction: p.faction, name: 'Orgiz Processing Station' },
);
}
+3 -1
View File
@@ -1,4 +1,5 @@
import { FACTIONS } from './catalog';
import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile';
/** Public configuration only; never gate a skill on a hidden card or random deal. */
export type LeaderSkillProfile = {
@@ -356,5 +357,6 @@ export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): bool
(!game.expansions.length || nativeExpansionLeaderSkillsProfile(game))) ||
classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) ||
strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) ||
pairedNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game);
pairedNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) ||
classicDiscoveryLeaderSkillsProfile(game);
}
+6 -5
View File
@@ -4080,6 +4080,7 @@ export const RULE_TOPICS: RuleTopic[] = [
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.',
'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.',
@@ -4088,7 +4089,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The five revealed location tokens become separate, initially empty territories inside their surroundings. A private peek does not open the location. Normal ground movement enters through the surrounding territory, shipment uses stronghold prices, at most two factions may occupy it, and forces inside are protected from storms and sandworms. Only Jacurutu Sietch counts toward stronghold victory.',
'Native typed forces enter as their actual ordinary and elite groups, never as passengers from the mobile stronghold or the concealed value of a No-Field. Richese may ship or move a concealed marker into an actually revealed nested location at sector zero; it remains one effective presence until its original reveal materializes available reserves. CHOAM Card Stash uses its five-card limit, drawing first and choosing any held discard afterward.',
'At the start of the next turn, before the storm and mobile stronghold movement, each eligible faction may move any positive subset of its non-advisor groups from the surrounding territory into the newly revealed location. The signed choice preserves ordinary and elite forces across sectors, normal source storm and occupancy restrictions, and costs no spice, shipment or normal movement. The printed text gives no sequence and does not restate the source-storm restriction; processing tokens in reveal order and factions in storm order while excluding storm sources are source inferences.',
'A sole occupant of Cistern receives two bank spice during Collection; contested occupancy withholds that bonus without blocking the phase. A sole Orgiz occupant takes one spice from each rival who collected a positive board deposit, using each collected pile as one observable blow. This is a provisional interpretation of the printed blow wording. Contested Orgiz occupancy and unresolved Ecaz shared lots withhold only the uncertain transfer while ordinary collection continues.',
'A sole Cistern occupant receives two bank spice during Collection. In Advanced, a sole Orgiz occupant takes one collected spice per territory where an unambiguous rival collected board spice. Multiple collected deposits in that territory do not add transfers. Basic retains its provisional one-per-positive-deposit interpretation. Contested ownership, different or shared payers leave only that uncertain transfer unpaid; ordinary Collection continues.',
'Jacurutu automatically pays one bank spice for each opposing undialed physical force sent to the Tanks. Where Advanced normal/elite allocations would produce different rewards, this prototype records the unpaid gap pending a ruling; it never guesses from the numeric wheel alone.',
'A sole non-advisor occupant of Ecological Testing Station may decrease ordinary storm movement by one, keep it, or increase it by one. Choose after the ordinary storm-card window, before movement and storm protection. Weather Control cannot be changed. Two-occupant adjustment order remains unresolved. An already saved pending storm without its original movement source keeps its distance until a new storm is produced.',
'A current non-advisor Shrine occupant may play a physical Truthtrance card as Karama, or a physical Karama as Truthtrance, within the implemented timing and effect paths. The printed identity and actual card custody remain unchanged; a committed Truthtrance conversion survives departure. This does not create missing Karama powers or settle their pending rules.',
@@ -4098,7 +4099,7 @@ export const RULE_TOPICS: RuleTopic[] = [
checklist: [
{
area: 'Implementation', status: 'Partial',
detail: 'Working prototypes cover original classic/E1/E2/standalone E3 setup with optional original Tech, native typed free entry, paid nested No-Field shipment/movement/reveal, CHOAM stash capacity, native Face Dance rewards and Moritani nested-site assassination. Existing destructive blows, Great Maker, Cistern, bounded Orgiz, Ornithopter, unambiguous Jacurutu, sole Testing Station and Shrine remain. Contested and mixed interpretations are pending.',
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.',
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'],
},
{
@@ -4113,13 +4114,13 @@ export const RULE_TOPICS: RuleTopic[] = [
},
{
area: 'Documentation', status: 'Partial',
detail: 'Sourced identities, connected behavior and the remaining dependency queue are recorded. Great Maker first-turn/Sandtrout handling, exhausted token supply, free-entry order, source-storm handling and Orgiz per-collected-deposit interpretation remain explicit inferences; contested Cistern/Testing Station/Orgiz, mixed-force Jacurutu and shared-lot theft remain unresolved.',
detail: 'Sourced identities and native/classic-skill boundaries are recorded. Advanced Orgiz follows the authorized territory-Collection trigger; Basic keeps its provisional deposit mapping. Great Maker first-turn/Sandtrout, exhausted supply, entry order and source-storm handling remain explicit inferences. Contested Cistern/Testing Station/Orgiz, mixed-force Jacurutu and shared-lot theft are unresolved.',
evidence: ['docs/DISCOVERY_COMPONENTS.md', 'docs/DISCOVERY_PROTOTYPE.md'],
},
{
area: 'Verification', status: 'Partial',
detail: 'Focused native runtime cases and actual phone controls cover original setup, typed entry, No-Field nested admission/materialization, stash capacity, Face Dance reward order and original Moritani assassination. Five genuine six-seat Advanced Tech samples finish after repairing and resuming the preserved Moritani profile. Specific changed effects use controlled source programs, not a claim that every sample naturally triggers them. Full Discovery, assurance and deployed acceptance remain open.',
evidence: ['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: 'Native and classic-skill original programmes/phone controls cover setup, physical entry, nested tariff/No-Field arrival, stash capacity, skill movement/rescue, reward ordering and Moritani assassination. Controlled Collection shows Basic three versus Advanced two transfers; two genuine six-seat Skills/Discovery/Tech games finish on one unchanged tree. Native five-game repair/resume evidence remains separate. These are targeted proofs, not natural-every-effect, full combinations, assurance or deployed acceptance.',
evidence: ['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'],
},
],
},
@@ -0,0 +1,260 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { gameDistance, gameTerritories, location, splitLocation, validGameLocation } from '../game/board';
import { strongholdPathBlocked } from '../game/occupancy';
import { discoveryEntryMoveAction, discoveryEntryDeclineAction } from '../game/discovery-entry-options';
import { discoveryFlightMove } from '../game/discovery-flight-options';
import { groundMovementRange, planetologistLeader } from '../game/planetologist-movement';
import { reserveShipmentCost } from '../game/shipment-price';
import { ownedTech } from '../game/tech-tokens';
import {
advanceClassicDiscoverySkills, classicDiscoverySkillsClean, classicDiscoverySkillsEntryWindow,
classicDiscoverySkillsShipmentWindow, createClassicDiscoverySkillsFixture, enterClassicDiscoverySkills,
openClassicDiscoverySkillsBattle, revealClassicDiscoverySkills,
settleClassicDiscoverySkillsArrival,
} from './fixture-discovery-classic-skills';
/** Exercise the actual owned bot controls, not a separately invented strategy. */
function legalPolicies(game: Game, actor: string): void {
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, actor);
view.players.find(player => player.id === actor)!.bot = difficulty;
const choices = botActions(view);
assert.ok(choices.length, `${difficulty} needs an owned legal continuation.`);
for (const action of choices) assert.doesNotThrow(() => applyAction(structuredClone(game), actor, action));
}
}
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function routeAtDistance(game: Game, owner: string, from: string, distance: number) {
return gameTerritories(game).flatMap(territory => territory.sectors.map(sector => ({ territory: territory.id, sector })))
.find(target => target.sector !== game.storm && gameDistance(game, from, location(target.territory, target.sector), key => {
const source = splitLocation(key);
return (source.sector !== 0 && source.sector === game.storm) || strongholdPathBlocked(game.players, owner, source.territory, false);
}) === distance && !strongholdPathBlocked(game.players, owner, target.territory, false));
}
void test('classic trained offers and Discovery enter original Basic/Advanced setup and optional canonical Tech at three through six seats', () => {
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
const fixture = createClassicDiscoverySkillsFixture({ advanced, seats, tech: seats >= 3 });
assert.equal(fixture.setup.leaderSkills!.deck.length + Object.values(fixture.setup.leaderSkills!.offers)
.reduce((sum, offer) => sum + offer.cards.length, 0), 14);
assert.equal(fixture.setup.discoveries!.tokens.length, 8);
assert.equal(fixture.game.leaderSkills!.assignments.length, seats);
assert.equal(fixture.game.players.length, seats);
assert.equal(fixture.game.turn, 1);
assert.equal(fixture.game.phase, 7);
if (seats >= 3) assert.equal(Object.values(fixture.game.techTokens!).filter(token => token.owner).length, 3);
}
});
void test('physical next-turn free entry preserves the real trained posture, wallet, reserves and ordinary movement allowance', () => {
for (const advanced of [false, true]) {
const fixture = createClassicDiscoverySkillsFixture({ advanced, tech: true });
const revealed = revealClassicDiscoverySkills(fixture);
const old = revealed.players.find(player => player.id === fixture.collector)!;
assert.equal(old.forces[`${fixture.face}:0`] ?? 0, 0, 'Reveal itself never occupies the nested site.');
assert.equal(validGameLocation(revealed, fixture.face, 0), true);
const window = classicDiscoverySkillsEntryWindow(fixture);
assert.equal(window.turn, 2);
assert.equal(window.phase, 0);
const view = viewGame(window, fixture.collector), before = window.players.find(player => player.id === fixture.collector)!;
assert.equal(planetologistLeader(view, fixture.collector), fixture.leader);
assert.ok(view.leaderSkills!.assignments.find(assignment => assignment.owner === fixture.collector)!.faceUp);
legalPolicies(window, fixture.collector);
const accept = discoveryEntryMoveAction(view, view.discoveryEntry!.sources);
const decline = discoveryEntryDeclineAction(view);
assert.ok(accept && decline);
reject(window, fixture.opponent, accept);
const declined = applyAction(structuredClone(window), fixture.collector, decline);
assert.deepEqual(declined.players.find(player => player.id === fixture.collector)!.forces, before.forces);
const entered = applyAction(window, fixture.collector, accept);
const after = entered.players.find(player => player.id === fixture.collector)!;
assert.equal(after.forces[`${fixture.face}:0`], 3);
assert.equal(after.forces[`${fixture.parent}:${fixture.parentSector}`] ?? 0, 0);
assert.equal(after.spice, before.spice);
assert.equal(after.reserves, before.reserves);
assert.equal(after.moved, before.moved);
assert.equal(after.shipped, before.shipped);
assert.equal(planetologistLeader(viewGame(entered, fixture.collector), fixture.collector), fixture.leader);
const shipping = classicDiscoverySkillsShipmentWindow(entered, fixture.collector);
const held = shipping.players.find(player => player.id === fixture.collector)!;
assert.equal(held.forces[`${fixture.face}:0`], 3, 'Original Storm leaves the protected physical nested forces in place.');
assert.equal(held.tanks, before.tanks);
assert.equal(held.moved, 0);
}
});
void test('ordinary nested paid shipment and Planetologist range use original one-price and one-movement producers', () => {
for (const advanced of [false, true]) {
const fixture = createClassicDiscoverySkillsFixture({ advanced, tech: true });
let game = classicDiscoverySkillsShipmentWindow(enterClassicDiscoverySkills(fixture), fixture.collector);
const owner = structuredClone(game.players.find(player => player.id === fixture.collector)!);
legalPolicies(game, fixture.collector);
const site = gameTerritories(game).find(territory => territory.id === fixture.face)!;
const cost = reserveShipmentCost({ faction: owner.faction, halfRate: true }, site.type, 1);
assert.equal(cost, 1);
game = applyAction(game, fixture.collector, { type: 'ship', territory: fixture.face, sector: 0, amount: 1, elite: 0, allyPayment: 0 });
game = settleClassicDiscoverySkillsArrival(game);
const shipped = game.players.find(player => player.id === fixture.collector)!;
assert.equal(shipped.spice, owner.spice - cost);
assert.equal(shipped.reserves, owner.reserves - 1);
assert.equal(shipped.forces[`${fixture.face}:0`], 4);
assert.equal(shipped.moved, 0);
assert.equal(shipped.shipped, true);
const target = routeAtDistance(game, fixture.collector, `${fixture.face}:0`, 2);
assert.ok(target, 'The real nested board needs a two-territory ordinary Planetologist route.');
assert.equal(groundMovementRange({ faction: shipped.faction, cityOrnithopters: false, selectedElites: 0, nativeBlocked: false }, 'range'), 2);
const ordinary: Action = { type: 'move', from: `${fixture.face}:0`, amount: 1, elite: 0, ...target };
reject(game, fixture.collector, ordinary);
const beforeMove = structuredClone(shipped);
game = applyAction(game, fixture.collector, { ...ordinary, planetologist: 'range' });
game = settleClassicDiscoverySkillsArrival(game);
const moved = game.players.find(player => player.id === fixture.collector)!;
assert.equal(moved.moved, beforeMove.moved + 1);
assert.equal(moved.spice, beforeMove.spice);
assert.equal(moved.reserves, beforeMove.reserves);
assert.equal(moved.forces[location(target.territory, target.sector)], 1);
assert.equal(moved.forces[`${fixture.face}:0`], 3);
reject(game, fixture.collector, { ...ordinary, planetologist: 'range' });
}
});
void test('carried Discovery Ornithopter offers a fixed-three ordinary replacement, consumes its physical token once and adds no action', () => {
for (const advanced of [false, true]) {
const fixture = createClassicDiscoverySkillsFixture({ advanced, face: 'ornithopter', tech: true });
const acquired = revealClassicDiscoverySkills(fixture);
assert.equal(acquired.discoveries!.tokens.find(token => token.id === fixture.token)!.status, 'carried');
assert.ok(viewGame(acquired, fixture.collector).discoveryOrnithopter!.blocked);
let game = classicDiscoverySkillsShipmentWindow(acquired, fixture.collector);
const before = structuredClone(game.players.find(player => player.id === fixture.collector)!);
const from = `${fixture.parent}:${fixture.parentSector}`, target = routeAtDistance(game, fixture.collector, from, 3);
assert.ok(target, 'The physical parent board needs an original three-territory route.');
const ordinary: Action = { type: 'move', from, amount: 1, elite: 0, ...target };
reject(game, fixture.collector, ordinary);
const quote = discoveryFlightMove(viewGame(game, fixture.collector), ordinary);
assert.equal(quote.blocked, null);
assert.ok(quote.action);
legalPolicies(game, fixture.collector);
game = applyAction(game, fixture.collector, quote.action);
game = settleClassicDiscoverySkillsArrival(game);
const after = game.players.find(player => player.id === fixture.collector)!;
assert.equal(after.moved, before.moved + 1);
assert.equal(after.spice, before.spice);
assert.equal(after.reserves, before.reserves);
assert.equal(after.forces[location(target.territory, target.sector)], 1);
assert.equal(game.discoveries!.tokens.find(token => token.id === fixture.token)!.status, 'removed');
assert.equal(viewGame(game, fixture.collector).discoveryOrnithopter, null);
reject(game, fixture.collector, quote.action);
reject(game, fixture.collector, { ...ordinary, planetologist: 'range' });
}
});
void test('original Harkonnen eight-card capacity overflows through actual Card Stash and only its owner selects the physical discard', () => {
for (const advanced of [false, true]) {
const fixture = createClassicDiscoverySkillsFixture({ advanced, collector: 'harkonnen', face: 'treachery-card-stash', amount: 1 });
const owner = fixture.game.players.find(player => player.id === fixture.collector)!;
const capacity = handLimit(owner);
assert.equal(capacity, 8);
// Explicit conserved physical card position, not an auction receipt or natural-play claim.
while (owner.hand.length < capacity) {
const card = fixture.game.deck.shift(); assert.ok(card); owner.hand.push(card);
}
fixture.staging.push('Moved original undealt base33 cards into the original Harkonnen eight-card hand to exercise the actual capacity boundary.');
const wallet = owner.spice, originals = owner.hand.map(card => card.id), drawn = fixture.game.deck[0];
assert.ok(drawn);
const pending = revealClassicDiscoverySkills(fixture);
assert.equal(pending.decision?.kind, 'discoveryDiscard');
assert.equal(pending.decision?.player, fixture.collector);
assert.deepEqual(pending.players.find(player => player.id === fixture.collector)!.hand.map(card => card.id), [...originals, drawn.id]);
assert.equal(pending.players.find(player => player.id === fixture.collector)!.spice, wallet);
legalPolicies(pending, fixture.collector);
const action: Action = { type: 'decision', event: pending.discoveryStash!.event, card: drawn.id };
reject(pending, fixture.opponent, action);
reject(pending, fixture.collector, { ...action, card: 'not-a-physical-card' });
const done = settleClassicDiscoverySkillsArrival(applyAction(pending, fixture.collector, action));
assert.equal(done.players.find(player => player.id === fixture.collector)!.hand.length, capacity);
assert.equal(done.players.find(player => player.id === fixture.collector)!.spice, wallet);
assert.equal(done.discard.filter(card => card.id === drawn.id).length, 1);
assert.equal(done.discoveries!.tokens.find(token => token.id === fixture.token)!.status, 'removed');
reject(done, fixture.collector, action);
}
});
void test('real nested battle keeps trained/untrained leader posture, Suk casualties and subsequent winner Tech reward ordering', () => {
for (const advanced of [false, true]) for (const hide of [false, true]) {
const fixture = createClassicDiscoverySkillsFixture({ advanced, tech: true, skill: 'suk-graduate', face: 'jacurutu-sietch' });
let game = openClassicDiscoverySkillsBattle(fixture, hide);
const winner = game.players.find(player => player.id === fixture.collector)!;
const loser = game.players.find(player => player.id === fixture.opponent)!;
const loserTech = ownedTech(game.techTokens, loser.id);
assert.equal(loserTech.length, 1);
assert.equal(viewGame(game, winner.id).leaderSkills!.assignments.find(assignment => assignment.owner === winner.id)!.faceUp, !hide);
const leader = hide ? fixture.leader : winner.leaders.filter(candidate => candidate.id !== fixture.leader && !candidate.dead)
.sort((a, b) => b.strength - a.strength)[0].id;
const enemyLeader = loser.leaders.filter(candidate => !candidate.dead &&
!game.leaderSkills!.assignments.some(assignment => assignment.leader === candidate.id))
.sort((a, b) => a.strength - b.strength)[0].id;
const counters = { tanks: winner.tanks, reserves: winner.reserves, forces: winner.forces[`${fixture.face}:0`] };
game = applyAction(game, winner.id, { type: 'battlePlan', dial: 1, support: advanced ? 1 : 0, leader });
game = applyAction(game, loser.id, { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader });
game = advanceClassicDiscoverySkills(game, state => state.decision?.kind === 'sukRescue' ||
state.lastBattleContext?.sukRescue?.completed === true);
if (hide) {
assert.equal(game.decision?.kind, 'sukRescue');
if (game.decision?.kind !== 'sukRescue') throw new Error('Original skilled Suk decision missing.');
assert.equal(game.decision.mode, 'skilled');
assert.deepEqual(ownedTech(game.techTokens, loser.id), loserTech, 'Tech transfer must wait for actual winner casualties and rescue.');
legalPolicies(game, winner.id);
const choice = game.decision.options.findIndex(option => option.normal === 1 && option.elite === 0);
assert.ok(choice >= 0);
game = applyAction(game, winner.id, { type: 'decision', event: game.decision.event, choice });
} else {
// One ordinary casualty has one legal normal rescue and resolves automatically.
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
}
game = advanceClassicDiscoverySkills(game, state => !state.pendingSukRescue &&
state.techTokens![loserTech[0]].owner === winner.id && classicDiscoverySkillsClean(state));
const rescued = game.players.find(player => player.id === winner.id)!;
assert.equal(rescued.tanks, counters.tanks);
assert.equal(rescued.reserves, counters.reserves + Number(!hide));
assert.equal(rescued.forces[`${fixture.face}:0`], counters.forces - Number(!hide));
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
assert.equal(game.lastBattleContext!.winner, winner.id);
assert.equal(game.lastBattleContext!.territory, fixture.face);
assert.equal(viewGame(game, winner.id).leaderSkills!.assignments.find(assignment => assignment.owner === winner.id)!.faceUp, true);
assert.equal(game.players.find(player => player.id === loser.id)!.tanks, 1);
}
});
void test('original five-force nested invoice allows the distinct Bureaucrat holder to divert two or decline without changing shipment cost', () => {
for (const advanced of [false, true]) {
const fixture = createClassicDiscoverySkillsFixture({ advanced, skill: 'bureaucrat', skillOwner: 'emperor' });
const revealed = revealClassicDiscoverySkills(fixture);
const payer = revealed.players.find(player => player.faction === 'harkonnen')!.id;
const window = classicDiscoverySkillsShipmentWindow(revealed, payer);
const payerBefore = window.players.find(player => player.id === payer)!, receiverBefore = window.players.find(player => player.id === fixture.collector)!;
let pending = applyAction(window, payer, { type: 'ship', territory: fixture.face, sector: 0, amount: 5, elite: 0, allyPayment: 0 });
pending = advanceClassicDiscoverySkills(pending, game => game.decision?.kind === 'bureaucratPayment');
assert.ok(pending.decision?.kind === 'bureaucratPayment');
assert.equal(pending.decision!.player, fixture.opponent);
legalPolicies(pending, fixture.opponent);
for (const redirect of [false, true]) {
let game = applyAction(structuredClone(pending), fixture.opponent, { type: 'decision', event: pending.decision!.event, redirect });
game = settleClassicDiscoverySkillsArrival(game);
const paid = game.players.find(player => player.id === payer)!, received = game.players.find(player => player.id === fixture.collector)!;
assert.equal(paid.spice, payerBefore.spice - 5);
assert.equal(paid.reserves, payerBefore.reserves - 5);
assert.equal(paid.forces[`${fixture.face}:0`], 5);
assert.equal(paid.moved, payerBefore.moved);
assert.equal(paid.shipped, true);
assert.equal(received.spice, receiverBefore.spice + 5 - (redirect ? 2 : 0));
}
}
});
+125 -4
View File
@@ -11,7 +11,10 @@ import {
import {
CISTERN,
ORGIZ_PROCESSING_STATION,
DiscoveryCollectionError,
quoteDiscoveryCollection,
type DiscoveryCollectionContext,
type DiscoveryCollectionQuote,
} from '../game/discovery-collection';
import {
applyAction,
@@ -75,7 +78,7 @@ function composed(game: Game) {
}
function balance(
quote: ReturnType<typeof quoteDiscoveryCollection>,
quote: DiscoveryCollectionQuote,
player: string,
) {
return quote.receipts.find((receipt) => receipt.player === player)!.balance;
@@ -151,7 +154,7 @@ void test('Cistern follows Collection advisor releases and excludes remaining ad
]);
});
void test('the bounded Orgiz quote transfers one spice from each uniquely collected observable board deposit', () => {
void test('Basic Orgiz retains one spice from each uniquely collected observable board deposit', () => {
const game = fixture();
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].forces = {
@@ -205,7 +208,7 @@ void test('the bounded Orgiz quote transfers one spice from each uniquely collec
assert.deepEqual(quoteSpiceCollection(game), ordinary);
});
void test('one rival pays once per collected pile, not once per player or stacked spice amount', () => {
void test('Basic charges one rival per collected pile, not per player or stacked spice amount', () => {
const game = fixture();
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
@@ -409,7 +412,7 @@ void test('the real collection phase credits Cistern once and preserves the rece
);
});
void test('a revealed sole Orgiz occupant steals one from each rival collected deposit in Basic and Advanced', () => {
void test('a revealed sole Orgiz occupant steals once from each rival territory with one deposit in either mode', () => {
for (const advanced of [false, true]) {
const game = fixture(advanced);
game.discoveryEnabled = true;
@@ -533,3 +536,121 @@ void test('a saved Ecaz response commits Cistern once only after the collection
1,
);
});
void test('Advanced counts each territory once across positive partial sector collections; Basic retains both deposits', () => {
for (const advanced of [false, true]) {
const game = fixture(advanced);
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1, 'red_chasm:7': 1 };
game.players[1].forces = { 'hagga_basin:12': 1, 'hagga_basin:13': 1, 'cielago_south:3': 1 };
game.spice = {
'hagga_basin:12': 8, 'hagga_basin:13': 1, 'cielago_south:3': 3,
'red_chasm:7': 2, 'cielago_south:2': 4, 'funeral_plain:15': 0,
};
const before = structuredClone(game);
const { ordinary, discovery } = composed(game);
assert.deepEqual(ordinary.spice, {
'hagga_basin:12': 6, 'hagga_basin:13': 0, 'cielago_south:3': 1,
'red_chasm:7': 0, 'cielago_south:2': 4, 'funeral_plain:15': 0,
[`${ORGIZ_PROCESSING_STATION}:0`]: 0,
});
assert.deepEqual(discovery.effects.map(effect =>
effect.kind === 'orgiz' ? [effect.from, effect.location] : effect.kind),
advanced
? [['h', 'cielago_south:3'], ['h', 'hagga_basin:12']]
: [['h', 'cielago_south:3'], ['h', 'hagga_basin:12'], ['h', 'hagga_basin:13']]);
const count = advanced ? 2 : 3;
assert.equal(balance(discovery, 'a'), ordinary.receipts[0].balance + count);
assert.equal(balance(discovery, 'h'), ordinary.receipts[1].balance - count);
assert.deepEqual(discovery.receipts.map(({ balance: _, ...facts }) => facts),
ordinary.receipts.map(({ balance: _, ...facts }) => facts));
assert.equal(discovery.receipts.reduce((sum, receipt) => sum + receipt.balance, 0),
ordinary.receipts.reduce((sum, receipt) => sum + receipt.balance, 0));
assert.deepEqual(game, before);
}
});
void test('Advanced withholds a territory with distinct collected payers, including an own collection, but not independent theft', () => {
for (const second of ['a', 'g']) {
const game = fixture(true);
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.players[1].forces = { 'hagga_basin:12': 1, 'red_chasm:7': 1 };
game.players.find(player => player.id === second)!.forces['hagga_basin:13'] = 1;
game.spice = { 'hagga_basin:12': 3, 'hagga_basin:13': 2, 'red_chasm:7': 2 };
const { ordinary, discovery } = composed(game);
assert.deepEqual(discovery.effects, [{
kind: 'orgiz', player: 'a', from: 'h', location: 'red_chasm:7',
amount: 1, source: 'player',
}]);
assert.equal(balance(discovery, 'a'), ordinary.receipts[0].balance + 1);
assert.equal(balance(discovery, 'h'), ordinary.receipts[1].balance - 1);
assert.equal(balance(discovery, 'g'), ordinary.receipts[2].balance);
assert.equal(ordinary.spice['hagga_basin:13'], 0);
}
});
void test('Advanced does not select a payer from an uncontested sector when another collected sector is contested', () => {
const game = fixture(true);
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.players[1].forces = { 'hagga_basin:12': 1, 'hagga_basin:13': 1, 'red_chasm:7': 1 };
game.players[2].forces = { 'hagga_basin:13': 1 };
game.spice = { 'hagga_basin:12': 4, 'hagga_basin:13': 4, 'red_chasm:7': 2 };
const { ordinary, discovery } = composed(game);
assert.equal(ordinary.spice['hagga_basin:12'], 2);
assert.equal(ordinary.spice['hagga_basin:13'], 0);
assert.deepEqual(discovery.effects, [{
kind: 'orgiz', player: 'a', from: 'h', location: 'red_chasm:7',
amount: 1, source: 'player',
}]);
assert.equal(balance(discovery, 'g'), ordinary.receipts[2].balance);
});
void test('Advanced shared territories remain unpaid across all their source keys while independent Collection proceeds', () => {
const game = fixture(true, [['e', 'ecaz'], ['a', 'atreides'], ['g', 'guild']]);
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].ally = 'a';
game.players[1].ally = 'e';
game.players[0].forces = { 'hagga_basin:12': 1, 'red_chasm:7': 1 };
game.players[1].forces = { 'hagga_basin:13': 1 };
game.players[2].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.spice = { 'hagga_basin:12': 4, 'hagga_basin:13': 4, 'red_chasm:7': 2 };
const { ordinary, discovery } = composed(game);
assert.deepEqual(ordinary.shared, [{ territory: 'hagga_basin', ecaz: 'e', ally: 'a', amount: 4 }]);
assert.deepEqual(discovery.effects, [{
kind: 'orgiz', player: 'g', from: 'e', location: 'red_chasm:7',
amount: 1, source: 'player',
}]);
assert.equal(balance(discovery, 'e'), 1);
assert.equal(balance(discovery, 'g'), 1);
});
void test('Advanced retains public effective No-Field presence for the Orgiz occupant and collected source', () => {
const game = fixture(true, [['r', 'richese'], ['h', 'harkonnen']]);
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].noField = {
deployed: { location: { territory: ORGIZ_PROCESSING_STATION, sector: 0 } },
} as NonNullable<Game['players'][number]['noField']>;
game.players[1].forces = { 'hagga_basin:12': 1 };
game.spice = { 'hagga_basin:12': 2 };
assert.equal(composed(game).discovery.effects.length, 1);
game.players[0].noField!.deployed!.location = { territory: 'hagga_basin', sector: 13 };
game.players[1].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.spice = { 'hagga_basin:12': 2, 'hagga_basin:13': 4 };
const { ordinary, discovery } = composed(game);
assert.equal(ordinary.spice['hagga_basin:13'], 2);
assert.deepEqual(discovery.effects, [{
kind: 'orgiz', player: 'h', from: 'r', location: 'hagga_basin:13',
amount: 1, source: 'player',
}]);
});
void test('Discovery Collection requires an explicit mode rather than treating missing Advanced as authoritative', () => {
const game = fixture(true);
const ordinary = quoteSpiceCollection(game);
const { advanced: _, ...missingMode } = game;
assert.throws(() => quoteDiscoveryCollection(
missingMode as unknown as DiscoveryCollectionContext, ordinary,
), DiscoveryCollectionError);
});
+20 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, RuleError, type Game } from '../game/engine';
import { createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, RuleError, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { createTechTokens } from '../game/tech-tokens';
import { createStrongholdCards } from '../game/stronghold-cards';
@@ -51,3 +51,22 @@ void test('Discovery cannot redeal started components or silently remove another
overlays[2].strongholdCards = createStrongholdCards();
for (const game of overlays) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError);
});
void test('classic Discovery Skills does not silently drop native families or unrelated modules and previews', () => {
const native = lobby(['ixians', 'guild', 'fremen'], ['ix']);
assert.throws(() => initializeLeaderSkillsGameForAudit(native), RuleError);
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[3].mentatQuestionPreview = true;
overlays[4].spiceBankerIncomePreview = true;
for (const game of overlays) assert.throws(() => initializeLeaderSkillsGameForAudit(game), RuleError);
const two = lobby(['guild', 'emperor'], []); two.techTokens = createTechTokens();
assert.throws(() => initializeLeaderSkillsGameForAudit(two), RuleError);
});
void test('Discovery Skills cannot replace already initialized Discovery components', () => {
const started = initializeDiscoveryGameForAudit(lobby(['guild', 'emperor', 'harkonnen'], []));
assert.throws(() => initializeLeaderSkillsGameForAudit(started), RuleError);
});
@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { splitLocation } from '../game/board';
import { viewGame } from '../game/engine';
import { nativeTypedPhase } from './fixture-discovery-native-typed';
import { commitOrgizCollection, prepareOrgizCollection } from './fixture-discovery-orgiz-advanced';
for (const advanced of [true, false]) {
void test(`${advanced ? 'Advanced territory events' : 'Basic positive deposits'}: original Discovery reveal and later Collection preserve bank income and pay the distinct Orgiz count`, () => {
const position = prepareOrgizCollection(advanced);
const count = advanced ? 2 : 3;
const orgiz = position.discovery.effects.filter(effect => effect.kind === 'orgiz');
assert.equal(orgiz.length, count);
assert.deepEqual(orgiz.map(effect => [effect.from, splitLocation(effect.location).territory]), advanced
? [['g', 'cielago_south'], ['g', 'hagga_basin']]
: [['g', 'cielago_south'], ['g', 'hagga_basin'], ['g', 'hagga_basin']]);
assert.equal(position.ordinary.receipts.find(receipt => receipt.player === 'g')!.collected, 5);
assert.equal(position.ordinary.receipts.find(receipt => receipt.player === 'a')!.collected, 1);
assert.equal(position.ordinary.receipts.find(receipt => receipt.player === 'f')!.strongholds, advanced ? 2 : 0);
assert.deepEqual(position.discovery.receipts.map(({ balance: _, ...facts }) => facts),
position.ordinary.receipts.map(({ balance: _, ...facts }) => facts));
assert.equal(position.ordinary.spice[position.haggaKeys[0]], 1);
assert.equal(position.ordinary.spice[position.haggaKeys[1]], 0);
assert.equal(position.ordinary.spice[position.cielagoKey], 0);
assert.equal(position.ordinary.spice[position.untouchedKey], position.boardSpice - 7);
const collected = commitOrgizCollection(position.game);
assert.deepEqual(position.game, position.before);
assert.equal(collected.turn, 2);
assert.deepEqual(collected.spice, position.ordinary.spice);
for (const receipt of position.discovery.receipts)
assert.equal(collected.players.find(player => player.id === receipt.player)!.spice, receipt.balance);
const ordinaryOwner = position.ordinary.receipts.find(receipt => receipt.player === 'a')!.balance;
const ordinaryPayer = position.ordinary.receipts.find(receipt => receipt.player === 'g')!.balance;
assert.equal(collected.players.find(player => player.id === 'a')!.spice, ordinaryOwner + count);
assert.equal(collected.players.find(player => player.id === 'g')!.spice, ordinaryPayer - count);
assert.equal(viewGame(collected, 'a').players.find(player => player.id === 'a')!.spice, ordinaryOwner + count);
assert.equal(collected.players.reduce((sum, player) => sum + player.spice, 0),
position.ordinary.receipts.reduce((sum, receipt) => sum + receipt.balance, 0));
assert.equal(collected.players.reduce((sum, player) => sum + player.spice, 0) +
Object.values(collected.spice).reduce((sum, amount) => sum + amount, 0),
position.before.players.reduce((sum, player) => sum + player.spice, 0) + position.boardSpice + (advanced ? 2 : 0));
const theftLogs = collected.log.filter(entry => entry.automatic?.name === 'Orgiz Processing Station');
assert.equal(theftLogs.length, count);
// Continue the original phase controls; never rerun or retarget a paid quote.
const mentat = nativeTypedPhase(collected, 8);
assert.deepEqual(mentat.players.map(player => player.spice), collected.players.map(player => player.spice));
assert.equal(mentat.log.filter(entry => entry.automatic?.name === 'Orgiz Processing Station').length, count);
});
}
+227
View File
@@ -0,0 +1,227 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
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';
export type ClassicDiscoverySkillsStep = { actor: string; action: Action };
export type ClassicDiscoverySkillsOptions = {
/** Authenticated fresh lobby or original staged setup; never a played save. */
initial?: Game;
advanced?: boolean;
tech?: boolean;
seats?: 2 | 3 | 4 | 5 | 6;
collector?: 'guild' | 'harkonnen';
skill?: LeaderSkillId;
skillOwner?: 'guild' | 'harkonnen' | 'emperor';
face?: DiscoveryTokenFace;
amount?: number;
};
export type ClassicDiscoverySkillsFixture = {
initial: Game; setup: Game; game: Game; actions: ClassicDiscoverySkillsStep[];
staging: string[]; collector: string; opponent: string; leader: string;
token: string; face: DiscoveryTokenFace; parent: string; parentSector: number;
};
export const classicDiscoverySkillsClean = (game: Game) =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
export function nextClassicDiscoverySkillsStep(game: Game): ClassicDiscoverySkillsStep {
if (game.decision?.kind === 'discoveryEntry') return { actor: game.decision.player,
action: { type: 'decision', event: game.decision.event, accept: false } };
if (game.battle?.preLeader?.closed === false && !game.response && !game.decision && !game.battle.preparation) {
const frame = game.battle.preLeader;
return { actor: frame.ready.includes(game.battle.attacker) ? game.battle.defender : game.battle.attacker,
action: { type: 'battlePreparationReady', event: frame.event } };
}
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next, 'The original classic program needs a live owned continuation.');
return next;
}
export function advanceClassicDiscoverySkills(state: Game, until: (game: Game) => boolean,
actions?: ClassicDiscoverySkillsStep[]): Game {
let game = state;
for (let i = 0; !until(game) && i < 1600; i++) {
assert.notEqual(game.status, 'finished');
const next = nextClassicDiscoverySkillsStep(game);
actions?.push(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(until(game), 'The connected classic program did not reach its original action window.');
return game;
}
/** Physical offer lottery only: each original seat receives a source-clear skill.
* The rest of setup entropy, including Treachery and Traitor cards, stays real. */
function withSkillOffers<T>(skills: LeaderSkillId[], operation: () => T): T {
const original = crypto.getRandomValues.bind(crypto);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const current = LEADER_SKILL_CARDS.map(card => card.id) as LeaderSkillId[];
const desired = Array<LeaderSkillId>(current.length);
skills.forEach((skill, index) => { desired[index * 2] = skill; });
const rest = current.filter(skill => !skills.includes(skill));
for (let i = 0; i < desired.length; i++) if (!desired[i]) desired[i] = rest.shift()!;
let index = current.length - 1;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (index < 1) return Reflect.apply(original, crypto, [array]) as V;
assert.ok(array instanceof Uint32Array && array.length === 1);
const swap = current.indexOf(desired[index]);
assert.ok(swap <= index);
array[0] = Math.floor((swap + 0.5) / (index + 1) * 0x100000000);
[current[index], current[swap]] = [current[swap], current[index]];
index--;
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function withDiscoveryPlacement<T>(game: Game, face: DiscoveryTokenFace, operation: () => T): T {
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
const index = eligible.findIndex(token => token.face === face);
assert.ok(index >= 0);
const original = crypto.getRandomValues.bind(crypto);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = Math.floor((index + 0.5) / eligible.length * 0x100000000);
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Original base33/all14 staged setup, original Storm/Bidding/Shipment/Collection.
* Explicit positions: actual skill offers, four unplayed Spice Cards and supply
* token lottery. Never stage clocks, phases, wallets, forces or pending frames. */
export function createClassicDiscoverySkillsFixture(options: ClassicDiscoverySkillsOptions = {}): ClassicDiscoverySkillsFixture {
const collectorFaction = options.collector ?? 'guild', requested = options.skill ?? 'planetologist';
const face = options.face ?? 'cistern';
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
const factions = [collectorFaction, 'emperor', collectorFaction === 'guild' ? 'harkonnen' : 'guild',
'atreides', 'fremen', 'beneGesserit'] as FactionId[];
game = createGame('CLASSICDISCOVERYSKILLS', newPlayer(factions[0], factions[0], factions[0]), options.advanced ?? false);
for (const faction of factions.slice(1, options.seats ?? 3)) joinGame(game, newPlayer(faction, faction, faction));
}
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);
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;
const choices = (['planetologist', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz',
'warmaster', 'master-of-assassins', 'bureaucrat'] as LeaderSkillId[]).filter(skill => skill !== requested);
const skills = game.players.map(player => player.id === skillOwner ? requested : choices.shift()!);
const actions: ClassicDiscoverySkillsStep[] = [];
if (game.status === 'lobby') {
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
game.discoveryEnabled = true;
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
}
const initial = structuredClone(game);
if (game.status === 'lobby') game = withSkillOffers(skills, () => initializeLeaderSkillsGameForAudit(game));
assert.ok(game.discoveryEnabled && game.discoveries && game.leaderSkills);
const setup = structuredClone(game);
for (let i = 0; game.status === 'setup' && i < 200; i++) {
if (game.setupStage !== 'leaderSkills') {
const next = nextClassicDiscoverySkillsStep(game);
actions.push(next); game = applyAction(game, next.actor, next.action); continue;
}
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const skill = actor === skillOwner ? requested : offer.cards.find(card =>
!['spice-banker', 'mentat', 'diplomat'].includes(card) && !view.unavailableSkills?.[card]);
assert.ok(skill && offer.cards.includes(skill), 'Authenticated offer must contain the requested source-clear skill.');
const eligible = view.eligibleLeaders.map(candidate => game.players.find(player => player.id === actor)!.leaders.find(leader => leader.id === candidate.id)!);
const leader = eligible.sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader);
const next: ClassicDiscoverySkillsStep = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
actions.push(next); game = applyAction(game, actor, next.action);
}
assert.equal(game.status, 'playing');
const leader = game.leaderSkills!.assignments.find(assignment => assignment.owner === collector)!.leader;
const printed = DISCOVERY_TOKEN_BY_ID[face].type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north';
const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed);
assert.ok(index >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
for (let position = 1; position < 4; position++) {
const ordinary = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
assert.ok(ordinary >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(ordinary, 1)[0]);
}
const staging = [controlledOffers
? 'Selected original physical all14 offer lottery; reordered four original unplayed Spice Cards; selected original supply-token lottery.'
: 'Kept actual already-dealt Skill offers and assignments; reordered four original unplayed Spice Cards; selected original supply-token lottery.'];
game = advanceClassicDiscoverySkills(game, state => state.phase === 1 && classicDiscoverySkillsClean(state), actions);
game = withDiscoveryPlacement(game, face, () => advanceClassicDiscoverySkills(game,
state => state.discoveries!.tokens.some(token => token.face === face && token.status === 'placed'), actions));
game = advanceClassicDiscoverySkills(game, state => state.phase === 5 && state.active === collector && classicDiscoverySkillsClean(state), actions);
const placement = DISCOVERY_CARD_PLACEMENTS[printed];
const shipment: ClassicDiscoverySkillsStep = { actor: collector, action: { type: 'ship',
territory: placement.territory, sector: placement.sector, amount: options.amount ?? 3, elite: 0, allyPayment: 0 } };
actions.push(shipment); game = applyAction(game, collector, shipment.action);
game = advanceClassicDiscoverySkills(game, state => state.phase === 7 && classicDiscoverySkillsClean(state), actions);
const token = game.discoveries!.tokens.find(candidate => candidate.face === face)!;
assert.equal(game.turn, 1);
const offer = viewGame(game, collector).discoveries!;
assert.ok(offer.canInspect.includes(token.id) || offer.canReveal.includes(token.id));
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 {
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 });
}
export function classicDiscoverySkillsEntryWindow(fixture: ClassicDiscoverySkillsFixture): Game {
return advanceClassicDiscoverySkills(revealClassicDiscoverySkills(fixture), game =>
game.turn === 2 && game.decision?.kind === 'discoveryEntry' && game.decision.player === fixture.collector);
}
export function enterClassicDiscoverySkills(fixture: ClassicDiscoverySkillsFixture): Game {
const game = classicDiscoverySkillsEntryWindow(fixture), view = viewGame(game, fixture.collector);
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources);
assert.ok(action);
return applyAction(game, fixture.collector, action);
}
export function classicDiscoverySkillsShipmentWindow(state: Game, owner: string): Game {
return advanceClassicDiscoverySkills(state, game => game.turn === 2 && game.phase === 5 &&
game.active === owner && classicDiscoverySkillsClean(game));
}
export function settleClassicDiscoverySkillsArrival(state: Game): Game {
return advanceClassicDiscoverySkills(state, game => !game.pendingShipment && classicDiscoverySkillsClean(game));
}
/** Original two-turn nested battle: collector enters free, Emperor ships one
* physical normal force at the printed nested rate, then original battle setup. */
export function openClassicDiscoverySkillsBattle(fixture: ClassicDiscoverySkillsFixture, hide = true): Game {
let game = classicDiscoverySkillsShipmentWindow(enterClassicDiscoverySkills(fixture), fixture.opponent);
game = applyAction(game, fixture.opponent, { type: 'ship', territory: fixture.face, sector: 0, amount: 1, elite: 0, allyPayment: 0 });
game = advanceClassicDiscoverySkills(game, state => state.phase === 6 && !!state.active && classicDiscoverySkillsClean(state));
const actor = game.active!;
assert.ok(actor === fixture.collector || actor === fixture.opponent);
game = applyAction(game, actor, { type: 'chooseBattle', territory: fixture.face,
target: actor === fixture.collector ? fixture.opponent : fixture.collector });
for (let i = 0; (game.response || game.decision || game.battle?.preparation || game.battle?.preLeader?.closed === false) && i < 100; i++) {
if (game.decision?.kind === 'leaderSkillVisibility') game = applyAction(game, game.decision.player,
{ type: 'leaderSkillVisibility', event: game.decision.event, hide: game.decision.player === fixture.collector && hide });
else {
const next = nextClassicDiscoverySkillsStep(game); game = applyAction(game, next.actor, next.action);
}
}
assert.ok(game.battle && !game.response && !game.decision);
return game;
}
+85
View File
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { applyAction, initializeDiscoveryGameForAudit, viewGame, type Game } from '../game/engine';
import { territory } from '../game/board';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { ORGIZ_PROCESSING_STATION, quoteDiscoveryCollection } from '../game/discovery-collection';
import { discoveryFixture, putDiscovery } from './fixture-discovery';
import { nativeTypedClearBoard, nativeTypedPhase, nativeTypedPlace, nativeTypedStep } from './fixture-discovery-native-typed';
/** Fresh original classic Discovery setup and phase controls. No phase, clock,
* wallet, deck identity or receipt is manufactured. Only conserved unplayed
* Spice Card order, supply-token, reserve-counter and board-spice positions are
* controlled. Orgiz inspect/reveal and Collection are original engine actions. */
export function prepareOrgizCollection(advanced: boolean, initial?: Game) {
let game: Game;
if (initial) {
assert.equal(initial.advanced, advanced);
game = initializeDiscoveryGameForAudit({ ...structuredClone(initial), discoveryEnabled: true });
for (let step = 0; game.status === 'setup' && step < 100; step++) game = nativeTypedStep(game);
assert.equal(game.status, 'playing');
} else game = discoveryFixture(advanced);
const owner = game.players.find(player => player.faction === 'atreides')!.id;
const payer = game.players.find(player => player.faction === 'guild')!.id;
const supporter = game.players.find(player => player.faction === 'fremen')!.id;
for (const [position, territory] of ['hagga_basin', 'cielago_south', 'red_chasm', 'funeral_plain'].entries()) {
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
'territory' in card && card.territory === territory && !card.discovery);
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = nativeTypedPhase(game, 5);
nativeTypedClearBoard(game);
// Conserved original supply token -> its printed placement; no reveal staged.
const token = putDiscovery(game, ORGIZ_PROCESSING_STATION);
const source = `${token.territory}:${token.sector}`;
assert.notEqual(token.sector, game.storm);
nativeTypedPlace(game, owner, source, 2);
game = nativeTypedPhase(game, 7);
assert.ok(viewGame(game, owner).discoveries!.canInspect.includes(token.id));
game = applyAction(game, owner, { type: 'discovery', token: token.id, reveal: false });
assert.ok(viewGame(game, owner).discoveries!.canReveal.includes(token.id));
game = applyAction(game, owner, { type: 'discovery', token: token.id, reveal: true });
assert.equal(game.log.filter(entry => entry.automatic?.name === 'Orgiz Processing Station').length, 0);
game = nativeTypedPhase(game, 8);
game = nativeTypedPhase(game, 5, 2);
nativeTypedClearBoard(game);
// Conserved original reserve counters -> revealed Orgiz, three rival desert
// sectors, own Collection, and a normal stronghold bank-income prefix.
const haggaKeys = territory('hagga_basin').sectors.filter(sector => sector !== game.storm)
.slice(0, 2).map(sector => `hagga_basin:${sector}`);
assert.equal(haggaKeys.length, 2);
const cielagoKey = `cielago_south:${territory('cielago_south').sectors.find(sector => sector !== game.storm)!}`;
const ownKey = game.storm === 7 ? 'funeral_plain:15' : 'red_chasm:7';
const untouchedKey = 'the_great_flat:15';
nativeTypedPlace(game, owner, `${ORGIZ_PROCESSING_STATION}:0`, 1);
nativeTypedPlace(game, owner, ownKey, 1);
for (const key of [...haggaKeys, cielagoKey]) nativeTypedPlace(game, payer, key, 1);
nativeTypedPlace(game, supporter, 'carthag:11', 1);
assert.notEqual(game.storm, 11);
// Relocate only actual spice already produced by original Spice Blows. Keep
// every excess counter in an untouched deposit; do not debit a private wallet.
const boardSpice = Object.values(game.spice).reduce((sum, amount) => sum + amount, 0);
assert.ok(boardSpice >= 8);
game.spice = {
[haggaKeys[0]]: 3, [haggaKeys[1]]: 1, [cielagoKey]: 2,
[ownKey]: 1, [untouchedKey]: boardSpice - 7,
};
const ordinary = quoteSpiceCollection(game);
const discovery = quoteDiscoveryCollection(game, ordinary);
const before = structuredClone(game);
return { game, before, ordinary, discovery, boardSpice, owner, payer, token: token.id, source,
haggaKeys, cielagoKey, ownKey, untouchedKey };
}
/** Continue only original pending/phase controls into the new Collection. */
export function commitOrgizCollection(state: Game): Game {
let game = state;
for (let step = 0; step < 100 && (game.phase !== 7 || game.phaseOpening || game.response || game.decision); step++) {
assert.equal(game.turn, 2);
assert.ok(game.phase === 5 || game.phase === 6 || game.phase === 7);
game = nativeTypedStep(game);
}
assert.equal(game.phase, 7);
assert.ok(!game.phaseOpening && !game.response && !game.decision);
return game;
}
+3 -1
View File
@@ -9,6 +9,8 @@ import { TERRITORIES } from '../game/board';
import type { Card } from '../game/cards';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type SkillsTechBattleStep = { actor: string; action: Action };
export type SkillsTechBattleOptions = {
/** A fresh authenticated, undealt classic lobby; its seat IDs are retained. */
initial?: Game;
@@ -63,7 +65,7 @@ function withSkillOffer<T>(skill: SkillsTechBattleFixture['skill'], slot: number
}
/** Native policy, with deliberate no-call/no-spend decisions at original windows. */
export function nextSkillsTechBattleStep(game: Game): { actor: string; action: Action } {
export function nextSkillsTechBattleStep(game: Game): SkillsTechBattleStep {
if (game.decision?.kind === 'battleCards')
return { actor: game.decision.player, action: { type: 'decision', discard: [] } };
if (game.decision?.kind === 'techToken')
+19 -10
View File
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';
import { classicNexusModulesProfile, pairedNexusModulesProfile } from '../game/nexus-module-profile';
import { nativeFactionTechProfile } from '../game/faction-module-profile';
import { classicDiscoveryLeaderSkillsProfile } from '../game/discovery-module-profile';
import { botActions } from '../game/bots';
import { treacheryDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
@@ -48,7 +49,7 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm
| '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' | 'homeworld-occupation'
| 'discovery' | 'discovery-tech';
| 'discovery' | 'discovery-tech' | 'discovery-skills' | 'discovery-skills-tech';
type Rules = 'basic' | 'advanced';
type Scenario = {
@@ -393,6 +394,10 @@ const DISCOVERY_SCENARIOS: readonly Scenario[] =
.map((scenario, index): Scenario => ({
...scenario, profile, ordinal: 2700 + band * 100 + index,
})));
const DISCOVERY_SKILLS_SCENARIOS: readonly Scenario[] =
(['discovery-skills', 'discovery-skills-tech'] as const).flatMap((profile, band) =>
BASE_SCENARIOS.filter(scenario => profile === 'discovery-skills' || scenario.roster.length >= 3)
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 3000 + band * 100 + index })));
const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [
'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit',
];
@@ -469,7 +474,8 @@ 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 skillProfile = (profile: string) => 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 discoverySkillsProfile = (profile: string) => profile === 'discovery-skills' || profile === 'discovery-skills-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 assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' &&
scenario.roster.includes('moritani') &&
@@ -511,7 +517,7 @@ type Result = {
function usage() {
return (
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
'[--seed UINT32] [--profile all|base|discovery|discovery-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] ' +
'[--seed UINT32] [--profile all|base|discovery|discovery-tech|discovery-skills|discovery-skills-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' +
'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.' +
@@ -532,6 +538,7 @@ function usage() {
+ '\nPaired-nexus-skills-tech preserves original Tech3..6 Basic/Advanced with both Ixians+Tleilaxu OR CHOAM+Richese and classics, one original family deck/all14/all12. Paired-nexus-skills-stronghold uses Advanced2..6; both requires3..6 Advanced. Native setup, actual closing deals, typed rescue/equal substitution, original signed marker pairs, phase-end industry, held support and original winner reward before Face Dance remain. No mixed families/E3/HW/previews/companions/public starts or new rulings.'
+ '\nE3-nexus-skills composes standalone Ecaz OR Moritani and classics, ecaz33/all14/all12, Basic/Advanced2..6. Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Original Duke/assignment and Moritani assassination/normal-traitor-forfeit guards remain, including Advanced Harkonnen exclusion. Real closing draws, first-Storm/end-Mentat custody, source skill effects and original payments/rewards remain. No E3 pair/mixed families/HW/independent cards/previews/public starts or new rulings.'
+ '\nDiscovery uses fresh classic, paired E1/E2 or standalone Ecaz OR Moritani setups with original selected decks, six Discovery territory cards, Great Maker and eight physical tokens in Basic/Advanced2..6. Discovery-tech preserves unused original Tech3..6. Native setup, actual token entry, typed force paths, stash and nested location controls reuse original handlers. No Skills/HW/Nexus/Strongholds, mixed E3 families, new contested/shared-payer rulings, public starts or played-game conversion.'
+ '\nDiscovery-skills selects classic/base33/all14 Skills with original Discovery7/8, Basic/Advanced2..6. Discovery-skills-tech preserves unused original Tech3..6. Original trained posture, physical free entry, fixed-three carried movement, paid nested routes, stash capacity, skill battle rescue/rewards and distinct-party Bureaucrat invoices remain. Native families, Nexus/Strongholds/HW, Banker/Mentat previews, pending contested/shared/mixed effects, public starts and save conversion stay separate.'
);
}
@@ -651,12 +658,13 @@ function freshGame(scenario: Scenario) {
player.ready = true;
}
if (scenario.ecazTreachery) game.ecazTreachery = true;
if (scenario.profile === 'discovery-tech' || scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile) || nexusTechProfile(scenario.profile)) game.techTokens = createTechTokens();
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 (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')
game.nexusCards = { cards: null, phase: null };
if (discoverySkillsProfile(scenario.profile)) game.discoveryEnabled = true;
if (discoveryProfile(scenario.profile)) {
game.discoveryEnabled = true;
return initializeDiscoveryGameForAudit(game);
@@ -717,7 +725,7 @@ function resumedGame(path: string) {
throw new Error(
'--resume is not an incomplete faction-games snapshot with saved AI profiles.',
);
const composedSkills = (standaloneE3NexusLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview;
const composedSkills = (classicDiscoveryLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview;
const discovery = game.discoveryEnabled === true && !!game.discoveries;
if (
(game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !composedSkills) ||
@@ -726,14 +734,14 @@ 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].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].find(
(candidate) =>
discoveryProfile(candidate.profile) === discovery &&
(discoveryProfile(candidate.profile) || discoverySkillsProfile(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 === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
(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 &&
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
assassinationScenario(candidate) === !!game.moritaniAssassinatePreview &&
@@ -954,10 +962,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' && players === 2)
if ((profile === 'discovery-tech' || profile === 'discovery-skills-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 = discoveryProfile(profile) ? DISCOVERY_SCENARIOS
const requestedSamples = discoverySkillsProfile(profile) ? DISCOVERY_SKILLS_SCENARIOS
: discoveryProfile(profile) ? DISCOVERY_SCENARIOS
: profile === 'homeworld-occupation' ? HOMEWORLD_OCCUPATION_SCENARIOS
: profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
? MODULE_SCENARIOS
+5
View File
@@ -55,6 +55,8 @@ export type PrototypeOptions = {
mentatQuestion?: boolean;
/** Independent classic Nexus/Skills composition, not a public activation. */
nexusCards?: boolean;
/** Explicit classic Discovery/Skills composition, not a public activation. */
discoveries?: boolean;
};
export function isPrototypeProfile(value: string): value is PrototypeProfile {
return PROTOTYPE_PROFILES.some((profile) => profile === value);
@@ -100,10 +102,13 @@ export function startPrototypeRoom(
throw new Error('--mentat-question requires the leader-skills profile.');
if (options.nexusCards && profile !== 'leader-skills')
throw new Error('--nexus-cards requires the leader-skills profile.');
if (options.discoveries && profile !== 'leader-skills')
throw new Error('--discoveries requires the leader-skills profile.');
if (options.ecazTreachery) initial.ecazTreachery = true;
if (options.mentatQuestion) initial.mentatQuestionPreview = true;
if ((profile === 'nexus' || options.nexusCards) && !initial.nexusCards)
initial.nexusCards = { cards: null, phase: null };
if (options.discoveries) initial.discoveryEnabled = true;
const game =
profile === 'ix'
? initializeIxGameForAudit(initial)
+5 -1
View File
@@ -18,6 +18,7 @@ async function main() {
'ecaz-treachery': { type: 'boolean' },
'mentat-question': { type: 'boolean' },
'nexus-cards': { type: 'boolean' },
discoveries: { type: 'boolean' },
help: { type: 'boolean' },
},
});
@@ -41,6 +42,7 @@ async function main() {
'\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.',
);
return;
}
@@ -50,6 +52,8 @@ async function main() {
throw new Error('--mentat-question requires --profile leader-skills.');
if (values['nexus-cards'] && values.profile !== 'leader-skills')
throw new Error('--nexus-cards requires --profile leader-skills.');
if (values.discoveries && values.profile !== 'leader-skills')
throw new Error('--discoveries requires --profile leader-skills.');
if (
!isPrototypeProfile(values.profile) ||
!values.db ||
@@ -73,7 +77,7 @@ async function main() {
const db = new DatabaseSync(values.db);
try {
const result = startPrototypeRoom(db, values.room, version, values.profile,
{ ecazTreachery: values['ecaz-treachery'], mentatQuestion: values['mentat-question'], nexusCards: values['nexus-cards'] });
{ ecazTreachery: values['ecaz-treachery'], mentatQuestion: values['mentat-question'], nexusCards: values['nexus-cards'], discoveries: values.discoveries });
writeFileSync(
resolve(values.out, 'prototype.json'),
JSON.stringify(