Connect Basic Moritani to the full Leader Skills prototype
Admit the genuine private setup profile for Basic Moritani with base opponents, exactly the Ecaz expansion and no other optional modules. Keep all14 physical skills, private two-card offers, starting-card/Traitor order and Moritani's final six-force placement. Public starts and complete-mode/publication gates remain. Share public profile eligibility across Banker, Diplomat, Sandmaster movement and worm riding, Smuggler shipping/collection, Bureaucrat payments, Planetologist controls/bots and Suk/Rihani/Sandmaster battle settlement. Admission reads no hidden cards or random deal. Preserve base/CHOAM boundaries and existing source questions; no full AI implementation, strategy or calibration changes. Genuine setup regressions cover all14 assignments, legal profiles and guards, Terror Assassination death and exact skill return, optional own revival draw/privacy, Sabotage, movement controls/bots, Smuggler shipment and Bureaucrat paid bribes. Battle actions cover Rihani exchange, Sandmaster spice, Banker funds, Suk casualties and Smuggler collection. Actual enemy alliances prove losing-ally retention excludes mandatory Planetologist Special and Diplomat-copy disposal. A real worm/Nexus ride collects Sandmaster spice once. Authenticated in-memory SQLite restarts and concurrent skill choices preserve one assignment, private offers, saved seats and final Moritani placement. Conserved staged positions are explicitly distinct from played phase histories. Add a reproducible moritani-skills profile to the existing faction-game runner, including fixed2-6-player Basic rosters, seed offsets16-20 and saved resume. Verify expected module presence, all14 physical skills and ordinary Traitor custody alongside existing card/force/private-view checks. Default six expansion samples and base samples retain their rosters/ordinals. No live DB is used by these complete-game samples. CLI regressions check interrupted continuation and Advanced refusal; corruption tests catch lost cards and entire module loss. Independent review caught an old all-base worm guard and a sample checker that missed whole-module deletion; both fixed with meaningful regressions and reviewed. Initial edits had a stale helper reference and final lint found an unused import; these were corrected. Types, lint, all 5034 offline tests and build pass on stable final source. Five genuine complete-game samples passed with no rejected actions:2 players189 actions/5 restores,3 players53/1,4 players862/23, 5 players116/3 and6 players1338/36. These are legality samples, not calibration or exhaustive faction/card acceptance. No endpoint/auth/storage changes; existing HTTP suite not repeated, while new authenticated storage and live browser actions exercise the newly admitted profile through production boundaries. Browser created a fresh Basic human Moritani/Easy Emperor lobby, then the existing backed-up local initializer started that named ready room without replacing seats. Human inspected both Killer Medic bands, assigned Lupino Ord, chose a private Traitor and placed six in Polar Sink after all other setup. Refresh preserved the two public skills, own hand/Traitor and6 deployed/14 reserves at pending Storm. Mobile skill panel339px, client/scroll337; viewport restored afterward. Screenshot capture timed out, so full visual/screen-reader acceptance stays open. All 1580 opening games, 3348 saved seats and auxiliary rows remain identical. One isolated QA game adds one human seat and entry receipt; its row is unchanged after setup through refresh, with all seats unchanged. SQLite integrity and foreign keys pass. No server restart, migration, reset or recurring automation. Existing unfinished Leader Skill bands and material rulings remain explicit in the new integration guide and internal checklist. Private evidence: 20260921-moritani-skills. Verified source tree: d5078661ecc2199b2bbfebd18034b16585d06410a697a78dc514bfa5b3b77a1c
This commit is contained in:
1 parent
e7e16024b7
commit
489bd3265d
29 files changed
+1474
-126
No files matched your search
@@ -77,6 +77,10 @@ In new Advanced Ecaz development games, **Loyalty** shows the publicly set-aside
|
|||||||
Traitor Card before dealing. Inspect it without making a game choice; its identity
|
Traitor Card before dealing. Inspect it without making a game choice; its identity
|
||||||
persists through refresh. See [Ecaz Loyalty](docs/ECAZ_LOYALTY.md).
|
persists through refresh. See [Ecaz Loyalty](docs/ECAZ_LOYALTY.md).
|
||||||
|
|
||||||
|
The local Leader Skills prototype also supports Basic Moritani with base
|
||||||
|
opponents and the full fourteen-card deck. See [entry, connected interactions
|
||||||
|
and remaining boundaries](docs/MORITANI_LEADER_SKILLS.md).
|
||||||
|
|
||||||
Above the board, **Treachery draw pile** and **Spice draw pile** show live card
|
Above the board, **Treachery draw pile** and **Spice draw pile** show live card
|
||||||
counts separately from hands, auction cards and discards. See
|
counts separately from hands, auction cards and discards. See
|
||||||
[draw-pile counts](docs/DRAW_PILES.md).
|
[draw-pile counts](docs/DRAW_PILES.md).
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useId, useState } from 'react';
|
|||||||
import type { Action, GameView } from '@/game/engine';
|
import type { Action, GameView } from '@/game/engine';
|
||||||
import {
|
import {
|
||||||
planetologistLeader,
|
planetologistLeader,
|
||||||
|
planetologistMovementModeSupported,
|
||||||
type PlanetologistMoveMode,
|
type PlanetologistMoveMode,
|
||||||
} from '@/game/planetologist-movement';
|
} from '@/game/planetologist-movement';
|
||||||
import { botGroundMoveAllowed } from '@/game/bot-mobility';
|
import { botGroundMoveAllowed } from '@/game/bot-mobility';
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
} from '@/game/board';
|
} from '@/game/board';
|
||||||
import { isAdvisor } from '@/game/advisors';
|
import { isAdvisor } from '@/game/advisors';
|
||||||
import { presenceAt } from '@/game/force-presence';
|
import { presenceAt } from '@/game/force-presence';
|
||||||
import { faction } from '@/game/catalog';
|
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export function planetologistMoveDraft(
|
|||||||
blocked: 'A living, uncaptured Planetologist is required.',
|
blocked: 'A living, uncaptured Planetologist is required.',
|
||||||
};
|
};
|
||||||
if (
|
if (
|
||||||
game.players.some((player) => faction(player.faction).expansion !== 'base')
|
!planetologistMovementModeSupported(game)
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
action: null,
|
action: null,
|
||||||
|
|||||||
+24
-25
@@ -6,33 +6,32 @@ gated.** Prototypes do not open completion or publication gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
|
||||||
Latest delivered checkpoint: `de26aaf`, [Ecaz Loyalty](ECAZ_LOYALTY.md).
|
Latest delivered checkpoint: `e7e1602`, [revealed battle components](REVEALED_BATTLE_COMPONENTS.md).
|
||||||
Types, lint, **5,012 offline tests**, production build and **49 HTTP tests** passed.
|
Types, lint, **5,017 offline tests** and the production build passed. The shared
|
||||||
Six genuine faction-game samples completed with no rejected actions. A fresh
|
public display retains direct inspection across decision ownership; browser
|
||||||
Advanced Ecaz/Harkonnen browser setup preserved the public set-aside card through
|
checks covered genuine plan sealing, keyboard navigation, mobile layout and
|
||||||
Traitor/force choices and refresh. All 1,539 opening rooms, 3,259 human seats and
|
refresh after an explicitly staged conserved battle. All 1,579 opening rooms,
|
||||||
auxiliary rows stayed unchanged; isolated QA/HTTP fixtures brought the baseline
|
3,347 human seats and auxiliary rows stayed unchanged. One new QA room/seat
|
||||||
to 1,579 rooms and 3,347 human seats. Screenshot capture timed out. The healthy
|
brings the preservation baseline to 1,580 rooms and 3,348 human seats. Screenshot
|
||||||
server was reused. Earlier checkpoints include `c2f24fc` for public faction sheets
|
capture timed out; full visual acceptance remains open. The healthy server was
|
||||||
and `e3cfd82` for private-hand browsing.
|
reused. Earlier checkpoints include `de26aaf` for [Ecaz Loyalty](ECAZ_LOYALTY.md).
|
||||||
|
|
||||||
Current work: [revealed battle components](REVEALED_BATTLE_COMPONENTS.md) adds
|
Current work: [Basic Moritani with Leader Skills](MORITANI_LEADER_SKILLS.md)
|
||||||
public dials, leader portraits and readable used-card faces in one shared area,
|
connects the full fourteen-card deck with base opponents and no other optional
|
||||||
with direct inspectors and navigation to pending decisions. Sealed plans and
|
modules. Coordinated public-profile guards connect movement, shipment, payment
|
||||||
private hands stay separate. Focused checks, independent review, browser QA and
|
and battle effects; focused tests cover Terror death/revival, real alliance
|
||||||
final check/build/saved-preservation evidence accompany this presentation work.
|
retention exclusions and authenticated saved choices. A reusable two-through-six
|
||||||
The separate Bureaucrat/Emperor gift question is pending.
|
player sample profile exercises legal continuation. Final results and browser,
|
||||||
The `8f7a62c` policy checkpoint removed usage checks/cutoffs and deferred AI
|
independent review, preservation and Git evidence accompany this checkpoint.
|
||||||
refinement until feature completion, followed by three adjacent 75% targets.
|
|
||||||
|
|
||||||
The next concrete gameplay milestone is **Basic Moritani plus Leader Skills**
|
The remaining Leader Skills effects and source questions retain their explicit
|
||||||
with base opponents and no other optional modules. A bounded current-code review
|
boundaries; admitting Basic Moritani does not finish the module. Next integration
|
||||||
found no new Moritani-specific ruling, but widening setup alone is insufficient:
|
work should assess the remaining faction/module dependencies against these new
|
||||||
coordinate Banker, Diplomat, Sandmaster, Smuggler, Bureaucrat, Planetologist and
|
shared profile checks, without reducing the physical deck or hiding unsupported
|
||||||
battle/victory public-profile guards, keep the full fourteen-card deck, and test
|
cards after dealing. Advanced Moritani and Ecaz/Skills remain separate gates.
|
||||||
Terror leader death, skill return and revival through real continuations. Do not
|
The Bureaucrat/Emperor gift question is still pending. The `8f7a62c` policy
|
||||||
hide unsupported cards after a random deal or claim complete combination support.
|
checkpoint removed usage checks/cutoffs and deferred full AI work until feature
|
||||||
This provides a concrete dependency-ordered integration target for the next pass.
|
completion, followed by three adjacent 75% targets.
|
||||||
|
|
||||||
The reviewed remaining native Ecaz/Moritani effects, Duke revival and Richese
|
The reviewed remaining native Ecaz/Moritani effects, Duke revival and Richese
|
||||||
empty-cache auction arithmetic still cross recorded source questions; Loyalty is
|
empty-cache auction arithmetic still cross recorded source questions; Loyalty is
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 21 September 2026 — Basic Moritani with Leader Skills
|
||||||
|
|
||||||
|
The existing private initializer now connects Basic Moritani with base opponents
|
||||||
|
and the full fourteen-card skill deck. Shared public-profile guards cover the
|
||||||
|
movement, shipment, payment and battle paths; Terror death/revival and ally
|
||||||
|
retention compose with physical skill/card custody. Focused genuine-setup tests,
|
||||||
|
authenticated concurrent recovery and a reusable two-through-six-player sample
|
||||||
|
profile accompany the [integration contract](MORITANI_LEADER_SKILLS.md). Existing
|
||||||
|
unfinished effects, Advanced/other roster combinations and publication gates
|
||||||
|
remain explicit; private checkpoint reports record actual final evidence.
|
||||||
|
|
||||||
## 21 September 2026 — public battle component display
|
## 21 September 2026 — public battle component display
|
||||||
|
|
||||||
Revealed plans now share one table area with exact dials, played leader portraits
|
Revealed plans now share one table area with exact dials, played leader portraits
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ records the fresh publisher/designer search requested by the user.
|
|||||||
|
|
||||||
## Connected boundary
|
## Connected boundary
|
||||||
|
|
||||||
|
The [Basic Moritani follow-up](MORITANI_LEADER_SKILLS.md) now connects Moritani
|
||||||
|
with base opponents and no other optional modules. It preserves all fourteen
|
||||||
|
cards and existing unfinished-effect boundaries. The older base-only scope
|
||||||
|
descriptions below are extended only by that explicit integration contract.
|
||||||
|
|
||||||
Genuine Basic or Advanced setup supports two through six base factions. Starting
|
Genuine Basic or Advanced setup supports two through six base factions. Starting
|
||||||
Treachery Cards, including Harkonnen's extra card, precede simultaneous private
|
Treachery Cards, including Harkonnen's extra card, precede simultaneous private
|
||||||
two-card skill offers. Every faction selects one physical skill and an eligible
|
two-card skill offers. Every faction selects one physical skill and an eligible
|
||||||
@@ -74,7 +79,7 @@ Captured-card replacement entitlement remains unresolved. The prototype rejects
|
|||||||
own-leader revival while its original skill is captured, before beginning a
|
own-leader revival while its original skill is captured, before beginning a
|
||||||
paid revival or consuming the Ghola/special-Karama action. This is an explicit
|
paid revival or consuming the Ghola/special-Karama action. This is an explicit
|
||||||
unsupported boundary, not a ruling that the physical game forbids that revival.
|
unsupported boundary, not a ruling that the physical game forbids that revival.
|
||||||
Foreign gholas, expansion rosters and combined optional modules remain gated.
|
Foreign gholas, other expansion rosters and combined optional modules remain gated.
|
||||||
Before enabling them, add response-driven automatic revival continuation
|
Before enabling them, add response-driven automatic revival continuation
|
||||||
coverage; the current before/after revival observer does not certify deferred
|
coverage; the current before/after revival observer does not certify deferred
|
||||||
Tleilaxu response chains. The other source-contract timing questions also remain
|
Tleilaxu response chains. The other source-contract timing questions also remain
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Basic Moritani with Leader Skills
|
||||||
|
|
||||||
|
Connected development prototype, 21 September 2026. Basic rules, Moritani and
|
||||||
|
two through six total seats with base-faction opponents now use the full
|
||||||
|
fourteen-card Leader Skills deck. Public starts and publication remain gated;
|
||||||
|
this integrates existing effects, not complete Leader Skills compliance.
|
||||||
|
|
||||||
|
## Public configuration and setup
|
||||||
|
|
||||||
|
The existing `leader-skills` local initializer accepts exactly `['ecaz']`,
|
||||||
|
Basic rules, Moritani plus base factions, and no other optional modules. It
|
||||||
|
does not enable Ecaz, Advanced assassination, the separate Ecaz Treachery
|
||||||
|
variant, Homeworlds, Nexus, Discoveries, Tech Tokens or Stronghold Cards with
|
||||||
|
this roster. Existing base and CHOAM/Richese profiles retain their boundaries.
|
||||||
|
Admission uses only public configuration, never hidden hands or the random deal.
|
||||||
|
|
||||||
|
Starting Treachery Cards and the ordinary private two-card skill offers precede
|
||||||
|
Traitors. Every physical skill remains eligible for Basic Moritani assignment;
|
||||||
|
there is no reduced deck or card-specific admission workaround. Moritani places
|
||||||
|
its six starting forces after the other setup choices. Existing human controls,
|
||||||
|
private offers, public assignments and minimal legal AI choices are reused.
|
||||||
|
|
||||||
|
## Connected interactions
|
||||||
|
|
||||||
|
Shared public-profile checks connect Banker spending, Diplomat base-defense
|
||||||
|
copying, Sandmaster ground/worm collection, Smuggler shipping/battle collection,
|
||||||
|
Bureaucrat payment redirection and Planetologist movement across engine,
|
||||||
|
projection, controls and bots. Suk casualty rescue, Rihani exchanges and
|
||||||
|
Sandmaster victory spice use the same expanded battle boundary. Printed leader
|
||||||
|
strength stays distinct from skill bonuses and bounty calculations.
|
||||||
|
|
||||||
|
Terror Assassination kills an ordinary skilled leader under the existing Terror
|
||||||
|
rules and returns its exact physical skill once. A later own-leader revival
|
||||||
|
offers an optional draw before revealing either card; the drawn choice remains
|
||||||
|
private through saved continuation. Sabotage affects Treachery custody only.
|
||||||
|
An Enemy of My Enemy alliance changes allies without transferring assignments.
|
||||||
|
The losing ally's retention choice excludes a used Planetologist Special and a
|
||||||
|
Diplomat copied Worthless card, since both must be discarded after use.
|
||||||
|
|
||||||
|
These are compositions of the recorded [Leader Skills contract](LEADER_SKILLS_RULES.md),
|
||||||
|
[Terror Assassination](MORITANI_ASSASSINATION_SNEAK.md),
|
||||||
|
[enemy alliance](MORITANI_ENEMY_ALLIANCE.md),
|
||||||
|
[ally retention](MORITANI_ALLY_RETENTION_RULES.md),
|
||||||
|
[Planetologist](PLANETOLOGIST_RULES.md) and [Diplomat](DIPLOMAT_DEFENSE.md)
|
||||||
|
contracts. No new publisher ruling is asserted. Existing Atomics/Extortion,
|
||||||
|
exceptional assassination pools and competing-arrival questions remain explicit.
|
||||||
|
|
||||||
|
## Remaining work
|
||||||
|
|
||||||
|
The [Leader Skills runtime](LEADER_SKILLS_RUNTIME.md) retains unfinished normal
|
||||||
|
Banker income, ordinary Mentat-question activation, Diplomat retreat, modified
|
||||||
|
Smuggler collection, captured replacement entitlement and other source gaps.
|
||||||
|
The private Mentat question preview has not been enabled for this roster.
|
||||||
|
Basic Moritani admission does not settle those effects or certify every valid
|
||||||
|
expansion/module combination. Advanced Moritani, Ecaz, Ixians and Tleilaxu need
|
||||||
|
their own integration, including the recorded deferred Tleilaxu revival timing.
|
||||||
|
Full AI strategy and difficulty calibration still wait for feature completion.
|
||||||
|
|
||||||
|
## Reproducible entry and verification
|
||||||
|
|
||||||
|
Create a fresh Basic lobby with Ecaz & Moritani selected, seat Moritani and only
|
||||||
|
base opponents, and ready every human. Preserve the room cookie and run the
|
||||||
|
existing backed-up, version-checked local tool:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --import tsx tools/start-prototype.ts --profile leader-skills \
|
||||||
|
--db PATH_TO_LOCAL_D1_SQLITE --room EIGHT_CHARACTER_CODE \
|
||||||
|
--version CURRENT_LOBBY_VERSION --out /private/new-moritani-skills
|
||||||
|
node --import tsx tools/faction-games.ts --profile moritani-skills \
|
||||||
|
--out /private/new-moritani-skills-samples
|
||||||
|
npm test -- moritani-skills
|
||||||
|
```
|
||||||
|
|
||||||
|
The sample profile runs Basic games at two through six seats, adding Emperor,
|
||||||
|
Guild, Harkonnen, Fremen and Bene Gesserit in that order. It preserves all four
|
||||||
|
existing difficulty profiles and verifies Treachery, skill, Traitor and force
|
||||||
|
custody, rejected-action immutability and periodic JSON/private-view restoration.
|
||||||
|
Separate genuine-setup focused tests include Atreides. These are legal-play
|
||||||
|
samples, not calibration or exhaustive roster/skill coverage. Interrupted games
|
||||||
|
can use the existing `--resume` path, which retains the full module and roster.
|
||||||
|
|
||||||
|
Focused regressions exercise every physical skill assignment, public guards,
|
||||||
|
movement controls, legal bot choices, Terror death/Sabotage, revival, payment,
|
||||||
|
battle effects, real alliances and mandatory retention exclusions. Authenticated
|
||||||
|
in-memory SQLite restart and concurrent choices verify one physical assignment,
|
||||||
|
private offers, preserved seats and the final Moritani placement. Conserved
|
||||||
|
staged phase/battle positions are labeled rather than claimed as played games.
|
||||||
|
|
||||||
|
Private source-bound final checks, sample results, browser observations,
|
||||||
|
saved-game preservation and Git delivery live under `20260921-moritani-skills`
|
||||||
|
outside the checkout. Actual outcomes and remaining limitations are recorded in
|
||||||
|
the commit message; no saved game is reset or discarded for verification.
|
||||||
@@ -6,6 +6,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi
|
|||||||
|
|
||||||
| Topic | Decision to preserve | Detailed authority and evidence |
|
| Topic | Decision to preserve | Detailed authority and evidence |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| Basic Moritani / Leader Skills | Ordinary Terror death returns the assigned skill once; own revival offers the existing private optional replacement. Sabotage does not take skills, alliances do not transfer assignments, and mandatory Planetologist/Diplomat disposal excludes those cards from ally retention. This is composition of existing contracts, not a new ruling or resolution of unfinished skill effects. Advanced and other roster/module combinations remain gated. | [Integration contract](MORITANI_LEADER_SKILLS.md) |
|
||||||
| Ecaz Loyalty | Before initial Traitor dealing, randomly set aside one native Ecaz card face up permanently. No Karama effect; no change to its leader disc. Resolving after existing pre-Traitor setup is an explicit sequencing composition. Older dealt saves are not retrofitted; combined Skills/assassination rosters remain gated. | [Official sources and runtime](ECAZ_LOYALTY.md) |
|
| Ecaz Loyalty | Before initial Traitor dealing, randomly set aside one native Ecaz card face up permanently. No Karama effect; no change to its leader disc. Resolving after existing pre-Traitor setup is an explicit sequencing composition. Older dealt saves are not retrofitted; combined Skills/assassination rosters remain gated. | [Official sources and runtime](ECAZ_LOYALTY.md) |
|
||||||
| Sapho battle order | First/last reorders remaining Battle-phase choosers at clean boundaries. Phase priority persists through aftermath; actual legal pairs determine eligibility. Separating the chooser from physical attacker/tie advantage is an explicit implementation interpretation of the card's distinct alternatives, not a dedicated FAQ ruling. Last does not prevent others from choosing the holder as an opponent. | [Runtime scope and source interpretation](SAPHO_BATTLE_ORDER.md) |
|
| Sapho battle order | First/last reorders remaining Battle-phase choosers at clean boundaries. Phase priority persists through aftermath; actual legal pairs determine eligibility. Separating the chooser from physical attacker/tie advantage is an explicit implementation interpretation of the card's distinct alternatives, not a dedicated FAQ ruling. Last does not prevent others from choosing the holder as an opponent. | [Runtime scope and source interpretation](SAPHO_BATTLE_ORDER.md) |
|
||||||
| Harass & Withdraw | Either slot but neither category; private Prescience answers null. Return uniquely determined own undialed physical forces before weapon losses, canceled by an opposing successful Traitor. Mandatory after-use discard overrides winner retention. Explosion ordering and own-Traitor disposal are explicit timing/specificity inferences, consistent with the recorded Planetologist precedent. | [Runtime and sources](HARASS_WITHDRAW_RUNTIME.md) |
|
| Harass & Withdraw | Either slot but neither category; private Prescience answers null. Return uniquely determined own undialed physical forces before weapon losses, canceled by an opposing successful Traitor. Mandatory after-use discard overrides winner retention. Explosion ordering and own-Traitor disposal are explicit timing/specificity inferences, consistent with the recorded Planetologist precedent. | [Runtime and sources](HARASS_WITHDRAW_RUNTIME.md) |
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ An interrupted or incomplete report is never evidence of success.
|
|||||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-faction-games
|
node --import tsx tools/faction-games.ts --out /tmp/dune-new-faction-games
|
||||||
# Ten genuine base samples: Basic and Advanced at each player count.
|
# Ten genuine base samples: Basic and Advanced at each player count.
|
||||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-base-games --profile base
|
node --import tsx tools/faction-games.ts --out /tmp/dune-new-base-games --profile base
|
||||||
|
# Basic Moritani with the full Leader Skills deck, two through six seats.
|
||||||
|
node --import tsx tools/faction-games.ts --out /tmp/dune-new-moritani-skills --profile moritani-skills
|
||||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-base-three --profile base --players 3 --rules advanced
|
node --import tsx tools/faction-games.ts --out /tmp/dune-new-base-three --profile base --players 3 --rules advanced
|
||||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-combined --profile combined --rules advanced --seed 20260926
|
node --import tsx tools/faction-games.ts --out /tmp/dune-new-combined --profile combined --rules advanced --seed 20260926
|
||||||
node --import tsx tools/faction-games.ts --out /tmp/dune-new-resume --resume /private/failed-combined-advanced.json
|
node --import tsx tools/faction-games.ts --out /tmp/dune-new-resume --resume /private/failed-combined-advanced.json
|
||||||
@@ -47,6 +49,13 @@ five counts. The fixed base roster adds Atreides, Harkonnen, Fremen, Emperor,
|
|||||||
Guild and Bene Gesserit in that order. These are sample rosters, not an assertion
|
Guild and Bene Gesserit in that order. These are sample rosters, not an assertion
|
||||||
that these are the only rules-permitted player-count configurations.
|
that these are the only rules-permitted player-count configurations.
|
||||||
|
|
||||||
|
`--profile moritani-skills` adds five Basic samples with Moritani, then Emperor,
|
||||||
|
Guild, Harkonnen, Fremen and Bene Gesserit. `--players` also narrows this profile;
|
||||||
|
Advanced requests fail before running. Its seed offsets are 16–20, leaving
|
||||||
|
existing samples unchanged. The full fourteen-card skill census and ordinary
|
||||||
|
Moritani/base Traitor census are checked throughout. `--resume` preserves this
|
||||||
|
explicit profile; other optional modules still fail its admission checks.
|
||||||
|
|
||||||
All samples use genuine setup and saved AI profiles, cycling Easy, Medium,
|
All samples use genuine setup and saved AI profiles, cycling Easy, Medium,
|
||||||
Hard and Brutal by seat. Smaller games necessarily contain fewer profiles;
|
Hard and Brutal by seat. Smaller games necessarily contain fewer profiles;
|
||||||
the matrix collectively exercises all four. Filters retain each scenario's
|
the matrix collectively exercises all four. Filters retain each scenario's
|
||||||
|
|||||||
+2
-3
@@ -83,10 +83,9 @@ import {
|
|||||||
fremenReserveEntry,
|
fremenReserveEntry,
|
||||||
guildTransportCost,
|
guildTransportCost,
|
||||||
} from './bot-mobility';
|
} from './bot-mobility';
|
||||||
import { planetologistLeader } from './planetologist-movement';
|
import { planetologistMovementModeSupported, planetologistLeader } from './planetologist-movement';
|
||||||
import { sandmasterDefaultChoice } from './sandmaster-movement';
|
import { sandmasterDefaultChoice } from './sandmaster-movement';
|
||||||
import { sandmasterWormCollection } from './sandmaster-worm';
|
import { sandmasterWormCollection } from './sandmaster-worm';
|
||||||
import { faction } from './catalog';
|
|
||||||
import { richeseCardDefinition } from './richese-cards';
|
import { richeseCardDefinition } from './richese-cards';
|
||||||
import { presenceAt } from './force-presence';
|
import { presenceAt } from './force-presence';
|
||||||
import { botArrivalBlock } from './bot-arrival';
|
import { botArrivalBlock } from './bot-arrival';
|
||||||
@@ -3301,7 +3300,7 @@ function policyActions(g: GameView): Action[] {
|
|||||||
movementAvailable &&
|
movementAvailable &&
|
||||||
planetologist &&
|
planetologist &&
|
||||||
!g.ornithopter?.active &&
|
!g.ornithopter?.active &&
|
||||||
g.players.every((player) => faction(player.faction).expansion === 'base')
|
planetologistMovementModeSupported(g)
|
||||||
) {
|
) {
|
||||||
const usefulTargets = targets
|
const usefulTargets = targets
|
||||||
.filter(
|
.filter(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { basicMoritaniLeaderSkillsProfile, noOtherLeaderSkillModules, type LeaderSkillProfile } from './leader-skill-profile';
|
||||||
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe';
|
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe';
|
||||||
export type BureaucratPaymentSource = {
|
export type BureaucratPaymentSource = {
|
||||||
event: string;
|
event: string;
|
||||||
@@ -33,25 +34,11 @@ export class BureaucratPaymentError extends Error {}
|
|||||||
export function bureaucratPaymentSignature(value: object): string {
|
export function bureaucratPaymentSignature(value: object): string {
|
||||||
return JSON.stringify({ ...value, signature: undefined });
|
return JSON.stringify({ ...value, signature: undefined });
|
||||||
}
|
}
|
||||||
export function bureaucratPaymentModeSupported(game: {
|
export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolean {
|
||||||
expansions: readonly string[];
|
return noOtherLeaderSkillModules(game) &&
|
||||||
homeworlds?: unknown;
|
|
||||||
nexusCards?: unknown;
|
|
||||||
discoveries?: unknown;
|
|
||||||
discoveryEnabled?: unknown;
|
|
||||||
strongholdCards?: unknown;
|
|
||||||
techTokens?: unknown;
|
|
||||||
}): boolean {
|
|
||||||
return (
|
|
||||||
(!game.expansions.length ||
|
(!game.expansions.length ||
|
||||||
(game.expansions.length === 1 && game.expansions[0] === 'choam')) &&
|
(game.expansions.length === 1 && game.expansions[0] === 'choam') ||
|
||||||
!game.homeworlds &&
|
basicMoritaniLeaderSkillsProfile(game));
|
||||||
!game.nexusCards &&
|
|
||||||
!game.discoveries &&
|
|
||||||
!game.discoveryEnabled &&
|
|
||||||
!game.strongholdCards &&
|
|
||||||
!game.techTokens
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
export function bureaucratUsed(
|
export function bureaucratUsed(
|
||||||
used: readonly BureaucratPaymentUse[],
|
used: readonly BureaucratPaymentUse[],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ordinaryLeaderSkillModeSupported, type LeaderSkillProfile } from './leader-skill-profile';
|
||||||
import { baseDeck, type Card } from './cards';
|
import { baseDeck, type Card } from './cards';
|
||||||
import type { BattleLeaderSkill } from './leader-skill-combat';
|
import type { BattleLeaderSkill } from './leader-skill-combat';
|
||||||
|
|
||||||
@@ -69,22 +70,6 @@ export function copiedDiplomatDefense(
|
|||||||
return { id: card, name: 'Diplomat defense', kind: quote.kind };
|
return { id: card, name: 'Diplomat defense', kind: quote.kind };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function diplomatDefenseModeSupported(game: {
|
export function diplomatDefenseModeSupported(game: LeaderSkillProfile): boolean {
|
||||||
expansions: readonly string[];
|
return ordinaryLeaderSkillModeSupported(game);
|
||||||
homeworlds?: unknown;
|
|
||||||
nexusCards?: unknown;
|
|
||||||
discoveries?: unknown;
|
|
||||||
discoveryEnabled?: unknown;
|
|
||||||
strongholdCards?: unknown;
|
|
||||||
techTokens?: unknown;
|
|
||||||
}): boolean {
|
|
||||||
return (
|
|
||||||
!game.expansions.length &&
|
|
||||||
!game.homeworlds &&
|
|
||||||
!game.nexusCards &&
|
|
||||||
!game.discoveries &&
|
|
||||||
!game.discoveryEnabled &&
|
|
||||||
!game.strongholdCards &&
|
|
||||||
!game.techTokens
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
+10
-8
@@ -1,3 +1,4 @@
|
|||||||
|
import { basicMoritaniLeaderSkillsProfile, ordinaryLeaderSkillModeSupported } from './leader-skill-profile';
|
||||||
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
|
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
|
||||||
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
|
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
|
||||||
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
|
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
|
import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
|
||||||
import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board';
|
import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board';
|
||||||
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
|
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
|
||||||
import { planetologistLeader, planetologistRange, type PlanetologistMovement } from './planetologist-movement';
|
import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, type PlanetologistMovement } from './planetologist-movement';
|
||||||
import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field';
|
import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field';
|
||||||
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
|
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
|
||||||
import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, type SmugglerBattleReceipt } from './smuggler-battle';
|
import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, type SmugglerBattleReceipt } from './smuggler-battle';
|
||||||
@@ -5902,7 +5903,8 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
|||||||
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
|
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
|
||||||
const g = structuredClone(state);
|
const g = structuredClone(state);
|
||||||
g.leaderSkills = createLeaderSkills(random);
|
g.leaderSkills = createLeaderSkills(random);
|
||||||
return initializeSetupGameForAudit(g, false, false, false, false, true, g.expansions.length === 1 && g.expansions[0] === 'choam');
|
return initializeSetupGameForAudit(g, false, false, false, false, true,
|
||||||
|
g.expansions.length === 1 && g.expansions[0] === 'choam', basicMoritaniLeaderSkillsProfile(g));
|
||||||
}
|
}
|
||||||
/** Gated development setup for the independent three-card Ecaz variant. */
|
/** Gated development setup for the independent three-card Ecaz variant. */
|
||||||
export function initializeEcazTreacheryGameForAudit(state: Game): Game {
|
export function initializeEcazTreacheryGameForAudit(state: Game): Game {
|
||||||
@@ -9398,7 +9400,7 @@ function validatePlanetologistMove(g: Game, p: Player, move: Pick<MovementOrder,
|
|||||||
(skill.mode === 'range' || skill.mode === 'gather') &&
|
(skill.mode === 'range' || skill.mode === 'gather') &&
|
||||||
skill.leader === planetologistLeader(g, p.id),
|
skill.leader === planetologistLeader(g, p.id),
|
||||||
'This Planetologist movement no longer has its living, uncaptured skilled leader.');
|
'This Planetologist movement no longer has its living, uncaptured skilled leader.');
|
||||||
requireRule(g.players.every(player => faction(player.faction).expansion === 'base'),
|
requireRule(planetologistMovementModeSupported(g),
|
||||||
'Planetologist movement combined with expansion factions is still being implemented.');
|
'Planetologist movement combined with expansion factions is still being implemented.');
|
||||||
requireRule(!move.noField && !move.ornithopterEvent && !move.discoveryFlight,
|
requireRule(!move.noField && !move.ornithopterEvent && !move.discoveryFlight,
|
||||||
'Planetologist combined with special movement cards or No-Field is still being implemented.');
|
'Planetologist combined with special movement cards or No-Field is still being implemented.');
|
||||||
@@ -15615,12 +15617,12 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
|||||||
!g.advanced || getPlayer(g, quote.winner!).faction !== 'atreides',
|
!g.advanced || getPlayer(g, quote.winner!).faction !== 'atreides',
|
||||||
'Suk Graduate rescue for Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.');
|
'Suk Graduate rescue for Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.');
|
||||||
if (quote.sukGraduate) requireRule(
|
if (quote.sukGraduate) requireRule(
|
||||||
!g.homeworlds && !g.nexusCards && !g.discoveryEnabled && !g.strongholdCards && !g.techTokens && !g.expansions.length &&
|
ordinaryLeaderSkillModeSupported(g) &&
|
||||||
g.players.every((p) => faction(p.faction).expansion === 'base'),
|
(basicMoritaniLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
|
||||||
'Suk Graduate rescue with expansion factions or other optional modules is still being implemented.');
|
'Suk Graduate rescue with expansion factions or other optional modules is still being implemented.');
|
||||||
if (quote.rihani || quote.sandmaster) requireRule(
|
if (quote.rihani || quote.sandmaster) requireRule(
|
||||||
!g.homeworlds && !g.nexusCards && !g.discoveryEnabled && !g.strongholdCards && !g.techTokens && !g.expansions.length &&
|
ordinaryLeaderSkillModeSupported(g) &&
|
||||||
g.players.every((p) => faction(p.faction).expansion === 'base'),
|
(basicMoritaniLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
|
||||||
'These Leader Skill victory effects with expansion factions or other optional modules are still being integrated.');
|
'These Leader Skill victory effects with expansion factions or other optional modules are still being integrated.');
|
||||||
const smuggler = b.smugglerCollection ? nexusRule(() => settleSmugglerBattle(b.smugglerCollection!,
|
const smuggler = b.smugglerCollection ? nexusRule(() => settleSmugglerBattle(b.smugglerCollection!,
|
||||||
!(b.smugglerCollection!.player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender), g.spice)) : null;
|
!(b.smugglerCollection!.player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender), g.spice)) : null;
|
||||||
@@ -18714,7 +18716,7 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
|
|||||||
if (shipment.smuggler) requireRule(
|
if (shipment.smuggler) requireRule(
|
||||||
!shipment.source && !shipment.noField && !shipment.alliedNoField &&
|
!shipment.source && !shipment.noField && !shipment.alliedNoField &&
|
||||||
!shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent &&
|
!shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent &&
|
||||||
!g.expansions.length && !g.homeworlds && !g.nexusCards && !g.discoveryEnabled && !g.strongholdCards && !g.techTokens,
|
ordinaryLeaderSkillModeSupported(g),
|
||||||
'Smuggler shipping with expansion modules is still being integrated.',
|
'Smuggler shipping with expansion modules is still being integrated.',
|
||||||
);
|
);
|
||||||
if (shipment.guildSecretEvent) {
|
if (shipment.guildSecretEvent) {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { FACTIONS } from './catalog';
|
||||||
|
|
||||||
|
/** Public configuration only; never gate a skill on a hidden card or random deal. */
|
||||||
|
export type LeaderSkillProfile = {
|
||||||
|
expansions: readonly string[];
|
||||||
|
advanced?: boolean;
|
||||||
|
players?: readonly { faction: string }[];
|
||||||
|
homeworlds?: unknown;
|
||||||
|
nexusCards?: unknown;
|
||||||
|
discoveries?: unknown;
|
||||||
|
discoveryEnabled?: unknown;
|
||||||
|
strongholdCards?: unknown;
|
||||||
|
techTokens?: unknown;
|
||||||
|
ecazTreachery?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean {
|
||||||
|
return !game.homeworlds && !game.nexusCards && !game.discoveries &&
|
||||||
|
!game.discoveryEnabled && !game.strongholdCards && !game.techTokens && !game.ecazTreachery;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounded integration of the ordinary Moritani roster, not Advanced assassination. */
|
||||||
|
export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||||
|
return game.advanced === false && game.expansions.length === 1 &&
|
||||||
|
game.expansions[0] === 'ecaz' && noOtherLeaderSkillModules(game) &&
|
||||||
|
!!game.players?.some(player => player.faction === 'moritani') &&
|
||||||
|
game.players.every(player => player.faction === 'moritani' ||
|
||||||
|
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by rule quotes, controls and minimal legal bot participation. */
|
||||||
|
export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): boolean {
|
||||||
|
return noOtherLeaderSkillModules(game) &&
|
||||||
|
(!game.expansions.length || basicMoritaniLeaderSkillsProfile(game));
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { FACTIONS } from './catalog';
|
||||||
|
import { basicMoritaniLeaderSkillsProfile } from './leader-skill-profile';
|
||||||
import type { Game, GameView } from './engine';
|
import type { Game, GameView } from './engine';
|
||||||
|
|
||||||
export type PlanetologistMoveMode = 'range' | 'gather';
|
export type PlanetologistMoveMode = 'range' | 'gather';
|
||||||
@@ -35,3 +37,9 @@ export function planetologistRange(
|
|||||||
): number {
|
): number {
|
||||||
return mode === 'range' ? Math.min(3, base + 1) : base;
|
return mode === 'range' ? Math.min(3, base + 1) : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keep the existing base-roster boundary and add the connected Basic Moritani profile. */
|
||||||
|
export function planetologistMovementModeSupported(game: Game | GameView): boolean {
|
||||||
|
return game.players.every(player => FACTIONS.some(f => f.id === player.faction && f.expansion === 'base')) ||
|
||||||
|
basicMoritaniLeaderSkillsProfile(game);
|
||||||
|
}
|
||||||
+24
-1
@@ -4000,6 +4000,28 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
|
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'moritani-leader-skills',
|
||||||
|
title: 'Moritani with Leader Skills',
|
||||||
|
category: 'Advanced & expansions',
|
||||||
|
coverage: 'Partial',
|
||||||
|
developmentStage: 'Prototyped',
|
||||||
|
summary: 'Basic Moritani and base opponents use the full fourteen-card Leader Skills deck in development tables.',
|
||||||
|
steps: [
|
||||||
|
'Assign skills after starting Treachery Cards and before Traitor choices. Moritani places its six starting forces last. All fourteen skills remain in the physical deck; each player keeps one of two private choices and publicly assigns it to an eligible leader.',
|
||||||
|
'Connected movement, shipping, payment and battle skills use the same controls and legal quotes as the base profile. Terror Assassination returns a killed leader’s exact skill once; a later own-leader revival offers the normal private optional replacement. Sabotage affects Treachery Cards, not skills.',
|
||||||
|
'An Enemy of My Enemy alliance preserves each faction’s assignments. A losing ally cannot retain a used Planetologist Special or the Worthless card used for a Diplomat copy, because those cards must be discarded.',
|
||||||
|
'This profile uses Basic rules, Moritani with base opponents and no other optional modules. Existing unfinished Leader Skill effects and disputed cases remain unfinished. Advanced Moritani, Ecaz and further roster/module combinations need separate integration; ordinary public starts remain disabled.',
|
||||||
|
],
|
||||||
|
related: ['leader-skills', 'expansion-faction-games', 'implementation-checklist'],
|
||||||
|
checklist: [
|
||||||
|
{ area: 'Implementation', status: 'Partial', detail: 'Genuine setup and shared public-profile guards connect existing skill effects to Basic Moritani. Terror custody and ally-retention disposal reuse settled contracts; unfinished effects remain explicit.', evidence: ['game/leader-skill-profile.ts', 'game/engine.ts'] },
|
||||||
|
{ area: 'Player controls', status: 'Implemented', detail: 'Existing private offers, public assignments, battle and revival choices are reused; Planetologist controls share the expanded server predicate.', evidence: ['components/leader-skills.tsx', 'components/planetologist-movement.tsx'] },
|
||||||
|
{ area: 'AI', status: 'Partial', detail: 'Existing profiles retain minimal legal participation through shared public choices. Strategy and difficulty calibration remain deferred until game features are complete.', evidence: ['game/bots.ts', 'tools/faction-games.ts'] },
|
||||||
|
{ area: 'Documentation', status: 'Implemented', detail: 'The integration guide records entry, exact scope, existing source contracts and remaining effects without claiming full module compliance.', evidence: ['docs/MORITANI_LEADER_SKILLS.md'] },
|
||||||
|
{ area: 'Verification', status: 'Partial', detail: 'Genuine setup, all physical assignments, Terror death/revival, movement/payment/battle continuations, mandatory retention exclusions and authenticated concurrent recovery have focused checks. Sample and browser results are recorded separately; full combined-rule acceptance remains open.', evidence: ['tests/moritani-skills-integration.test.ts', 'tests/moritani-skills-custody.test.ts', 'tests/moritani-skills-battle.test.ts', 'tests/moritani-skills-recovery.test.ts', 'tests/moritani-skills-worm.test.ts'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'leader-skills',
|
id: 'leader-skills',
|
||||||
title: 'Leader Skills',
|
title: 'Leader Skills',
|
||||||
@@ -4008,6 +4030,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
developmentStage: 'Prototyped',
|
developmentStage: 'Prototyped',
|
||||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and unmodified battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
|
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and unmodified battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||||
steps: [
|
steps: [
|
||||||
|
'A development profile also connects Basic Moritani with base opponents and no other optional modules. It keeps the full deck and existing unfinished-effect boundaries; see Moritani with Leader Skills.',
|
||||||
'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.',
|
'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.',
|
||||||
'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.',
|
'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.',
|
||||||
'Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and Prana-Bindu Adept are connected. They give another leader +1 for the matching physical card role, or the surviving assigned leader +3. These bonuses do not stack to +4. They change battle strength, not the printed disc value or leader bounty.',
|
'Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and Prana-Bindu Adept are connected. They give another leader +1 for the matching physical card role, or the surviving assigned leader +3. These bonuses do not stack to +4. They change battle strength, not the printed disc value or leader bounty.',
|
||||||
@@ -4028,7 +4051,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'Other special Sandmaster relocation, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
'Other special Sandmaster relocation, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||||
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
|
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
|
||||||
],
|
],
|
||||||
related: ['setup','choam-modules','implementation-checklist'],
|
related: ['moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
|
||||||
checklist: [
|
checklist: [
|
||||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; Spice Banker normal income, other remaining bands, Diplomat retreat and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/engine.ts'] },
|
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; Spice Banker normal income, other remaining bands, Diplomat retreat and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/engine.ts'] },
|
||||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
|
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ordinaryLeaderSkillModeSupported } from './leader-skill-profile';
|
||||||
import type { Action, Game, GameView } from './engine';
|
import type { Action, Game, GameView } from './engine';
|
||||||
import { GRAPH, location, mobileRouteDistance, splitLocation } from './board';
|
import { GRAPH, location, mobileRouteDistance, splitLocation } from './board';
|
||||||
import { isAdvisor } from './advisors';
|
import { isAdvisor } from './advisors';
|
||||||
@@ -32,15 +33,7 @@ const record = (value: unknown): value is Record<string, unknown> =>
|
|||||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
export function sandmasterModeSupported(g: Game | GameView): boolean {
|
export function sandmasterModeSupported(g: Game | GameView): boolean {
|
||||||
return (
|
return ordinaryLeaderSkillModeSupported(g);
|
||||||
!g.expansions.length &&
|
|
||||||
!g.homeworlds &&
|
|
||||||
!g.nexusCards &&
|
|
||||||
!g.discoveries &&
|
|
||||||
!('discoveryEnabled' in g && g.discoveryEnabled) &&
|
|
||||||
!g.strongholdCards &&
|
|
||||||
!g.techTokens
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
export function sandmasterLeader(
|
export function sandmasterLeader(
|
||||||
g: Game | GameView,
|
g: Game | GameView,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Game, GameView } from './engine';
|
import type { Game, GameView } from './engine';
|
||||||
import { splitLocation, validGameLocation } from './board';
|
import { splitLocation, validGameLocation } from './board';
|
||||||
import { faction } from './catalog';
|
import { faction } from './catalog';
|
||||||
|
import { basicMoritaniLeaderSkillsProfile } from './leader-skill-profile';
|
||||||
import { sandmasterLeader, sandmasterModeSupported } from './sandmaster-movement';
|
import { sandmasterLeader, sandmasterModeSupported } from './sandmaster-movement';
|
||||||
|
|
||||||
export type SandmasterWormCollection = {
|
export type SandmasterWormCollection = {
|
||||||
@@ -25,7 +26,8 @@ export function sandmasterWormCollection(
|
|||||||
const blocked = (reason: string): SandmasterWormCollection =>
|
const blocked = (reason: string): SandmasterWormCollection =>
|
||||||
({ leader, key: null, before: 0, blocked: reason });
|
({ leader, key: null, before: 0, blocked: reason });
|
||||||
if (!sandmasterModeSupported(game) || game.ecazTreachery ||
|
if (!sandmasterModeSupported(game) || game.ecazTreachery ||
|
||||||
game.players.some(p => faction(p.faction).expansion !== 'base'))
|
!(basicMoritaniLeaderSkillsProfile(game) ||
|
||||||
|
game.players.every(p => faction(p.faction).expansion === 'base')))
|
||||||
return blocked('Sandmaster worm collection with other optional modules is still being integrated.');
|
return blocked('Sandmaster worm collection with other optional modules is still being integrated.');
|
||||||
if (destination === decision.territory ||
|
if (destination === decision.territory ||
|
||||||
!validGameLocation(game, destination, sector) || sector === game.storm)
|
!validGameLocation(game, destination, sector) || sector === game.storm)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { FACTIONS } from './catalog';
|
||||||
|
import { basicMoritaniLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, type LeaderSkillProfile } from './leader-skill-profile';
|
||||||
import type { Card, Leader } from './cards';
|
import type { Card, Leader } from './cards';
|
||||||
import { faction } from './catalog';
|
|
||||||
import { splitLocation, validGameLocation } from './board';
|
import { splitLocation, validGameLocation } from './board';
|
||||||
import { leaderSkillBattleBonus, usesSurvivingSkilledLeader, type BattleLeaderSkill } from './leader-skill-combat';
|
import { leaderSkillBattleBonus, usesSurvivingSkilledLeader, type BattleLeaderSkill } from './leader-skill-combat';
|
||||||
|
|
||||||
@@ -8,13 +9,9 @@ export type SmugglerBattleReceipt = {
|
|||||||
frame: string; strength: number; key: string | null; before: number; amount: number;
|
frame: string; strength: number; key: string | null; before: number; amount: number;
|
||||||
stage: 'pending' | 'collected' | 'void'; signature: string;
|
stage: 'pending' | 'collected' | 'void'; signature: string;
|
||||||
};
|
};
|
||||||
export function smugglerBattleModeSupported(g: {
|
export function smugglerBattleModeSupported(g: LeaderSkillProfile & { players: readonly { faction: string }[] }) {
|
||||||
expansions: readonly string[]; players: readonly { faction: string }[];
|
return ordinaryLeaderSkillModeSupported(g) &&
|
||||||
homeworlds?: unknown; nexusCards?: unknown; discoveries?: unknown;
|
(basicMoritaniLeaderSkillsProfile(g) || g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base')));
|
||||||
discoveryEnabled?: boolean; strongholdCards?: unknown; techTokens?: unknown; ecazTreachery?: unknown;
|
|
||||||
}) {
|
|
||||||
return !g.expansions.length && g.players.every(p => faction(p.faction)?.expansion === 'base') &&
|
|
||||||
!g.homeworlds && !g.nexusCards && !g.discoveries && !g.discoveryEnabled && !g.strongholdCards && !g.techTokens && !g.ecazTreachery;
|
|
||||||
}
|
}
|
||||||
export type SmugglerBattlePlan = {
|
export type SmugglerBattlePlan = {
|
||||||
assignments: readonly BattleLeaderSkill[];
|
assignments: readonly BattleLeaderSkill[];
|
||||||
|
|||||||
+3
-18
@@ -1,3 +1,4 @@
|
|||||||
|
import { ordinaryLeaderSkillModeSupported, type LeaderSkillProfile } from './leader-skill-profile';
|
||||||
import {
|
import {
|
||||||
usesSurvivingSkilledLeader,
|
usesSurvivingSkilledLeader,
|
||||||
type BattleLeaderSkill,
|
type BattleLeaderSkill,
|
||||||
@@ -6,24 +7,8 @@ import {
|
|||||||
export class SpiceBankerError extends Error {}
|
export class SpiceBankerError extends Error {}
|
||||||
|
|
||||||
/** The same development boundary applies to declarations, saved plans, controls and bots. */
|
/** The same development boundary applies to declarations, saved plans, controls and bots. */
|
||||||
export function spiceBankerModeSupported(game: {
|
export function spiceBankerModeSupported(game: LeaderSkillProfile): boolean {
|
||||||
expansions: readonly string[];
|
return ordinaryLeaderSkillModeSupported(game);
|
||||||
homeworlds?: unknown;
|
|
||||||
nexusCards?: unknown;
|
|
||||||
discoveries?: unknown;
|
|
||||||
discoveryEnabled?: boolean;
|
|
||||||
strongholdCards?: unknown;
|
|
||||||
techTokens?: unknown;
|
|
||||||
}): boolean {
|
|
||||||
return (
|
|
||||||
!game.expansions.length &&
|
|
||||||
!game.homeworlds &&
|
|
||||||
!game.nexusCards &&
|
|
||||||
!game.discoveries &&
|
|
||||||
!game.discoveryEnabled &&
|
|
||||||
!game.strongholdCards &&
|
|
||||||
!game.techTokens
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spiceBankerBattleMaximum(
|
export function spiceBankerBattleMaximum(
|
||||||
|
|||||||
@@ -95,6 +95,26 @@ function assertFailedEvidence(
|
|||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void test('Moritani Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
|
||||||
|
const area = temporary(t);
|
||||||
|
const out = join(area, 'skills');
|
||||||
|
const result = run(out, '--profile', 'moritani-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assertFailedEvidence(out, 'moritani-skills-2-basic', 1016, false);
|
||||||
|
const snapshot = join(out, 'failed-moritani-skills-2-basic.json');
|
||||||
|
const game = json<{ advanced: boolean; leaderSkills: { deck: string[]; offers: Record<string, { cards: string[] }>; assignments: { skill: string }[] } }>(snapshot);
|
||||||
|
assert.equal(game.advanced, false);
|
||||||
|
const physical = [...game.leaderSkills.deck, ...Object.values(game.leaderSkills.offers).flatMap(o => o.cards), ...game.leaderSkills.assignments.map(a => a.skill)];
|
||||||
|
assert.equal(physical.length, 14); assert.equal(new Set(physical).size, 14);
|
||||||
|
const resumed = join(area, 'skills-resumed');
|
||||||
|
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
|
||||||
|
assertFailedEvidence(resumed, 'moritani-skills-2-basic', 1016, true);
|
||||||
|
const unsupported = join(area, 'unsupported');
|
||||||
|
const bad = run(unsupported, '--profile', 'moritani-skills', '--rules', 'advanced');
|
||||||
|
assert.equal(bad.status, 1); assert.match(bad.stderr, /only Basic/);
|
||||||
|
assert.equal(existsSync(unsupported), false);
|
||||||
|
});
|
||||||
|
|
||||||
void test('selected genuine three-player Advanced base sample fails honestly, resumes its fixed snapshot and keeps source-bound private evidence', (t) => {
|
void test('selected genuine three-player Advanced base sample fails honestly, resumes its fixed snapshot and keeps source-bound private evidence', (t) => {
|
||||||
const area = temporary(t);
|
const area = temporary(t);
|
||||||
const first = join(area, 'first');
|
const first = join(area, 'first');
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { baseDeck, type Card } from '../game/cards';
|
||||||
|
import { validateLeaderSkills } from '../game/leader-skills';
|
||||||
|
import type { LeaderSkillId } from '../game/leader-skill-cards';
|
||||||
|
import {
|
||||||
|
completedMoritaniSkillsGame,
|
||||||
|
assertMoritaniSkillsCustody,
|
||||||
|
moritaniSkillsPlayer as player,
|
||||||
|
reloadMoritaniSkillsGame as reload,
|
||||||
|
rejectMoritaniSkillsAction as reject,
|
||||||
|
placeMoritaniSkillsTerror,
|
||||||
|
enterMoritaniSkillsTerror,
|
||||||
|
} from './moritani-skills-fixture';
|
||||||
|
|
||||||
|
const act = (g: Game, owner: string, action: Action) => applyAction(reload(g), owner, action);
|
||||||
|
|
||||||
|
function conserved(g: Game) {
|
||||||
|
assertMoritaniSkillsCustody(g);
|
||||||
|
validateLeaderSkills(g.leaderSkills!, g.players);
|
||||||
|
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)].map(c => c.id);
|
||||||
|
assert.deepEqual(cards.sort(), baseDeck().map(c => c.id).sort());
|
||||||
|
for (const p of g.players)
|
||||||
|
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20);
|
||||||
|
const traitors = [...g.traitorReserve!, ...g.players.flatMap(p => p.traitors)];
|
||||||
|
assert.equal(traitors.length, 15);
|
||||||
|
assert.equal(new Set(traitors).size, 15);
|
||||||
|
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
|
||||||
|
}
|
||||||
|
|
||||||
|
function take(g: Game, owner: string, predicate: (card: Card) => boolean) {
|
||||||
|
const index = g.deck.findIndex(predicate);
|
||||||
|
assert.ok(index >= 0, 'The staged card must come from the existing physical deck.');
|
||||||
|
const [card] = g.deck.splice(index, 1);
|
||||||
|
player(g, owner).hand.push(card);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allow(g: Game): Game {
|
||||||
|
for (let i = 0; g.response && i < 20; i++) {
|
||||||
|
const responder = g.players.find(p => !g.response!.passed.includes(p.id));
|
||||||
|
assert.ok(responder);
|
||||||
|
g = act(g, responder.id, { type: 'passResponse' });
|
||||||
|
}
|
||||||
|
assert.equal(g.response, null);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The alliance is produced by real Terror placement, shipment, offer and reply. */
|
||||||
|
function alliedSetup(skill: LeaderSkillId): Game {
|
||||||
|
let g = completedMoritaniSkillsGame({ requestedSkill: skill, skillOwner: 'emperor' });
|
||||||
|
g = placeMoritaniSkillsTerror(g, 'robbery');
|
||||||
|
g = enterMoritaniSkillsTerror(g, 'e');
|
||||||
|
g = allow(act(g, 'm', { type: 'decision', alliance: true }));
|
||||||
|
g = act(g, 'e', { type: 'decision', accept: true });
|
||||||
|
assert.equal(player(g, 'm').ally, 'e');
|
||||||
|
assert.equal(player(g, 'e').ally, 'm');
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After genuine setup, stage conserved armies/hands, then choose the battle normally. */
|
||||||
|
function stageBattle(g: Game, owner = 'm', opponent = 'e'): Game {
|
||||||
|
for (const p of g.players) {
|
||||||
|
g.deck.push(...p.hand);
|
||||||
|
p.hand = [];
|
||||||
|
p.forces = [owner, opponent].includes(p.id)
|
||||||
|
? { 'wind_pass:14': 5, 'carthag:11': 1 }
|
||||||
|
: { 'polar_sink:0': 1 };
|
||||||
|
p.reserves = [owner, opponent].includes(p.id) ? 14 : 19;
|
||||||
|
p.tanks = 0;
|
||||||
|
p.spice = 20;
|
||||||
|
}
|
||||||
|
Object.assign(g, { phase: 6, storm: 18, order: [owner, opponent, ...g.players.map(p => p.id).filter(id => id !== owner && id !== opponent)],
|
||||||
|
active: owner, ready: [], decision: null, response: null, phaseOpening: null, spice: {} });
|
||||||
|
// The second unresolved battle prevents automatic Spice Collection from
|
||||||
|
// obscuring this battle's own board-spice and income results.
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBattle(state: Game, owner = 'm', opponent = 'e', hide = true): Game {
|
||||||
|
let g = act(state, owner, { type: 'chooseBattle', territory: 'wind_pass', target: opponent });
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
g = allow(g);
|
||||||
|
if (g.decision?.kind === 'leaderSkillVisibility') {
|
||||||
|
const d = g.decision;
|
||||||
|
g = act(g, d.player, { type: 'leaderSkillVisibility', event: d.event, hide: d.player === owner ? hide : true });
|
||||||
|
} else if (g.decision?.kind === 'mentatQuestion' || g.decision?.kind === 'fullPlanOffer') {
|
||||||
|
g = act(g, g.decision.player, { type: 'decision', decline: true });
|
||||||
|
} else if (g.battle?.preparation) {
|
||||||
|
g = act(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
|
||||||
|
} else break;
|
||||||
|
}
|
||||||
|
assert.ok(g.battle);
|
||||||
|
assert.equal(g.decision, null);
|
||||||
|
assert.ok(!g.battle.preparation);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reveal(g: Game, own: Partial<Action> = {}, other: Partial<Action> = {}, owner = 'm', opponent = 'e'): Game {
|
||||||
|
g = act(g, owner, { type: 'battlePlan', dial: 4, leader: player(g, owner).leaders[0].id, ...own });
|
||||||
|
return act(g, opponent, { type: 'battlePlan', dial: 0, leader: player(g, opponent).leaders[1].id, ...other });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(g: Game, owner = 'm', opponent = 'e'): Game {
|
||||||
|
g = act(g, owner, { type: 'traitorCall', call: false });
|
||||||
|
return act(g, opponent, { type: 'traitorCall', call: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('Basic Moritani Rihani completes its private peek and actual exchange across saved decisions', () => {
|
||||||
|
const setup = completedMoritaniSkillsGame({ requestedSkill: 'rihani-decipherer' });
|
||||||
|
let g = resolve(reveal(openBattle(stageBattle(setup))));
|
||||||
|
const receipt = g.rihaniHistory!.at(-1)!;
|
||||||
|
assert.equal(g.decision?.kind, 'rihani');
|
||||||
|
assert.equal(receipt.owner, 'm');
|
||||||
|
assert.equal(receipt.peeked.length, 2);
|
||||||
|
assert.equal(receipt.drawn.length, 0);
|
||||||
|
assert.equal(viewGame(g, 'm').rihani!.history.at(-1)!.peeked.length, 2);
|
||||||
|
assert.deepEqual(viewGame(g, 'e').rihani!.history, []);
|
||||||
|
assert.deepEqual(viewGame(g, 'a').rihani!.history, []);
|
||||||
|
conserved(g);
|
||||||
|
reject(g, 'e', { type: 'decision', event: receipt.event, draw: true });
|
||||||
|
g = act(g, 'm', { type: 'decision', event: receipt.event, draw: true });
|
||||||
|
const drawn = g.rihaniHistory!.at(-1)!;
|
||||||
|
assert.equal(drawn.drawn.length, 2);
|
||||||
|
g = act(g, 'm', { type: 'decision', event: drawn.event, cards: [drawn.drawn[0], drawn.eligible[0]] });
|
||||||
|
assert.equal(player(g, 'm').traitors.length, 1);
|
||||||
|
assert.ok(player(g, 'm').traitors.includes(drawn.drawn[0]));
|
||||||
|
assert.ok(g.traitorReserve!.includes(drawn.eligible[0]));
|
||||||
|
assert.ok(g.traitorReserve!.includes(drawn.drawn[1]));
|
||||||
|
assert.equal(g.lastBattleContext?.rihani?.completed, true);
|
||||||
|
conserved(g);
|
||||||
|
reject(g, 'm', { type: 'decision', event: drawn.event, cards: [drawn.drawn[0], drawn.eligible[0]] });
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Basic Moritani Sandmaster adds board spice once and Banker spends its separately committed spice', () => {
|
||||||
|
for (const skill of ['sandmaster', 'spice-banker'] as const) {
|
||||||
|
const staged = stageBattle(completedMoritaniSkillsGame({ requestedSkill: skill }));
|
||||||
|
staged.spice = { 'wind_pass:14': 2 };
|
||||||
|
let g = openBattle(staged);
|
||||||
|
const printed = player(g, 'm').leaders[0].strength;
|
||||||
|
g = resolve(reveal(g, skill === 'spice-banker' ? { bankerSpice: 3 } : {}));
|
||||||
|
assert.equal(g.lastBattleContext?.winner, 'm');
|
||||||
|
assert.equal(g.spice['wind_pass:14'], skill === 'sandmaster' ? 5 : 2);
|
||||||
|
assert.equal(player(g, 'm').spice, skill === 'spice-banker' ? 17 : 20);
|
||||||
|
assert.equal(player(g, 'm').leaders[0].strength, printed);
|
||||||
|
if (skill === 'sandmaster') assert.ok(g.lastBattleContext?.sandmaster);
|
||||||
|
else assert.ok(g.log.some(l => l.text.includes('gained 3 battle strength from Spice Banker')));
|
||||||
|
conserved(g);
|
||||||
|
reject(g, 'e', { type: 'traitorCall', call: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Basic Moritani Suk saves three actual casualties and completes its saved choice once', () => {
|
||||||
|
let g = resolve(reveal(openBattle(stageBattle(completedMoritaniSkillsGame({ requestedSkill: 'suk-graduate' })))));
|
||||||
|
const decision = g.decision;
|
||||||
|
assert.equal(decision?.kind, 'sukRescue');
|
||||||
|
if (decision?.kind !== 'sukRescue') throw new Error('Expected the real Suk rescue choice.');
|
||||||
|
assert.equal(player(g, 'm').tanks, 0);
|
||||||
|
const choice = decision.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.kind === 'normal');
|
||||||
|
assert.ok(choice >= 0);
|
||||||
|
conserved(g);
|
||||||
|
reject(g, 'e', { type: 'decision', event: decision.event, choice });
|
||||||
|
g = act(g, 'm', { type: 'decision', event: decision.event, choice });
|
||||||
|
assert.equal(player(g, 'm').tanks, 1);
|
||||||
|
assert.equal(player(g, 'm').reserves, 16);
|
||||||
|
assert.equal(player(g, 'm').forces['wind_pass:14'], 2);
|
||||||
|
assert.equal(g.lastBattleContext?.sukRescue?.completed, true);
|
||||||
|
conserved(g);
|
||||||
|
reject(g, 'm', { type: 'decision', event: decision.event, choice });
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Basic Moritani Smuggler freezes public collection and pays its surviving native disc once', () => {
|
||||||
|
const staged = stageBattle(completedMoritaniSkillsGame({ requestedSkill: 'smuggler' }));
|
||||||
|
staged.spice = { 'wind_pass:14': 8 };
|
||||||
|
let g = reveal(openBattle(staged));
|
||||||
|
const strength = player(g, 'm').leaders[0].strength;
|
||||||
|
assert.equal(g.battle!.smugglerCollection!.amount, strength);
|
||||||
|
assert.equal(player(g, 'm').spice, 20);
|
||||||
|
assert.equal(g.spice['wind_pass:14'], 8);
|
||||||
|
conserved(g);
|
||||||
|
g = resolve(g);
|
||||||
|
assert.equal(player(g, 'm').spice, 20 + strength);
|
||||||
|
assert.equal(g.spice['wind_pass:14'], 8 - strength);
|
||||||
|
assert.equal(g.lastBattleContext?.smugglerCollection?.stage, 'collected');
|
||||||
|
conserved(g);
|
||||||
|
reject(g, 'e', { type: 'traitorCall', call: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Moritani ally retention excludes the used Planetologist Special and the actual Diplomat copy', () => {
|
||||||
|
for (const skill of ['planetologist', 'diplomat'] as const) {
|
||||||
|
const staged = stageBattle(alliedSetup(skill), 'e', 'a');
|
||||||
|
const mandatory = take(staged, 'e', c => skill === 'planetologist' ? c.effect === 'atomics' : c.kind === 'worthless');
|
||||||
|
const retained = take(staged, 'e', c => skill === 'planetologist' ? c.kind === 'snooper' : c.kind === 'worthless');
|
||||||
|
const enemyDefense = take(staged, 'a', c => c.kind === 'snooper');
|
||||||
|
const enemyWeapon = skill === 'diplomat' ? take(staged, 'a', c => c.kind === 'poison') : undefined;
|
||||||
|
let g = openBattle(staged, 'e', 'a', skill === 'planetologist');
|
||||||
|
const ownLeader = player(g, 'e').leaders[skill === 'planetologist' ? 0 : 1].id;
|
||||||
|
g = reveal(g, { dial: 0, leader: ownLeader, weapon: mandatory.id, defense: retained.id },
|
||||||
|
{ dial: 5, weapon: enemyWeapon?.id, defense: enemyDefense.id }, 'e', 'a');
|
||||||
|
if (skill === 'diplomat') {
|
||||||
|
const d = g.decision;
|
||||||
|
assert.equal(d?.kind, 'diplomatDefense');
|
||||||
|
if (d?.kind !== 'diplomatDefense') throw new Error('Expected the actual copied-defense choice.');
|
||||||
|
g = act(g, 'e', { type: 'decision', event: d.event, card: mandatory.id });
|
||||||
|
assert.equal(g.battle!.diplomatDefense!.card, mandatory.id);
|
||||||
|
assert.equal(player(g, 'e').hand.find(c => c.id === mandatory.id)!.kind, 'worthless');
|
||||||
|
}
|
||||||
|
g = resolve(g, 'e', 'a');
|
||||||
|
assert.equal(g.lastBattleContext?.winner, 'a');
|
||||||
|
// Winner card cleanup precedes the losing ally's separate retention choice.
|
||||||
|
if (g.decision?.kind === 'battleCards')
|
||||||
|
g = act(g, g.decision.player, { type: 'decision', discard: [] });
|
||||||
|
assert.equal(g.decision?.kind, 'moritaniRetention');
|
||||||
|
assert.deepEqual(g.moritaniRetention!.eligible, [retained.id]);
|
||||||
|
assert.ok(!g.moritaniRetention!.played.includes(mandatory.id));
|
||||||
|
assert.equal(g.discard.filter(c => c.id === mandatory.id).length, 1);
|
||||||
|
assert.equal(player(g, 'e').leaders.find(l => l.id === ownLeader)!.dead, false);
|
||||||
|
conserved(g);
|
||||||
|
reject(g, 'e', { type: 'decision', keep: mandatory.id });
|
||||||
|
reject(g, 'm', { type: 'decision', keep: retained.id });
|
||||||
|
g = allow(act(g, 'e', { type: 'decision', keep: retained.id }));
|
||||||
|
assert.ok(player(g, 'e').hand.some(c => c.id === retained.id));
|
||||||
|
assert.equal(g.discard.filter(c => c.id === mandatory.id).length, 1);
|
||||||
|
assert.equal(g.moritaniRetention, null);
|
||||||
|
conserved(g);
|
||||||
|
reject(g, 'e', { type: 'decision', keep: retained.id });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||||
|
import { newRevivalRules } from '../game/revival';
|
||||||
|
import {
|
||||||
|
assertMoritaniSkillsCustody,
|
||||||
|
completedMoritaniSkillsGame,
|
||||||
|
enterMoritaniSkillsTerror,
|
||||||
|
initializedMoritaniSkillsOffers,
|
||||||
|
moritaniSkillsPlayer,
|
||||||
|
placeMoritaniSkillsTerror,
|
||||||
|
rejectMoritaniSkillsAction,
|
||||||
|
reloadMoritaniSkillsGame,
|
||||||
|
} from './moritani-skills-fixture';
|
||||||
|
|
||||||
|
const forceTotal = (game: Game, id: string) => {
|
||||||
|
const player = moritaniSkillsPlayer(game, id);
|
||||||
|
return (
|
||||||
|
player.reserves +
|
||||||
|
player.tanks +
|
||||||
|
Object.values(player.forces).reduce((sum, count) => sum + count, 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
void test('genuine Basic Moritani setup keeps all fourteen skill cards and each two-card offer private', () => {
|
||||||
|
const game = initializedMoritaniSkillsOffers({
|
||||||
|
requestedSkill: 'warmaster',
|
||||||
|
skillOwner: 'moritani',
|
||||||
|
});
|
||||||
|
assert.equal(game.advanced, false);
|
||||||
|
assert.deepEqual(game.expansions, ['ecaz']);
|
||||||
|
assert.deepEqual(
|
||||||
|
new Set(game.players.map((player) => player.faction)),
|
||||||
|
new Set(['moritani', 'atreides', 'emperor']),
|
||||||
|
);
|
||||||
|
assert.equal(game.leaderSkills!.deck.length, 8);
|
||||||
|
assert.equal(Object.keys(game.leaderSkills!.offers).length, 3);
|
||||||
|
assert.equal(game.leaderSkills!.assignments.length, 0);
|
||||||
|
assertMoritaniSkillsCustody(game);
|
||||||
|
|
||||||
|
for (const viewer of game.players) {
|
||||||
|
const view = viewGame(reloadMoritaniSkillsGame(game), viewer.id);
|
||||||
|
assert.deepEqual(
|
||||||
|
view.leaderSkills!.offer,
|
||||||
|
game.leaderSkills!.offers[viewer.id],
|
||||||
|
);
|
||||||
|
assert.equal(view.leaderSkills!.offer!.cards.length, 2);
|
||||||
|
assert.equal('deck' in view.leaderSkills!, false);
|
||||||
|
assert.equal('offers' in view.leaderSkills!, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Terror Assassination returns exactly the killed leader skill once and real revival offers one private optional replacement after JSON restore', () => {
|
||||||
|
let game = completedMoritaniSkillsGame({
|
||||||
|
requestedSkill: 'warmaster',
|
||||||
|
skillOwner: 'emperor',
|
||||||
|
});
|
||||||
|
const assignment = game.leaderSkills!.assignments.find(
|
||||||
|
(candidate) => candidate.owner === 'e',
|
||||||
|
)!;
|
||||||
|
assert.deepEqual(assignment, {
|
||||||
|
owner: 'e',
|
||||||
|
leader: 'emperor-0',
|
||||||
|
skill: 'warmaster',
|
||||||
|
});
|
||||||
|
const emperor = moritaniSkillsPlayer(game, 'e');
|
||||||
|
for (const leader of emperor.leaders)
|
||||||
|
if (leader.id !== assignment.leader) {
|
||||||
|
leader.dead = true;
|
||||||
|
leader.deaths = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forceTotals = Object.fromEntries(
|
||||||
|
game.players.map((player) => [player.id, forceTotal(game, player.id)]),
|
||||||
|
);
|
||||||
|
game = placeMoritaniSkillsTerror(game, 'assassination');
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.fromEntries(
|
||||||
|
game.players.map((player) => [player.id, forceTotal(game, player.id)]),
|
||||||
|
),
|
||||||
|
forceTotals,
|
||||||
|
);
|
||||||
|
const beforeEntry = reloadMoritaniSkillsGame(game);
|
||||||
|
game = enterMoritaniSkillsTerror(game, 'e');
|
||||||
|
assert.equal(moritaniSkillsPlayer(game, 'e').reserves,
|
||||||
|
moritaniSkillsPlayer(beforeEntry, 'e').reserves - 1);
|
||||||
|
assert.equal(moritaniSkillsPlayer(game, 'e').forces['carthag:11'], 1);
|
||||||
|
assert.equal(forceTotal(game, 'e'), forceTotals.e);
|
||||||
|
assert.equal(viewGame(game, 'm').terrorEntry!.kind, 'assassination');
|
||||||
|
assert.equal('kind' in viewGame(game, 'e').terrorEntry!, false);
|
||||||
|
assert.equal(game.leaderSkills!.deck.includes('warmaster'), false);
|
||||||
|
|
||||||
|
const killed = applyAction(game, 'm', {
|
||||||
|
type: 'decision',
|
||||||
|
reveal: true,
|
||||||
|
});
|
||||||
|
const killedLeader = moritaniSkillsPlayer(killed, 'e').leaders.find(
|
||||||
|
(leader) => leader.id === assignment.leader,
|
||||||
|
)!;
|
||||||
|
assert.equal(killedLeader.dead, true);
|
||||||
|
assert.equal(killedLeader.deaths, 1);
|
||||||
|
assert.equal(
|
||||||
|
killed.leaderSkills!.assignments.some(
|
||||||
|
(candidate) => candidate.owner === 'e',
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
killed.leaderSkills!.deck.filter((skill) => skill === 'warmaster').length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assertMoritaniSkillsCustody(killed);
|
||||||
|
rejectMoritaniSkillsAction(killed, 'm', {
|
||||||
|
type: 'decision',
|
||||||
|
reveal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restored = reloadMoritaniSkillsGame(killed);
|
||||||
|
Object.assign(restored, {
|
||||||
|
phase: 4,
|
||||||
|
active: null,
|
||||||
|
ready: [],
|
||||||
|
decision: null,
|
||||||
|
response: null,
|
||||||
|
phaseOpening: null,
|
||||||
|
revivalRules: newRevivalRules(),
|
||||||
|
});
|
||||||
|
const restoredEmperor = moritaniSkillsPlayer(restored, 'e');
|
||||||
|
restoredEmperor.spice = 20;
|
||||||
|
restoredEmperor.leaderRevived = false;
|
||||||
|
const offered = applyAction(restored, 'e', {
|
||||||
|
type: 'reviveLeader',
|
||||||
|
leader: assignment.leader,
|
||||||
|
});
|
||||||
|
assert.equal(offered.decision?.kind, 'leaderSkillRevival');
|
||||||
|
assert.equal(
|
||||||
|
moritaniSkillsPlayer(offered, 'e').leaders.find(
|
||||||
|
(leader) => leader.id === assignment.leader,
|
||||||
|
)!.dead,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.deepEqual(viewGame(offered, 'e').leaderSkills!.offer!.cards, []);
|
||||||
|
assert.equal(viewGame(offered, 'm').leaderSkills!.offer, null);
|
||||||
|
assert.equal(viewGame(offered, 'a').leaderSkills!.offer, null);
|
||||||
|
assertMoritaniSkillsCustody(offered);
|
||||||
|
|
||||||
|
const event = offered.leaderSkills!.offers.e.event;
|
||||||
|
rejectMoritaniSkillsAction(
|
||||||
|
offered,
|
||||||
|
'm',
|
||||||
|
{ type: 'leaderSkill', event, mode: 'draw' },
|
||||||
|
/not waiting for you/,
|
||||||
|
);
|
||||||
|
rejectMoritaniSkillsAction(
|
||||||
|
offered,
|
||||||
|
'e',
|
||||||
|
{ type: 'leaderSkill', event: `${event}:stale`, mode: 'draw' },
|
||||||
|
/not waiting for you/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const declined = applyAction(reloadMoritaniSkillsGame(offered), 'e', {
|
||||||
|
type: 'leaderSkill',
|
||||||
|
event,
|
||||||
|
mode: 'decline',
|
||||||
|
});
|
||||||
|
assert.equal(declined.decision, null);
|
||||||
|
assert.equal(declined.leaderSkills!.offers.e, undefined);
|
||||||
|
assert.equal(
|
||||||
|
declined.leaderSkills!.assignments.some(
|
||||||
|
(candidate) => candidate.owner === 'e',
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assertMoritaniSkillsCustody(declined);
|
||||||
|
|
||||||
|
const drawn = applyAction(reloadMoritaniSkillsGame(offered), 'e', {
|
||||||
|
type: 'leaderSkill',
|
||||||
|
event,
|
||||||
|
mode: 'draw',
|
||||||
|
});
|
||||||
|
const privateOffer = viewGame(reloadMoritaniSkillsGame(drawn), 'e')
|
||||||
|
.leaderSkills!.offer!;
|
||||||
|
assert.equal(privateOffer.cards.length, 2);
|
||||||
|
assert.equal(viewGame(drawn, 'm').leaderSkills!.offer, null);
|
||||||
|
assert.equal(viewGame(drawn, 'a').leaderSkills!.offer, null);
|
||||||
|
assertMoritaniSkillsCustody(drawn);
|
||||||
|
|
||||||
|
const choice = {
|
||||||
|
type: 'leaderSkill',
|
||||||
|
event,
|
||||||
|
skill: privateOffer.cards[0],
|
||||||
|
leader: assignment.leader,
|
||||||
|
} as const;
|
||||||
|
const selected = applyAction(drawn, 'e', choice);
|
||||||
|
assert.deepEqual(
|
||||||
|
selected.leaderSkills!.assignments.find(
|
||||||
|
(candidate) => candidate.owner === 'e',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
owner: 'e',
|
||||||
|
leader: assignment.leader,
|
||||||
|
skill: privateOffer.cards[0],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(selected.leaderSkills!.offers.e, undefined);
|
||||||
|
assertMoritaniSkillsCustody(selected);
|
||||||
|
rejectMoritaniSkillsAction(
|
||||||
|
selected,
|
||||||
|
'e',
|
||||||
|
choice,
|
||||||
|
/not waiting for you/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Sabotage discards only Treachery cards while skill custody and shared projections stay unchanged', () => {
|
||||||
|
let game = completedMoritaniSkillsGame({
|
||||||
|
requestedSkill: 'warmaster',
|
||||||
|
skillOwner: 'emperor',
|
||||||
|
});
|
||||||
|
game = placeMoritaniSkillsTerror(game, 'sabotage');
|
||||||
|
const beforeEntry = reloadMoritaniSkillsGame(game);
|
||||||
|
const entrantCard = moritaniSkillsPlayer(beforeEntry, 'e').hand[0];
|
||||||
|
assert.ok(entrantCard);
|
||||||
|
game = enterMoritaniSkillsTerror(game, 'e');
|
||||||
|
const revealed = applyAction(game, 'm', {
|
||||||
|
type: 'decision',
|
||||||
|
reveal: true,
|
||||||
|
});
|
||||||
|
assert.equal(revealed.pendingTerrorEntry?.stage, 'gift');
|
||||||
|
assert.equal(
|
||||||
|
moritaniSkillsPlayer(revealed, 'e').hand.some(
|
||||||
|
(card) => card.id === entrantCard.id,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
revealed.discard.filter((card) => card.id === entrantCard.id).length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.deepEqual(revealed.leaderSkills, beforeEntry.leaderSkills);
|
||||||
|
assertMoritaniSkillsCustody(revealed);
|
||||||
|
|
||||||
|
const projections = revealed.players.map(
|
||||||
|
(player) => viewGame(reloadMoritaniSkillsGame(revealed), player.id).leaderSkills!,
|
||||||
|
);
|
||||||
|
for (const projection of projections) {
|
||||||
|
assert.deepEqual(projection.assignments, projections[0].assignments);
|
||||||
|
assert.equal(projection.offer, null);
|
||||||
|
assert.equal('deck' in projection, false);
|
||||||
|
}
|
||||||
|
const opponentView = viewGame(revealed, 'a');
|
||||||
|
assert.equal('discard' in opponentView, false);
|
||||||
|
assert.equal(JSON.stringify(opponentView).includes(entrantCard.id), false);
|
||||||
|
|
||||||
|
const done = applyAction(reloadMoritaniSkillsGame(revealed), 'm', {
|
||||||
|
type: 'decision',
|
||||||
|
decline: true,
|
||||||
|
});
|
||||||
|
assert.equal(done.pendingTerrorEntry, null);
|
||||||
|
assert.equal(done.decision, null);
|
||||||
|
assert.deepEqual(done.leaderSkills, beforeEntry.leaderSkills);
|
||||||
|
assertMoritaniSkillsCustody(done);
|
||||||
|
rejectMoritaniSkillsAction(done, 'm', {
|
||||||
|
type: 'decision',
|
||||||
|
decline: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mock } from 'node:test';
|
||||||
|
import {
|
||||||
|
applyAction,
|
||||||
|
createGame,
|
||||||
|
initializeLeaderSkillsGameForAudit,
|
||||||
|
joinGame,
|
||||||
|
newPlayer,
|
||||||
|
type Action,
|
||||||
|
type Game,
|
||||||
|
type Player,
|
||||||
|
} from '../game/engine';
|
||||||
|
import {
|
||||||
|
LEADER_SKILL_CARDS,
|
||||||
|
type LeaderSkillId,
|
||||||
|
} from '../game/leader-skill-cards';
|
||||||
|
import type { TerrorKind } from '../game/moritani-terror';
|
||||||
|
|
||||||
|
export const MORITANI_SKILLS_SEATS = {
|
||||||
|
moritani: 'm',
|
||||||
|
atreides: 'a',
|
||||||
|
emperor: 'e',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type MoritaniSkillsFaction = keyof typeof MORITANI_SKILLS_SEATS;
|
||||||
|
|
||||||
|
export type MoritaniSkillsFixtureOptions = {
|
||||||
|
requestedSkill?: LeaderSkillId;
|
||||||
|
skillOwner?: MoritaniSkillsFaction;
|
||||||
|
};
|
||||||
|
|
||||||
|
const names: Record<MoritaniSkillsFaction, string> = {
|
||||||
|
moritani: 'Moritani',
|
||||||
|
atreides: 'Atreides',
|
||||||
|
emperor: 'Emperor',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reloadMoritaniSkillsGame = (game: Game): Game =>
|
||||||
|
JSON.parse(JSON.stringify(game)) as Game;
|
||||||
|
|
||||||
|
export function moritaniSkillsPlayer(game: Game, id: string): Player {
|
||||||
|
return game.players.find((player) => player.id === id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectMoritaniSkillsAction(
|
||||||
|
game: Game,
|
||||||
|
id: string,
|
||||||
|
action: Action,
|
||||||
|
pattern?: RegExp,
|
||||||
|
): void {
|
||||||
|
const before = structuredClone(game);
|
||||||
|
if (pattern) assert.throws(() => applyAction(game, id, action), pattern);
|
||||||
|
else assert.throws(() => applyAction(game, id, action));
|
||||||
|
assert.deepEqual(game, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertMoritaniSkillsCustody(game: Game): void {
|
||||||
|
const skills = game.leaderSkills!;
|
||||||
|
const physical = [
|
||||||
|
...skills.deck,
|
||||||
|
...Object.values(skills.offers).flatMap((offer) => offer.cards),
|
||||||
|
...skills.assignments.map((assignment) => assignment.skill),
|
||||||
|
];
|
||||||
|
const canonical = LEADER_SKILL_CARDS.map((card) => card.id);
|
||||||
|
assert.equal(physical.length, canonical.length);
|
||||||
|
assert.equal(new Set(physical).size, canonical.length);
|
||||||
|
assert.deepEqual([...physical].sort(), [...canonical].sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reach the real private Leader Skill setup offers from a ready Basic lobby.
|
||||||
|
* Seating the requested owner first lets the production shuffle seam put that
|
||||||
|
* requested physical card at the front of its genuine two-card deal.
|
||||||
|
*/
|
||||||
|
export function initializedMoritaniSkillsOffers(
|
||||||
|
options: MoritaniSkillsFixtureOptions = {},
|
||||||
|
): Game {
|
||||||
|
const requestedSkill = options.requestedSkill ?? 'warmaster';
|
||||||
|
const skillOwner = options.skillOwner ?? 'moritani';
|
||||||
|
const factions = (
|
||||||
|
[skillOwner, 'moritani', 'atreides', 'emperor'] as MoritaniSkillsFaction[]
|
||||||
|
).filter((faction, index, all) => all.indexOf(faction) === index);
|
||||||
|
const first = factions[0];
|
||||||
|
let game = createGame(
|
||||||
|
'MORISKIL',
|
||||||
|
newPlayer(MORITANI_SKILLS_SEATS[first], names[first], first),
|
||||||
|
false,
|
||||||
|
['ecaz'],
|
||||||
|
);
|
||||||
|
for (const faction of factions.slice(1))
|
||||||
|
joinGame(
|
||||||
|
game,
|
||||||
|
newPlayer(MORITANI_SKILLS_SEATS[faction], names[faction], faction),
|
||||||
|
);
|
||||||
|
for (const player of game.players)
|
||||||
|
game = applyAction(game, player.id, { type: 'ready' });
|
||||||
|
|
||||||
|
const targetIndex = LEADER_SKILL_CARDS.findIndex(
|
||||||
|
(card) => card.id === requestedSkill,
|
||||||
|
);
|
||||||
|
assert.ok(targetIndex >= 0);
|
||||||
|
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
|
||||||
|
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
|
||||||
|
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
|
||||||
|
return array;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
game = initializeLeaderSkillsGameForAudit(game);
|
||||||
|
} finally {
|
||||||
|
mock.restoreAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = MORITANI_SKILLS_SEATS[skillOwner];
|
||||||
|
assert.equal(game.status, 'setup');
|
||||||
|
assert.equal(game.setupStage, 'leaderSkills');
|
||||||
|
assert.equal(game.leaderSkills!.offers[owner].cards[0], requestedSkill);
|
||||||
|
assertMoritaniSkillsCustody(game);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete skills, traitors, and Moritani's six-force placement by actions. */
|
||||||
|
export function completedMoritaniSkillsGame(
|
||||||
|
options: MoritaniSkillsFixtureOptions = {},
|
||||||
|
): Game {
|
||||||
|
const requestedSkill = options.requestedSkill ?? 'warmaster';
|
||||||
|
const skillOwner = options.skillOwner ?? 'moritani';
|
||||||
|
const requestedOwner = MORITANI_SKILLS_SEATS[skillOwner];
|
||||||
|
let game = initializedMoritaniSkillsOffers(options);
|
||||||
|
|
||||||
|
for (const player of game.players) {
|
||||||
|
const offer = game.leaderSkills!.offers[player.id];
|
||||||
|
game = applyAction(game, player.id, {
|
||||||
|
type: 'leaderSkill',
|
||||||
|
event: offer.event,
|
||||||
|
skill: player.id === requestedOwner ? requestedSkill : offer.cards[0],
|
||||||
|
leader: player.leaders[0].id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.equal(game.setupStage, 'traitors');
|
||||||
|
for (const player of game.players) {
|
||||||
|
const choice = moritaniSkillsPlayer(game, player.id).traitorChoices[0];
|
||||||
|
assert.ok(choice);
|
||||||
|
game = applyAction(game, player.id, { type: 'traitor', leader: choice });
|
||||||
|
}
|
||||||
|
assert.deepEqual(game.decision, {
|
||||||
|
kind: 'moritaniSetup',
|
||||||
|
player: MORITANI_SKILLS_SEATS.moritani,
|
||||||
|
});
|
||||||
|
game = applyAction(game, MORITANI_SKILLS_SEATS.moritani, {
|
||||||
|
type: 'decision',
|
||||||
|
territory: 'polar_sink',
|
||||||
|
sector: 0,
|
||||||
|
});
|
||||||
|
assert.equal(game.status, 'playing');
|
||||||
|
assert.equal(game.setupStage, undefined);
|
||||||
|
assert.equal(moritaniSkillsPlayer(game, 'm').forces['polar_sink:0'], 6);
|
||||||
|
assert.equal(moritaniSkillsPlayer(game, 'm').reserves, 14);
|
||||||
|
assertMoritaniSkillsCustody(game);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage only the phase controls, then place a real hidden Terror token through
|
||||||
|
* the Mentat decision and its complete response window. Force custody is not
|
||||||
|
* changed by this helper.
|
||||||
|
*/
|
||||||
|
export function placeMoritaniSkillsTerror(
|
||||||
|
state: Game,
|
||||||
|
kind: TerrorKind,
|
||||||
|
territory = 'carthag',
|
||||||
|
): Game {
|
||||||
|
let game = structuredClone(state);
|
||||||
|
Object.assign(game, {
|
||||||
|
phase: 7,
|
||||||
|
active: null,
|
||||||
|
ready: [],
|
||||||
|
decision: null,
|
||||||
|
response: null,
|
||||||
|
phaseOpening: null,
|
||||||
|
});
|
||||||
|
for (const player of game.players)
|
||||||
|
game = applyAction(game, player.id, { type: 'ready' });
|
||||||
|
assert.equal(game.phase, 8);
|
||||||
|
assert.equal(game.decision?.kind, 'moritaniPlacement');
|
||||||
|
const token = game.moritaniTerror!.tokens.find(
|
||||||
|
(candidate) => candidate.kind === kind,
|
||||||
|
);
|
||||||
|
assert.ok(token);
|
||||||
|
game = applyAction(game, MORITANI_SKILLS_SEATS.moritani, {
|
||||||
|
type: 'decision',
|
||||||
|
token: token.id,
|
||||||
|
territory,
|
||||||
|
});
|
||||||
|
while (game.response) {
|
||||||
|
const responder = game.players.find(
|
||||||
|
(player) => !game.response!.passed.includes(player.id),
|
||||||
|
);
|
||||||
|
assert.ok(responder);
|
||||||
|
game = applyAction(game, responder.id, { type: 'passResponse' });
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
game.moritaniTerror!.tokens.find((candidate) => candidate.id === token.id)
|
||||||
|
?.location,
|
||||||
|
territory,
|
||||||
|
);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stage Shipment and perform the entrant's real reserve shipment. */
|
||||||
|
export function enterMoritaniSkillsTerror(
|
||||||
|
state: Game,
|
||||||
|
entrant: string,
|
||||||
|
territory = 'carthag',
|
||||||
|
sector = 11,
|
||||||
|
amount = 1,
|
||||||
|
): Game {
|
||||||
|
const game = structuredClone(state);
|
||||||
|
Object.assign(game, {
|
||||||
|
phase: 5,
|
||||||
|
storm: 18,
|
||||||
|
active: entrant,
|
||||||
|
ready: [],
|
||||||
|
decision: null,
|
||||||
|
response: null,
|
||||||
|
phaseOpening: null,
|
||||||
|
movementRemaining: [...game.order],
|
||||||
|
});
|
||||||
|
const player = moritaniSkillsPlayer(game, entrant);
|
||||||
|
player.shipped = false;
|
||||||
|
player.moved = 0;
|
||||||
|
return applyAction(game, entrant, {
|
||||||
|
type: 'ship',
|
||||||
|
territory,
|
||||||
|
sector,
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
||||||
|
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||||
|
import { basicMoritaniLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||||
|
import { spiceBankerModeSupported } from '../game/spice-banker';
|
||||||
|
import { diplomatDefenseModeSupported } from '../game/diplomat-defense';
|
||||||
|
import { sandmasterModeSupported } from '../game/sandmaster-movement';
|
||||||
|
import { smugglerBattleModeSupported } from '../game/smuggler-battle';
|
||||||
|
import { bureaucratPaymentModeSupported } from '../game/bureaucrat-payment';
|
||||||
|
import { planetologistMovementModeSupported } from '../game/planetologist-movement';
|
||||||
|
import { planetologistMoveDraft } from '../components/planetologist-movement';
|
||||||
|
import { TERRITORIES, distance, location, splitLocation } from '../game/board';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
import { completedMoritaniSkillsGame, initializedMoritaniSkillsOffers, assertMoritaniSkillsCustody, moritaniSkillsPlayer as player, reloadMoritaniSkillsGame as reload, rejectMoritaniSkillsAction as reject } from './moritani-skills-fixture';
|
||||||
|
|
||||||
|
function movementPosition(game: Game): Game {
|
||||||
|
// Conserved focused position following genuine setup, not a played phase history.
|
||||||
|
for (const p of game.players) {
|
||||||
|
p.forces = {}; p.reserves = 20; p.tanks = 0;
|
||||||
|
p.spice = 20; p.moved = 0; p.shipped = false;
|
||||||
|
}
|
||||||
|
Object.assign(game, { phase: 5, active: 'm', order: ['m', 'a', 'e'], storm: 18,
|
||||||
|
ready: [], decision: null, response: null, phaseOpening: null });
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('every canonical skill can be dealt and publicly assigned to Basic Moritani without reducing the deck', () => {
|
||||||
|
for (const card of LEADER_SKILL_CARDS) {
|
||||||
|
const game = completedMoritaniSkillsGame({ requestedSkill: card.id });
|
||||||
|
assert.equal(game.leaderSkills!.assignments.find(a => a.owner === 'm')!.skill, card.id);
|
||||||
|
assertMoritaniSkillsCustody(game);
|
||||||
|
for (const p of game.players) {
|
||||||
|
const view = viewGame(game, p.id);
|
||||||
|
assert.equal(view.leaderSkills!.offer, null);
|
||||||
|
assert.equal(view.leaderSkills!.assignments.length, 3);
|
||||||
|
assert.deepEqual(viewGame(reload(game), p.id), view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Moritani skill integration shares public guards and retains Advanced, Ecaz and optional-module boundaries', () => {
|
||||||
|
const game = completedMoritaniSkillsGame();
|
||||||
|
const gates = [basicMoritaniLeaderSkillsProfile, spiceBankerModeSupported, diplomatDefenseModeSupported,
|
||||||
|
sandmasterModeSupported, smugglerBattleModeSupported, bureaucratPaymentModeSupported, planetologistMovementModeSupported];
|
||||||
|
for (const gate of gates) {
|
||||||
|
assert.equal(gate(game), true, gate.name);
|
||||||
|
assert.equal(gate(viewGame(game, 'a')), true, `${gate.name} projected`);
|
||||||
|
}
|
||||||
|
for (const change of [
|
||||||
|
(g: Game) => { g.advanced = true; },
|
||||||
|
(g: Game) => { g.players[1] = newPlayer('a', 'Ecaz', 'ecaz'); },
|
||||||
|
(g: Game) => { g.expansions.push('choam'); },
|
||||||
|
(g: Game) => { g.discoveryEnabled = true; },
|
||||||
|
(g: Game) => { g.ecazTreachery = true; },
|
||||||
|
]) {
|
||||||
|
const lobby = createGame('MORIGATE', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']);
|
||||||
|
joinGame(lobby, newPlayer('a', 'Atreides', 'atreides'));
|
||||||
|
lobby.players.forEach(p => { p.ready = true; });
|
||||||
|
change(lobby);
|
||||||
|
const before = JSON.stringify(lobby);
|
||||||
|
assert.throws(() => initializeLeaderSkillsGameForAudit(lobby));
|
||||||
|
assert.equal(JSON.stringify(lobby), before);
|
||||||
|
assert.equal(basicMoritaniLeaderSkillsProfile(lobby), false);
|
||||||
|
}
|
||||||
|
const publicLobby = createGame('MORIPUBL', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']);
|
||||||
|
joinGame(publicLobby, newPlayer('a', 'Atreides', 'atreides'));
|
||||||
|
publicLobby.players.forEach(p => { p.ready = true; });
|
||||||
|
reject(publicLobby, 'm', { type: 'start' });
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('all four profiles legally choose from the same private Moritani setup offer', () => {
|
||||||
|
const game = initializedMoritaniSkillsOffers({ requestedSkill: 'planetologist' });
|
||||||
|
for (const difficulty of DIFFICULTIES) {
|
||||||
|
const view = viewGame(reload(game), 'm');
|
||||||
|
view.players.find(p => p.id === 'm')!.bot = difficulty;
|
||||||
|
const action = botActions(view)[0];
|
||||||
|
assert.equal(action.type, 'leaderSkill');
|
||||||
|
const next = applyAction(game, 'm', action);
|
||||||
|
assert.equal(next.leaderSkills!.assignments.find(a => a.owner === 'm')?.leader, action.leader);
|
||||||
|
assertMoritaniSkillsCustody(next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Moritani Planetologist controls and bots use the same extended movement through saved views', () => {
|
||||||
|
const game = movementPosition(completedMoritaniSkillsGame({ requestedSkill: 'planetologist' }));
|
||||||
|
const from = 'red_chasm:7';
|
||||||
|
player(game, 'm').forces = { [from]: 3 }; player(game, 'm').reserves = 17;
|
||||||
|
const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id, s)))
|
||||||
|
.find(key => splitLocation(key).sector !== 18 && distance(from, key, k => splitLocation(k).sector === 18) === 2)!;
|
||||||
|
assert.ok(to);
|
||||||
|
const target = splitLocation(to);
|
||||||
|
const draft = planetologistMoveDraft(viewGame(game, 'm'), 'range', target.territory, target.sector, { [from]: 3 }, {});
|
||||||
|
assert.equal(draft.blocked, null); assert.ok(draft.action);
|
||||||
|
const done = applyAction(reload(game), 'm', draft.action);
|
||||||
|
assert.equal(player(done, 'm').forces[to], 3);
|
||||||
|
assert.equal(player(done, 'm').forces[from] ?? 0, 0);
|
||||||
|
assert.equal(player(done, 'm').moved, 1);
|
||||||
|
assertMoritaniSkillsCustody(done);
|
||||||
|
// Make the same two-step target useful; only legal availability is assessed.
|
||||||
|
game.spice = { [to]: 8 };
|
||||||
|
for (const difficulty of DIFFICULTIES) {
|
||||||
|
const view = viewGame(game, 'm'); view.players.find(p => p.id === 'm')!.bot = difficulty;
|
||||||
|
const action = botActions(view).find(a => a.type === 'move' && a.planetologist === 'range');
|
||||||
|
assert.ok(action, difficulty);
|
||||||
|
assert.doesNotThrow(() => applyAction(game, 'm', action));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Moritani Smuggler ships the free companion once and Bureaucrat redirects a real paid bribe after restore', () => {
|
||||||
|
let game = movementPosition(completedMoritaniSkillsGame({ requestedSkill: 'smuggler' }));
|
||||||
|
const ship = { type: 'ship', territory: 'carthag', sector: 11, amount: 3, smuggler: true };
|
||||||
|
game = applyAction(game, 'm', ship);
|
||||||
|
assert.equal(player(game, 'm').forces['carthag:11'], 3);
|
||||||
|
assert.equal(player(game, 'm').reserves, 17);
|
||||||
|
assert.equal(player(game, 'm').spice, 18);
|
||||||
|
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||||
|
reject(game, 'm', ship); assertMoritaniSkillsCustody(game);
|
||||||
|
|
||||||
|
game = movementPosition(completedMoritaniSkillsGame({ requestedSkill: 'bureaucrat' }));
|
||||||
|
game = applyAction(game, 'a', { type: 'bribe', target: 'e', amount: 5 });
|
||||||
|
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||||
|
assert.equal(game.decision?.player, 'm');
|
||||||
|
const event = game.bureaucratPaymentEvent;
|
||||||
|
const done = applyAction(reload(game), 'm', { type: 'decision', event, redirect: true });
|
||||||
|
assert.equal(player(done, 'a').spice, 15);
|
||||||
|
assert.equal(player(done, 'e').bribes, 3);
|
||||||
|
assert.equal(done.bureaucratPayments!.used.length, 1);
|
||||||
|
reject(done, 'm', { type: 'decision', event, redirect: true });
|
||||||
|
assertMoritaniSkillsCustody(done);
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { unitStore } from './fixture-nexus-room-store';
|
||||||
|
import { startPrototypeRoom } from '../tools/prototype-room';
|
||||||
|
import { assertMoritaniSkillsCustody } from './moritani-skills-fixture';
|
||||||
|
import type { Action } from '../game/engine';
|
||||||
|
|
||||||
|
void test('authenticated restart and concurrent Moritani setup choices retain one skill and the final force placement', async () => {
|
||||||
|
const f = unitStore();
|
||||||
|
const db: DatabaseSync = f.sqlite;
|
||||||
|
const clock = { now: () => 10000, sleep: async () => {} };
|
||||||
|
try {
|
||||||
|
const created = await f.rooms.createRoom('Moritani', 'moritani', false, ['ecaz']);
|
||||||
|
const code = created.view.code;
|
||||||
|
const joined = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
|
||||||
|
const auths = await Promise.all([created.token!, joined.token!].map(token => f.restart().authenticate(code, token)));
|
||||||
|
const act = async (index: number, action: Action) => {
|
||||||
|
const before = await f.restart().readRoom(code);
|
||||||
|
await f.restart().act(code, auths[index], before.version, action, clock);
|
||||||
|
return f.restart().readRoom(code);
|
||||||
|
};
|
||||||
|
await act(0, { type: 'ready' }); await act(1, { type: 'ready' });
|
||||||
|
const ready = await f.restart().readRoom(code);
|
||||||
|
const seats = db.prepare('SELECT * FROM seats').all();
|
||||||
|
startPrototypeRoom(db, code, ready.version, 'leader-skills');
|
||||||
|
const initial = await f.restart().readRoom(code);
|
||||||
|
assertMoritaniSkillsCustody(initial);
|
||||||
|
for (const auth of auths) {
|
||||||
|
const view = await f.restart().readSeatView(code, auth);
|
||||||
|
assert.deepEqual(view.leaderSkills!.offer, initial.leaderSkills!.offers[auth.playerId]);
|
||||||
|
assert.equal('offers' in view.leaderSkills!, false);
|
||||||
|
assert.equal('deck' in view.leaderSkills!, false);
|
||||||
|
}
|
||||||
|
const offer = initial.leaderSkills!.offers[auths[0].playerId];
|
||||||
|
const choice = { type: 'leaderSkill', event: offer.event, skill: offer.cards[0], leader: 'moritani-0' };
|
||||||
|
let arrivals = 0; let release!: () => void;
|
||||||
|
const barrier = new Promise<void>(resolve => { release = resolve; });
|
||||||
|
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; };
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
f.rooms.act(code, auths[0], initial.version, choice, clock),
|
||||||
|
f.restart().act(code, auths[0], initial.version, choice, clock),
|
||||||
|
]);
|
||||||
|
delete f.hooks.beforeWrite;
|
||||||
|
assert.equal(arrivals, 2);
|
||||||
|
assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
|
||||||
|
assert.equal(results.filter(r => r.status === 'rejected').length, 1);
|
||||||
|
let game = await f.restart().readRoom(code);
|
||||||
|
assert.equal(game.version, initial.version + 1);
|
||||||
|
assert.equal(game.leaderSkills!.assignments.filter(a => a.owner === auths[0].playerId).length, 1);
|
||||||
|
assert.equal(game.leaderSkills!.deck.filter(s => s === offer.cards[1]).length, 1);
|
||||||
|
const other = game.leaderSkills!.offers[auths[1].playerId];
|
||||||
|
game = await act(1, { type: 'leaderSkill', event: other.event, skill: other.cards[0], leader: 'atreides-0' });
|
||||||
|
for (let index = 0; index < auths.length; index++)
|
||||||
|
game = await act(index, { type: 'traitor', leader: game.players.find(p => p.id === auths[index].playerId)!.traitorChoices[0] });
|
||||||
|
assert.equal(game.decision?.kind, 'moritaniSetup');
|
||||||
|
game = await act(0, { type: 'decision', territory: 'polar_sink', sector: 0 });
|
||||||
|
assert.equal(game.status, 'playing');
|
||||||
|
assert.equal(game.players.find(p => p.id === auths[0].playerId)!.forces['polar_sink:0'], 6);
|
||||||
|
assertMoritaniSkillsCustody(game);
|
||||||
|
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
|
||||||
|
for (const auth of auths) {
|
||||||
|
const view = await f.restart().readSeatView(code, auth);
|
||||||
|
assert.equal(view.leaderSkills!.assignments.length, 2);
|
||||||
|
assert.equal(view.leaderSkills!.offer, null);
|
||||||
|
}
|
||||||
|
} finally { db.close(); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { mock } from 'node:test';
|
||||||
|
import {
|
||||||
|
applyAction,
|
||||||
|
createGame,
|
||||||
|
initializeLeaderSkillsGameForAudit,
|
||||||
|
joinGame,
|
||||||
|
newPlayer,
|
||||||
|
viewGame,
|
||||||
|
type Game,
|
||||||
|
} from '../game/engine';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||||
|
import { sandmasterWormCollection } from '../game/sandmaster-worm';
|
||||||
|
|
||||||
|
function totalForces(game: Game, player: string) {
|
||||||
|
const owner = game.players.find((candidate) => candidate.id === player)!;
|
||||||
|
return (
|
||||||
|
owner.reserves +
|
||||||
|
owner.tanks +
|
||||||
|
Object.values(owner.forces).reduce((sum, count) => sum + count, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSpiceCards = (game: Game) =>
|
||||||
|
game.spiceDeck.length +
|
||||||
|
game.spiceDiscard.reduce((sum, pile) => sum + pile.length, 0);
|
||||||
|
|
||||||
|
/** Genuine Basic setup through ready, skill, traitor and both force decisions. */
|
||||||
|
function completedMoritaniFremenSkillsGame(): Game {
|
||||||
|
let game = createGame(
|
||||||
|
'MORIWORM',
|
||||||
|
newPlayer('f', 'Fremen', 'fremen'),
|
||||||
|
false,
|
||||||
|
['ecaz'],
|
||||||
|
);
|
||||||
|
joinGame(game, newPlayer('m', 'Moritani', 'moritani'));
|
||||||
|
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||||
|
for (const player of game.players)
|
||||||
|
game = applyAction(game, player.id, { type: 'ready' });
|
||||||
|
|
||||||
|
const targetIndex = LEADER_SKILL_CARDS.findIndex(
|
||||||
|
(card) => card.id === 'sandmaster',
|
||||||
|
);
|
||||||
|
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
|
||||||
|
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
|
||||||
|
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
|
||||||
|
return array;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
game = initializeLeaderSkillsGameForAudit(game);
|
||||||
|
} finally {
|
||||||
|
mock.restoreAll();
|
||||||
|
}
|
||||||
|
assert.equal(game.leaderSkills!.offers.f.cards[0], 'sandmaster');
|
||||||
|
|
||||||
|
for (const player of game.players) {
|
||||||
|
const offer = game.leaderSkills!.offers[player.id];
|
||||||
|
game = applyAction(game, player.id, {
|
||||||
|
type: 'leaderSkill',
|
||||||
|
event: offer.event,
|
||||||
|
skill: player.id === 'f' ? 'sandmaster' : offer.cards[0],
|
||||||
|
leader: player.leaders[0].id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const player of game.players) {
|
||||||
|
const current = game.players.find((candidate) => candidate.id === player.id)!;
|
||||||
|
game = applyAction(game, player.id, {
|
||||||
|
type: 'traitor',
|
||||||
|
leader: current.traitorChoices[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
game = applyAction(game, 'f', {
|
||||||
|
type: 'fremenSetup',
|
||||||
|
placements: { sietch_tabr: 10 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(game.decision, { kind: 'moritaniSetup', player: 'm' });
|
||||||
|
game = applyAction(game, 'm', {
|
||||||
|
type: 'decision',
|
||||||
|
territory: 'polar_sink',
|
||||||
|
sector: 0,
|
||||||
|
});
|
||||||
|
assert.equal(game.status, 'playing');
|
||||||
|
assert.deepEqual(
|
||||||
|
game.leaderSkills!.assignments.find((assignment) => assignment.owner === 'f'),
|
||||||
|
{ owner: 'f', leader: 'fremen-0', skill: 'sandmaster' },
|
||||||
|
);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summonedWormRide(): Game {
|
||||||
|
let game = completedMoritaniFremenSkillsGame();
|
||||||
|
const fremen = game.players.find((player) => player.id === 'f')!;
|
||||||
|
// Stage a conserved turn-two Spice Blow position. Only locations, deck order
|
||||||
|
// and phase controls change; all twenty Fremen forces and Spice cards remain.
|
||||||
|
fremen.forces = { 'wind_pass_north:17': 4 };
|
||||||
|
fremen.reserves = 16;
|
||||||
|
fremen.moved = 1;
|
||||||
|
game.spice = { 'habbanya_ridge_flat:17': 2 };
|
||||||
|
const spiceCardCount = totalSpiceCards(game);
|
||||||
|
const priorIndex = game.spiceDeck.findIndex(
|
||||||
|
(card) => 'territory' in card && card.territory === 'wind_pass_north',
|
||||||
|
);
|
||||||
|
assert.ok(priorIndex >= 0);
|
||||||
|
const [prior] = game.spiceDeck.splice(priorIndex, 1);
|
||||||
|
const wormIndex = game.spiceDeck.findIndex((card) => 'worm' in card);
|
||||||
|
assert.ok(wormIndex >= 0);
|
||||||
|
const [worm] = game.spiceDeck.splice(wormIndex, 1);
|
||||||
|
const nextIndex = game.spiceDeck.findIndex(
|
||||||
|
(card) =>
|
||||||
|
'territory' in card &&
|
||||||
|
card.territory !== 'wind_pass_north' &&
|
||||||
|
card.territory !== 'habbanya_ridge_flat',
|
||||||
|
);
|
||||||
|
assert.ok(nextIndex >= 0);
|
||||||
|
const [next] = game.spiceDeck.splice(nextIndex, 1);
|
||||||
|
game.spiceDeck = [worm, next, ...game.spiceDeck];
|
||||||
|
game.spiceDiscard = [[prior], []];
|
||||||
|
assert.equal(totalSpiceCards(game), spiceCardCount);
|
||||||
|
Object.assign(game, {
|
||||||
|
turn: 2,
|
||||||
|
phase: 1,
|
||||||
|
storm: 18,
|
||||||
|
active: null,
|
||||||
|
ready: [],
|
||||||
|
decision: null,
|
||||||
|
response: null,
|
||||||
|
phaseOpening: null,
|
||||||
|
nexus: false,
|
||||||
|
spiceSequence: null,
|
||||||
|
spiceResolution: null,
|
||||||
|
spiceWindow: null,
|
||||||
|
wormRides: [],
|
||||||
|
movementRemaining: null,
|
||||||
|
});
|
||||||
|
assert.equal(totalForces(game, 'f'), 20);
|
||||||
|
|
||||||
|
for (const player of game.players)
|
||||||
|
game = applyAction(game, player.id, { type: 'ready' });
|
||||||
|
while (game.response) {
|
||||||
|
const responder = game.players.find(
|
||||||
|
(player) => !game.response!.passed.includes(player.id),
|
||||||
|
);
|
||||||
|
assert.ok(responder);
|
||||||
|
game = applyAction(game, responder.id, { type: 'passResponse' });
|
||||||
|
}
|
||||||
|
assert.equal(game.nexus, true);
|
||||||
|
for (let round = 0; round < 3 && game.decision?.kind !== 'wormRide'; round += 1)
|
||||||
|
for (const player of game.players)
|
||||||
|
if (!game.ready.includes(player.id))
|
||||||
|
game = applyAction(game, player.id, { type: 'ready' });
|
||||||
|
assert.deepEqual(game.decision, {
|
||||||
|
kind: 'wormRide',
|
||||||
|
player: 'f',
|
||||||
|
territory: 'wind_pass_north',
|
||||||
|
});
|
||||||
|
assert.equal(totalForces(game, 'f'), 20);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('Basic Moritani permits a projected Sandmaster worm collection and one legal bot ride after JSON restoration', () => {
|
||||||
|
const pending = summonedWormRide();
|
||||||
|
const spiceCardCount = totalSpiceCards(pending);
|
||||||
|
const restored = JSON.parse(JSON.stringify(pending)) as Game;
|
||||||
|
assert.deepEqual(viewGame(restored, 'f'), viewGame(pending, 'f'));
|
||||||
|
|
||||||
|
const authoritative = sandmasterWormCollection(
|
||||||
|
restored,
|
||||||
|
'f',
|
||||||
|
'habbanya_ridge_flat',
|
||||||
|
17,
|
||||||
|
);
|
||||||
|
const projected = sandmasterWormCollection(
|
||||||
|
viewGame(restored, 'f'),
|
||||||
|
'f',
|
||||||
|
'habbanya_ridge_flat',
|
||||||
|
17,
|
||||||
|
);
|
||||||
|
assert.deepEqual(projected, authoritative);
|
||||||
|
assert.deepEqual(authoritative, {
|
||||||
|
leader: 'fremen-0',
|
||||||
|
key: 'habbanya_ridge_flat:17',
|
||||||
|
before: 2,
|
||||||
|
blocked: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const botView = viewGame(restored, 'f');
|
||||||
|
botView.players.find((player) => player.id === 'f')!.bot = 'Easy';
|
||||||
|
const actions = botActions(botView);
|
||||||
|
const collecting = actions.find(
|
||||||
|
(action) =>
|
||||||
|
action.accept === true &&
|
||||||
|
action.territory === 'habbanya_ridge_flat' &&
|
||||||
|
action.sector === 17 &&
|
||||||
|
action.sandmasterCollect === true,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
collecting,
|
||||||
|
`the minimal bot path should include the legal collection: ${JSON.stringify(actions)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const beforeSpice = restored.players.find((player) => player.id === 'f')!.spice;
|
||||||
|
const done = applyAction(restored, 'f', collecting);
|
||||||
|
const rider = done.players.find((player) => player.id === 'f')!;
|
||||||
|
assert.equal(rider.spice, beforeSpice + 1);
|
||||||
|
assert.equal(done.spice['habbanya_ridge_flat:17'], 1);
|
||||||
|
assert.equal(rider.forces['wind_pass_north:17'] ?? 0, 0);
|
||||||
|
assert.equal(rider.forces['habbanya_ridge_flat:17'], 4);
|
||||||
|
assert.equal(rider.moved, 1);
|
||||||
|
assert.equal(totalForces(done, 'f'), 20);
|
||||||
|
assert.equal(totalSpiceCards(done), spiceCardCount);
|
||||||
|
assert.equal(
|
||||||
|
done.log.filter(
|
||||||
|
(entry) => entry.automatic?.name === 'Sandmaster collection',
|
||||||
|
).length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -11,6 +11,23 @@ import {
|
|||||||
viewGame,
|
viewGame,
|
||||||
} from '../game/engine';
|
} from '../game/engine';
|
||||||
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
|
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
|
||||||
|
import { completedMoritaniSkillsGame } from './moritani-skills-fixture';
|
||||||
|
|
||||||
|
void test('Moritani Skills sample custody checks the physical skill and Traitor inventories after genuine setup', () => {
|
||||||
|
const game = completedMoritaniSkillsGame();
|
||||||
|
const inventory = sampleInventory(game);
|
||||||
|
assert.ok(inventory.traitors);
|
||||||
|
verifySampleCustody(game, inventory);
|
||||||
|
const skills = structuredClone(game);
|
||||||
|
skills.leaderSkills!.deck.pop();
|
||||||
|
assert.throws(() => verifySampleCustody(skills, inventory));
|
||||||
|
const missing = structuredClone(game);
|
||||||
|
delete missing.leaderSkills;
|
||||||
|
assert.throws(() => verifySampleCustody(missing, inventory), /Leader Skills module custody/);
|
||||||
|
const traitors = structuredClone(game);
|
||||||
|
traitors.players[0].traitors[0] = traitors.players[1].traitors[0];
|
||||||
|
assert.throws(() => verifySampleCustody(traitors, inventory), /traitor custody/);
|
||||||
|
});
|
||||||
|
|
||||||
function fixture(advanced = false) {
|
function fixture(advanced = false) {
|
||||||
let game = createGame(
|
let game = createGame(
|
||||||
|
|||||||
+31
-13
@@ -11,6 +11,7 @@ import {
|
|||||||
createGame,
|
createGame,
|
||||||
initializeBaseGameForAudit,
|
initializeBaseGameForAudit,
|
||||||
initializeFactionExpansionsGameForAudit,
|
initializeFactionExpansionsGameForAudit,
|
||||||
|
initializeLeaderSkillsGameForAudit,
|
||||||
joinGame,
|
joinGame,
|
||||||
newPlayer,
|
newPlayer,
|
||||||
viewGame,
|
viewGame,
|
||||||
@@ -19,12 +20,13 @@ import {
|
|||||||
type Game,
|
type Game,
|
||||||
} from '../game/engine';
|
} from '../game/engine';
|
||||||
import { sampleInventory, verifySampleCustody } from './sample-custody';
|
import { sampleInventory, verifySampleCustody } from './sample-custody';
|
||||||
|
import { basicMoritaniLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||||
import { privateOutputDirectory, sourceSnapshot } from './verification';
|
import { privateOutputDirectory, sourceSnapshot } from './verification';
|
||||||
|
|
||||||
const DEFAULT_SEED = 20_260_926;
|
const DEFAULT_SEED = 20_260_926;
|
||||||
const DEFAULT_MAX_ACTIONS = 3_500;
|
const DEFAULT_MAX_ACTIONS = 3_500;
|
||||||
const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
|
const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
|
||||||
type Profile = 'base' | 'choam' | 'ecaz' | 'combined';
|
type Profile = 'base' | 'choam' | 'ecaz' | 'combined' | 'moritani-skills';
|
||||||
type Rules = 'basic' | 'advanced';
|
type Rules = 'basic' | 'advanced';
|
||||||
|
|
||||||
type Scenario = {
|
type Scenario = {
|
||||||
@@ -99,6 +101,17 @@ const BASE_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].flatMap((players) =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const MORITANI_SKILLS_ROSTER: readonly FactionId[] = [
|
||||||
|
'moritani', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit',
|
||||||
|
];
|
||||||
|
const MORITANI_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({
|
||||||
|
ordinal: 16 + players - 2,
|
||||||
|
profile: 'moritani-skills',
|
||||||
|
rules: 'basic',
|
||||||
|
expansions: ['ecaz'],
|
||||||
|
roster: MORITANI_SKILLS_ROSTER.slice(0, players),
|
||||||
|
}));
|
||||||
|
|
||||||
type TraceEntry = {
|
type TraceEntry = {
|
||||||
attempt: number;
|
attempt: number;
|
||||||
accepted: number;
|
accepted: number;
|
||||||
@@ -135,10 +148,10 @@ type Result = {
|
|||||||
function usage() {
|
function usage() {
|
||||||
return (
|
return (
|
||||||
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
|
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
|
||||||
'[--seed UINT32] [--profile all|base|choam|ecaz|combined] ' +
|
'[--seed UINT32] [--profile all|base|choam|ecaz|combined|moritani-skills] ' +
|
||||||
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
|
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
|
||||||
'[--resume FAILED_GAME.json]\n' +
|
'[--resume FAILED_GAME.json]\n' +
|
||||||
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; base defaults to all 2–6-player samples. --players requires --profile base. Output must be a new private directory outside the checkout.'
|
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; base and moritani-skills default to their 2–6-player samples. --players requires --profile base or moritani-skills. Output must be a new private directory outside the checkout.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,15 +182,15 @@ function positive(value: string | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scenarioName(scenario: Scenario) {
|
function scenarioName(scenario: Scenario) {
|
||||||
return scenario.profile === 'base'
|
return scenario.profile === 'base' || scenario.profile === 'moritani-skills'
|
||||||
? `base-${scenario.roster.length}-${scenario.rules}`
|
? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}`
|
||||||
: `${scenario.profile}-${scenario.rules}`;
|
: `${scenario.profile}-${scenario.rules}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseProfile(value: string | undefined) {
|
function parseProfile(value: string | undefined) {
|
||||||
const profile = value ?? 'all';
|
const profile = value ?? 'all';
|
||||||
if (!['all', 'base', 'choam', 'ecaz', 'combined'].includes(profile))
|
if (!['all', 'base', 'choam', 'ecaz', 'combined', 'moritani-skills'].includes(profile))
|
||||||
throw new Error('--profile must be all, base, choam, ecaz or combined.');
|
throw new Error('--profile must be all, base, choam, ecaz, combined or moritani-skills.');
|
||||||
return profile as Profile | 'all';
|
return profile as Profile | 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +234,9 @@ function freshGame(scenario: Scenario) {
|
|||||||
player.bot = DIFFICULTIES[index % DIFFICULTIES.length];
|
player.bot = DIFFICULTIES[index % DIFFICULTIES.length];
|
||||||
player.ready = true;
|
player.ready = true;
|
||||||
}
|
}
|
||||||
return scenario.profile === 'base'
|
return scenario.profile === 'moritani-skills'
|
||||||
|
? initializeLeaderSkillsGameForAudit(game)
|
||||||
|
: scenario.profile === 'base'
|
||||||
? initializeBaseGameForAudit(game)
|
? initializeBaseGameForAudit(game)
|
||||||
: initializeFactionExpansionsGameForAudit(game);
|
: initializeFactionExpansionsGameForAudit(game);
|
||||||
}
|
}
|
||||||
@@ -260,7 +275,7 @@ function resumedGame(path: string) {
|
|||||||
if (
|
if (
|
||||||
game.homeworlds ||
|
game.homeworlds ||
|
||||||
game.nexusCards ||
|
game.nexusCards ||
|
||||||
game.leaderSkills ||
|
(game.leaderSkills && !basicMoritaniLeaderSkillsProfile(game)) ||
|
||||||
game.discoveryEnabled ||
|
game.discoveryEnabled ||
|
||||||
game.discoveries ||
|
game.discoveries ||
|
||||||
game.discoveryStash ||
|
game.discoveryStash ||
|
||||||
@@ -275,8 +290,9 @@ function resumedGame(path: string) {
|
|||||||
game.moritaniAssassinateCallEvents
|
game.moritaniAssassinateCallEvents
|
||||||
)
|
)
|
||||||
throw new Error('--resume sample scenarios exclude optional modules.');
|
throw new Error('--resume sample scenarios exclude optional modules.');
|
||||||
const scenario = [...SCENARIOS, ...BASE_SCENARIOS].find(
|
const scenario = [...SCENARIOS, ...BASE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS].find(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
|
(candidate.profile === 'moritani-skills') === !!game.leaderSkills &&
|
||||||
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
|
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
|
||||||
JSON.stringify(candidate.expansions) ===
|
JSON.stringify(candidate.expansions) ===
|
||||||
JSON.stringify(game.expansions) &&
|
JSON.stringify(game.expansions) &&
|
||||||
@@ -469,12 +485,14 @@ async function main() {
|
|||||||
const profile = parseProfile(values.profile);
|
const profile = parseProfile(values.profile);
|
||||||
const rules = parseRules(values.rules);
|
const rules = parseRules(values.rules);
|
||||||
const players = parsePlayers(values.players);
|
const players = parsePlayers(values.players);
|
||||||
if (supplied('players') && profile !== 'base')
|
if (supplied('players') && profile !== 'base' && profile !== 'moritani-skills')
|
||||||
throw new Error('--players requires --profile base.');
|
throw new Error('--players requires --profile base or moritani-skills.');
|
||||||
|
if (profile === 'moritani-skills' && rules === 'advanced')
|
||||||
|
throw new Error('Moritani with Leader Skills currently supports only Basic samples.');
|
||||||
const resume = values.resume ? resumedGame(values.resume) : null;
|
const resume = values.resume ? resumedGame(values.resume) : null;
|
||||||
const selected = resume
|
const selected = resume
|
||||||
? [resume.scenario]
|
? [resume.scenario]
|
||||||
: (profile === 'base' ? BASE_SCENARIOS : SCENARIOS).filter(
|
: (profile === 'base' ? BASE_SCENARIOS : profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS : SCENARIOS).filter(
|
||||||
(scenario) =>
|
(scenario) =>
|
||||||
(profile === 'all' || scenario.profile === profile) &&
|
(profile === 'all' || scenario.profile === profile) &&
|
||||||
(rules === 'both' || scenario.rules === rules) &&
|
(rules === 'both' || scenario.rules === rules) &&
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { leaders, treacheryDeck } from '../game/cards';
|
|||||||
import type { Game } from '../game/engine';
|
import type { Game } from '../game/engine';
|
||||||
import { richeseCards } from '../game/richese-cards';
|
import { richeseCards } from '../game/richese-cards';
|
||||||
import { traitorDeck } from '../game/traitors';
|
import { traitorDeck } from '../game/traitors';
|
||||||
|
import { basicMoritaniLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||||
|
import { validateLeaderSkills } from '../game/leader-skills';
|
||||||
|
|
||||||
/** Fixed inventory captured after genuine setup initialization, before any choices. */
|
/** Fixed inventory captured after genuine setup initialization, before any choices. */
|
||||||
export function sampleInventory(game: Game) {
|
export function sampleInventory(game: Game) {
|
||||||
return {
|
return {
|
||||||
|
leaderSkills: !!game.leaderSkills,
|
||||||
cards: [
|
cards: [
|
||||||
...treacheryDeck(game.expansions),
|
...treacheryDeck(game.expansions),
|
||||||
...(game.players.some((player) => player.faction === 'richese')
|
...(game.players.some((player) => player.faction === 'richese')
|
||||||
@@ -16,7 +19,7 @@ export function sampleInventory(game: Game) {
|
|||||||
.map((card) => card.id)
|
.map((card) => card.id)
|
||||||
.sort(),
|
.sort(),
|
||||||
// Expansion Face Dancer/assassination zones require a separate adapter.
|
// Expansion Face Dancer/assassination zones require a separate adapter.
|
||||||
traitors: game.expansions.length
|
traitors: game.expansions.length && !basicMoritaniLeaderSkillsProfile(game)
|
||||||
? null
|
? null
|
||||||
: traitorDeck(
|
: traitorDeck(
|
||||||
game.players.map((player) => ({ leaders: leaders(player.faction) })),
|
game.players.map((player) => ({ leaders: leaders(player.faction) })),
|
||||||
@@ -28,6 +31,8 @@ export function verifySampleCustody(
|
|||||||
game: Game,
|
game: Game,
|
||||||
inventory: ReturnType<typeof sampleInventory>,
|
inventory: ReturnType<typeof sampleInventory>,
|
||||||
) {
|
) {
|
||||||
|
assert.equal(!!game.leaderSkills, inventory.leaderSkills, 'Leader Skills module custody');
|
||||||
|
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
|
||||||
const cards = [
|
const cards = [
|
||||||
...game.players.flatMap((player) => player.hand),
|
...game.players.flatMap((player) => player.hand),
|
||||||
...game.deck,
|
...game.deck,
|
||||||
|
|||||||
Reference in new issue
Block a user