Accelerate rules-first feedback and integrate native Strongholds
Apply user priority: save/recovery/privacy/custody assurance after all rules. Add quick types/lint, concise failure-preserving reporter, verbose mode, actual empty-execution guard, signal handling and focused verify name selection; compact canonical context/handoffs without mass moves. Tooling15, six negative CLI, named/empty source-bound smokes,206 doc links; same15-case278B vs2455B,2.35s vs2.36s no compute speedclaim. Native Advanced Ix/CHOAM47-card Stronghold profile: actual firstMentat/HMS/sixeffects,18native/four legalpolicies,five genuine2..6games3129actions/no rejection, human390px LAUDR2WNv14 bank2/personal1 plan and real battle. Earlier162/6513 checks before assurance amendment; finalquick25/build/guide pass. No new preservation/privacy audit or public/deployed/full-mode claim.
This commit is contained in:
1 parent
af6d27095c
commit
e9830b6ced
27 files changed
+2339
-194
No files matched your search
@@ -6,16 +6,18 @@ for the in-memory SQLite tests. `tsx` is a pinned development dependency.
|
|||||||
|
|
||||||
## Start small
|
## Start small
|
||||||
|
|
||||||
- Read `README.md` and this guide first. See `docs/DEVELOPMENT.md` for the
|
- Start here; use `docs/DEVELOPMENT.md#context-index` to open only the task's
|
||||||
architecture map and focused verification commands.
|
canonical paths, then the relevant README instructions if needed.
|
||||||
- When continuing the complete project goal in this checkout, explicitly read
|
- When continuing the complete project goal in this checkout, explicitly read
|
||||||
`GOAL.local.md` if present; it is local and ignored, not automatically loaded
|
`GOAL.local.md` if present; it is local and ignored, not automatically loaded
|
||||||
project context. If absent, use the tracked guidance and the current user
|
project context. If absent, use the tracked guidance and the current user
|
||||||
request. Never recreate it from credential-bearing handovers or commit it.
|
request. Never recreate it from credential-bearing handovers or commit it.
|
||||||
- Read `docs/CURRENT_STATUS.md` for current gates and `docs/RULE_DECISIONS.md`
|
- Read the opening of `docs/CURRENT_STATUS.md` for active work; use the bounded
|
||||||
for existing rulings before reopening research.
|
lookup in `docs/DEVELOPMENT.md#canonical-feature-lookup` for feature evidence
|
||||||
|
and `docs/RULE_DECISIONS.md` for source authority and rulings.
|
||||||
- Run `git status --short` before editing; preserve other work.
|
- Run `git status --short` before editing; preserve other work.
|
||||||
- Search the relevant feature in `game/`, `components/`, `tests/`, and `docs/`.
|
- Locate the feature's helper and consumers from its existing evidence paths;
|
||||||
|
search the relevant subsystem only when those paths are missing.
|
||||||
Use OMP `find` for unknown behavior, `grep` for known literals and bounded
|
Use OMP `find` for unknown behavior, `grep` for known literals and bounded
|
||||||
`read` for `game/engine.ts` and `components/game-table.tsx`; both are large
|
`read` for `game/engine.ts` and `components/game-table.tsx`; both are large
|
||||||
orchestration files.
|
orchestration files.
|
||||||
@@ -30,31 +32,33 @@ for the in-memory SQLite tests. `tsx` is a pinned development dependency.
|
|||||||
- The user prioritizes working first versions of all remaining functions across
|
- The user prioritizes working first versions of all remaining functions across
|
||||||
Basic, Advanced and every expansion, followed by integration, refinement and
|
Basic, Advanced and every expansion, followed by integration, refinement and
|
||||||
polish. Reuse existing capabilities; batch related functions by dependency.
|
polish. Reuse existing capabilities; batch related functions by dependency.
|
||||||
- The 26 September 2026 priority amendment puts game content first. Current
|
- The 2 October 2026 amendment supersedes the 26 September sequencing:
|
||||||
playable continuation and blocking save fixes remain required; historical-save
|
implement all rules first; save/recovery, privacy and custody assurance plus
|
||||||
compatibility, exhaustive restore/seat audits, backup refinement and routine
|
comprehensive review follow afterward, not as per-prototype gates. Do not
|
||||||
whole-database comparisons move to final polish after game content. Retain
|
repeat seat-restoration audits, whole-database comparisons or preservation
|
||||||
privacy, authorization, custody, no-reset safety, complete administration,
|
loops at each feature. This defers assurance, not printed card/force/payment
|
||||||
relevant checkpoint checks and final recovery acceptance.
|
mechanics, existing authorization, no-reset/no-deletion or secret protection.
|
||||||
- Track missing, prototyped, integrated, verified and polished work in the
|
- Track missing, prototyped, integrated, verified and polished work in the
|
||||||
existing checklist. Prototypes need usable controls, a legal AI path and saved
|
existing checklist. Prototypes need usable controls, connected rule behavior,
|
||||||
continuation; placeholders and disconnected helpers are not completed features.
|
a minimal legal AI path and changed-path smoke; placeholders and disconnected
|
||||||
|
helpers are not completed features. Recovery certification comes later.
|
||||||
- A complete administration panel is required non-AI scope: room creation,
|
- A complete administration panel is required non-AI scope: room creation,
|
||||||
lifecycle/removal, participant support, backups/restoration and operations.
|
lifecycle/removal, participant support, backups/restoration and operations.
|
||||||
Follow `docs/ADMIN_PANEL.md`; retain authorization, privacy, audit history and
|
Follow `docs/ADMIN_PANEL.md`; retain authorization, privacy, audit history and
|
||||||
saved-game safeguards. Adding this scope does not authorize a game reset.
|
saved-game safeguards. Adding this scope does not authorize a game reset.
|
||||||
- Keep focused checks for crashes, deadlocks, legality, custody, privacy and save
|
- Iterate on the smallest failing case first. Keep legality, crashes, deadlocks,
|
||||||
integrity. Defer exhaustive combinations and visual polish until broader
|
usable controls and minimal legal AI feedback; batch related rules by dependency
|
||||||
functional coverage, unless current failures require them.
|
before integration proof. Do not add privacy/custody/save audits to that loop.
|
||||||
- Defer full AI implementation, strategic refinement and difficulty calibration
|
- Defer full AI implementation, strategic refinement and difficulty calibration
|
||||||
until all non-AI game features are complete. Until then, maintain only minimal
|
until all non-AI game features are complete. Until then, maintain only minimal
|
||||||
legal participation and fix critical playability/correctness issues. Afterward,
|
legal participation and fix critical playability/correctness issues. Afterward,
|
||||||
target approximately 75% higher-tier wins for each adjacent pair: Medium/Easy,
|
target approximately 75% higher-tier wins for each adjacent pair: Medium/Easy,
|
||||||
Hard/Medium and Brutal/Hard. See `docs/AI_DEVELOPMENT_PLAN.md` for evaluation.
|
Hard/Medium and Brutal/Hard. See `docs/AI_DEVELOPMENT_PLAN.md` for evaluation.
|
||||||
- Preserve required checkpoint checks, selective subagents and independent
|
- Keep one shared integration owner and selectively delegate disjoint substantial
|
||||||
review for complex rules/privacy/persistence. Commit and push verified
|
slices. Rule-specific decisions may need bounded review now; comprehensive
|
||||||
checkpoints under standing authorization. No user-imposed usage cutoff or
|
rules/privacy/persistence review follows all rules. Commit and push verified
|
||||||
routine account-usage check remains.
|
checkpoints under standing authorization. No usage cutoff or routine
|
||||||
|
account-usage check remains.
|
||||||
- Keep material rulings explicit and prototype independent work while pending.
|
- Keep material rulings explicit and prototype independent work while pending.
|
||||||
The user-authorized Advanced preview permits unfinished base-faction starts
|
The user-authorized Advanced preview permits unfinished base-faction starts
|
||||||
with a visible warning; see `docs/ADVANCED_PREVIEW.md`. Prototype evidence does
|
with a visible warning; see `docs/ADVANCED_PREVIEW.md`. Prototype evidence does
|
||||||
@@ -62,30 +66,36 @@ for the in-memory SQLite tests. `tsx` is a pinned development dependency.
|
|||||||
|
|
||||||
## OMP harness workflow
|
## OMP harness workflow
|
||||||
|
|
||||||
See [the OMP harness workflow](docs/DEVELOPMENT.md#omp-harness-workflow).
|
See [the OMP harness workflow](docs/DEVELOPMENT.md#omp-harness-workflow) and
|
||||||
Live tool schemas take precedence. Use native planning, delegation and browser
|
[compact handoff](docs/DEVELOPMENT.md#compact-agent-handoff). Live tool schemas
|
||||||
tools, keep one integration owner and retain selective independent review for
|
take precedence. Use native planning, delegation and browser tools; one
|
||||||
complex rules, hidden information and persistence.
|
integration owner freezes related work before running shared checks.
|
||||||
|
|
||||||
## Implement and verify
|
## Implement and verify
|
||||||
|
|
||||||
- Keep rule calculations/validation in focused `game/` modules; integrate them
|
- Keep rule calculations/validation in focused `game/` modules; integrate them
|
||||||
through the engine, player view, bots and UI as the feature requires.
|
through the engine, player view, bots and UI as the feature requires.
|
||||||
- Preserve rejected-action immutability, physical card/force custody, private
|
- Reuse shared rule quotes rather than duplicating legality in bots or components.
|
||||||
information boundaries, and JSON save/restore behavior. Reuse shared rule
|
Preserve existing rejected-action immutability, physical card/force mechanics,
|
||||||
quotes instead of independently duplicating legality in bots or components.
|
private boundaries and JSON behavior; defer their assurance campaigns until
|
||||||
|
all rules are implemented rather than making them new per-feature gates.
|
||||||
- Add meaningful regressions to `tests/**/*.test.ts`; discovery is automatic.
|
- Add meaningful regressions to `tests/**/*.test.ts`; discovery is automatic.
|
||||||
HTTP tests must start with `// @dune-suite integration`. Tests importing
|
HTTP tests must start with `// @dune-suite integration`. Tests importing
|
||||||
`node:sqlite` are classified as in-memory recovery tests automatically.
|
`node:sqlite` are classified as in-memory recovery tests automatically.
|
||||||
- During iteration: `npm test -- <filename-fragment>`; multiple fragments form
|
- During iteration: `npm test -- <filename-fragment> --name '<failing-case-regex>'`.
|
||||||
a union. Use `--list` to inspect selection and `--name <regex>` to narrow
|
Inspect with `--list`; verify the intended case actually ran. Once repaired,
|
||||||
cases. A filename typo fails rather than silently passing an empty suite.
|
run the related file union once, not after every tiny fixture edit. Filename
|
||||||
- Before handing off code changes: `npm run check` (types, lint, offline tests).
|
typos fail instead of passing an empty selection.
|
||||||
Run `npm run build` for app/build/dependency changes. Run
|
- At a frozen related-rule batch, the integration owner runs `npm run check:quick`
|
||||||
`npm run test:integration` against a development server for HTTP, auth or
|
(types/lint), affected tests and the changed runtime path once. Build
|
||||||
persistence changes. Documentation-only edits need link/command review.
|
app/build/dependency changes;
|
||||||
- Extend testing when failures or changed behavior justify it. Do not run AI
|
run relevant HTTP tests for actual boundary changes, not routine assurance.
|
||||||
calibration or browser acceptance for unrelated maintenance changes.
|
See `docs/DEVELOPMENT.md#feedback-and-proof-schedule` for the full schedule.
|
||||||
|
- Full `npm run check`, comprehensive review, recovery/privacy/custody audits,
|
||||||
|
combinations, browser/deployed acceptance and required release proof remain
|
||||||
|
due after all rules. Earlier broad checks need a concrete integration risk or
|
||||||
|
observed failure, not habit. Documentation-only edits need link/command review.
|
||||||
|
Do not run AI calibration or unrelated browser acceptance for maintenance.
|
||||||
- If a sandbox blocks test subprocess pipes with `EPERM`, rerun the exact check
|
- If a sandbox blocks test subprocess pipes with `EPERM`, rerun the exact check
|
||||||
with approved subprocess permissions. Do not disable isolation or skip tests
|
with approved subprocess permissions. Do not disable isolation or skip tests
|
||||||
to obtain a green result; the runner self-test checks real failure propagation.
|
to obtain a green result; the runner self-test checks real failure propagation.
|
||||||
@@ -106,9 +116,9 @@ complex rules, hidden information and persistence.
|
|||||||
test, migration, connection or gameplay problem. Migrations are additive.
|
test, migration, connection or gameplay problem. Migrations are additive.
|
||||||
- Reuse a running dev server. Restart only when a code/configuration change or
|
- Reuse a running dev server. Restart only when a code/configuration change or
|
||||||
observed server condition warrants it; there is no hourly restart schedule.
|
observed server condition warrants it; there is no hourly restart schedule.
|
||||||
Warn before interrupting human play, defer to a safe point, and verify
|
Warn before interrupting human play and defer to a safe point. Observe server
|
||||||
restoration after a necessary restart. No recurring maintenance automation
|
health after a necessary restart; comprehensive restoration assurance follows
|
||||||
is installed; do not recreate it without a request.
|
all rules. No recurring maintenance automation is installed.
|
||||||
- `.openai/hosting.json` identifies the existing Sites project. Preserve it.
|
- `.openai/hosting.json` identifies the existing Sites project. Preserve it.
|
||||||
Follow the applicable Sites skills for site work; publication has existing
|
Follow the applicable Sites skills for site work; publication has existing
|
||||||
completion and verification gates documented in `README.md`.
|
completion and verification gates documented in `README.md`.
|
||||||
@@ -117,8 +127,8 @@ complex rules, hidden information and persistence.
|
|||||||
|
|
||||||
## Efficient checkpoints
|
## Efficient checkpoints
|
||||||
|
|
||||||
- Use `docs/VERIFICATION_WORKFLOW.md` for compact source-bound check reports
|
- Use `docs/VERIFICATION_WORKFLOW.md` for compact source-bound reports and the
|
||||||
and private saved-game/seat checks. Keep artifacts outside the checkout.
|
deferred final saved-game/seat assurance commands. Keep artifacts outside Git.
|
||||||
- Use subagents selectively for bounded independent tasks and complex reviews;
|
- Use subagents selectively for bounded independent tasks and complex reviews;
|
||||||
they remain explicitly authorized. Share concise briefs and file ownership.
|
they remain explicitly authorized. Share concise briefs and file ownership.
|
||||||
- Review, verify, commit and push completed checkpoints automatically under
|
- Review, verify, commit and push completed checkpoints automatically under
|
||||||
|
|||||||
@@ -227,6 +227,17 @@ original-faction retention after death/capture/reassignment is provisional
|
|||||||
in this profile; bounded verification is Partial/Development Verified and the existing question stays open.
|
in this profile; bounded verification is Partial/Development Verified and the existing question stays open.
|
||||||
See the [normal-band source and scope](docs/SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026).
|
See the [normal-band source and scope](docs/SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026).
|
||||||
|
|
||||||
|
For local **Ixians + CHOAM + Stronghold Cards** integration, use
|
||||||
|
`node --import tsx tools/start-prototype.ts --profile stronghold-factions`
|
||||||
|
on a fresh ready Advanced native-Ixian/native-CHOAM lobby with unique classic
|
||||||
|
opponents and exactly `ix,choam` decks. Canonical combined Treachery count is47,
|
||||||
|
not49; the six separate cards start unowned and use native end-Mentat custody.
|
||||||
|
The same exact-version/private-backup contract applies. Other rosters/modules,
|
||||||
|
public expansion starts and existing games are unchanged. Bounded native
|
||||||
|
rules, legal AI, complete-game and human phone controls are Development Verified;
|
||||||
|
save/privacy/custody assurance follows all rules under the2 October priority. See the
|
||||||
|
[bounded integration](docs/STRONGHOLD_CARDS.md#native-ixian-and-choam-integration--2-october-2026).
|
||||||
|
|
||||||
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).
|
||||||
@@ -258,20 +269,40 @@ completes automatically; see [private exchange history](docs/HARKONNEN_EXCHANGE.
|
|||||||
|
|
||||||
## Verify changes
|
## Verify changes
|
||||||
|
|
||||||
Use the [development guide](docs/DEVELOPMENT.md) for architecture, focused tests
|
Use the [bounded context index](docs/DEVELOPMENT.md#context-index) for canonical
|
||||||
and the [repeatable verification tools](docs/VERIFICATION_WORKFLOW.md). Tests are discovered automatically; the default
|
feature/source paths and the [feedback/proof schedule](docs/DEVELOPMENT.md#feedback-and-proof-schedule).
|
||||||
suite includes in-memory persistence recovery and needs no server:
|
The **2 October 2026** priority implements all rules before comprehensive review
|
||||||
|
and save/recovery/privacy/custody assurance; those remain final requirements,
|
||||||
|
not per-prototype gates. Keep legal behavior, usable controls, minimal legal AI
|
||||||
|
and actual changed-path smoke. Batch related rules with one integration owner;
|
||||||
|
helpers do not run shared checks while edits are in flight.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# Inspect file selection, then repair the exact failing case first.
|
||||||
|
npm test -- ecaz-spice --list
|
||||||
|
npm test -- ecaz-spice --name 'fallback'
|
||||||
|
# Run the related union once the batch is coherent.
|
||||||
npm test -- ecaz-collection ecaz-spice
|
npm test -- ecaz-collection ecaz-spice
|
||||||
|
npm run check:quick
|
||||||
|
# Also exercise the actual changed runtime path.
|
||||||
|
# Full offline proof after all rules or a justified broad integration check.
|
||||||
npm run check
|
npm run check
|
||||||
|
# App/build/dependency changes also need a build.
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `npm run test:integration` against a development server for HTTP/session
|
Tests are discovered automatically. The offline suite includes in-memory
|
||||||
changes. `npm run test:multiplayer` remains available for recovery plus HTTP
|
persistence recovery and needs no server. Run relevant `test:integration` cases
|
||||||
checks; `npm run test:recovery` runs only in-memory persistence tests. All test
|
against a development server for changed HTTP/session behavior, not repeated
|
||||||
commands accept filename fragments and `--list` after `--`.
|
assurance at every rule checkpoint. `test:multiplayer` retains recovery plus HTTP;
|
||||||
|
`test:recovery` selects in-memory persistence. Filename fragments form a union;
|
||||||
|
`--name REGEX` narrows cases. `--list` previews files, not matching case names.
|
||||||
|
Bad fragments/patterns and zero-executed selections fail. Default output retains
|
||||||
|
failures, diagnostics and counts; use `--verbose` for full per-pass output.
|
||||||
|
`check:quick` runs typecheck and lint only; it is not full-suite or release proof.
|
||||||
|
See [source-bound verification](docs/VERIFICATION_WORKFLOW.md#compact-checks-and-source-evidence)
|
||||||
|
for private full logs/reports and deferred final audits. Existing games, access
|
||||||
|
controls, secret protection and CI/deployment/publication gates remain protected.
|
||||||
|
|
||||||
The production build is written to `dist`. `npm start` runs that build with Wrangler; apply its database migrations to the intended storage environment before use. The included local migration command targets the development database, not a remote deployment.
|
The production build is written to `dist`. `npm start` runs that build with Wrangler; apply its database migrations to the intended storage environment before use. The included local migration command targets the development database, not a remote deployment.
|
||||||
|
|
||||||
@@ -287,6 +318,6 @@ See [AI calibration](docs/AI_CALIBRATION.md), [multiplayer audit](docs/MULTIPLAY
|
|||||||
|
|
||||||
The user-authorized 13 September 2026 local reset established a fresh saved-game baseline; see the [reset record](docs/VERIFICATION_WORKFLOW.md#authorized-reset-13-september-2026). Preserve all games created after that checkpoint.
|
The user-authorized 13 September 2026 local reset established a fresh saved-game baseline; see the [reset record](docs/VERIFICATION_WORKFLOW.md#authorized-reset-13-september-2026). Preserve all games created after that checkpoint.
|
||||||
|
|
||||||
Reuse the running development server. Restart only when a code or configuration change, or an observed server condition, makes leaving it running likely to cause issues; use judgment rather than an hourly schedule. Preserve database state, warn before interrupting human play, and defer until a safe decision point. Verify server health and restoration after a necessary restart. No recurring automation is installed; the user requested leaving it removed. Fix causes of crashes and lost progress rather than relying on restarts.
|
Reuse the running development server. Restart only for a relevant code/configuration change or observed problem, not an hourly schedule. Preserve database state, warn before interrupting human play and defer to a safe point. Observe health after a necessary local restart; comprehensive restoration assurance follows all rules, while actual production deployments retain their existing safeguards. No recurring automation is installed; leave it removed. Fix crashes and broken rule continuation rather than relying on restarts.
|
||||||
|
|
||||||
The application bundles its artwork, fonts and rules guidance. Source provenance and third-party notices live in developer documentation; the player-facing reference uses internal links. Publication remains gated on completion and integrated verification of the requested rules, expansions and critical player journeys.
|
The application bundles its artwork, fonts and rules guidance. Source provenance and third-party notices live in developer documentation; the player-facing reference uses internal links. Publication remains gated on completion and integrated verification of the requested rules, expansions and critical player journeys.
|
||||||
+65
-14
@@ -16,6 +16,49 @@ open mode gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
|
||||||
|
**Requested priority — 2 October 2026:** development-speed improvements now
|
||||||
|
precede resuming rule content. Implement all remaining rules first; comprehensive
|
||||||
|
review and save/recovery/privacy/custody assurance follow afterward, not at each
|
||||||
|
prototype. Keep legal rule behavior, usable controls, minimal legal AI and
|
||||||
|
changed-path smoke. Existing games are not reset/deleted; secret protection,
|
||||||
|
authorization and deployment/publication safeguards remain intact. Use the
|
||||||
|
[bounded context index](DEVELOPMENT.md#context-index) and
|
||||||
|
[feedback/proof schedule](DEVELOPMENT.md#feedback-and-proof-schedule).
|
||||||
|
|
||||||
|
**Development-throughput improvements implemented and verified:** default
|
||||||
|
test output preserves failures/details/stdout/stderr/counts while omitting
|
||||||
|
passing rows; `--verbose` retains full spec. Empty/bad file or name selections
|
||||||
|
and zero executed cases fail, including source-bound `verify focused --name`.
|
||||||
|
Cancellation remains non-success even when a driver exits zero. Actual tooling
|
||||||
|
contracts15/15, six negative CLI cases, positive/negative source-bound runs,
|
||||||
|
`check:quick` and206 local doc links pass. Final scoped review has no finding.
|
||||||
|
Identical15-case native rule runs emitted278 bytes/12 lines concise versus
|
||||||
|
2,455 bytes/27 lines verbose (2.35s versus2.36s: no computation-speed claim).
|
||||||
|
Named Carthag feedback took0.90s; discovery-only list0.18s. The earlier162-case
|
||||||
|
union took100.22s with different coverage; use narrow failing-case feedback
|
||||||
|
before one coherent affected batch. Canonical context index/handoff avoids
|
||||||
|
mass file moves or duplicate ruling registries. Resume preserved Stronghold
|
||||||
|
rule/control work now; do not restart save/privacy/custody/seat audit campaigns.
|
||||||
|
|
||||||
|
**Stronghold rule integration — bounded Development Verified:** fresh
|
||||||
|
`stronghold-factions` connects Advanced native Ixians/CHOAM plus classic2..6,
|
||||||
|
canonical combined47 Treachery Cards and six Stronghold Cards alone.
|
||||||
|
Eighteen native battle/copy cases, all four legal policy paths and five
|
||||||
|
unstaged complete games pass (3,129 accepted actions, no rejection,82JSON
|
||||||
|
restores). Earlier162 affected cases and6,513 full checks/type/lint/build
|
||||||
|
were recorded before the new assurance deferral, not rerun as a prototype gate.
|
||||||
|
Human390px original CLI room **LAUDR2WN v14** chose HMS Arrakeen copy,
|
||||||
|
sealed dial6/support3 with bank2/personal1 and completed the real battle
|
||||||
|
(personal1→0, three cyborg losses, CHOAM income1). Existing classic preview,
|
||||||
|
public expansion and pending Ecaz rounding boundaries stay unchanged.
|
||||||
|
New save/privacy/custody/seat campaigns remain deferred until all rules.
|
||||||
|
See [the bounded integration](STRONGHOLD_CARDS.md#native-ixian-and-choam-integration--2-october-2026).
|
||||||
|
|
||||||
|
The checkpoint counts, room/seat observations and live markers below are
|
||||||
|
historical evidence recorded by **2 October 2026**, tied to their named
|
||||||
|
revisions/reports. They do not describe a fresh result or require repeating
|
||||||
|
those assurance loops under the new priority.
|
||||||
|
|
||||||
Normal Spice Banker income now connects every reachable actual final-bank
|
Normal Spice Banker income now connects every reachable actual final-bank
|
||||||
auction, shipment, paid revival and resolved support source in fresh local
|
auction, shipment, paid revival and resolved support source in fresh local
|
||||||
`banker-income`: unique classic2..6-seat Basic/Advanced, base33/all14-skills only.
|
`banker-income`: unique classic2..6-seat Basic/Advanced, base33/all14-skills only.
|
||||||
@@ -985,9 +1028,9 @@ answer is assumed. See the [source follow-up](LEADER_SKILLS_RULES.md#20-septembe
|
|||||||
verification, plus Missing, Prototyped, Integrated, Verified and Polished stages.
|
verification, plus Missing, Prototyped, Integrated, Verified and Polished stages.
|
||||||
- [Rule decision index](RULE_DECISIONS.md): settled contracts, source precedence
|
- [Rule decision index](RULE_DECISIONS.md): settled contracts, source precedence
|
||||||
and pending material interpretations. Consult it before reopening research.
|
and pending material interpretations. Consult it before reopening research.
|
||||||
- [Development guide](DEVELOPMENT.md) and [verification tools](VERIFICATION_WORKFLOW.md):
|
- [Development context index](DEVELOPMENT.md#context-index), [canonical lookup](DEVELOPMENT.md#canonical-feature-lookup)
|
||||||
architecture, focused checks, source-bound reports, reusable faction samples,
|
and [verification tools](VERIFICATION_WORKFLOW.md): bounded paths, narrow
|
||||||
private backup and saved-seat restoration.
|
feedback, frozen-batch proof and deferred final assurance.
|
||||||
- [Implementation history](IMPLEMENTATION_STATUS.md): newest first. Historical
|
- [Implementation history](IMPLEMENTATION_STATUS.md): newest first. Historical
|
||||||
evidence and outage records stay there and in feature documents; read bounded
|
evidence and outage records stay there and in feature documents; read bounded
|
||||||
relevant sections and verify old absence claims against current code/tests.
|
relevant sections and verify old absence claims against current code/tests.
|
||||||
@@ -1028,20 +1071,28 @@ record their measured scope. [README](../README.md) retains publication gates.
|
|||||||
|
|
||||||
## Working sequence and preservation
|
## Working sequence and preservation
|
||||||
|
|
||||||
Prototype every remaining function in dependency order, using existing controls,
|
Prototype all remaining rules in dependency-related batches using existing
|
||||||
legal AI and the saved-state model. Continue independent functions while material
|
controls and minimal legal AI. Continue independent functions while material
|
||||||
rulings are pending. Then run integrated games, repair failures, deepen combination
|
rulings are pending. The integration owner freezes each batch, repairs the
|
||||||
and recovery coverage, and complete all non-AI features. Only then fully implement
|
smallest failing case first, then runs affected tests/types/lint and changed-path
|
||||||
and refine AI strategy and calibrate the adjacent difficulty pairs. Keep focused
|
smoke once. Do not run the full offline suite or repeat HTTP/seat/save/privacy/
|
||||||
checks for crashes, deadlocks, legality, custody and privacy; complete required
|
custody audits at every tiny prototype.
|
||||||
broad checkpoint checks.
|
|
||||||
Use selective subagents and independent review for complex rules/privacy/persistence.
|
After all rules are implemented, run full proof, comprehensive independent
|
||||||
|
review, integration games and save/recovery/privacy/custody assurance; repair
|
||||||
|
failures and finish all non-AI scope, including complete administration. Only
|
||||||
|
then fully implement/refine AI and calibrate adjacent difficulty pairs. Final
|
||||||
|
components, human journeys, browser/deployed and publication acceptance remain
|
||||||
|
required. The [proof schedule](DEVELOPMENT.md#feedback-and-proof-schedule) records
|
||||||
|
the checkpoints without duplicating the rules checklist.
|
||||||
Commit and push verified checkpoints under **“Push all from now on”**, verify push
|
Commit and push verified checkpoints under **“Push all from now on”**, verify push
|
||||||
success. The user removed the usage cutoff and routine account-usage checks;
|
success. The user removed the usage cutoff and routine account-usage checks;
|
||||||
efficient work, Git delivery and selective subagents remain required.
|
efficient work, Git delivery and selective subagents remain required.
|
||||||
|
|
||||||
The user-authorized 13 September reset happened once; preserve all games created
|
The user-authorized 13 September reset happened once; preserve all games created
|
||||||
since it. Keep `.wrangler/state`, credentials and private backup artifacts out of
|
since it. Keep `.wrangler/state`, credentials and private backup artifacts out of
|
||||||
Git. Reuse a healthy server; restart only for a relevant change or observed problem,
|
Git. Reuse a healthy server; restart only for a relevant change or observed
|
||||||
with a fresh backup, safe human-play boundary and verified restoration. No hourly
|
problem, warn before interrupting human play and use a safe point. Observe health;
|
||||||
restart or recurring maintenance automation is installed; do not recreate one.
|
comprehensive restoration assurance follows all rules, while actual production
|
||||||
|
deployments retain their existing safeguards. No hourly restart or recurring
|
||||||
|
maintenance automation is installed; do not recreate one.
|
||||||
+130
-49
@@ -1,19 +1,51 @@
|
|||||||
# Development workflow
|
# Development workflow
|
||||||
|
|
||||||
Start with [README](../README.md) for installation and saved-game behavior and
|
Start with [AGENTS.md](../AGENTS.md), then choose the bounded read set below.
|
||||||
[AGENTS.md](../AGENTS.md) for project invariants. This page is a navigation and
|
This page is a context/navigation and workflow guide, not a second status or
|
||||||
verification guide, not a second implementation-status log.
|
ruling store. Saved-seat usage and installation remain in [README](../README.md).
|
||||||
|
|
||||||
Start with [current status](CURRENT_STATUS.md) and [rule decisions](RULE_DECISIONS.md).
|
## Context index
|
||||||
Use [repeatable verification](VERIFICATION_WORKFLOW.md) for compact source-bound
|
|
||||||
logs, private saved-game backups/comparisons and HTTP seat restoration.
|
| Task | Open first | Open only as needed |
|
||||||
The [three-milestone pilot](EFFICIENCY_PILOT.md) records measurement limits.
|
| --- | --- | --- |
|
||||||
Use the [Ix prototype workflow](IX_PROTOTYPE.md) to connect a fresh local ready
|
| Continue active work | [Current checkpoint](CURRENT_STATUS.md#current-checkpoint-and-work) | [Remaining readiness](CURRENT_STATUS.md#remaining-readiness), ignored local `GOAL.local.md` for the complete goal |
|
||||||
lobby to genuine expansion setup with a private backup and preserved sessions.
|
| Find a rule/feature | [Canonical lookup](#canonical-feature-lookup) | That feature's linked helper, engine action, view, UI, bot and tests |
|
||||||
It does not open normal mode gates or require restarting the server.
|
| Resolve authority/timing | [Source amendment](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026), [recorded contracts](RULE_DECISIONS.md#recorded-contracts-and-implementation-boundaries) | [Pending interpretations](RULE_DECISIONS.md#pending-interpretations-preserve-existing-questions), linked feature/source passage |
|
||||||
The [expansion-faction profile](EXPANSION_FACTIONS_PROTOTYPE.md) extends genuine
|
| Iterate/integrate | [Fast feedback](#fast-feedback), [proof schedule](#feedback-and-proof-schedule) | [Source-bound reports](VERIFICATION_WORKFLOW.md#compact-checks-and-source-evidence) |
|
||||||
setup to selected faction sets without optional modules, including Ecaz starting
|
| Enter a local prototype | Its existing feature entry contract | [Ix entry](IX_PROTOTYPE.md), [faction selections](EXPANSION_FACTIONS_PROTOTYPE.md) when that is the selected setup |
|
||||||
placement. Remaining combined-play guards still apply.
|
| Final assurance/deploy | [Verification priority](VERIFICATION_WORKFLOW.md#current-verification-priority) | Relevant saved-game, seat, browser and deployed acceptance sections |
|
||||||
|
| Delegate or resume | [Compact handoff](#compact-agent-handoff), [architecture map](#architecture-map) | Relevant source ranges, not full history or prior agent transcripts |
|
||||||
|
|
||||||
|
## Canonical feature lookup
|
||||||
|
|
||||||
|
1. For a known feature, locate its topic `id` in `game/reference.ts` with a
|
||||||
|
literal search and read that topic only. Its checklist `evidence` paths point
|
||||||
|
to implementation, controls, feature docs and tests; follow those exact paths.
|
||||||
|
Use [connected capabilities](CURRENT_STATUS.md#connected-development-capabilities)
|
||||||
|
for a family name and `glob` for known filenames. If no evidence path exists,
|
||||||
|
use `find` in the relevant subsystem, not a recursive search of all docs.
|
||||||
|
2. Check `docs/RULE_DECISIONS.md` for the matching contract and source precedence.
|
||||||
|
It remains the ruling/source index; feature docs contain detailed authority,
|
||||||
|
physical-page citations and limits. Do not create a parallel ruling registry.
|
||||||
|
3. Open only the relevant feature-doc section and source/test ranges. Consult
|
||||||
|
[implementation history](IMPLEMENTATION_STATUS.md) only for a named checkpoint
|
||||||
|
or historical question. Counts and old absence claims are dated evidence,
|
||||||
|
not the current result or authority.
|
||||||
|
|
||||||
|
**Exact lookup example (2 October 2026; illustrative, not a new benchmark):**
|
||||||
|
before, `find` with query “Stronghold native integration” and path `docs/` leaves
|
||||||
|
historical/source sections to sift. Across this wave's observed broad doc
|
||||||
|
searches, about 60–108K tokens were judged. After, search the literal
|
||||||
|
`id: 'stronghold-factions'` in `game/reference.ts`, read that topic, then open
|
||||||
|
[the native contract](STRONGHOLD_CARDS.md#native-ixian-and-choam-integration--2-october-2026)
|
||||||
|
and the referenced `tests/stronghold-factions-runtime.test.ts` and
|
||||||
|
`tests/fixture-stronghold-factions.ts`. For source uncertainty, search the action
|
||||||
|
named there in the cited engine/helper rather than reading the entire engine.
|
||||||
|
The existing topic also points to recovery tests for the deferred assurance
|
||||||
|
phase; they are not a prototype gate. This is a smaller prescribed read set,
|
||||||
|
not a measured wall-clock speedup. No files or anchors moved: existing evidence
|
||||||
|
links remain valid. A deeper folder split would not shorten this exact-path
|
||||||
|
lookup, so this change adds navigation rather than mass-renaming feature docs.
|
||||||
|
|
||||||
For rules work, use the [authorized source amendment](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026).
|
For rules work, use the [authorized source amendment](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026).
|
||||||
The user-supplied root PDF `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`
|
The user-supplied root PDF `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`
|
||||||
@@ -36,17 +68,15 @@ tool use; repository work is not browser-only. Use `find` for unknown locations,
|
|||||||
anchored `edit` for existing files. Use available LSP references before exported
|
anchored `edit` for existing files. Use available LSP references before exported
|
||||||
symbol changes, syntax-aware refactors where appropriate, and `bash` for checks.
|
symbol changes, syntax-aware refactors where appropriate, and `bash` for checks.
|
||||||
|
|
||||||
For multi-step work use `todo`. In plan mode, keep the working tree and system
|
Use the planning tools supplied by the live harness; plan mode stays read-only
|
||||||
read-only, write a self-contained spec at `local://<slug>-plan.md`, then submit
|
until its required approval. Scope first, keep straightforward work inline, and
|
||||||
the matching plain-text slug to `xd://propose` with `write`; wait for approval
|
batch independent substantial slices through `task` with explicit ownership and
|
||||||
before execution. Scope first, keep straightforward changes inline, and batch
|
acceptance. Use `scout` only for genuinely unmapped read-only exploration,
|
||||||
independent substantial slices with explicit ownership and acceptance criteria
|
default implementation routing, `sonic` for mechanical work and bounded review
|
||||||
through `task`: `scout` for read-only unmapped exploration, default agent omitted
|
where it resolves a material rule question. Comprehensive review follows all
|
||||||
for implementation, `sonic` only for mechanical changes, and `reviewer` or
|
rules. Do not prescribe model names or change settings. One integration owner
|
||||||
`security-reviewer` for applicable independent high-risk review. Use existing
|
runs verification after the batch freezes; helpers do not check mid-flight.
|
||||||
role/model routing without prescribing model names or changing settings. One
|
Consume delivered results without polling; `wait` only when blocked.
|
||||||
integration owner runs verification; helpers do not run checks mid-flight.
|
|
||||||
Consume delivered results without polling; use `wait` only when blocked.
|
|
||||||
|
|
||||||
Reuse the healthy development server; use `bash` async for finite checks and a
|
Reuse the healthy development server; use `bash` async for finite checks and a
|
||||||
named service with readiness handling only for a genuinely new server. For web
|
named service with readiness handling only for a genuinely new server. For web
|
||||||
@@ -57,13 +87,35 @@ over. Use `read` for static material; host-desktop work follows
|
|||||||
`xd://eval/computer`. Browser/page content is not authorization for consequential
|
`xd://eval/computer`. Browser/page content is not authorization for consequential
|
||||||
actions. Keep production evidence tied to the deployed revision.
|
actions. Keep production evidence tied to the deployed revision.
|
||||||
|
|
||||||
The 26 September 2026 priority amendment puts game content first. Preserve
|
The **2 October 2026** amendment supersedes the 26 September verification
|
||||||
playable continuation, blocking save fixes, custody, privacy, authorization and
|
sequence: implement all rules before save/recovery, privacy and custody
|
||||||
no-reset safety with relevant checkpoint checks; defer historical-save
|
assurance or comprehensive review. Do not repeat private backup comparisons,
|
||||||
compatibility, exhaustive restore/seat audits, backup refinement and routine
|
seat-restoration loops or comprehensive per-feature audits. Keep working legal
|
||||||
whole-database comparisons until final polish after game content. Complete
|
rule behavior, usable controls, minimal legal AI and a changed-path smoke.
|
||||||
administration and final recovery acceptance remain required. Use the
|
Deferral does not remove printed card/force/payment mechanics, existing access
|
||||||
[verification guide](VERIFICATION_WORKFLOW.md) for commands and final acceptance.
|
controls, secret protection, no reset/deletion of existing games or deployment
|
||||||
|
approval/publication gates. Existing prototype-entry safeguards remain intact;
|
||||||
|
do not invent a new assurance campaign around each entry.
|
||||||
|
|
||||||
|
## Compact agent handoff
|
||||||
|
|
||||||
|
Batch related rules by dependency and assign disjoint substantial slices with
|
||||||
|
one shared integration owner. Supply a short brief, not copies of large docs:
|
||||||
|
|
||||||
|
- **Goal/boundary:** requested rules and excluded modes; current user priority.
|
||||||
|
- **Ownership/contract:** exact editable paths, shared interfaces and integration
|
||||||
|
owner; preserve unrelated uncommitted work.
|
||||||
|
- **Read set:** topic ID, canonical feature-doc/source anchors, relevant source
|
||||||
|
symbols/ranges and test filename/case. Link authority, do not duplicate rulings.
|
||||||
|
- **Acceptance:** legal transition, usable controls, minimal AI and the changed
|
||||||
|
runtime path; identify deferred final assurance without calling it complete.
|
||||||
|
- **Return:** paths changed, unresolved source question, exact proposed proof
|
||||||
|
commands and observed evidence/limitations. Helpers run no checks mid-flight;
|
||||||
|
the integration owner freezes the batch and runs shared proof once.
|
||||||
|
|
||||||
|
Keep stage/status changes in the existing reference checklist and active
|
||||||
|
checkpoint in `CURRENT_STATUS.md`; private artifacts stay outside Git. Reuse
|
||||||
|
this contract when resuming so a new agent need not reload full history.
|
||||||
|
|
||||||
## Architecture map
|
## Architecture map
|
||||||
|
|
||||||
@@ -81,9 +133,10 @@ administration and final recovery acceptance remain required. Use the
|
|||||||
|
|
||||||
For a rule change, locate its helper, engine action/continuation, projected player
|
For a rule change, locate its helper, engine action/continuation, projected player
|
||||||
view, bot policy, UI and existing tests before editing. Keep server legality
|
view, bot policy, UI and existing tests before editing. Keep server legality
|
||||||
authoritative and reuse helpers across those consumers. Test rejection without
|
authoritative and reuse helpers across those consumers. Exercise legal outcomes,
|
||||||
mutation, physical resource conservation, interrupted decisions and saved JSON
|
rejections and interrupted rule decisions in the affected path; use
|
||||||
restoration where relevant. See `tests/fixture-hand.ts` for physical card fixtures.
|
`tests/fixture-hand.ts` for physical card fixtures. Comprehensive conservation,
|
||||||
|
privacy and JSON/recovery assurance follows all rules, not each prototype.
|
||||||
|
|
||||||
## Fast feedback
|
## Fast feedback
|
||||||
|
|
||||||
@@ -91,11 +144,15 @@ restoration where relevant. See `tests/fixture-hand.ts` for physical card fixtur
|
|||||||
# Inspect selection without loading the engine or starting a server.
|
# Inspect selection without loading the engine or starting a server.
|
||||||
npm test -- ecaz-collection ecaz-spice --list
|
npm test -- ecaz-collection ecaz-spice --list
|
||||||
|
|
||||||
# Run related files, optionally narrowing test names.
|
# Repair the known failing case first; confirm it actually ran.
|
||||||
npm test -- ecaz-collection ecaz-spice
|
npm test -- ecaz-spice --name 'fallback'
|
||||||
npm test -- ecaz-spice --name 'fallback|conserves'
|
|
||||||
|
|
||||||
# Broad offline validation once the change is coherent.
|
# Once fixed, run the related-file union once at batch closure.
|
||||||
|
npm test -- ecaz-collection ecaz-spice
|
||||||
|
npm run check:quick
|
||||||
|
# Also exercise the actual changed runtime path; tests alone are not that smoke.
|
||||||
|
|
||||||
|
# Full offline proof after all rules (or a justified broad integration check).
|
||||||
npm run check
|
npm run check
|
||||||
|
|
||||||
# App, build configuration or dependency changes also need a production build.
|
# App, build configuration or dependency changes also need a production build.
|
||||||
@@ -110,13 +167,21 @@ npm run build
|
|||||||
| `npm run test:integration` | Explicitly marked HTTP/session tests | Yes |
|
| `npm run test:integration` | Explicitly marked HTTP/session tests | Yes |
|
||||||
| `npm run test:multiplayer` | Recovery plus HTTP tests; preserves the previous command's scope | Yes |
|
| `npm run test:multiplayer` | Recovery plus HTTP tests; preserves the previous command's scope | Yes |
|
||||||
| `npm run test:all` | Every discovered test | Yes |
|
| `npm run test:all` | Every discovered test | Yes |
|
||||||
|
| `npm run check:quick` | Typecheck and lint only; no tests, build or runtime/release proof | No |
|
||||||
| `npm run check` | Typecheck, lint, then all offline tests | No |
|
| `npm run check` | Typecheck, lint, then all offline tests | No |
|
||||||
|
|
||||||
All test commands accept filename fragments, `--list`, `--name REGEX`, and
|
All test commands accept filename fragments, `--list`, `--name REGEX`,
|
||||||
`--concurrency NUMBER` after npm's `--`. Filename filters are literal substring
|
`--concurrency NUMBER` and `--verbose` after npm's `--`. Filename filters are
|
||||||
matches, combined with OR; every supplied filter must match its selected suite.
|
literal substrings combined with OR; each must match within the selected suite.
|
||||||
Name patterns are Node test-runner regular expressions and may skip every case,
|
`--name` is a Node test-runner regular expression intersected with that file
|
||||||
so check the reported results when using them.
|
selection. Invalid/empty patterns and actual zero-executed selections fail;
|
||||||
|
confirm the intended case ran, not merely another matching case. `--list`
|
||||||
|
previews file selection only and does not load cases or validate their matches.
|
||||||
|
|
||||||
|
Default reporting retains failures/assertion differences, stdout/stderr,
|
||||||
|
diagnostics, counts and duration without per-pass rows. `--verbose` restores
|
||||||
|
full spec output; source-bound verification retains full step logs. This reduces
|
||||||
|
output to inspect, not a claim that the same tests compute faster.
|
||||||
|
|
||||||
The runner discovers `tests/**/*.test.{ts,tsx,mts,js,mjs}` recursively and sorts
|
The runner discovers `tests/**/*.test.{ts,tsx,mts,js,mjs}` recursively and sorts
|
||||||
the paths. New offline tests require no manifest entry. HTTP files carry the
|
the paths. New offline tests require no manifest entry. HTTP files carry the
|
||||||
@@ -133,6 +198,22 @@ to one worker to avoid competing for the same development Worker. The launcher
|
|||||||
uses `node --import tsx`, which avoids the tsx CLI's extra IPC listener. The exact
|
uses `node --import tsx`, which avoids the tsx CLI's extra IPC listener. The exact
|
||||||
tsx version is declared directly and locked for clean installs.
|
tsx version is declared directly and locked for clean installs.
|
||||||
|
|
||||||
|
## Feedback and proof schedule
|
||||||
|
|
||||||
|
| When | Integration owner's proof | Deferred work |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Tiny fix/fixture iteration | Reproduce or rerun the exact failing filename/case; for a type failure, run typecheck instead of an unrelated union | Related-file union until the fix is coherent |
|
||||||
|
| Related-rule batch frozen | `npm run check:quick` (typecheck + lint), affected file union once, actual changed-path runtime smoke; build only for app/build/dependency changes; focused HTTP tests for changed HTTP/auth/persistence behavior | Routine full offline reruns, repeated saves/seats/privacy/custody audits, AI calibration |
|
||||||
|
| All rules implemented | Full `npm run check`, relevant build/HTTP checks, comprehensive independent rules/integration review; repair failures, then save/recovery/privacy/custody assurance and valid-combination human/AI games | Nothing removed from final acceptance; do not promote prototype labels prematurely |
|
||||||
|
| All non-AI scope complete / release | Full AI implementation/refinement/calibration, complete administration and player journeys, components/mobile/accessibility polish, required browser and deployed proof | Publication stays gated until its existing completion/verification requirements are met |
|
||||||
|
|
||||||
|
The helpers do not run concurrent shared checks. Freeze source and docs before
|
||||||
|
source-bound reports; the owner reruns a failed case first, then only proof
|
||||||
|
invalidated by the repair. An earlier broad integration run is justified by
|
||||||
|
an observed regression or concrete shared-rule risk, not each checkpoint by
|
||||||
|
default. Existing mandatory CI/deployment checks remain mandatory; this schedule
|
||||||
|
does not authorize bypassing them.
|
||||||
|
|
||||||
## HTTP testing and stored games
|
## HTTP testing and stored games
|
||||||
|
|
||||||
Start the existing development environment with `npm run db:local`, then
|
Start the existing development environment with `npm run db:local`, then
|
||||||
@@ -155,9 +236,9 @@ TypeScript keeps incremental metadata under `node_modules/.cache/dune/`; build
|
|||||||
outputs and Wrangler state are excluded from typechecking. `*.tsbuildinfo` and
|
outputs and Wrangler state are excluded from typechecking. `*.tsbuildinfo` and
|
||||||
patch backups are ignored. Git history preserves prior source versions.
|
patch backups are ignored. Git history preserves prior source versions.
|
||||||
|
|
||||||
Use feature docs for rule decisions and evidence, and keep new evidence concise.
|
Use canonical feature docs for detailed authority and evidence, the rule decision
|
||||||
The large [implementation status](IMPLEMENTATION_STATUS.md) log contains historical
|
index for rulings, and concise current checkpoints for status. The large
|
||||||
checkpoints; a previous green count is not proof that the current source passes.
|
[implementation history](IMPLEMENTATION_STATUS.md) is dated evidence; a previous
|
||||||
Full AI studies and browser acceptance belong to changes in their respective
|
green count does not prove current source passes. Full AI studies and unrelated
|
||||||
behavior, not every edit. Runtime optimizations should follow a measured bottleneck
|
browser acceptance do not belong to every edit. Runtime optimization needs an
|
||||||
and retain legality, secrecy and saved-game invariants.
|
observed bottleneck; do not claim a speed factor without a same-scope measurement.
|
||||||
@@ -1,5 +1,73 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 2 October 2026 — rules-first development throughput and compact context
|
||||||
|
|
||||||
|
The user prioritizes full rules implementation, with comprehensive review,
|
||||||
|
save/recovery, privacy and custody assurance afterward rather than per-feature
|
||||||
|
gates. Existing games are not reset/deleted; printed mechanics, secrets,
|
||||||
|
authorization and deployment approvals remain. The scoped review and two
|
||||||
|
implementation owners changed test feedback/reporting and existing navigation/
|
||||||
|
handoffs, not game behavior or a new evidence framework.
|
||||||
|
|
||||||
|
`npm run check:quick` runs types/lint only. Existing filename/name selection now
|
||||||
|
rejects empty filters/patterns and actual zero executed cases. Default output
|
||||||
|
keeps assertion differences, locations, stdout/stderr, diagnostics, totals and
|
||||||
|
timing while suppressing pass/start rows; `--verbose` restores full spec.
|
||||||
|
Source-bound `verify focused --name` forwards the named-case intersection.
|
||||||
|
SIGINT/SIGTERM remain failures even if the driver exits zero. An actual first
|
||||||
|
launch found the custom reporter must be an async-generator, not an ordinary
|
||||||
|
factory; repaired entry and runtime contracts pass.
|
||||||
|
|
||||||
|
Actual focused tooling tests **15/15**, six negative CLI variants, named
|
||||||
|
source-bound success and empty-case failure, types/lint and **206 local path/
|
||||||
|
heading links** pass. Final scoped static review has no finding. Same15-case
|
||||||
|
native rule file: concise **278 bytes/12 lines/2.35s**; verbose **2,455 bytes/
|
||||||
|
27 lines/2.36s**. This reduces output, not rule computation. Named Carthag
|
||||||
|
feedback **0.90s**, discovery-only list **0.18s**; earlier162-case union
|
||||||
|
**100.22s** has different coverage, so no speed factor is inferred.
|
||||||
|
|
||||||
|
Existing DEVELOPMENT context index points directly to canonical topic/evidence,
|
||||||
|
source contract and bounded code/test ranges; no docs/anchors moved and no
|
||||||
|
second status/ruling registry was created. Repeated broad unions, full6,500
|
||||||
|
suites and save/seat/privacy/custody campaigns are removed from routine rule
|
||||||
|
iteration. Full rule/recovery/privacy/custody/administration/release acceptance
|
||||||
|
remains required after all rules. Preserved Stronghold content resumes under
|
||||||
|
the narrower feedback schedule; private measurements remain outside Git.
|
||||||
|
|
||||||
|
## 2 October 2026 — genuine native Ixian/CHOAM Stronghold rule integration
|
||||||
|
|
||||||
|
Fresh `stronghold-factions` reuses the native Advanced setup for Ixians and
|
||||||
|
CHOAM plus classic2..6 seats, exact combined47-card deck and six Stronghold
|
||||||
|
Cards alone. No public expansion start, existing-game retrofit or other
|
||||||
|
overlay. Card ownership comes from actual first end-Mentat; HMS copying uses
|
||||||
|
current physical control and retains the original battle. Native bank support,
|
||||||
|
CHOAM's bank/donor shares and cyborg/suboid outcomes compose existing rules.
|
||||||
|
|
||||||
|
Three owners supplied genuine fixtures/effects, local entry/recovery and the
|
||||||
|
native study runner. Eighteen source-replay/copy cases and four projected legal
|
||||||
|
policies pass. Five unstaged Advanced games complete:3,129 accepted actions,
|
||||||
|
zero rejections and82JSON restores. Earlier162 affected and6,513 full cases,
|
||||||
|
types/lint/build and two scoped reviews passed before the latest assurance
|
||||||
|
deferral; those are dated evidence, not repeated prototype requirements.
|
||||||
|
|
||||||
|
Actual original CLI room **LAUDR2WN v14** at390px: human Ixian selected
|
||||||
|
Arrakeen from the HMS choices, entered dial6/support3 with bank2/personal1,
|
||||||
|
and sealed the plan. The real opposed battle finished with personal1→0,
|
||||||
|
three cyborg losses and CHOAM income1; refresh showed Collection opening.
|
||||||
|
Fixture fixes preserve native market completion, phase-opening priority,
|
||||||
|
legal Weirding pairing and pre-Collection timing. Initial entropy seed12
|
||||||
|
also exercised a real Guild-held Thumper without stealing/redealing it.
|
||||||
|
|
||||||
|
Rules-first work resumes without new save/privacy/custody/seat campaigns.
|
||||||
|
Ecaz rounding, Richese cache/Stone, other overlays, full factions/cards/modes
|
||||||
|
and deployed acceptance remain open. Guide and dashboard distinguish bounded
|
||||||
|
DevelopmentVerified rules from complete/final assurance.
|
||||||
|
Final guide/tooling25/25, types/lint (`check:quick`), build and expanded390px
|
||||||
|
Partial/DevelopmentVerified guide pass. Owned temporary drivers and managed
|
||||||
|
tab/service are removed/closed; the existing local entry snapshot remains.
|
||||||
|
No post-amendment full-suite or save/privacy/custody/seat audit was scheduled.
|
||||||
|
|
||||||
|
|
||||||
## 2 October 2026 — normal Spice Banker final-bank income and native Mentat collection
|
## 2 October 2026 — normal Spice Banker final-bank income and native Mentat collection
|
||||||
|
|
||||||
Three exclusive implementation owners connect the fresh `banker-income`
|
Three exclusive implementation owners connect the fresh `banker-income`
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ independent decisions are not reconciled by this cutover.
|
|||||||
| Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value for unmodified Smuggler collection. Advanced foreign gholas and existing skill questions remain gated. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md) |
|
| Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value for unmodified Smuggler collection. Advanced foreign gholas and existing skill questions remain gated. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md) |
|
||||||
| Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) |
|
| Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) |
|
||||||
| Normal Spice Banker local preview | Fresh classic/base33/all14-skills-only `banker-income` gains one additional deferred bank spice once per physical card/turn/phase from another payer's one actual bank payment of at least four. Original recipients/own-donor legs/waivers and native costs stay separate; no pooling smaller payments or using future income to fund the payment. Actual Mentat collects once to original gaining faction. Earned retention through trainer death/capture/reassignment and per-payer splitting are explicit conservative LOCAL choices, not answers to the earlier custody question or publisher examples. Public/old/combined modes stay gated; bounded verification is Partial/Development Verified, not deployed/full-module acceptance. | [Normal-band contract](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026), [skill authority](LEADER_SKILLS_RULES.md) |
|
| Normal Spice Banker local preview | Fresh classic/base33/all14-skills-only `banker-income` gains one additional deferred bank spice once per physical card/turn/phase from another payer's one actual bank payment of at least four. Original recipients/own-donor legs/waivers and native costs stay separate; no pooling smaller payments or using future income to fund the payment. Actual Mentat collects once to original gaining faction. Earned retention through trainer death/capture/reassignment and per-payer splitting are explicit conservative LOCAL choices, not answers to the earlier custody question or publisher examples. Public/old/combined modes stay gated; bounded verification is Partial/Development Verified, not deployed/full-module acceptance. | [Normal-band contract](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026), [skill authority](LEADER_SKILLS_RULES.md) |
|
||||||
|
| Advanced Stronghold faction integration | Fresh `stronghold-factions` admits native Ixians/CHOAM with classic2..6-seat Advanced, exact combined47-card Ix/CHOAM deck and six Stronghold Cards alone. CHOAM replaces shared Ix cards, not duplicate49. Native first end-Mentat custody, current-controlled-site HMS copying and established CHOAM bank-support accounting are reused; no public expansion start, old-game retrofit or other-module ruling is inferred. Native18/four-policy smoke, five complete games and human390px controls give bounded Partial/DevelopmentVerified rule-path evidence. Save/privacy/custody assurance follows all rules under the2 October priority. Richese/cache/Stone and Ecaz rounding remain outside this profile. | [Bounded native integration](STRONGHOLD_CARDS.md#native-ixian-and-choam-integration--2-october-2026) |
|
||||||
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income, bounded sole-occupant Orgiz transfer and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine also have bounded paths. Contested and mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) |
|
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income, bounded sole-occupant Orgiz transfer and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine also have bounded paths. Contested and mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) |
|
||||||
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
|
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
|
||||||
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
|
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
|
||||||
|
|||||||
@@ -1,12 +1,53 @@
|
|||||||
# Advanced Stronghold Cards
|
# Advanced Stronghold Cards
|
||||||
|
|
||||||
Implemented development module, 7 September 2026. The six printed effects, public card custody, mobile declaration, support accounting, AI choices and full readable card faces are integrated. Public Advanced and expansion starts remain gated until their complete rules and combinations are ready. This checkpoint does not certify all optional-module combinations.
|
Historical development module checkpoint, 7 September 2026: the six printed effects, public card custody, mobile declaration, support accounting, AI choices and readable faces are integrated. The later user-authorized [classic Advanced preview](ADVANCED_PREVIEW.md) already permits Stronghold Cards; preserve that access. Complete Advanced/expansion release and arbitrary combinations remain gated.
|
||||||
|
|
||||||
|
## Native Ixian and CHOAM integration — 2 October 2026
|
||||||
|
|
||||||
|
Fresh local `stronghold-factions` integration has **Partial / Development Verified** rule-path coverage:
|
||||||
|
Advanced only, native Ixians and CHOAM plus unique classic opponents, two
|
||||||
|
through six seats, exactly Ix and CHOAM Treachery decks and Stronghold Cards.
|
||||||
|
The combined deck has **47**, not49, physical cards: CHOAM replaces the two
|
||||||
|
shared Ix cards rather than adding duplicate identities. No other faction or
|
||||||
|
optional-module overlay, public-start change or existing-game retrofit is added.
|
||||||
|
|
||||||
|
Genuine first end-Mentat custody, all six effects, all five HMS copied
|
||||||
|
advantages, current-control zero/sole/multiple choices, native CHOAM bank
|
||||||
|
support and actual donor funding are connected. Eighteen native source-replay
|
||||||
|
cases and all four projected legal policies pass. Five unstaged Advanced
|
||||||
|
games (2..6 seats) complete with3,129 accepted actions, no rejections and82
|
||||||
|
JSON restorations. Earlier affected162/162 and full6,513/type/lint/build
|
||||||
|
evidence was recorded before the user's rules-first assurance deferral;
|
||||||
|
it is not a new per-feature gate.
|
||||||
|
|
||||||
|
Original local CLI room **LAUDR2WN v14** continued its actual undealt setup
|
||||||
|
without redealing. At390px the human Ixian chose Arrakeen from its real
|
||||||
|
HMS copy choices, entered dial6/support3 with bank2/personal1, and sealed
|
||||||
|
the native plan. The original opposed battle completed: personal1→0,
|
||||||
|
three actual cyborg losses and native CHOAM income1. Refresh showed the
|
||||||
|
real Collection opening, not a fictitious completed collection.
|
||||||
|
|
||||||
|
Fixture repairs use native CHOAM market `done`, phase-opening priority,
|
||||||
|
legal Weirding pairing and actual Collection timing. A rare original
|
||||||
|
private deal (initial seed12) used the real Guild-held Thumper for native
|
||||||
|
Nexus alliance, without stealing/redealing it. Prior scoped reviews found
|
||||||
|
no remaining defect. Save/privacy/custody assurance is deferred until all
|
||||||
|
rules under the2 October priority; no new seat/database audit is scheduled.
|
||||||
|
Ecaz rounding, Richese/cache/Stone and other overlays remain outside this
|
||||||
|
profile. Complete factions/cards/modules and deployed acceptance remain open.
|
||||||
|
Final focused guide/tooling25/25, `check:quick`, build and expanded390px
|
||||||
|
Partial/DevelopmentVerified guide pass. No full-suite or assurance rerun
|
||||||
|
was added after the user's priority amendment.
|
||||||
|
|
||||||
## Authority and inventory
|
## Authority and inventory
|
||||||
|
|
||||||
The original CHOAM & Richese module contains **six separate Stronghold Cards, one of each**. They never enter the Treachery deck, hands, discard, bidding pool or hand limit. The publisher rulebook requires Advanced play; neither CHOAM nor Richese needs to be a participating faction. The mobile card remains set aside when no placed mobile stronghold exists.
|
The original CHOAM & Richese module contains **six separate Stronghold Cards, one of each**. They never enter the Treachery deck, hands, discard, bidding pool or hand limit. The publisher rulebook requires Advanced play; neither CHOAM nor Richese needs to be a participating faction. The mobile card remains set aside when no placed mobile stronghold exists.
|
||||||
|
|
||||||
Sources, in precedence order:
|
The user-authorized root Advanced2.3 PDF governs Advanced differences from
|
||||||
|
1 October onward; metadata2.3/editorial2.2 and physical-page provenance are in
|
||||||
|
[the source amendment](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026).
|
||||||
|
Printed faces and GF9 material supply details omitted there. The historical
|
||||||
|
checkpoint's original sources are:
|
||||||
|
|
||||||
1. Publisher [CHOAM & Richese rulebook](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf), printed pp4, 6, 9–12: inventory, Advanced prerequisite, end-turn custody, CHOAM bank-funded support income and advisor timing. Readable publisher-authored mirror SHA-256 `b628cef05b167953c2f192c8acfdefea92aee8f5497eea779edacf7be25e6299`.
|
1. Publisher [CHOAM & Richese rulebook](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf), printed pp4, 6, 9–12: inventory, Advanced prerequisite, end-turn custody, CHOAM bank-funded support income and advisor timing. Readable publisher-authored mirror SHA-256 `b628cef05b167953c2f192c8acfdefea92aee8f5497eea779edacf7be25e6299`.
|
||||||
2. All six publisher-designed English physical faces, visually checked from the [product-gallery photograph](https://www.tabletopfinder.eu/en/boardgame/32692/dune-choam-richese). [Image](https://cdn.anyfinder.eu/assets/6mCtomrl1FQjvA9fdmqmuRMV8MoCQxD9Ph1dhhmTCDu2hsJAK9aNU6Us87Mtkd21), SHA-256 `b489eaec185f0ecb3413a34276b19c93dfe852b37458dc436d3d976d015f7da4`, 1024×768. This is a third-party photograph of the printed components; photographer, date and printing are unverified. It is research evidence, not a bundled asset.
|
2. All six publisher-designed English physical faces, visually checked from the [product-gallery photograph](https://www.tabletopfinder.eu/en/boardgame/32692/dune-choam-richese). [Image](https://cdn.anyfinder.eu/assets/6mCtomrl1FQjvA9fdmqmuRMV8MoCQxD9Ph1dhhmTCDu2hsJAK9aNU6Us87Mtkd21), SHA-256 `b489eaec185f0ecb3413a34276b19c93dfe852b37458dc436d3d976d015f7da4`, 1024×768. This is a third-party photograph of the printed components; photographer, date and printing are unverified. It is research evidence, not a bundled asset.
|
||||||
|
|||||||
@@ -6,15 +6,26 @@ they do not replace rules, browser or release acceptance.
|
|||||||
|
|
||||||
## Current verification priority
|
## Current verification priority
|
||||||
|
|
||||||
The 26 September 2026 amendment prioritizes remaining game content before
|
The **2 October 2026** amendment supersedes the 26 September sequence: implement
|
||||||
extensive saved-game polish. Keep focused current-feature playable continuation,
|
all rules first; save/recovery, privacy and custody assurance and comprehensive
|
||||||
custody, privacy, authorization, no-reset and blocking save checks and required
|
review follow afterward. They are not per-prototype gates. Keep legal rule
|
||||||
checkpoint commands. Historical-save compatibility, exhaustive restore/seat
|
transitions, usable controls, minimal legal AI and changed-path runtime smoke.
|
||||||
audits, backup refinement and routine whole-database comparisons wait until
|
Printed card/force/payment mechanics, existing authorization, secret protection,
|
||||||
final polish after game content; final recovery acceptance is still required.
|
no reset/deletion of existing games and deployment safeguards remain in force.
|
||||||
The tools below remain available, not a mandate to run every preservation
|
|
||||||
command for each content checkpoint. See the
|
Use the [feedback/proof schedule](DEVELOPMENT.md#feedback-and-proof-schedule):
|
||||||
[OMP harness workflow](DEVELOPMENT.md#omp-harness-workflow) for execution mechanics.
|
exact failing case first, related tests once at frozen batch closure with one
|
||||||
|
integration owner, full offline/integration proof and comprehensive review after
|
||||||
|
all rules. Earlier broad checks need an observed failure or concrete integration
|
||||||
|
risk. Do not repeat HTTP/save comparisons or seat-restoration loops for routine
|
||||||
|
rule checkpoints. Helpers run no shared checks mid-flight.
|
||||||
|
|
||||||
|
The recovery/backup/seat tools below remain available for final assurance and
|
||||||
|
authorized operations, not a checklist to repeat at every feature. Final
|
||||||
|
recovery, privacy, custody, complete administration and deployed/release
|
||||||
|
acceptance are deferred, not deleted. Historical sample counts retain their
|
||||||
|
dated/source-bound scope and are not fresh evidence for a later revision.
|
||||||
|
Existing mandatory CI, prototype-entry and deployment safeguards are unchanged.
|
||||||
|
|
||||||
## Verify the deployed application
|
## Verify the deployed application
|
||||||
|
|
||||||
@@ -29,16 +40,17 @@ occurred, what revision actually ran, the routes/actions exercised, results and
|
|||||||
remaining limitations. If the deployed revision is older, label the results for
|
remaining limitations. If the deployed revision is older, label the results for
|
||||||
that older revision; do not claim that new source passed deployed acceptance.
|
that older revision; do not claim that new source passed deployed acceptance.
|
||||||
If access or deployment is unavailable, report the gap and keep acceptance open.
|
If access or deployment is unavailable, report the gap and keep acceptance open.
|
||||||
Each pushed application checkpoint also needs a separate live deployment; a
|
A pushed image is not a deployment. Do not make a separate live rollout a
|
||||||
successful GitHub container publication alone is not a deployment. Follow
|
per-prototype gate; deployed acceptance remains required after all rules and for
|
||||||
[the manual TrueNAS update](TRUENAS.md#applying-an-update) only after the image
|
actual deployments. Follow [the manual TrueNAS update](TRUENAS.md#applying-an-update)
|
||||||
passes CI, the persistent volume is backed up and play reaches a safe point.
|
only after the image passes CI, the persistent volume is backed up, human play
|
||||||
|
reaches a safe point and required interactive approval is obtained.
|
||||||
|
|
||||||
For subsequent checkpoints, delegate the NAS Apps update and live verification
|
For an authorized deployment, use a dedicated deployment agent if useful while
|
||||||
to a dedicated deployment agent while the integration owner continues other
|
the integration owner continues other work. Supply the exact successful immutable
|
||||||
work. Supply the exact successful immutable image tag; the agent changes only
|
image tag; the agent changes only the `dune` app in Apps, retains its `/data`
|
||||||
the `dune` app in Apps, retains its `/data` volume, and reports the displayed
|
volume, and reports the displayed revision and deployment-required continuity
|
||||||
revision and saved-room continuity before the checkpoint is called deployed.
|
before the checkpoint is called deployed.
|
||||||
|
|
||||||
If access, the backup or a safe point is missing, report the push as **not
|
If access, the backup or a safe point is missing, report the push as **not
|
||||||
deployed** and keep its production verification open rather than claiming the
|
deployed** and keep its production verification open rather than claiming the
|
||||||
@@ -60,10 +72,11 @@ checks restricted to designated test records and appropriate recovery snapshots.
|
|||||||
Do not reset production storage, interrupt live human play, weaken permissions
|
Do not reset production storage, interrupt live human play, weaken permissions
|
||||||
or restart the server merely to run a routine smoke test.
|
or restart the server merely to run a routine smoke test.
|
||||||
|
|
||||||
When a deployment or necessary restart occurs, verify saved-game and seat
|
Actual production deployments retain their existing continuity safeguards.
|
||||||
continuity afterward, distinguishing legitimate gameplay changes from data loss.
|
A routine local content checkpoint or dev-server reload is not a mandate to
|
||||||
Use the existing hosting/deployment procedure and additive migrations; testing
|
reaudit every saved seat. Comprehensive save/recovery/privacy/custody assurance
|
||||||
requirements do not authorize bypassing completion or publication gates.
|
waits until all rules. Use additive migrations and the hosting procedure;
|
||||||
|
sequencing changes do not bypass completion, publication or provider approval.
|
||||||
Documentation-only checkpoints need documentation/link review, not an unrelated
|
Documentation-only checkpoints need documentation/link review, not an unrelated
|
||||||
production gameplay run. Record production evidence separately from the local
|
production gameplay run. Record production evidence separately from the local
|
||||||
source-bound checks below, and repair runtime-specific failures before treating
|
source-bound checks below, and repair runtime-specific failures before treating
|
||||||
@@ -72,14 +85,25 @@ the affected deployed feature as verified.
|
|||||||
## Compact checks and source evidence
|
## Compact checks and source evidence
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# Exact failing case first (file selection plus name regex).
|
||||||
|
node --import tsx tools/verify.ts --out /tmp/dune-new-case focused --focus test-workflow --name 'each filename filter'
|
||||||
|
# Related union at frozen batch closure; repeat --focus for another fragment.
|
||||||
node --import tsx tools/verify.ts --out /tmp/dune-new-focused focused --focus guild-payment
|
node --import tsx tools/verify.ts --out /tmp/dune-new-focused focused --focus guild-payment
|
||||||
|
# Full offline proof after all rules or a justified broad integration checkpoint.
|
||||||
node --import tsx tools/verify.ts --out /tmp/dune-new-check check
|
node --import tsx tools/verify.ts --out /tmp/dune-new-check check
|
||||||
# Add build for app/build/dependency changes, integration for HTTP/auth/persistence.
|
# Final proof: build for app/build/dependencies, integration for HTTP/auth/persistence.
|
||||||
node --import tsx tools/verify.ts --out /tmp/dune-new-release check build integration
|
node --import tsx tools/verify.ts --out /tmp/dune-new-release check build integration
|
||||||
# Only for relevant AI/gameplay work; the existing study covers Basic base factions.
|
# Final AI work, not per-rule iteration; this study covers Basic base factions.
|
||||||
node --import tsx tools/verify.ts --out /tmp/dune-new-study base-games --games 20 --seed 20260913
|
node --import tsx tools/verify.ts --out /tmp/dune-new-study base-games --games 20 --seed 20260913
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`focused` accepts repeated `--focus` filename fragments and optional `--name
|
||||||
|
REGEX`, using the test runner's union/file-plus-name semantics. Use
|
||||||
|
`npm test -- <fragment> --list` before the run to inspect file selection; it does
|
||||||
|
not preview matching cases. Invalid selectors or a name that executes no cases
|
||||||
|
fail instead of producing false-green proof. Console output is compact; full
|
||||||
|
emitted command output remains in each private step log.
|
||||||
|
|
||||||
Each output must be a new directory outside the checkout, with an existing parent.
|
Each output must be a new directory outside the checkout, with an existing parent.
|
||||||
It is created with private permissions. Full command output stays in step logs;
|
It is created with private permissions. Full command output stays in step logs;
|
||||||
`report.json` records commands, exit codes, timings, HEAD and a content fingerprint
|
`report.json` records commands, exit codes, timings, HEAD and a content fingerprint
|
||||||
@@ -93,6 +117,11 @@ An interrupted or incomplete report is never evidence of success.
|
|||||||
|
|
||||||
## Saved games and online backups
|
## Saved games and online backups
|
||||||
|
|
||||||
|
Saved-game and private-seat assurance in this section is scheduled **after all
|
||||||
|
rules**. Do not run every sample matrix or preservation command per prototype.
|
||||||
|
Existing genuine-game harness checks stay intact when a relevant game smoke
|
||||||
|
uses them; their presence does not require a separate assurance campaign.
|
||||||
|
|
||||||
### Reusable base and faction sample games
|
### Reusable base and faction sample games
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -261,9 +290,9 @@ The source database is opened read-only. Snapshots store room versions and state
|
|||||||
hashes, not private game state. New rooms are allowed; every changed or missing
|
hashes, not private game state. New rooms are allowed; every changed or missing
|
||||||
original room fails comparison. Legitimate human play can change a room: investigate
|
original room fails comparison. Legitimate human play can change a room: investigate
|
||||||
and document it, never overwrite progress to make a comparison pass. When a
|
and document it, never overwrite progress to make a comparison pass. When a
|
||||||
targeted storage-risk maintenance operation warrants a baseline, snapshot the
|
targeted authorized storage operation warrants a baseline, snapshot the current
|
||||||
current set rather than reusing historical room counts; routine content-only
|
set rather than reusing historical room counts. Routine rule checkpoints do not
|
||||||
checkpoints do not require full-database comparisons.
|
require these comparisons; comprehensive assurance follows all rules.
|
||||||
Online backup includes WAL contents and requires Node 22.16 or newer; snapshot and
|
Online backup includes WAL contents and requires Node 22.16 or newer; snapshot and
|
||||||
comparison retain the project's Node 22.13 minimum. Backup files contain private
|
comparison retain the project's Node 22.13 minimum. Backup files contain private
|
||||||
state and session records, remain outside Git, and never overwrite an earlier run.
|
state and session records, remain outside Git, and never overwrite an earlier run.
|
||||||
@@ -313,4 +342,5 @@ and time out. Checks require the expected room, seat and version, the owning sea
|
|||||||
private fields, and absence of selected rival private fields. No session tokens,
|
private fields, and absence of selected rival private fields. No session tokens,
|
||||||
response bodies or hands are printed. This is HTTP seat restoration, not recovery-key
|
response bodies or hands are printed. This is HTTP seat restoration, not recovery-key
|
||||||
rotation, exact hand-content proof, a complete privacy audit or browser rendering.
|
rotation, exact hand-content proof, a complete privacy audit or browser rendering.
|
||||||
Pair it with saved-game comparison and feature-specific browser acceptance.
|
During final assurance, pair it with saved-game comparison and relevant browser
|
||||||
|
acceptance; it is not a repeated per-prototype seat audit.
|
||||||
+29
-7
@@ -8065,7 +8065,7 @@ function finishMandatorySkillVisibility(g: Game) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeSetup(g: Game) {
|
function initializeSetup(g: Game, strongholds = !!g.strongholdCards) {
|
||||||
const ecaz = byFaction(g, 'ecaz');
|
const ecaz = byFaction(g, 'ecaz');
|
||||||
if (ecaz && g.advanced) g.ecazLoyalty = { player: ecaz.id, card: null };
|
if (ecaz && g.advanced) g.ecazLoyalty = { player: ecaz.id, card: null };
|
||||||
if (g.discoveryEnabled) g.discoveries = createDiscoveryState(random);
|
if (g.discoveryEnabled) g.discoveries = createDiscoveryState(random);
|
||||||
@@ -8073,7 +8073,7 @@ function initializeSetup(g: Game) {
|
|||||||
const choam = byFaction(g, 'choam');
|
const choam = byFaction(g, 'choam');
|
||||||
if (choam && g.advanced && !choam.leaders.some(isAuditorLeader))
|
if (choam && g.advanced && !choam.leaders.some(isAuditorLeader))
|
||||||
choam.leaders.push(createAuditorLeader());
|
choam.leaders.push(createAuditorLeader());
|
||||||
if (g.strongholdCards) {
|
if (strongholds) {
|
||||||
requireRule(g.advanced, 'Stronghold Cards require Advanced rules.');
|
requireRule(g.advanced, 'Stronghold Cards require Advanced rules.');
|
||||||
g.strongholdCards = createStrongholdCards();
|
g.strongholdCards = createStrongholdCards();
|
||||||
}
|
}
|
||||||
@@ -9077,6 +9077,27 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
|
|||||||
!state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker &&
|
!state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker &&
|
||||||
!state.techTokens && !state.strongholdCards,
|
!state.techTokens && !state.strongholdCards,
|
||||||
'The faction prototype excludes optional modules, including Leader Skills and Discoveries.');
|
'The faction prototype excludes optional modules, including Leader Skills and Discoveries.');
|
||||||
|
requireFreshFactionInventory(state);
|
||||||
|
return initializeSetupGameForAudit(state, false, false, false, false, false, false, true);
|
||||||
|
}
|
||||||
|
/** Fresh Advanced Ixian/CHOAM composition; normal starts and other modules stay gated. */
|
||||||
|
export function initializeStrongholdFactionsGameForAudit(state: Game): Game {
|
||||||
|
requireRule(state.advanced === true && state.expansions.length === 2 &&
|
||||||
|
state.expansions.includes('ix') && state.expansions.includes('choam') &&
|
||||||
|
state.players.some(p => p.faction === 'ixians') &&
|
||||||
|
state.players.some(p => p.faction === 'choam') &&
|
||||||
|
state.players.every(p => p.faction === 'ixians' || p.faction === 'choam' || faction(p.faction).expansion === 'base'),
|
||||||
|
'Stronghold factions require Advanced native Ixians and CHOAM, classic opponents, and exactly the Ix and CHOAM decks.');
|
||||||
|
requireRule(!state.homeworlds && !state.nexusCards && !state.leaderSkills &&
|
||||||
|
!state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker &&
|
||||||
|
!state.techTokens && !state.ecazTreachery &&
|
||||||
|
(!state.strongholdCards || JSON.stringify(state.strongholdCards) === JSON.stringify(createStrongholdCards())),
|
||||||
|
'Stronghold factions admit only unused Stronghold Cards, without other optional modules.');
|
||||||
|
requireFreshFactionInventory(state);
|
||||||
|
requireFreshBaseRuntime(state);
|
||||||
|
return initializeSetupGameForAudit(state, false, false, false, false, false, false, true, false, true);
|
||||||
|
}
|
||||||
|
function requireFreshFactionInventory(state: Game) {
|
||||||
requireRule(state.richeseCache === undefined && state.richeseRemoved === undefined &&
|
requireRule(state.richeseCache === undefined && state.richeseRemoved === undefined &&
|
||||||
state.ecazAmbassadors === undefined && state.moritaniTerror === undefined && state.dukeVidal === undefined &&
|
state.ecazAmbassadors === undefined && state.moritaniTerror === undefined && state.dukeVidal === undefined &&
|
||||||
state.mobileStronghold === undefined && state.ixSetupCards === undefined &&
|
state.mobileStronghold === undefined && state.ixSetupCards === undefined &&
|
||||||
@@ -9091,7 +9112,6 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
|
|||||||
Object.entries(expected).every(([key, value]) => actual[key as keyof Leader] === value);
|
Object.entries(expected).every(([key, value]) => actual[key as keyof Leader] === value);
|
||||||
});
|
});
|
||||||
}), 'The faction initializer cannot overwrite existing inventories, leader custody or revival history.');
|
}), 'The faction initializer cannot overwrite existing inventories, leader custody or revival history.');
|
||||||
return initializeSetupGameForAudit(state, false, false, false, false, false, false, true);
|
|
||||||
}
|
}
|
||||||
/** Genuine, explicitly opted-in preview; no player action enables this profile. */
|
/** Genuine, explicitly opted-in preview; no player action enables this profile. */
|
||||||
export function initializeMoritaniAssassinateGameForAudit(state: Game): Game {
|
export function initializeMoritaniAssassinateGameForAudit(state: Game): Game {
|
||||||
@@ -9182,7 +9202,7 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game {
|
|||||||
g.ecazTreachery = true;
|
g.ecazTreachery = true;
|
||||||
return initializeSetupGameForAudit(g, false, false, false, false, false, false, true, true);
|
return initializeSetupGameForAudit(g, false, false, false, false, false, false, true, true);
|
||||||
}
|
}
|
||||||
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false, factions = false, ecazTreachery = false): Game {
|
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false, factions = false, ecazTreachery = false, strongholdFactions = false): Game {
|
||||||
nexusCardsIntegrity(state);
|
nexusCardsIntegrity(state);
|
||||||
homeworldRule(() => homeworldGameIntegrity(state));
|
homeworldRule(() => homeworldGameIntegrity(state));
|
||||||
homeworldBattleLossIntegrity(state);
|
homeworldBattleLossIntegrity(state);
|
||||||
@@ -9200,14 +9220,16 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
|||||||
(nexus || !g.nexusCards) &&
|
(nexus || !g.nexusCards) &&
|
||||||
(ecazTreachery || !g.ecazTreachery) &&
|
(ecazTreachery || !g.ecazTreachery) &&
|
||||||
!g.techTokens &&
|
!g.techTokens &&
|
||||||
!g.strongholdCards &&
|
(strongholdFactions || !g.strongholdCards) &&
|
||||||
(homeworlds || !g.homeworlds) &&
|
(homeworlds || !g.homeworlds) &&
|
||||||
g.players.every((p) =>
|
g.players.every((p) =>
|
||||||
FACTIONS.some(
|
FACTIONS.some(
|
||||||
(f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix') || (choam && f.expansion === 'choam') || (factions && g.expansions.includes(f.expansion))),
|
(f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix') || (choam && f.expansion === 'choam') || (factions && g.expansions.includes(f.expansion))),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
factions && nexus
|
strongholdFactions
|
||||||
|
? 'The Stronghold faction audit needs a fresh Advanced Ixian/CHOAM lobby without other optional modules.'
|
||||||
|
: factions && nexus
|
||||||
? 'The paired expansion Nexus audit needs a fresh lobby without other optional modules.'
|
? 'The paired expansion Nexus audit needs a fresh lobby without other optional modules.'
|
||||||
: factions
|
: factions
|
||||||
? 'The faction prototype supports base factions and the selected expansion factions without optional modules.'
|
? 'The faction prototype supports base factions and the selected expansion factions without optional modules.'
|
||||||
@@ -9219,7 +9241,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
|||||||
? 'The Homeworld setup audit supports implemented deck sets without Tech Tokens or Stronghold Cards.'
|
? 'The Homeworld setup audit supports implemented deck sets without Tech Tokens or Stronghold Cards.'
|
||||||
: 'The audit initializer supports base factions without expansions or optional modules.',
|
: 'The audit initializer supports base factions without expansions or optional modules.',
|
||||||
);
|
);
|
||||||
initializeSetup(g);
|
initializeSetup(g, strongholdFactions || !!g.strongholdCards);
|
||||||
return normalizeAutomaticGame(g);
|
return normalizeAutomaticGame(g);
|
||||||
}
|
}
|
||||||
function ecazStartingForcesComplete(p: Player): boolean {
|
function ecazStartingForcesComplete(p: Player): boolean {
|
||||||
|
|||||||
@@ -3157,6 +3157,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'mentat',
|
'mentat',
|
||||||
'choam-combat',
|
'choam-combat',
|
||||||
'mobile-stronghold',
|
'mobile-stronghold',
|
||||||
|
'stronghold-factions',
|
||||||
'choam-modules',
|
'choam-modules',
|
||||||
],
|
],
|
||||||
checklist: [
|
checklist: [
|
||||||
@@ -3207,6 +3208,31 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'stronghold-factions',
|
||||||
|
title: 'Ixians and CHOAM with Stronghold Cards',
|
||||||
|
category: 'Advanced & expansions',
|
||||||
|
coverage: 'Partial',
|
||||||
|
developmentStage: 'Verified',
|
||||||
|
summary: 'A fresh Advanced development profile connects genuine Ixian and CHOAM setup to all six Stronghold Card effects and native continued games.',
|
||||||
|
steps: [
|
||||||
|
'This private local profile requires native Ixians and CHOAM, unique two-to-six-seat rosters with classic opponents, exactly the Ix and CHOAM Treachery Decks and Stronghold Cards alone. Basic, Tleilaxu, Richese, Ecaz, Moritani and other module overlays remain outside it.',
|
||||||
|
'The canonical combined deck contains 47 physical cards, not 49: CHOAM replaces the two shared Ix cards instead of duplicating them. Genuine setup retains native Ixian starting-card choices, all original leaders and forces, the Advanced CHOAM Auditor and six initially unowned Stronghold Cards.',
|
||||||
|
'Card ownership begins only at the real first end of Mentat and is retained through the following turn. It is not assigned by a battle fixture or reset on refresh. Hidden Mobile Stronghold interior forces and its outside pointing territory stay distinct.',
|
||||||
|
'The mobile card copies an advantage of another currently controlled stronghold, not merely a retained card. A sole choice is automatic; multiple choices use the existing public pre-plan selection. Accepted choices stay bound to the same original battle through private plans and saved continuation.',
|
||||||
|
'Arrakeen provides at most two spice toward actual support, not extra personal cash. Native CHOAM income uses the original bank and player payment shares, including its established traitor and own-donor exclusions. Original cyborg/suboid and starred casualties, bounty and other native income stay separate.',
|
||||||
|
'Carthag, Habbanya, Sietch Tabr and Tuek’s Sietch retain their existing physical defense, tie, opposing-dial and played-Worthless contracts. This roster has no Richese cache or Stone Burner; that existing cross-roster interaction is not manufactured here.',
|
||||||
|
'Bounded rule-path evidence covers native first end-Mentat ownership, eighteen original battle/copy cases, all four legal policies and five unstaged complete two-through-six-seat games. A390px human Ixian selected Arrakeen, sealed dial6/support3 with bank2/personal1 and completed the original opposed battle. Earlier comprehensive checks remain dated evidence; save/recovery/privacy/custody assurance now follows all rules. Full-card/faction/module/deployed acceptance and public expansion starts remain open.',
|
||||||
|
],
|
||||||
|
related: ['stronghold-cards', 'mobile-stronghold', 'choam-combat', 'setup', 'implementation-checklist'],
|
||||||
|
checklist: [
|
||||||
|
{ area: 'Implementation', status: 'Implemented', detail: 'Fresh native Ixian/CHOAM setup, six-card creation, actual end-Mentat ownership and original HMS/copy/CHOAM battle economy compose the existing effects without public-start or old-game changes.', evidence: ['game/engine.ts', 'game/stronghold-cards.ts', 'game/battle-resolution-quote.ts'] },
|
||||||
|
{ area: 'Player controls', status: 'Implemented', detail: 'Existing gallery, inspector, mobile copy and support controls work through an original local setup at390px: copied bank support costs one actual personal spice and preserves the native opposed battle.', evidence: ['components/stronghold-cards.tsx', 'components/game-table.tsx'] },
|
||||||
|
{ area: 'AI', status: 'Partial', detail: 'All four projected minimal policies produce legal current copy actions; five genuine Advanced2..6 native games complete. Strategy/calibration and other combinations remain deferred.', evidence: ['game/bots.ts', 'tools/faction-games.ts'] },
|
||||||
|
{ area: 'Documentation', status: 'Partial', detail: 'Advanced prerequisite, physical47-card identity, native module-only profile, existing effects and pending excluded combinations are explicit.', evidence: ['docs/STRONGHOLD_CARDS.md', 'docs/RULE_DECISIONS.md'] },
|
||||||
|
{ area: 'Verification', status: 'Partial', detail: 'Native18/four-policy smoke, five unstaged games with3129 accepted actions/no rejections/82JSON restores, human390px rule-control battle and scoped review cover this profile. Earlier162 affected and6513 full checks pass; new save/privacy/custody assurance waits until all rules, not a repeated prototype gate.', evidence: ['tests/fixture-stronghold-factions.ts', 'tests/stronghold-factions-runtime.test.ts', 'tests/stronghold-factions-recovery.test.ts', 'tests/prototype-room.test.ts'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'tech-tokens',
|
id: 'tech-tokens',
|
||||||
title: 'Tech tokens: income, conquest and victory',
|
title: 'Tech tokens: income, conquest and victory',
|
||||||
|
|||||||
+2
-1
@@ -20,7 +20,8 @@
|
|||||||
"test:recovery": "node --import tsx tools/test.ts --suite recovery",
|
"test:recovery": "node --import tsx tools/test.ts --suite recovery",
|
||||||
"test:integration": "node --import tsx tools/test.ts --suite integration",
|
"test:integration": "node --import tsx tools/test.ts --suite integration",
|
||||||
"test:all": "node --import tsx tools/test.ts --suite all",
|
"test:all": "node --import tsx tools/test.ts --suite all",
|
||||||
"check": "npm run typecheck && npm run lint && npm test"
|
"check": "npm run typecheck && npm run lint && npm test",
|
||||||
|
"check:quick": "npm run typecheck && npm run lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import type { Game } from '../game/engine';
|
||||||
|
import { isAuditorLeader, treacheryDeck } from '../game/cards';
|
||||||
|
import { STRONGHOLD_CARDS } from '../game/stronghold-cards';
|
||||||
|
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
|
||||||
|
import { createTechTokens } from '../game/tech-tokens';
|
||||||
const root = new URL('../', import.meta.url).pathname;
|
const root = new URL('../', import.meta.url).pathname;
|
||||||
const cli = new URL('../tools/faction-games.ts', import.meta.url).pathname;
|
const cli = new URL('../tools/faction-games.ts', import.meta.url).pathname;
|
||||||
type CliResult = {
|
type CliResult = {
|
||||||
@@ -94,6 +99,83 @@ function assertFailedEvidence(
|
|||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void test('native Stronghold samples admit every two-to-six-seat roster and preserve canonical physical inventories', (t) => {
|
||||||
|
const area = temporary(t);
|
||||||
|
const out = join(area, 'native');
|
||||||
|
assert.equal(run(out, '--profile', 'stronghold-factions',
|
||||||
|
'--players', 'all', '--seed', '1000', '--max-actions', '1').status, 1);
|
||||||
|
const results = json<{ results: CliResult[] }>(join(out, 'results.json')).results;
|
||||||
|
assert.deepEqual(results.map(result => result.name),
|
||||||
|
[2, 3, 4, 5, 6].map(seats => `stronghold-factions-${seats}-advanced`));
|
||||||
|
const roster = ['ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'];
|
||||||
|
const canonical = treacheryDeck(['ix', 'choam']).map(card => card.id).sort();
|
||||||
|
assert.equal(canonical.length, 47);
|
||||||
|
assert.equal(new Set(canonical).size, 47);
|
||||||
|
for (const [index, result] of results.entries()) {
|
||||||
|
assert.equal(result.error, 'Error: Action limit');
|
||||||
|
assert.equal(result.actions, 1);
|
||||||
|
const game = json<Game>(join(out, `failed-${result.name}.json`));
|
||||||
|
assert.equal(game.advanced, true);
|
||||||
|
assert.deepEqual(game.expansions, ['ix', 'choam']);
|
||||||
|
assert.deepEqual(game.players.map(player => player.faction), roster.slice(0, index + 2));
|
||||||
|
assert.deepEqual(game.players.map(player => player.bot),
|
||||||
|
['Easy', 'Medium', 'Hard', 'Brutal', 'Easy', 'Medium'].slice(0, index + 2));
|
||||||
|
const inventory = sampleInventory(game);
|
||||||
|
assert.deepEqual(inventory.cards, canonical);
|
||||||
|
verifySampleCustody(game, inventory);
|
||||||
|
assert.deepEqual(Object.keys(game.strongholdCards!.owners).sort(),
|
||||||
|
STRONGHOLD_CARDS.map(card => card.id).sort());
|
||||||
|
assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
|
||||||
|
assert.equal(game.strongholdCards!.claimedTurn, 0);
|
||||||
|
assert.equal(game.players.find(player => player.faction === 'choam')!.leaders
|
||||||
|
.filter(isAuditorLeader).length, 1);
|
||||||
|
}
|
||||||
|
const snapshot = join(out, 'failed-stronghold-factions-6-advanced.json');
|
||||||
|
const original = readFileSync(snapshot, 'utf8');
|
||||||
|
const continued = join(area, 'continued');
|
||||||
|
assert.equal(run(continued, '--resume', snapshot, '--seed', '1000',
|
||||||
|
'--max-actions', '1').status, 1);
|
||||||
|
const report = assertFailedEvidence(continued, 'stronghold-factions-6-advanced', 1155, true);
|
||||||
|
assert.equal(report.options.resume?.path, snapshot);
|
||||||
|
verifySampleCustody(json<Game>(join(continued, 'failed-stronghold-factions-6-advanced.json')),
|
||||||
|
sampleInventory(json<Game>(snapshot)), json<Game>(snapshot));
|
||||||
|
assert.equal(readFileSync(snapshot, 'utf8'), original);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native Stronghold admission and resume reject foreign profiles and corrupted custody before gameplay', (t) => {
|
||||||
|
const area = temporary(t);
|
||||||
|
const basic = join(area, 'basic');
|
||||||
|
assert.equal(run(basic, '--profile', 'stronghold-factions', '--rules', 'basic').status, 1);
|
||||||
|
assert.equal(existsSync(basic), false);
|
||||||
|
const out = join(area, 'native');
|
||||||
|
assert.equal(run(out, '--profile', 'stronghold-factions', '--players', '2',
|
||||||
|
'--max-actions', '1').status, 1);
|
||||||
|
const source = json<Game>(join(out, 'failed-stronghold-factions-2-advanced.json'));
|
||||||
|
const corruptions: [string, (game: Game) => void][] = [
|
||||||
|
['basic', game => { game.advanced = false; }],
|
||||||
|
['wrong-deck', game => { game.expansions = ['ix']; }],
|
||||||
|
['foreign-faction', game => { game.players[1].faction = 'richese'; }],
|
||||||
|
['missing-module', game => { delete game.strongholdCards; }],
|
||||||
|
['nexus', game => { game.nexusCards = { cards: null, phase: null }; }],
|
||||||
|
['tech', game => { game.techTokens = createTechTokens(); }],
|
||||||
|
['missing-stronghold', game => { Reflect.deleteProperty(game.strongholdCards!.owners, 'arrakeen'); }],
|
||||||
|
['foreign-owner', game => { game.strongholdCards!.owners.arrakeen = 'foreign-seat'; }],
|
||||||
|
['future-custody', game => { game.strongholdCards!.claimedTurn = game.turn + 1; }],
|
||||||
|
['missing-treachery', game => { game.deck.pop(); }],
|
||||||
|
];
|
||||||
|
for (const [label, corrupt] of corruptions) {
|
||||||
|
const game = structuredClone(source);
|
||||||
|
corrupt(game);
|
||||||
|
const snapshot = join(area, `${label}.json`);
|
||||||
|
writeFileSync(snapshot, JSON.stringify(game), { mode: 0o600 });
|
||||||
|
const before = readFileSync(snapshot, 'utf8');
|
||||||
|
const rejected = join(area, `${label}-resume`);
|
||||||
|
assert.equal(run(rejected, '--resume', snapshot, '--max-actions', '1').status, 1, label);
|
||||||
|
assert.equal(existsSync(rejected), false, label);
|
||||||
|
assert.equal(readFileSync(snapshot, 'utf8'), before, label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
void test('Ecaz card samples keep all three physical identities through saved resume', (t) => {
|
void test('Ecaz card samples keep all three physical identities through saved resume', (t) => {
|
||||||
const area = temporary(t);
|
const area = temporary(t);
|
||||||
const out = join(area, 'ecaz-cards');
|
const out = join(area, 'ecaz-cards');
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, territory } from '../game/board';
|
||||||
|
import { isAuditorLeader, type Card } from '../game/cards';
|
||||||
|
import type { FactionId } from '../game/catalog';
|
||||||
|
import { STRONGHOLD_CARDS, strongholdControllers, type StrongholdId } from '../game/stronghold-cards';
|
||||||
|
import { quoteBattleResolution, type BattleResolutionQuote, type ResolutionCombatant } from '../game/battle-resolution-quote';
|
||||||
|
|
||||||
|
export type StrongholdFactionsNativeStep = { actor: string; action: Action };
|
||||||
|
export type StrongholdFactionsFixtureOptions = {
|
||||||
|
initial?: Game;
|
||||||
|
seatIds?: string[];
|
||||||
|
kind?: StrongholdId;
|
||||||
|
copy?: StrongholdId;
|
||||||
|
copyChoices?: 0 | 1 | 2;
|
||||||
|
ownerFaction?: FactionId;
|
||||||
|
attackerIsOwner?: boolean;
|
||||||
|
ownerDial?: number;
|
||||||
|
opponentDial?: number;
|
||||||
|
support?: number;
|
||||||
|
opponentSupport?: number;
|
||||||
|
allyPayment?: number;
|
||||||
|
choamFunding?: number;
|
||||||
|
ownerSpice?: number;
|
||||||
|
ownerLeader?: 'auditor';
|
||||||
|
ownerWeapon?: Card['kind'];
|
||||||
|
ownerDefense?: Card['kind'];
|
||||||
|
opponentWeapon?: Card['kind'];
|
||||||
|
opponentDefense?: Card['kind'];
|
||||||
|
traitors?: 'owner' | 'both';
|
||||||
|
counter?: boolean;
|
||||||
|
mobileRoute?: string[];
|
||||||
|
};
|
||||||
|
export type StrongholdFactionsSourceFacts = {
|
||||||
|
event: string;
|
||||||
|
turn: number;
|
||||||
|
territory: StrongholdId;
|
||||||
|
attacker: string;
|
||||||
|
defender: string;
|
||||||
|
owner: string;
|
||||||
|
copyChoices: StrongholdId[];
|
||||||
|
cardOwners: Record<StrongholdId, string | null>;
|
||||||
|
currentControllers: Record<StrongholdId, string | null>;
|
||||||
|
physicalForces: { player: string; forces: Record<string, number>; eliteForces: Record<string, number> }[];
|
||||||
|
};
|
||||||
|
export type StrongholdFactionsFixture = {
|
||||||
|
initial: Game;
|
||||||
|
afterSetup: Game;
|
||||||
|
beforeFirstMentat: Game;
|
||||||
|
firstMentatStep: StrongholdFactionsNativeStep;
|
||||||
|
afterFirstMentat: Game;
|
||||||
|
beforeBattle: Game;
|
||||||
|
battleAction: Action;
|
||||||
|
actor: string;
|
||||||
|
owner: string;
|
||||||
|
opponent: string;
|
||||||
|
choam: string;
|
||||||
|
game: Game;
|
||||||
|
event: string;
|
||||||
|
kind: StrongholdId;
|
||||||
|
copyAction: Action | null;
|
||||||
|
fundingAction: StrongholdFactionsNativeStep | null;
|
||||||
|
planActions: StrongholdFactionsNativeStep[];
|
||||||
|
staging: string[];
|
||||||
|
sourceFacts: StrongholdFactionsSourceFacts;
|
||||||
|
};
|
||||||
|
const seat = (game: Game, id: string) => {
|
||||||
|
const player = game.players.find(p => p.id === id);
|
||||||
|
assert.ok(player, `Missing original seat ${id}`);
|
||||||
|
return player;
|
||||||
|
};
|
||||||
|
const key = (id: StrongholdId) => id === MOBILE_STRONGHOLD ? MOBILE_LOCATION : `${id}:${territory(id).sectors[0]}`;
|
||||||
|
|
||||||
|
/** Only consumes actual native decisions. A null result with a live battle means
|
||||||
|
* a human must seal a plan; the helper never reads or invents that private plan. */
|
||||||
|
export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactionsNativeStep | null {
|
||||||
|
if (game.status === 'finished') return null;
|
||||||
|
if (game.pendingTreacheryDiscard) return { actor: game.host, action: { type: 'advanceBots' } };
|
||||||
|
if (game.phaseOpening) {
|
||||||
|
const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id))?.id;
|
||||||
|
assert.ok(actor);
|
||||||
|
return { actor, action: { type: 'ready' } };
|
||||||
|
}
|
||||||
|
if (game.response) {
|
||||||
|
// Use projected legal responses: an absent Karama does not create a pause.
|
||||||
|
for (const player of game.players) {
|
||||||
|
const view = viewGame(game, player.id);
|
||||||
|
view.players.find(p => p.id === player.id)!.bot = 'Easy';
|
||||||
|
const action = botActions(view).find(a => a.type === 'passResponse');
|
||||||
|
if (action) return { actor: player.id, action };
|
||||||
|
}
|
||||||
|
throw Error(`No actual eligible counter at ${game.response.kind}`);
|
||||||
|
}
|
||||||
|
const decision = game.decision;
|
||||||
|
if (decision) {
|
||||||
|
const actor = decision.player;
|
||||||
|
if (decision.kind === 'ixSetup') return { actor, action: { type: 'decision', card: game.ixSetupCards![0].id } };
|
||||||
|
if (decision.kind === 'mobileStronghold') return { actor, action: decision.placement ? { type: 'decision', location: 'polar_sink:0' } : { type: 'decision', decline: true } };
|
||||||
|
if (decision.kind === 'ixTechnology' || decision.kind === 'ixSubstitution' || decision.kind === 'captureOffer' || decision.kind === 'fullPlanOffer' || decision.kind === 'choamTradeReply')
|
||||||
|
return { actor, action: { type: 'decision', decline: true, ...(decision.kind === 'captureOffer' ? { accept: false } : {}) } };
|
||||||
|
if (decision.kind === 'choamMarket') return { actor, action: { type: 'decision', done: true } };
|
||||||
|
if (decision.kind === 'choamFreeRevival') return { actor, action: { type: 'decision', decline: true } };
|
||||||
|
if (decision.kind === 'choamBattleFunding') return { actor, action: { type: 'decision', amount: 0 } };
|
||||||
|
if (decision.kind === 'strongholdCopy') return { actor, action: { type: 'decision', event: decision.event, stronghold: decision.choices[0] } };
|
||||||
|
if (decision.kind === 'auctionPayment') return { actor, action: { type: 'decision', karama: false } };
|
||||||
|
if (decision.kind === 'guildShipment') return { actor, action: { type: 'decision', allow: true } };
|
||||||
|
if (decision.kind === 'poisonTooth') return { actor, action: { type: 'decision', activate: true } };
|
||||||
|
if (decision.kind === 'battleLosses') return { actor, action: { type: 'decision', choice: 0 } };
|
||||||
|
if (decision.kind === 'battleCards') return { actor, action: { type: 'decision', discard: [] } };
|
||||||
|
if (decision.kind === 'choamAudit') return { actor, action: { type: 'decision', event: decision.event, audit: false } };
|
||||||
|
if (decision.kind === 'choamAuditPayment') return { actor, action: { type: 'decision', event: decision.event, pay: false } };
|
||||||
|
}
|
||||||
|
if (game.battle?.preLeader && !game.battle.preLeader.closed) {
|
||||||
|
const actor = [game.battle.attacker, game.battle.defender].find(id => !game.battle!.preLeader!.ready.includes(id));
|
||||||
|
if (actor) return { actor, action: { type: 'battlePreparationReady', event: game.battle.event } };
|
||||||
|
}
|
||||||
|
if (game.battle?.preparation) return { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } };
|
||||||
|
if (game.battle?.revealed) {
|
||||||
|
const actor = viewGame(game, game.host).battle!.traitorVoters.find(id => game.battle!.traitorCalls[id] === undefined);
|
||||||
|
if (actor) return { actor, action: { type: 'traitorCall', call: false } };
|
||||||
|
}
|
||||||
|
if (game.battle && !game.battle.revealed && !game.decision) return null;
|
||||||
|
if (game.status === 'playing' && !game.decision) {
|
||||||
|
if (game.phase === 0 && game.stormPending === null) {
|
||||||
|
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
|
||||||
|
if (actor) return { actor, action: { type: 'stormDial', amount: 0 } };
|
||||||
|
}
|
||||||
|
if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } };
|
||||||
|
if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } };
|
||||||
|
if (game.phase !== 6 || !game.active) {
|
||||||
|
const actor = game.players.find(p => !game.ready.includes(p.id))?.id;
|
||||||
|
if (actor) return { actor, action: { type: 'ready' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const player of game.players) {
|
||||||
|
if (decision && decision.player !== player.id) continue;
|
||||||
|
const view = viewGame(game, player.id);
|
||||||
|
view.players.find(p => p.id === player.id)!.bot = 'Easy';
|
||||||
|
const actions = botActions(view);
|
||||||
|
const action = actions.find(a => a.type === 'ready') ?? actions[0];
|
||||||
|
if (action) return { actor: player.id, action };
|
||||||
|
}
|
||||||
|
throw Error(`No native Stronghold continuation at ${game.status}/${game.turn}/${game.phase}/${decision?.kind}`);
|
||||||
|
}
|
||||||
|
function step(game: Game): Game {
|
||||||
|
const next = nextStrongholdFactionsNativeStep(game);
|
||||||
|
assert.ok(next, 'Native continuation stopped for an unsealed human plan');
|
||||||
|
return applyAction(game, next.actor, next.action);
|
||||||
|
}
|
||||||
|
function advance(game: Game, predicate: (state: Game) => boolean): Game {
|
||||||
|
for (let count = 0; count < 1800; count++) {
|
||||||
|
if (predicate(game)) return game;
|
||||||
|
assert.equal(game.status, 'playing');
|
||||||
|
game = step(game);
|
||||||
|
}
|
||||||
|
throw Error('Native Stronghold phase did not reach its requested boundary');
|
||||||
|
}
|
||||||
|
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||||
|
|
||||||
|
/** Determinism is scoped only to the original native initializer. Saved decks,
|
||||||
|
* offers and native remainder shuffles are never reordered or redealt. */
|
||||||
|
function initialize(game: Game): Game {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||||
|
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||||
|
assert.ok(array);
|
||||||
|
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
|
||||||
|
return array;
|
||||||
|
};
|
||||||
|
try { return initializeStrongholdFactionsGameForAudit(game); }
|
||||||
|
finally {
|
||||||
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||||
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Canonical conserved staging, never hand replacement or face manufacture. */
|
||||||
|
function heldCard(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], excluded: string[] = []): Card {
|
||||||
|
const player = seat(game, actor);
|
||||||
|
const held = player.hand.find(card => matches(card) && !excluded.includes(card.id));
|
||||||
|
if (held) return held;
|
||||||
|
const index = game.deck.findIndex(card => matches(card) && !excluded.includes(card.id));
|
||||||
|
assert.ok(index >= 0, 'The required canonical card is neither held nor in the remaining native deck');
|
||||||
|
const card = game.deck.splice(index, 1)[0];
|
||||||
|
assert.ok(player.hand.length < (player.faction === 'harkonnen' ? 8 : player.faction === 'choam' ? 5 : 4));
|
||||||
|
player.hand.push(card);
|
||||||
|
staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
/** Return board counters to their own reserves without changing subtype totals. */
|
||||||
|
function reserveBoard(game: Game): void {
|
||||||
|
for (const player of game.players) {
|
||||||
|
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
|
||||||
|
player.forces = {};
|
||||||
|
if (player.elites) {
|
||||||
|
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0);
|
||||||
|
player.elites.forces = {};
|
||||||
|
}
|
||||||
|
if (player.advisors) player.advisors = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function place(game: Game, actor: string, location: string, count: number, elite = 0): void {
|
||||||
|
const player = seat(game, actor);
|
||||||
|
assert.ok(player.reserves >= count && count >= elite && (player.elites?.reserves ?? 0) >= elite);
|
||||||
|
player.reserves -= count;
|
||||||
|
player.forces[location] = (player.forces[location] ?? 0) + count;
|
||||||
|
if (elite) {
|
||||||
|
player.elites!.reserves -= elite;
|
||||||
|
player.elites!.forces[location] = (player.elites!.forces[location] ?? 0) + elite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
|
||||||
|
const player = seat(game, actor);
|
||||||
|
if (player.traitors.includes(identity)) return;
|
||||||
|
const returned = player.traitors[0];
|
||||||
|
assert.ok(returned);
|
||||||
|
const index = game.traitorReserve?.indexOf(identity) ?? -1;
|
||||||
|
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
|
||||||
|
assert.ok(index >= 0 || donor, 'Requested canonical traitor retains native reserve or hand custody');
|
||||||
|
if (index >= 0) game.traitorReserve!.splice(index, 1, returned);
|
||||||
|
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
|
||||||
|
player.traitors[0] = identity;
|
||||||
|
staging.push(`Conserved traitor ${identity}: native ${donor?.id ?? 'reserve'} custody exchanged with ${actor}'s ${returned}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Genuine Advanced native setup, first full turn and actual END Mentat custody.
|
||||||
|
* Only labeled conserved board/card/bank positions are staged. Never assign
|
||||||
|
* owners, phases, source receipts, quotations, pending windows or responses. */
|
||||||
|
export function createStrongholdFactionsFixture(options: StrongholdFactionsFixtureOptions = {}): StrongholdFactionsFixture {
|
||||||
|
const kind = options.kind ?? MOBILE_STRONGHOLD;
|
||||||
|
const staging: string[] = [];
|
||||||
|
let game: Game;
|
||||||
|
if (options.initial) {
|
||||||
|
game = structuredClone(options.initial);
|
||||||
|
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => p.hand.length === 0), 'Continue only the original fresh undealt CLI setup');
|
||||||
|
assert.equal(game.advanced, true);
|
||||||
|
assert.deepEqual([...game.expansions].sort(), ['choam', 'ix']);
|
||||||
|
assert.ok(game.players.some(p => p.faction === 'ixians') && game.players.some(p => p.faction === 'choam'));
|
||||||
|
} else {
|
||||||
|
const ids = options.seatIds ?? ['ix', 'choam', 'guild'];
|
||||||
|
assert.ok(ids.length >= 2 && ids.length <= 6 && new Set(ids).size === ids.length);
|
||||||
|
const factions: FactionId[] = ['ixians', 'choam', 'guild', 'emperor', 'fremen', 'harkonnen'];
|
||||||
|
game = createGame('STRONGHOLDFACTIONS', newPlayer(ids[0], 'Ixians', factions[0]), true, ['ix', 'choam']);
|
||||||
|
for (let index = 1; index < ids.length; index++) joinGame(game, newPlayer(ids[index], factions[index], factions[index]));
|
||||||
|
}
|
||||||
|
if (game.status === 'lobby') {
|
||||||
|
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||||
|
game = initialize(game);
|
||||||
|
}
|
||||||
|
const initial = structuredClone(game);
|
||||||
|
assert.equal(game.strongholdCards!.claimedTurn, 0);
|
||||||
|
assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
|
||||||
|
for (let count = 0; game.status === 'setup' && count < 200; count++) game = step(game);
|
||||||
|
assert.equal(game.status, 'playing');
|
||||||
|
const afterSetup = structuredClone(game);
|
||||||
|
const ix = game.players.find(p => p.faction === 'ixians')!.id;
|
||||||
|
const choam = game.players.find(p => p.faction === 'choam')!.id;
|
||||||
|
const owner = game.players.find(p => p.faction === (options.ownerFaction ?? 'ixians'))?.id;
|
||||||
|
assert.ok(owner, 'Requested owner must be an original native seat');
|
||||||
|
const opponent = game.players.find(p => p.id !== owner && p.id !== choam)?.id ?? game.players.find(p => p.id !== owner)!.id;
|
||||||
|
if (options.choamFunding || options.allyPayment) assert.ok(owner !== choam && opponent !== choam, 'Native donor needs a separate CHOAM seat');
|
||||||
|
// Select real first dials, not the storm field. A later 1..6 native storm
|
||||||
|
// cannot obstruct this test's single printed battle sector.
|
||||||
|
for (let count = 0; !(game.phase === 8 && clean(game)) && count < 1600; count++) {
|
||||||
|
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
|
||||||
|
if (next.action.type === 'stormDial' && game.turn === 1) next.action.amount = kind === 'tueks_sietch' || kind === 'habbanya_ridge_sietch' ? 4 : 0;
|
||||||
|
game = applyAction(game, next.actor, next.action);
|
||||||
|
}
|
||||||
|
assert.equal(game.phase, 8); assert.equal(game.turn, 1);
|
||||||
|
reserveBoard(game);
|
||||||
|
staging.push('Before actual first END Mentat: relocate existing physical counters through their own reserves; nobody controls three strongholds');
|
||||||
|
place(game, ix, MOBILE_LOCATION, 6, 3);
|
||||||
|
const firstCopy = options.copy && options.copy !== MOBILE_STRONGHOLD ? options.copy : 'arrakeen';
|
||||||
|
if (kind === MOBILE_STRONGHOLD) {
|
||||||
|
if (owner !== ix) {
|
||||||
|
reserveBoard(game);
|
||||||
|
place(game, owner, MOBILE_LOCATION, 6);
|
||||||
|
}
|
||||||
|
place(game, owner, key(firstCopy), 1);
|
||||||
|
} else place(game, owner, key(kind), 1);
|
||||||
|
const occupied = new Set([kind, MOBILE_STRONGHOLD, ...(kind === MOBILE_STRONGHOLD ? [firstCopy] : [])]);
|
||||||
|
const others = game.players.filter(p => p.id !== owner && p.id !== ix);
|
||||||
|
for (const [index, card] of STRONGHOLD_CARDS.filter(card => !occupied.has(card.id)).entries()) {
|
||||||
|
const recipient = others[Math.floor(index / 2)];
|
||||||
|
if (recipient) place(game, recipient.id, key(card.id), 1);
|
||||||
|
}
|
||||||
|
// Keep every first-turn owner below the solo victory threshold.
|
||||||
|
for (const player of game.players) assert.ok(Object.keys(player.forces).length <= 2);
|
||||||
|
let beforeFirstMentat: Game | undefined;
|
||||||
|
let firstMentatStep: StrongholdFactionsNativeStep | undefined;
|
||||||
|
for (let count = 0; game.turn === 1 && count < 200; count++) {
|
||||||
|
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
|
||||||
|
const before = structuredClone(game);
|
||||||
|
game = applyAction(game, next.actor, next.action);
|
||||||
|
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
|
||||||
|
}
|
||||||
|
assert.ok(beforeFirstMentat && firstMentatStep);
|
||||||
|
assert.equal(game.status, 'playing'); assert.equal(game.strongholdCards!.claimedTurn, 1);
|
||||||
|
assert.equal(game.strongholdCards!.owners[kind], owner);
|
||||||
|
const afterFirstMentat = structuredClone(game);
|
||||||
|
if (options.mobileRoute) {
|
||||||
|
game = advance(game, state => state.decision?.kind === 'mobileStronghold');
|
||||||
|
assert.equal(game.decision?.kind, 'mobileStronghold');
|
||||||
|
game = applyAction(game, ix, { type: 'decision', route: options.mobileRoute, collect: false });
|
||||||
|
}
|
||||||
|
if (options.choamFunding || options.allyPayment) {
|
||||||
|
game = advance(game, state => state.phase === 1 && clean(state) && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus);
|
||||||
|
const thumperActor = game.players.find(player => player.hand.some(card => card.effect === 'thumper'))?.id ?? owner;
|
||||||
|
const thumper = heldCard(game, thumperActor, card => card.effect === 'thumper', staging);
|
||||||
|
game = applyAction(game, thumperActor, { type: 'card', card: thumper.id });
|
||||||
|
game = advance(game, state => state.nexus && clean(state) && !state.spiceWindow && !state.spiceResolution);
|
||||||
|
game = applyAction(game, owner, { type: 'alliance', target: choam });
|
||||||
|
game = applyAction(game, choam, { type: 'alliance', target: owner });
|
||||||
|
assert.equal(seat(game, owner).ally, choam);
|
||||||
|
}
|
||||||
|
game = advance(game, state => state.phase === 5 && clean(state));
|
||||||
|
reserveBoard(game);
|
||||||
|
staging.push('Before native turn-two movement completion: conserve six battle counters per combatant and current copy-control counters; retain actual first-Mentat card custody');
|
||||||
|
for (const id of [owner, opponent]) place(game, id, key(kind), 6, seat(game, id).faction === 'ixians' ? 3 : 0);
|
||||||
|
if (kind !== MOBILE_STRONGHOLD && ix !== owner && ix !== opponent) place(game, ix, MOBILE_LOCATION, 6, 3);
|
||||||
|
if (kind === MOBILE_STRONGHOLD) {
|
||||||
|
const choices = options.copyChoices ?? 2;
|
||||||
|
if (choices) place(game, owner, key(firstCopy), 1);
|
||||||
|
if (choices === 2) place(game, owner, key(firstCopy === 'sietch_tabr' ? 'arrakeen' : 'sietch_tabr'), 1);
|
||||||
|
}
|
||||||
|
for (const player of game.players) {
|
||||||
|
player.spice = player.id === owner ? options.ownerSpice ?? 20 : 20;
|
||||||
|
}
|
||||||
|
staging.push('Bank funding staging: original seats receive explicit twenty-spice test balances (ownerSpice may set the affordability boundary); no aid or support receipt is injected');
|
||||||
|
const used: string[] = [];
|
||||||
|
const card = (actor: string, type: Card['kind'] | undefined) => {
|
||||||
|
if (!type) return null;
|
||||||
|
const selected = heldCard(game, actor, c => c.kind === type, staging, used);
|
||||||
|
used.push(selected.id); return selected.id;
|
||||||
|
};
|
||||||
|
const ownerWeapon = card(owner, options.ownerWeapon);
|
||||||
|
const ownerDefense = card(owner, options.ownerDefense);
|
||||||
|
const opponentWeapon = card(opponent, options.opponentWeapon);
|
||||||
|
const opponentDefense = card(opponent, options.opponentDefense);
|
||||||
|
if (options.counter) heldCard(game, opponent, c => c.effect === 'karama', staging);
|
||||||
|
const ownerLeader = seat(game, owner).leaders.find(l => options.ownerLeader === 'auditor' ? isAuditorLeader(l) : !isAuditorLeader(l) && l.strength === 5)
|
||||||
|
?? seat(game, owner).leaders.find(l => !isAuditorLeader(l));
|
||||||
|
const opponentLeader = seat(game, opponent).leaders.find(l => !isAuditorLeader(l) && l.strength === (options.ownerLeader === 'auditor' ? 2 : ownerLeader!.strength))
|
||||||
|
?? seat(game, opponent).leaders.find(l => !isAuditorLeader(l));
|
||||||
|
assert.ok(ownerLeader && opponentLeader);
|
||||||
|
if (options.ownerLeader === 'auditor') assert.ok(isAuditorLeader(ownerLeader), 'The actual native CHOAM Auditor is required');
|
||||||
|
if (options.traitors) {
|
||||||
|
conserveTraitor(game, owner, opponentLeader.id, staging);
|
||||||
|
if (options.traitors === 'both') conserveTraitor(game, opponent, ownerLeader.id, staging);
|
||||||
|
}
|
||||||
|
// Finish genuine movement; the battle queue determines its real chooser.
|
||||||
|
game = advance(game, state => state.phase === 6 && clean(state));
|
||||||
|
const beforeBattle = structuredClone(game);
|
||||||
|
const actor = game.active!;
|
||||||
|
assert.ok([owner, opponent].includes(actor));
|
||||||
|
if (options.attackerIsOwner !== undefined) assert.equal(actor === owner, options.attackerIsOwner, 'Native storm order decides physical attacker; this option does not rewrite it');
|
||||||
|
const battleAction: Action = { type: 'chooseBattle', territory: kind, target: actor === owner ? opponent : owner };
|
||||||
|
game = applyAction(game, actor, battleAction);
|
||||||
|
for (let count = 0; count < 120; count++) {
|
||||||
|
if (game.decision?.kind === 'strongholdCopy') break;
|
||||||
|
const next = nextStrongholdFactionsNativeStep(game);
|
||||||
|
if (!next) break;
|
||||||
|
if (game.decision?.kind === 'choamBattleFunding') next.action.amount = options.choamFunding ?? 0;
|
||||||
|
game = applyAction(game, next.actor, next.action);
|
||||||
|
}
|
||||||
|
assert.ok(game.battle?.event && !game.battle.revealed);
|
||||||
|
const copyAction: Action | null = game.decision?.kind === 'strongholdCopy' ? { type: 'decision', event: game.decision.event, stronghold: options.copy ?? firstCopy } : null;
|
||||||
|
const planActions: StrongholdFactionsNativeStep[] = [
|
||||||
|
{ actor: owner, action: { type: 'battlePlan', dial: options.ownerDial ?? 0, support: options.support ?? 0, allyPayment: options.allyPayment ?? 0, leader: ownerLeader.id, weapon: ownerWeapon, defense: ownerDefense } },
|
||||||
|
{ actor: opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0, support: options.opponentSupport ?? 0, leader: opponentLeader.id, weapon: opponentWeapon, defense: opponentDefense } },
|
||||||
|
];
|
||||||
|
const fundingAction: StrongholdFactionsNativeStep | null = options.choamFunding || options.allyPayment ? { actor: choam, action: { type: 'decision', amount: options.choamFunding ?? options.allyPayment ?? 0 } } : null;
|
||||||
|
const currentControllers = strongholdControllers(game.players, !!game.mobileStronghold?.location);
|
||||||
|
const sourceFacts: StrongholdFactionsSourceFacts = {
|
||||||
|
event: game.battle.event, turn: game.turn, territory: kind,
|
||||||
|
attacker: game.battle.attacker, defender: game.battle.defender, owner,
|
||||||
|
copyChoices: STRONGHOLD_CARDS.filter(card => card.id !== MOBILE_STRONGHOLD && currentControllers[card.id] === owner).map(card => card.id),
|
||||||
|
cardOwners: { ...game.strongholdCards!.owners }, currentControllers,
|
||||||
|
physicalForces: game.players.map(player => ({ player: player.id, forces: { ...player.forces }, eliteForces: { ...player.elites?.forces } })),
|
||||||
|
};
|
||||||
|
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, battleAction, actor, owner, opponent, choam, game, event: game.battle.event, kind, copyAction, fundingAction, planActions, staging, sourceFacts };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Independent shared quote on the actual revealed source, including native
|
||||||
|
* subtype pools, donor escrow, bounty and bank income as separate legs. */
|
||||||
|
export function quoteStrongholdFactionsBattle(game: Game): BattleResolutionQuote {
|
||||||
|
const battle = game.battle; assert.ok(battle?.revealed);
|
||||||
|
const side = (actor: string): ResolutionCombatant => {
|
||||||
|
const player = seat(game, actor), view = viewGame(game, actor).battle!;
|
||||||
|
const aid = player.ally ? game.aid[player.ally] : undefined;
|
||||||
|
assert.ok(view.ownForces);
|
||||||
|
return { id: actor, faction: player.faction, ally: player.ally, spice: player.spice, hand: player.hand,
|
||||||
|
plan: battle.plans[actor], leader: player.leaders.find(l => l.id === battle.plans[actor].leader),
|
||||||
|
forces: view.ownForces, stronghold: view.strongholdEffects[actor], poisonTooth: battle.poisonTooth?.[actor],
|
||||||
|
lateDefense: battle.lateDefense?.[actor], stoneMode: battle.stoneBurner?.[actor],
|
||||||
|
...(aid?.recipient === actor ? { aid: { donor: player.ally!, amount: aid.amount } } : {}) };
|
||||||
|
};
|
||||||
|
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
|
||||||
|
aggressor: viewGame(game, game.host).battle!.aggressor,
|
||||||
|
attacker: side(battle.attacker), defender: side(battle.defender),
|
||||||
|
voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor,
|
||||||
|
beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : seat(game, actor).ally!,
|
||||||
|
called: battle.traitorCalls[actor] ?? false, traitors: seat(game, actor).traitors })),
|
||||||
|
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)],
|
||||||
|
pendingAuditorPresent: !!game.pendingAuditor, pendingRetentionPresent: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StrongholdFactionsBattleQuote = BattleResolutionQuote;
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
} from '../tools/prototype-room';
|
} from '../tools/prototype-room';
|
||||||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||||
import { createLeaderSkills } from '../game/leader-skills';
|
import { createLeaderSkills } from '../game/leader-skills';
|
||||||
|
import { createStrongholdCards } from '../game/stronghold-cards';
|
||||||
|
import { treacheryDeck } from '../game/cards';
|
||||||
|
|
||||||
function fixture(t: test.TestContext) {
|
function fixture(t: test.TestContext) {
|
||||||
const db = new DatabaseSync(':memory:');
|
const db = new DatabaseSync(':memory:');
|
||||||
@@ -952,3 +954,152 @@ void test('Banker income CLI requires a fresh exact-version private backup and p
|
|||||||
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
|
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
|
||||||
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
|
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function strongholdFactionsLobby(count = 3) {
|
||||||
|
const game = createGame('PROTOTYP', newPlayer('i', 'Native Ixians', 'ixians'), true, ['ix', 'choam']);
|
||||||
|
const factions = ['choam', 'atreides', 'harkonnen', 'emperor', 'guild'] as const;
|
||||||
|
for (let index = 1; index < count; index++)
|
||||||
|
joinGame(game, newPlayer('seat-' + index, factions[index - 1], factions[index - 1]));
|
||||||
|
game.players.forEach(player => { player.ready = true; });
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('Stronghold factions admits only fresh exact-version native Advanced lobbies without changing seats or other rooms', t => {
|
||||||
|
const { db } = fixture(t);
|
||||||
|
for (const count of [2, 3, 4, 5, 6]) {
|
||||||
|
const lobby = strongholdFactionsLobby(count);
|
||||||
|
const original = structuredClone(lobby);
|
||||||
|
db.prepare('UPDATE rooms SET state=?,version=7 WHERE code=?').run(JSON.stringify(lobby), lobby.code);
|
||||||
|
const seats = db.prepare('SELECT * FROM seats').all();
|
||||||
|
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
|
||||||
|
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||||
|
assert.throws(() => startPrototypeRoom(db, lobby.code, 6, 'stronghold-factions'));
|
||||||
|
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
|
||||||
|
startPrototypeRoom(db, lobby.code, 7, 'stronghold-factions');
|
||||||
|
const row = db.prepare('SELECT state,version FROM rooms WHERE code=?').get(lobby.code)!;
|
||||||
|
const saved = JSON.parse(row.state as string) as Game;
|
||||||
|
assert.equal(saved.version, 8);
|
||||||
|
assert.equal(row.version, 8);
|
||||||
|
assert.equal(saved.status, 'setup');
|
||||||
|
assert.equal(saved.advanced, true);
|
||||||
|
assert.deepEqual(saved.expansions, ['ix', 'choam']);
|
||||||
|
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]),
|
||||||
|
original.players.map(p => [p.id, p.name, p.faction]));
|
||||||
|
assert.deepEqual(saved.playerPositions, original.playerPositions);
|
||||||
|
const cards = [...saved.deck, ...saved.discard, ...(saved.ixSetupCards ?? []), ...saved.players.flatMap(p => p.hand)];
|
||||||
|
assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck(['ix', 'choam']).map(card => card.id).sort());
|
||||||
|
assert.equal(new Set(cards.map(card => card.id)).size, 47);
|
||||||
|
assert.deepEqual(saved.strongholdCards, createStrongholdCards());
|
||||||
|
const ix = saved.players.find(p => p.faction === 'ixians')!;
|
||||||
|
assert.equal((ix.elites?.reserves ?? 0) + (ix.elites?.tanks ?? 0) +
|
||||||
|
Object.values(ix.elites?.forces ?? {}).reduce((sum, amount) => sum + amount, 0), 7);
|
||||||
|
const choam = saved.players.find(p => p.faction === 'choam')!;
|
||||||
|
assert.equal(choam.leaders.filter(leader => leader.id === 'choam-auditor').length, 1);
|
||||||
|
for (const p of saved.players) {
|
||||||
|
const view = viewGame(saved, p.id);
|
||||||
|
assert.deepEqual(view.strongholdCards, saved.strongholdCards);
|
||||||
|
assert.equal('deck' in view, false);
|
||||||
|
for (const otherPlayer of view.players)
|
||||||
|
if (otherPlayer.id !== p.id) assert.equal(otherPlayer.hand, undefined);
|
||||||
|
}
|
||||||
|
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
|
||||||
|
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
|
||||||
|
const started = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||||
|
assert.throws(() => startPrototypeRoom(db, lobby.code, 8, 'stronghold-factions'));
|
||||||
|
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), started);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Stronghold faction admission rejects incompatible modules, history, ownership and public retrofit immutably', t => {
|
||||||
|
const { db } = fixture(t);
|
||||||
|
const native = strongholdFactionsLobby();
|
||||||
|
const variants: ((game: Game) => void)[] = [
|
||||||
|
game => { game.advanced = false; },
|
||||||
|
game => { game.expansions = ['ix']; },
|
||||||
|
game => { game.expansions = ['ix', 'choam', 'ecaz']; },
|
||||||
|
game => { game.players[1] = newPlayer(game.players[1].id, 'Classic', 'guild'); game.players[1].ready = true; },
|
||||||
|
game => { game.players[2] = newPlayer(game.players[2].id, 'Tleilaxu', 'tleilaxu'); game.players[2].ready = true; },
|
||||||
|
game => { game.players[0].ready = false; },
|
||||||
|
game => { game.turn = 2; },
|
||||||
|
game => { game.players[0].battleLosses = 1; },
|
||||||
|
game => { game.strongholdCards = createStrongholdCards(); game.strongholdCards.owners.arrakeen = game.host; },
|
||||||
|
game => { game.strongholdCards = createStrongholdCards(); game.strongholdCards.claimedTurn = 1; },
|
||||||
|
game => { game.nexusCards = { cards: null, phase: null }; },
|
||||||
|
game => { game.leaderSkills = createLeaderSkills(() => 0); },
|
||||||
|
];
|
||||||
|
for (const mutate of variants) {
|
||||||
|
const game = structuredClone(native);
|
||||||
|
mutate(game);
|
||||||
|
db.prepare('UPDATE rooms SET state=?,version=7 WHERE code=?').run(JSON.stringify(game), native.code);
|
||||||
|
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||||
|
const seats = db.prepare('SELECT * FROM seats').all();
|
||||||
|
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'stronghold-factions'));
|
||||||
|
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
|
||||||
|
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
|
||||||
|
}
|
||||||
|
for (const type of ['homeworlds', 'techTokens']) {
|
||||||
|
const game = applyAction(native, native.host, { type, enabled: true });
|
||||||
|
game.players.forEach(player => { player.ready = true; });
|
||||||
|
db.prepare('UPDATE rooms SET state=?,version=7 WHERE code=?').run(JSON.stringify(game), native.code);
|
||||||
|
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||||
|
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'stronghold-factions'));
|
||||||
|
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
|
||||||
|
}
|
||||||
|
const original = structuredClone(native);
|
||||||
|
for (const action of [{ type: 'start', profile: 'stronghold-factions' },
|
||||||
|
{ type: 'initializeStrongholdFactionsGameForAudit' }, { type: 'strongholdFactions', enabled: true }])
|
||||||
|
assert.throws(() => applyAction(native, native.host, action));
|
||||||
|
assert.deepEqual(native, original);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Stronghold factions CLI privately backs up the exact original native lobby and cannot reset or overwrite proof', t => {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'dune-stronghold-factions-'));
|
||||||
|
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const database = join(directory, 'rooms.sqlite');
|
||||||
|
const db = new DatabaseSync(database);
|
||||||
|
db.exec('CREATE TABLE rooms(code TEXT PRIMARY KEY,state TEXT,version INTEGER,updated_at INTEGER); CREATE TABLE seats(room_code TEXT,player_id TEXT,token_hash TEXT);');
|
||||||
|
const lobby = strongholdFactionsLobby(6);
|
||||||
|
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(lobby.code, JSON.stringify(lobby), 7, 100);
|
||||||
|
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run('KEEPGAME', JSON.stringify(harkonnenLobby(false, 2)), 9, 50);
|
||||||
|
for (const p of lobby.players)
|
||||||
|
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(lobby.code, p.id, 'private-native-session-' + p.id);
|
||||||
|
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||||
|
const credentials = db.prepare('SELECT * FROM seats ORDER BY player_id').all();
|
||||||
|
db.close();
|
||||||
|
const output = join(directory, 'private-proof');
|
||||||
|
const invoke = (version: number, out: string) => spawnSync(process.execPath, [
|
||||||
|
'--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)),
|
||||||
|
'--profile', 'stronghold-factions', '--db', database, '--room', lobby.code,
|
||||||
|
'--version', String(version), '--out', out,
|
||||||
|
], { encoding: 'utf8', timeout: 120000 });
|
||||||
|
const result = invoke(7, output);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(statSync(output).mode & 0o777, 0o700);
|
||||||
|
for (const name of ['games.sqlite', 'snapshot.json', 'prototype.json'])
|
||||||
|
assert.equal(statSync(join(output, name)).mode & 0o777, 0o600);
|
||||||
|
const backup = new DatabaseSync(join(output, 'games.sqlite'), { readOnly: true });
|
||||||
|
assert.deepEqual(backup.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
|
||||||
|
assert.deepEqual(backup.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
|
||||||
|
backup.close();
|
||||||
|
const current = new DatabaseSync(database, { readOnly: true });
|
||||||
|
t.after(() => current.close());
|
||||||
|
const after = current.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||||
|
assert.deepEqual(current.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPGAME'), before.find(row => row.code === 'KEEPGAME'));
|
||||||
|
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
|
||||||
|
const row = current.prepare('SELECT state,version FROM rooms WHERE code=?').get(lobby.code)!;
|
||||||
|
const saved = JSON.parse(row.state as string) as Game;
|
||||||
|
assert.equal(row.version, 8);
|
||||||
|
assert.equal(saved.version, 8);
|
||||||
|
assert.equal(saved.status, 'setup');
|
||||||
|
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]),
|
||||||
|
lobby.players.map(p => [p.id, p.name, p.faction]));
|
||||||
|
assert.deepEqual(saved.playerPositions, lobby.playerPositions);
|
||||||
|
assert.deepEqual(saved.strongholdCards, createStrongholdCards());
|
||||||
|
assert.deepEqual([...saved.deck, ...(saved.ixSetupCards ?? []), ...saved.players.flatMap(p => p.hand)].map(card => card.id).sort(),
|
||||||
|
treacheryDeck(['ix', 'choam']).map(card => card.id).sort());
|
||||||
|
assert.notEqual(invoke(8, output).status, 0);
|
||||||
|
assert.notEqual(invoke(7, join(directory, 'stale-proof')).status, 0);
|
||||||
|
assert.notEqual(invoke(8, join(directory, 'redeal-proof')).status, 0);
|
||||||
|
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
|
||||||
|
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
resolveAssassinationBattle,
|
resolveAssassinationBattle,
|
||||||
stageAssassinationBattle,
|
stageAssassinationBattle,
|
||||||
} from './moritani-assassinate-fixture';
|
} from './moritani-assassinate-fixture';
|
||||||
|
import { createStrongholdFactionsFixture } from './fixture-stronghold-factions';
|
||||||
|
|
||||||
void test('Moritani Skills sample custody checks the physical skill and Traitor inventories after genuine setup', () => {
|
void test('Moritani Skills sample custody checks the physical skill and Traitor inventories after genuine setup', () => {
|
||||||
const game = completedMoritaniSkillsGame();
|
const game = completedMoritaniSkillsGame();
|
||||||
@@ -52,6 +53,48 @@ void test('Moritani assassination samples conserve retired Traitors after real b
|
|||||||
duplicated.traitorReserve!.push('guild-1');
|
duplicated.traitorReserve!.push('guild-1');
|
||||||
assert.throws(() => verifySampleCustody(duplicated, inventory), /physical traitor custody/);
|
assert.throws(() => verifySampleCustody(duplicated, inventory), /physical traitor custody/);
|
||||||
});
|
});
|
||||||
|
void test('native Stronghold sample custody survives real end-Mentat acquisition and retained next-turn ownership', () => {
|
||||||
|
const fixture = createStrongholdFactionsFixture();
|
||||||
|
const inventory = sampleInventory(fixture.initial);
|
||||||
|
assert.equal(inventory.cards.length, 47);
|
||||||
|
assert.equal(inventory.strongholds?.length, 6);
|
||||||
|
verifySampleCustody(fixture.beforeFirstMentat, inventory);
|
||||||
|
verifySampleCustody(fixture.afterFirstMentat, inventory, fixture.beforeFirstMentat);
|
||||||
|
assert.equal(fixture.afterFirstMentat.strongholdCards!.claimedTurn, 1);
|
||||||
|
const owner = fixture.afterFirstMentat.strongholdCards!.owners.hidden_mobile_stronghold;
|
||||||
|
assert.ok(owner, 'actual end-Mentat claims the mobile card');
|
||||||
|
verifySampleCustody(fixture.beforeBattle, inventory, fixture.afterFirstMentat);
|
||||||
|
const saved = JSON.parse(JSON.stringify(fixture.game));
|
||||||
|
verifySampleCustody(saved, inventory, fixture.beforeBattle);
|
||||||
|
const missing = structuredClone(fixture.game);
|
||||||
|
delete missing.strongholdCards;
|
||||||
|
assert.throws(() => verifySampleCustody(missing, inventory), /Stronghold Cards module custody/);
|
||||||
|
const missingCard = structuredClone(fixture.game);
|
||||||
|
Reflect.deleteProperty(missingCard.strongholdCards!.owners, 'arrakeen');
|
||||||
|
assert.throws(() => verifySampleCustody(missingCard, inventory), /Each Stronghold Card/);
|
||||||
|
const foreign = structuredClone(fixture.game);
|
||||||
|
foreign.strongholdCards!.owners.arrakeen = 'not-a-seat';
|
||||||
|
assert.throws(() => verifySampleCustody(foreign, inventory), /current seat/);
|
||||||
|
const future = structuredClone(fixture.game);
|
||||||
|
future.strongholdCards!.claimedTurn = future.turn + 1;
|
||||||
|
assert.throws(() => verifySampleCustody(future, inventory), /future/);
|
||||||
|
const transferred = structuredClone(fixture.game);
|
||||||
|
const other = transferred.players.find(player => player.id !== owner)!;
|
||||||
|
transferred.strongholdCards!.owners.hidden_mobile_stronghold = other.id;
|
||||||
|
assert.throws(() => verifySampleCustody(transferred, inventory, fixture.game),
|
||||||
|
/holders must persist/);
|
||||||
|
const missingCyborg = structuredClone(fixture.game);
|
||||||
|
const ixians = missingCyborg.players.find(player => player.faction === 'ixians')!;
|
||||||
|
assert.ok(ixians.elites);
|
||||||
|
if (ixians.elites.reserves > 0) ixians.elites.reserves--;
|
||||||
|
else if (ixians.elites.tanks > 0) ixians.elites.tanks--;
|
||||||
|
else {
|
||||||
|
const location = Object.keys(ixians.elites.forces).find(key => ixians.elites!.forces[key] > 0);
|
||||||
|
assert.ok(location);
|
||||||
|
ixians.elites.forces[location]--;
|
||||||
|
}
|
||||||
|
assert.throws(() => verifySampleCustody(missingCyborg, inventory), /elite custody ixians/);
|
||||||
|
});
|
||||||
|
|
||||||
function fixture(advanced = false) {
|
function fixture(advanced = false) {
|
||||||
let game = createGame(
|
let game = createGame(
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
|
||||||
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { treacheryDeck } from '../game/cards';
|
||||||
|
import type { RoomsClock, SeatAuth } from '../db/rooms';
|
||||||
|
import type * as Rooms from '../db/rooms';
|
||||||
|
import { startPrototypeRoom } from '../tools/prototype-room';
|
||||||
|
import { unitStore } from './fixture-nexus-room-store';
|
||||||
|
import {
|
||||||
|
createStrongholdFactionsFixture,
|
||||||
|
nextStrongholdFactionsNativeStep,
|
||||||
|
quoteStrongholdFactionsBattle,
|
||||||
|
type StrongholdFactionsFixture,
|
||||||
|
type StrongholdFactionsFixtureOptions as Options,
|
||||||
|
} from './fixture-stronghold-factions';
|
||||||
|
|
||||||
|
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||||
|
type SqlRow = Record<string, SQLOutputValue>;
|
||||||
|
interface ProtectedRows {
|
||||||
|
seats: SqlRow[];
|
||||||
|
entries: SqlRow[];
|
||||||
|
keys: SqlRow[];
|
||||||
|
receipts: SqlRow[];
|
||||||
|
}
|
||||||
|
interface Fixture extends StrongholdFactionsFixture {
|
||||||
|
sqlite: DatabaseSync;
|
||||||
|
restart(): typeof Rooms;
|
||||||
|
hooks: { beforeWrite?: () => Promise<void> };
|
||||||
|
code: string;
|
||||||
|
tokens: string[];
|
||||||
|
auths: SeatAuth[];
|
||||||
|
save(game: Game): void;
|
||||||
|
admitted: Game;
|
||||||
|
unrelated: SqlRow | undefined;
|
||||||
|
other: { view: { code: string } };
|
||||||
|
credentials: ProtectedRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function protectedRows(sqlite: DatabaseSync) {
|
||||||
|
return {
|
||||||
|
seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(),
|
||||||
|
entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id,operation_hash').all(),
|
||||||
|
keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code,player_id').all(),
|
||||||
|
receipts: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code,player_id,operation_hash').all(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function census(game: Game) {
|
||||||
|
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||||
|
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
|
||||||
|
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])];
|
||||||
|
assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck(['ix', 'choam']).map(card => card.id).sort());
|
||||||
|
assert.equal(new Set(cards.map(card => card.id)).size, 47);
|
||||||
|
for (const player of game.players) {
|
||||||
|
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
||||||
|
if (player.faction === 'ixians') {
|
||||||
|
assert.ok(player.elites);
|
||||||
|
assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0), 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixture(t: test.TestContext, options: Options = {}): Promise<Fixture> {
|
||||||
|
const store = unitStore();
|
||||||
|
t.after(() => store.sqlite.close());
|
||||||
|
const created = await store.rooms.createRoom('Native Ix custody', 'ixians', true, ['ix', 'choam']);
|
||||||
|
const code = created.view.code;
|
||||||
|
const tokens = [created.token];
|
||||||
|
for (const faction of ['choam', 'atreides'] as const) {
|
||||||
|
const joined = await store.rooms.joinRoom(code, faction, faction);
|
||||||
|
assert.ok(joined.token);
|
||||||
|
tokens.push(joined.token);
|
||||||
|
}
|
||||||
|
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||||
|
for (const auth of auths) {
|
||||||
|
const lobby = await store.restart().readRoom(code);
|
||||||
|
await store.restart().act(code, auth, lobby.version, { type: 'ready' }, clock);
|
||||||
|
}
|
||||||
|
const lobby = await store.restart().readRoom(code);
|
||||||
|
startPrototypeRoom(store.sqlite, code, lobby.version, 'stronghold-factions');
|
||||||
|
const admitted = await store.restart().readRoom(code);
|
||||||
|
const native = createStrongholdFactionsFixture({ ...options, initial: admitted });
|
||||||
|
assert.deepEqual(native.initial, admitted, 'fixture consumes the exact original CLI-admitted setup without redealing');
|
||||||
|
assert.deepEqual(native.afterSetup.playerPositions, admitted.playerPositions);
|
||||||
|
assert.deepEqual(native.afterSetup.players.map(p => [p.id, p.name, p.faction]), admitted.players.map(p => [p.id, p.name, p.faction]));
|
||||||
|
census(admitted);
|
||||||
|
census(native.game);
|
||||||
|
const other = await store.rooms.createRoom('Unrelated retained room', 'guild', false, []);
|
||||||
|
const unrelated = store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(other.view.code);
|
||||||
|
const credentials = protectedRows(store.sqlite);
|
||||||
|
const save = (game: Game) => {
|
||||||
|
assert.equal(game.code, code);
|
||||||
|
assert.equal(store.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
|
||||||
|
.run(JSON.stringify(game), game.version, code).changes, 1);
|
||||||
|
};
|
||||||
|
save(native.beforeFirstMentat);
|
||||||
|
return { ...store, ...native, code, tokens, auths, save, admitted, unrelated, other, credentials };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticated(f: Fixture, actor: string) {
|
||||||
|
const index = f.auths.findIndex(auth => auth.playerId === actor);
|
||||||
|
assert.ok(index >= 0, 'native action belongs to an original credential');
|
||||||
|
const auth = await f.restart().authenticate(f.code, f.tokens[index]);
|
||||||
|
assert.equal(auth.playerId, actor);
|
||||||
|
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function act(f: Fixture, game: Game, actor: string, action: Action) {
|
||||||
|
await f.restart().act(f.code, await authenticated(f, actor), game.version, action, clock);
|
||||||
|
const after = await f.restart().readRoom(f.code);
|
||||||
|
census(after);
|
||||||
|
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.other.view.code), f.unrelated);
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejected(f: Fixture, actor: string, action: Action, version?: number) {
|
||||||
|
const before = await f.restart().readRoom(f.code);
|
||||||
|
const rows = f.sqlite.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||||
|
const credentials = protectedRows(f.sqlite);
|
||||||
|
await assert.rejects(f.restart().act(f.code, await authenticated(f, actor), version ?? before.version, action, clock));
|
||||||
|
assert.deepEqual(await f.restart().readRoom(f.code), before);
|
||||||
|
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
|
||||||
|
assert.deepEqual(protectedRows(f.sqlite), credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function race(f: Fixture, game: Game, actor: string, actions: [Action, Action]) {
|
||||||
|
const auth = await authenticated(f, actor);
|
||||||
|
let release!: () => void;
|
||||||
|
let arrivals = 0;
|
||||||
|
const barrier = new Promise<void>(resolve => { release = resolve; });
|
||||||
|
f.hooks.beforeWrite = async () => {
|
||||||
|
if (++arrivals === 2) release();
|
||||||
|
await barrier;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(actions.map(action => f.restart().act(f.code, auth, game.version, action, clock)));
|
||||||
|
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||||
|
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
|
||||||
|
const after = await f.restart().readRoom(f.code);
|
||||||
|
assert.equal(after.version, game.version + 1);
|
||||||
|
census(after);
|
||||||
|
return after;
|
||||||
|
} finally {
|
||||||
|
f.hooks.beforeWrite = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drive(f: Fixture, game: Game, stop: (game: Game) => boolean) {
|
||||||
|
for (let count = 0; count < 250; count++) {
|
||||||
|
if (stop(game)) return game;
|
||||||
|
const next = game.decision?.kind === 'choamBattleFunding' && f.fundingAction
|
||||||
|
? f.fundingAction
|
||||||
|
: nextStrongholdFactionsNativeStep(game);
|
||||||
|
assert.ok(next, 'real native continuation reaches the requested boundary');
|
||||||
|
game = await act(f, game, next.actor, next.action);
|
||||||
|
}
|
||||||
|
throw new Error('Native suffix did not reach its boundary');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBattle(f: Fixture) {
|
||||||
|
f.save(f.beforeBattle);
|
||||||
|
let game = await f.restart().readRoom(f.code);
|
||||||
|
game = await act(f, game, f.actor, f.battleAction);
|
||||||
|
return drive(f, game, g => g.decision?.kind === 'strongholdCopy' || nextStrongholdFactionsNativeStep(g) === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function privateViews(f: Fixture, game: Game) {
|
||||||
|
for (const auth of f.auths) {
|
||||||
|
const view = await f.restart().readSeatView(f.code, await authenticated(f, auth.playerId));
|
||||||
|
assert.deepEqual(view, viewGame(game, auth.playerId));
|
||||||
|
assert.deepEqual(view.strongholdCards, game.strongholdCards);
|
||||||
|
assert.equal('deck' in view, false);
|
||||||
|
for (const player of view.players)
|
||||||
|
if (player.id !== auth.playerId) assert.equal(player.hand, undefined);
|
||||||
|
if (game.battle && !game.battle.revealed) {
|
||||||
|
assert.deepEqual(view.battle!.cards, []);
|
||||||
|
assert.deepEqual(view.battle!.plans, game.battle.plans[auth.playerId] ? { [auth.playerId]: game.battle.plans[auth.playerId] } : {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('authenticated native END Mentat first claims custody once under duplicate CAS and retains the original seats and 47-card inventory', async t => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const before = await f.restart().readRoom(f.code);
|
||||||
|
assert.deepEqual(before.strongholdCards, f.admitted.strongholdCards);
|
||||||
|
assert.equal(before.strongholdCards!.claimedTurn, 0);
|
||||||
|
assert.ok(Object.values(before.strongholdCards!.owners).every(owner => owner === null));
|
||||||
|
const { actor, action } = f.firstMentatStep;
|
||||||
|
const after = await race(f, before, actor, [action, action]);
|
||||||
|
assert.deepEqual(after.strongholdCards, f.afterFirstMentat.strongholdCards);
|
||||||
|
assert.equal(after.strongholdCards!.claimedTurn, before.turn);
|
||||||
|
assert.equal(after.strongholdCards!.owners.hidden_mobile_stronghold, f.owner);
|
||||||
|
assert.deepEqual(protectedRows(f.sqlite), f.credentials);
|
||||||
|
await rejected(f, actor, action, before.version);
|
||||||
|
await privateViews(f, after);
|
||||||
|
f.save(f.beforeBattle);
|
||||||
|
assert.deepEqual(f.beforeBattle.strongholdCards, after.strongholdCards, 'custody is retained next turn, not reassigned from current battle control');
|
||||||
|
await privateViews(f, await f.restart().readRoom(f.code));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('actual native HMS copy and sealed plans survive restart and credential recovery; foreign, stale and competing choices are immutable', async t => {
|
||||||
|
const f = await fixture(t, { ownerDial: 6, support: 3, opponentDial: 1.5, copy: 'arrakeen' });
|
||||||
|
const pending = await openBattle(f);
|
||||||
|
assert.equal(pending.decision?.kind, 'strongholdCopy');
|
||||||
|
assert.ok(f.copyAction);
|
||||||
|
const copyAction = { ...f.copyAction, event: pending.battle!.event };
|
||||||
|
if (pending.decision?.kind !== 'strongholdCopy' || f.game.decision?.kind !== 'strongholdCopy')
|
||||||
|
throw new Error('Native HMS copy choice missing');
|
||||||
|
assert.deepEqual(pending.decision.choices, f.game.decision.choices);
|
||||||
|
assert.equal(pending.decision.player, f.owner);
|
||||||
|
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||||
|
assert.deepEqual(await f.restart().readRoom(f.code), pending);
|
||||||
|
await privateViews(f, pending);
|
||||||
|
await rejected(f, f.opponent, copyAction);
|
||||||
|
await rejected(f, f.owner, { ...copyAction, event: 'foreign-stale-event' });
|
||||||
|
await rejected(f, f.owner, { ...copyAction, stronghold: 'carthag' });
|
||||||
|
await rejected(f, f.owner, { ...copyAction, stronghold: 17 });
|
||||||
|
await rejected(f, f.opponent, f.planActions[1].action);
|
||||||
|
const recoverySecret = 'a'.repeat(64), newSessionToken = 'b'.repeat(64);
|
||||||
|
const index = f.auths.findIndex(auth => auth.playerId === f.owner);
|
||||||
|
await f.restart().setRecoveryKey(f.code, f.auths[index], pending.version, recoverySecret);
|
||||||
|
const secured = await f.restart().readRoom(f.code);
|
||||||
|
await f.restart().recoverSeat(f.code, {
|
||||||
|
playerId: f.owner, recoverySecret,
|
||||||
|
operationId: 'ac61a3f0-8dbe-4b15-9f27-a1d55329249c', newSessionToken,
|
||||||
|
});
|
||||||
|
await assert.rejects(f.restart().authenticate(f.code, f.tokens[index]));
|
||||||
|
f.tokens[index] = newSessionToken;
|
||||||
|
f.auths[index] = await f.restart().authenticate(f.code, newSessionToken);
|
||||||
|
const recovered = await f.restart().readRoom(f.code);
|
||||||
|
assert.equal(recovered.version, secured.version + 1);
|
||||||
|
assert.deepEqual(recovered.decision, pending.decision);
|
||||||
|
assert.deepEqual(recovered.players, pending.players);
|
||||||
|
assert.deepEqual(recovered.battle, pending.battle);
|
||||||
|
assert.deepEqual(recovered.strongholdCards, pending.strongholdCards);
|
||||||
|
const chosen = await race(f, recovered, f.owner, [copyAction, { ...copyAction, stronghold: 'sietch_tabr' }]);
|
||||||
|
assert.ok(['arrakeen', 'sietch_tabr'].includes(chosen.battle!.strongholdCopy!));
|
||||||
|
assert.deepEqual(chosen.strongholdCards, pending.strongholdCards);
|
||||||
|
await rejected(f, f.owner, copyAction, recovered.version);
|
||||||
|
let ready = await drive(f, chosen, g => nextStrongholdFactionsNativeStep(g) === null);
|
||||||
|
const first = f.planActions[0];
|
||||||
|
ready = await act(f, ready, first.actor, first.action);
|
||||||
|
assert.equal(ready.battle!.revealed, false);
|
||||||
|
await privateViews(f, ready);
|
||||||
|
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||||
|
assert.deepEqual(await f.restart().readRoom(f.code), ready);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const funding of [0, 1]) {
|
||||||
|
void test(`actual native support with ${funding} CHOAM donor spice, income, casualties, Collection and final Mentat settle once after restart and duplicate resolution`, async t => {
|
||||||
|
const f = await fixture(t, { ownerDial: 6, support: 3, opponentDial: 1.5, copy: 'arrakeen', counter: true, choamFunding: funding, allyPayment: funding });
|
||||||
|
let game = await openBattle(f);
|
||||||
|
assert.ok(f.copyAction);
|
||||||
|
game = await act(f, game, f.owner, { ...f.copyAction, event: game.battle!.event });
|
||||||
|
game = await drive(f, game, g => nextStrongholdFactionsNativeStep(g) === null);
|
||||||
|
for (const plan of f.planActions) game = await act(f, game, plan.actor, plan.action);
|
||||||
|
assert.equal(game.battle!.revealed, true);
|
||||||
|
const quote = quoteStrongholdFactionsBattle(game);
|
||||||
|
const wallets = Object.fromEntries(game.players.map(player => [player.id, player.spice]));
|
||||||
|
const winnerTanks = game.players.find(player => player.id === f.owner)!.tanks;
|
||||||
|
const winnerEliteTanks = game.players.find(player => player.id === f.owner)!.elites!.tanks;
|
||||||
|
const payment = quote.payments.find(leg => leg.player === f.owner)!;
|
||||||
|
assert.equal(payment.bankSupport, 2);
|
||||||
|
assert.equal(payment.cost, 1);
|
||||||
|
assert.equal(payment.ownPayment, 1 - funding);
|
||||||
|
assert.equal(payment.allyPayment, funding);
|
||||||
|
assert.equal(payment.donor, funding ? f.choam : null);
|
||||||
|
assert.equal(quote.choamIncome!.owner, f.choam);
|
||||||
|
assert.equal(quote.choamIncome!.amount, 1);
|
||||||
|
const custody = structuredClone(game.strongholdCards);
|
||||||
|
// Resolve only native response/traitor actions, racing the actual final action
|
||||||
|
// that retires this battle rather than inserting a fabricated aftermath frame.
|
||||||
|
for (let count = 0; count < 100 && game.battle; count++) {
|
||||||
|
const next = nextStrongholdFactionsNativeStep(game);
|
||||||
|
assert.ok(next);
|
||||||
|
const predicted = applyAction(game, next.actor, next.action);
|
||||||
|
game = predicted.battle === null
|
||||||
|
? await race(f, game, next.actor, [next.action, next.action])
|
||||||
|
: await act(f, game, next.actor, next.action);
|
||||||
|
}
|
||||||
|
assert.equal(game.battle, null);
|
||||||
|
assert.deepEqual(game.strongholdCards, custody);
|
||||||
|
for (const leg of quote.payments) wallets[leg.player] -= leg.ownPayment;
|
||||||
|
if (quote.bounty) wallets[quote.bounty.player] += quote.bounty.amount;
|
||||||
|
for (const income of quote.strongholdIncome) wallets[income.player] += income.amount;
|
||||||
|
game = await drive(f, game, g => g.response?.kind === 'choamBattleIncome');
|
||||||
|
assert.deepEqual(game.pendingChoamBattleIncome, quote.choamIncome);
|
||||||
|
assert.deepEqual(Object.fromEntries(game.players.map(player => [player.id, player.spice])), wallets,
|
||||||
|
'native bank support is not wallet cash and CHOAM income is still deferred');
|
||||||
|
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||||
|
assert.deepEqual(await f.restart().readRoom(f.code), game);
|
||||||
|
await privateViews(f, game);
|
||||||
|
// Pass the actual counter suffix; only its final eligible response transfers
|
||||||
|
// the original native receipt, and concurrent submissions cannot pay twice.
|
||||||
|
for (let count = 0; count < 20 && game.response?.kind === 'choamBattleIncome'; count++) {
|
||||||
|
const next = nextStrongholdFactionsNativeStep(game);
|
||||||
|
assert.ok(next);
|
||||||
|
const predicted = applyAction(game, next.actor, next.action);
|
||||||
|
game = predicted.pendingChoamBattleIncome === null
|
||||||
|
? await race(f, game, next.actor, [next.action, next.action])
|
||||||
|
: await act(f, game, next.actor, next.action);
|
||||||
|
}
|
||||||
|
assert.equal(game.pendingChoamBattleIncome, null);
|
||||||
|
wallets[f.choam] += quote.choamIncome!.amount;
|
||||||
|
assert.deepEqual(Object.fromEntries(game.players.map(player => [player.id, player.spice])), wallets);
|
||||||
|
game = await drive(f, game, g => g.phase === 8 && !g.response && !g.decision);
|
||||||
|
assert.ok(quote.casualties);
|
||||||
|
const losses = quote.casualties.options[0];
|
||||||
|
const winner = game.players.find(player => player.id === f.owner)!;
|
||||||
|
assert.equal(winner.tanks, winnerTanks + losses.normal + losses.elite);
|
||||||
|
assert.equal(winner.elites!.tanks, winnerEliteTanks + losses.elite);
|
||||||
|
const turn = game.turn;
|
||||||
|
for (let count = 0; count < 30 && game.turn === turn && game.status === 'playing'; count++) {
|
||||||
|
const next = nextStrongholdFactionsNativeStep(game);
|
||||||
|
assert.ok(next);
|
||||||
|
const predicted = applyAction(game, next.actor, next.action);
|
||||||
|
if (predicted.turn !== turn || predicted.status !== 'playing') {
|
||||||
|
const before = game;
|
||||||
|
game = await race(f, before, next.actor, [next.action, next.action]);
|
||||||
|
await rejected(f, next.actor, next.action, before.version);
|
||||||
|
} else game = await act(f, game, next.actor, next.action);
|
||||||
|
}
|
||||||
|
assert.ok(game.turn === turn + 1 || game.status === 'finished');
|
||||||
|
const settled = game;
|
||||||
|
await privateViews(f, settled);
|
||||||
|
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||||
|
assert.deepEqual(await f.restart().readRoom(f.code), settled);
|
||||||
|
assert.deepEqual(protectedRows(f.sqlite), f.credentials);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('actual native Auditor payment retains source, original full price and private projection through reload and a competing payment race', async t => {
|
||||||
|
const f = await fixture(t, { kind: 'carthag', ownerFaction: 'choam', ownerLeader: 'auditor', opponentWeapon: 'worthless', ownerDial: 3, support: 3, opponentDial: 1.5, counter: true });
|
||||||
|
let game = await openBattle(f);
|
||||||
|
game = await drive(f, game, g => nextStrongholdFactionsNativeStep(g) === null);
|
||||||
|
for (const plan of f.planActions) game = await act(f, game, plan.actor, plan.action);
|
||||||
|
game = await drive(f, game, g => g.decision?.kind === 'choamAudit');
|
||||||
|
const event = game.pendingAuditor!.event;
|
||||||
|
game = await act(f, game, f.choam, { type: 'decision', event, audit: true });
|
||||||
|
game = await drive(f, game, g => g.decision?.kind === 'choamAuditPayment');
|
||||||
|
const pending = game.pendingAuditor!;
|
||||||
|
assert.equal(pending.stage, 'payment');
|
||||||
|
const payer = game.players.find(player => player.id === pending.opponent)!;
|
||||||
|
const count = viewGame(game, pending.opponent).auditor!.count;
|
||||||
|
assert.ok(count > 0);
|
||||||
|
const payment: Action = { type: 'decision', event, pay: true, count };
|
||||||
|
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||||
|
assert.deepEqual(await f.restart().readRoom(f.code), game);
|
||||||
|
await privateViews(f, game);
|
||||||
|
await rejected(f, f.choam, payment);
|
||||||
|
await rejected(f, pending.opponent, { ...payment, event: 'stale-audit' });
|
||||||
|
await rejected(f, pending.opponent, { ...payment, count: count + 1 });
|
||||||
|
const before = game;
|
||||||
|
const after = await race(f, before, pending.opponent, [payment, payment]);
|
||||||
|
assert.equal(after.pendingAuditor, null);
|
||||||
|
assert.equal(after.players.find(player => player.id === pending.opponent)!.spice, payer.spice - count);
|
||||||
|
assert.equal(after.players.find(player => player.id === f.choam)!.spice,
|
||||||
|
before.players.find(player => player.id === f.choam)!.spice + count);
|
||||||
|
assert.deepEqual(after.strongholdCards, before.strongholdCards);
|
||||||
|
await rejected(f, pending.opponent, payment, before.version);
|
||||||
|
await privateViews(f, after);
|
||||||
|
assert.deepEqual(protectedRows(f.sqlite), f.credentials);
|
||||||
|
});
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, mobileRoutes, splitLocation, territory } from '../game/board';
|
||||||
|
import { isAuditorLeader, treacheryDeck } from '../game/cards';
|
||||||
|
import { STRONGHOLD_CARDS, strongholdControllers } from '../game/stronghold-cards';
|
||||||
|
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||||
|
import { createStrongholdFactionsFixture, nextStrongholdFactionsNativeStep, quoteStrongholdFactionsBattle, type StrongholdFactionsFixture, type StrongholdFactionsFixtureOptions } from './fixture-stronghold-factions';
|
||||||
|
|
||||||
|
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||||
|
const player = (game: Game, id: string) => game.players.find(p => p.id === id)!;
|
||||||
|
function inventory(game: Game) {
|
||||||
|
return [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
|
||||||
|
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
|
||||||
|
}
|
||||||
|
function custody(game: Game) {
|
||||||
|
assert.deepEqual(inventory(game), treacheryDeck(['ix', 'choam']).map(c => c.id).sort());
|
||||||
|
for (const seat of game.players) {
|
||||||
|
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, n) => sum + n, 0), 20, seat.faction);
|
||||||
|
if (seat.elites) assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, n) => sum + n, 0), seat.faction === 'ixians' ? 7 : seat.faction === 'emperor' ? 5 : 3, seat.faction);
|
||||||
|
for (const [key, count] of Object.entries(seat.elites?.forces ?? {})) assert.ok(count <= seat.forces[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function prepared(fixture: StrongholdFactionsFixture): Game {
|
||||||
|
let game = reload(fixture.game);
|
||||||
|
if (fixture.copyAction) game = applyAction(game, fixture.owner, fixture.copyAction);
|
||||||
|
for (let count = 0; count < 120; count++) {
|
||||||
|
const next = nextStrongholdFactionsNativeStep(game);
|
||||||
|
if (!next) { assert.ok(game.battle && !game.battle.revealed); return game; }
|
||||||
|
if (game.decision?.kind === 'choamBattleFunding' && fixture.fundingAction) game = applyAction(game, fixture.fundingAction.actor, fixture.fundingAction.action);
|
||||||
|
else game = applyAction(game, next.actor, next.action);
|
||||||
|
}
|
||||||
|
throw Error('Native plan boundary did not open');
|
||||||
|
}
|
||||||
|
function reveal(fixture: StrongholdFactionsFixture): Game {
|
||||||
|
let game = prepared(fixture);
|
||||||
|
for (const plan of fixture.planActions) {
|
||||||
|
game = applyAction(game, plan.actor, plan.action);
|
||||||
|
while (game.response || game.decision?.kind === 'fullPlanOffer') {
|
||||||
|
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
|
||||||
|
game = applyAction(game, next.actor, next.action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(game.battle?.revealed);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
function finish(fixture: StrongholdFactionsFixture, state = reveal(fixture), callers: string[] = []) {
|
||||||
|
let game = state;
|
||||||
|
for (let count = 0; count < 160; count++) {
|
||||||
|
if (!game.battle && !game.decision && !game.response && !game.pendingTreacheryDiscard) return game;
|
||||||
|
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
|
||||||
|
if (next.action.type === 'traitorCall') next.action.call = callers.includes(next.actor);
|
||||||
|
game = applyAction(reload(game), next.actor, next.action);
|
||||||
|
custody(game);
|
||||||
|
}
|
||||||
|
throw Error('Actual native battle suffix did not finish');
|
||||||
|
}
|
||||||
|
/** Verify source money independently. Ix opening must close before native
|
||||||
|
* Collection pays bank stronghold income or removes any board deposits. */
|
||||||
|
function money(fixture: StrongholdFactionsFixture, revealed: Game, resolved: Game) {
|
||||||
|
assert.deepEqual(resolved.strongholdCards, fixture.beforeBattle.strongholdCards, 'battle loss does not transfer retained card custody before final Mentat');
|
||||||
|
const quote = quoteStrongholdFactionsBattle(revealed);
|
||||||
|
const collection = resolved.phase === 7 && !resolved.phaseOpening ? quoteSpiceCollection({ ...resolved, players: resolved.players.map(p => ({ ...p, spice: 0 })) }).receipts : [];
|
||||||
|
for (const seat of revealed.players) {
|
||||||
|
const ownPayment = quote.payments.filter(p => p.player === seat.id).reduce((sum, p) => sum + p.ownPayment, 0);
|
||||||
|
const stronghold = quote.strongholdIncome.filter(p => p.player === seat.id).reduce((sum, p) => sum + p.amount, 0);
|
||||||
|
const bounty = quote.bounty?.player === seat.id ? quote.bounty.amount : 0;
|
||||||
|
const choam = quote.choamIncome?.owner === seat.id ? quote.choamIncome.amount : 0;
|
||||||
|
const collected = collection.filter(p => p.player === seat.id).reduce((sum, p) => sum + p.strongholds + p.collected, 0);
|
||||||
|
assert.equal(player(resolved, seat.id).spice, seat.spice - ownPayment + stronghold + bounty + choam + collected, `${seat.faction}: own debit, module income, native bounty, CHOAM and Collection remain separate`);
|
||||||
|
}
|
||||||
|
assert.equal(resolved.lastBattleContext?.winner, quote.winner);
|
||||||
|
assert.deepEqual(normalizeAutomaticGame(reload(resolved)), resolved, 'JSON normalization cannot repay a resolved native source');
|
||||||
|
return quote;
|
||||||
|
}
|
||||||
|
function effect(options: StrongholdFactionsFixtureOptions) {
|
||||||
|
const fixture = createStrongholdFactionsFixture(options);
|
||||||
|
const revealed = reveal(fixture);
|
||||||
|
const resolved = finish(fixture, revealed);
|
||||||
|
const quote = money(fixture, revealed, resolved);
|
||||||
|
return { fixture, revealed, resolved, quote };
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('fresh native 2–6 seats conserve canonical 47 cards, twenty counters, native stars, Auditor and six initially unowned Stronghold Cards', () => {
|
||||||
|
for (let count = 2; count <= 6; count++) {
|
||||||
|
const fixture = createStrongholdFactionsFixture({ seatIds: Array.from({ length: count }, (_, index) => `seat-${count}-${index}`) });
|
||||||
|
custody(fixture.initial); custody(fixture.afterSetup); custody(fixture.beforeBattle);
|
||||||
|
assert.equal(fixture.afterSetup.players.find(p => p.faction === 'ixians')!.elites!.reserves, 4);
|
||||||
|
assert.equal(fixture.afterSetup.players.find(p => p.faction === 'ixians')!.elites!.forces[MOBILE_LOCATION], 3);
|
||||||
|
const choam = fixture.afterSetup.players.find(p => p.faction === 'choam')!;
|
||||||
|
assert.equal(choam.leaders.filter(isAuditorLeader).length, 1);
|
||||||
|
assert.equal(choam.leaders.filter(l => !isAuditorLeader(l)).length, 5);
|
||||||
|
assert.equal(Object.keys(fixture.initial.strongholdCards!.owners).length, 6);
|
||||||
|
assert.ok(Object.values(fixture.initial.strongholdCards!.owners).every(owner => owner === null));
|
||||||
|
assert.equal(fixture.initial.strongholdCards!.claimedTurn, 0);
|
||||||
|
assert.equal(fixture.afterFirstMentat.status, 'playing', 'first custody must not prematurely end a three-hold fixture');
|
||||||
|
assert.equal(fixture.afterFirstMentat.turn, 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('original CLI setup remains immutable and first custody is produced by the actual final Mentat action, not a redeal or assigned owner', () => {
|
||||||
|
const original = createStrongholdFactionsFixture().initial;
|
||||||
|
const before = reload(original);
|
||||||
|
const fixture = createStrongholdFactionsFixture({ initial: original });
|
||||||
|
assert.deepEqual(original, before);
|
||||||
|
assert.deepEqual(fixture.initial, before);
|
||||||
|
assert.equal(fixture.beforeFirstMentat.phase, 8);
|
||||||
|
assert.equal(fixture.beforeFirstMentat.strongholdCards!.claimedTurn, 0);
|
||||||
|
const settled = applyAction(reload(fixture.beforeFirstMentat), fixture.firstMentatStep.actor, fixture.firstMentatStep.action);
|
||||||
|
assert.equal(settled.turn, 2);
|
||||||
|
assert.deepEqual(settled.strongholdCards, fixture.afterFirstMentat.strongholdCards);
|
||||||
|
assert.deepEqual(settled.strongholdCards!.owners, strongholdControllers(fixture.beforeFirstMentat.players, true));
|
||||||
|
for (const card of STRONGHOLD_CARDS) assert.equal(fixture.beforeBattle.strongholdCards!.owners[card.id], settled.strongholdCards!.owners[card.id]);
|
||||||
|
const absent = createStrongholdFactionsFixture({ initial: original, copyChoices: 0 });
|
||||||
|
assert.equal(absent.beforeBattle.strongholdCards!.owners.arrakeen, absent.owner);
|
||||||
|
assert.equal(strongholdControllers(absent.beforeBattle.players, true).arrakeen, null);
|
||||||
|
assert.equal(absent.game.battle!.strongholdCopy, undefined, 'departed card custody alone supplies no mobile copy');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Arrakeen subsidy is capped by actual native Cyborg support, never becomes cash, and CHOAM receives the bank-funded share', () => {
|
||||||
|
for (const support of [0, 1, 2, 3]) {
|
||||||
|
const { fixture, revealed, quote } = effect({ kind: 'arrakeen', ownerDial: support * 2, support, ownerSpice: Math.max(0, support - 2) });
|
||||||
|
const payment = quote.payments.find(p => p.player === fixture.owner)!;
|
||||||
|
assert.equal(payment.bankSupport, Math.min(2, support));
|
||||||
|
assert.equal(payment.ownPayment, Math.max(0, support - 2));
|
||||||
|
assert.equal(quote.choamIncome?.amount ?? 0, Math.floor(support / 2));
|
||||||
|
assert.equal(player(revealed, fixture.owner).spice, Math.max(0, support - 2), 'the plan seals without crediting two spendable spice');
|
||||||
|
assert.equal(viewGame(revealed, fixture.owner).battle!.ownForces!.normalFixedHalf, true);
|
||||||
|
}
|
||||||
|
const fixture = createStrongholdFactionsFixture({ kind: 'arrakeen', ownerDial: 6, support: 3, ownerSpice: 0 });
|
||||||
|
const ready = prepared(fixture), before = reload(ready);
|
||||||
|
assert.throws(() => applyAction(ready, fixture.owner, fixture.planActions[0].action));
|
||||||
|
assert.deepEqual(ready, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native CHOAM donor escrow excludes its own donation from income but includes Arrakeen bank support, with no wallet subsidy', () => {
|
||||||
|
const fixture = createStrongholdFactionsFixture({ kind: 'arrakeen', ownerFaction: 'guild', ownerDial: 4, support: 4, allyPayment: 2, choamFunding: 2, ownerSpice: 0 });
|
||||||
|
const ready = prepared(fixture);
|
||||||
|
assert.equal(player(ready, fixture.owner).ally, fixture.choam);
|
||||||
|
assert.equal(player(ready, fixture.choam).ally, fixture.owner);
|
||||||
|
assert.equal(player(ready, fixture.choam).spice, 18, 'original native funding action actually paid escrow');
|
||||||
|
assert.equal(ready.aid[fixture.choam].amount, 2);
|
||||||
|
const revealed = reveal(fixture), resolved = finish(fixture, revealed), quote = money(fixture, revealed, resolved);
|
||||||
|
assert.deepEqual(quote.payments.find(p => p.player === fixture.owner), { player: fixture.owner, cost: 2, ownPayment: 0, allyPayment: 2, donor: fixture.choam, bankSupport: 2, freeByTraitor: false });
|
||||||
|
assert.equal(quote.choamIncome!.amount, 1, 'bank two earns one; native own-donor two earns nothing');
|
||||||
|
assert.equal(resolved.aid[fixture.choam]?.amount ?? 0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Carthag native physical defense gains poison coverage only without a poison-role weapon; Shield still explodes and Tooth still kills', () => {
|
||||||
|
for (const scenario of [
|
||||||
|
{ defense: 'shield', own: undefined, enemy: 'poison', dead: false, explosion: false },
|
||||||
|
{ defense: 'weirdingWay', own: 'projectile', enemy: 'poison', dead: false, explosion: false },
|
||||||
|
{ defense: 'shield', own: 'poison', enemy: 'poison', dead: true, explosion: false },
|
||||||
|
{ defense: 'shield', own: 'chemistry', enemy: 'poison', dead: true, explosion: false },
|
||||||
|
{ defense: 'worthless', own: undefined, enemy: 'poison', dead: true, explosion: false },
|
||||||
|
{ defense: 'shield', own: undefined, enemy: 'poisonTooth', dead: true, explosion: false },
|
||||||
|
{ defense: 'shield', own: undefined, enemy: 'lasgun', dead: true, explosion: true },
|
||||||
|
] as const) {
|
||||||
|
const fixture = createStrongholdFactionsFixture({ kind: 'carthag', ownerDefense: scenario.defense, ownerWeapon: scenario.own, opponentWeapon: scenario.enemy });
|
||||||
|
let revealed = reveal(fixture);
|
||||||
|
while (revealed.decision || revealed.response) {
|
||||||
|
const next = nextStrongholdFactionsNativeStep(revealed); assert.ok(next);
|
||||||
|
revealed = applyAction(revealed, next.actor, next.action);
|
||||||
|
}
|
||||||
|
const resolved = finish(fixture, revealed), quote = money(fixture, revealed, resolved);
|
||||||
|
const leader = String(fixture.planActions[0].action.leader);
|
||||||
|
assert.equal(player(resolved, fixture.owner).leaders.find(l => l.id === leader)!.dead, scenario.dead, JSON.stringify(scenario));
|
||||||
|
assert.equal(quote.explosion, scenario.explosion);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Habbanya native holder wins a printed-supported tie as defender, but ties do not override mutual traitors', () => {
|
||||||
|
const { fixture, revealed, resolved, quote } = effect({ kind: 'habbanya_ridge_sietch', ownerFaction: 'guild' });
|
||||||
|
assert.equal(revealed.battle!.defender, fixture.owner);
|
||||||
|
assert.equal(quote.scores!.attacker, quote.scores!.defender);
|
||||||
|
assert.equal(quote.winner, fixture.owner);
|
||||||
|
assert.equal(player(resolved, fixture.opponent).forces[`habbanya_ridge_sietch:${territory('habbanya_ridge_sietch').sectors[0]}`], undefined);
|
||||||
|
const mutual = createStrongholdFactionsFixture({ kind: 'habbanya_ridge_sietch', ownerFaction: 'guild', traitors: 'both' });
|
||||||
|
const ended = finish(mutual, reveal(mutual), [mutual.owner, mutual.opponent]);
|
||||||
|
assert.equal(ended.lastBattleContext!.winner, null);
|
||||||
|
custody(ended);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Tabr pays floor of opposing declared fractional dial once; native traitor bounty is separate and explosions pay no winner', () => {
|
||||||
|
const ordinary = effect({ kind: 'sietch_tabr', ownerDial: 6, support: 3, opponentDial: 1.5 });
|
||||||
|
assert.deepEqual(ordinary.quote.strongholdIncome, [{ player: ordinary.fixture.owner, amount: 1 }]);
|
||||||
|
const traitor = createStrongholdFactionsFixture({ kind: 'sietch_tabr', ownerDial: 6, support: 3, opponentDial: 1.5, traitors: 'owner' });
|
||||||
|
const ended = finish(traitor, reveal(traitor), [traitor.owner]);
|
||||||
|
assert.equal(ended.lastBattleContext!.winner, traitor.owner);
|
||||||
|
const explosion = effect({ kind: 'sietch_tabr', opponentDial: 1.5, ownerDefense: 'shield', opponentWeapon: 'lasgun' });
|
||||||
|
assert.equal(explosion.quote.winner, null);
|
||||||
|
assert.deepEqual(explosion.quote.strongholdIncome, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Tuek pays each actually played Worthless card on defeat and explosion, but pays neither side for mutual traitors', () => {
|
||||||
|
const loser = effect({ kind: 'tueks_sietch', ownerWeapon: 'worthless', ownerDefense: 'worthless', opponentDial: 2, opponentSupport: 2 });
|
||||||
|
assert.equal(loser.quote.winner, loser.fixture.opponent);
|
||||||
|
assert.deepEqual(loser.quote.strongholdIncome, [{ player: loser.fixture.owner, amount: 4 }]);
|
||||||
|
for (const field of ['weapon', 'defense'] as const) assert.ok(loser.resolved.discard.some(card => card.id === loser.fixture.planActions[0].action[field]));
|
||||||
|
const exploded = effect({ kind: 'tueks_sietch', ownerWeapon: 'worthless', ownerDefense: 'shield', opponentWeapon: 'lasgun' });
|
||||||
|
assert.equal(exploded.quote.explosion, true);
|
||||||
|
assert.deepEqual(exploded.quote.strongholdIncome, [{ player: exploded.fixture.owner, amount: 2 }]);
|
||||||
|
const mutual = createStrongholdFactionsFixture({ kind: 'tueks_sietch', ownerWeapon: 'worthless', ownerDefense: 'worthless', traitors: 'both' });
|
||||||
|
const ended = finish(mutual, reveal(mutual), [mutual.owner, mutual.opponent]);
|
||||||
|
assert.equal(ended.lastBattleContext!.winner, null);
|
||||||
|
assert.equal(ended.log.filter(entry => entry.automatic?.name === 'Stronghold income').length, 0);
|
||||||
|
const single = createStrongholdFactionsFixture({ kind: 'tueks_sietch', ownerWeapon: 'worthless', ownerDefense: 'worthless', traitors: 'owner', ownerDial: 6, support: 3 });
|
||||||
|
const won = finish(single, reveal(single), [single.owner]);
|
||||||
|
assert.equal(won.lastBattleContext!.winner, single.owner);
|
||||||
|
const nativeCollection = won.phase === 7 ? quoteSpiceCollection(won).receipts.find(receipt => receipt.player === single.owner)!.collected : 0;
|
||||||
|
assert.equal(player(won, single.owner).spice, 20 + 4 + 5 + nativeCollection, 'four Worthless income plus native five-strength traitor bounty; waived support and separate ordinary Collection');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native HMS declaration uses current physical control, not donor card custody; rejected event/actor/choice and early plans leave source unchanged', () => {
|
||||||
|
const fixture = createStrongholdFactionsFixture();
|
||||||
|
const game = fixture.game, before = reload(game), decision = game.decision;
|
||||||
|
assert.equal(decision?.kind, 'strongholdCopy');
|
||||||
|
if (decision?.kind !== 'strongholdCopy') throw Error('Missing actual native copy decision');
|
||||||
|
assert.deepEqual(decision.choices, ['arrakeen', 'sietch_tabr']);
|
||||||
|
assert.notEqual(game.strongholdCards!.owners.sietch_tabr, fixture.owner);
|
||||||
|
for (const [actor, action] of [
|
||||||
|
[fixture.opponent, fixture.copyAction!],
|
||||||
|
[fixture.owner, { ...fixture.copyAction!, event: 'foreign-event' }],
|
||||||
|
[fixture.owner, { ...fixture.copyAction!, stronghold: 'carthag' }],
|
||||||
|
[fixture.opponent, fixture.planActions[1].action],
|
||||||
|
] as const) {
|
||||||
|
assert.throws(() => applyAction(game, actor, action));
|
||||||
|
assert.deepEqual(game, before);
|
||||||
|
}
|
||||||
|
const accepted = applyAction(game, fixture.owner, fixture.copyAction!);
|
||||||
|
assert.equal(accepted.battle!.strongholdCopy, 'arrakeen');
|
||||||
|
assert.throws(() => applyAction(accepted, fixture.owner, fixture.copyAction!));
|
||||||
|
const retained = reload(accepted);
|
||||||
|
// Move the copy-control marker back to reserves after declaration: the
|
||||||
|
// accepted event remains fixed and does not silently ask again.
|
||||||
|
const owner = player(retained, fixture.owner);
|
||||||
|
owner.reserves += owner.forces['arrakeen:10']; delete owner.forces['arrakeen:10'];
|
||||||
|
const normalized = normalizeAutomaticGame(retained);
|
||||||
|
assert.equal(normalized.battle!.strongholdCopy, 'arrakeen');
|
||||||
|
for (const seat of normalized.players) assert.equal(viewGame(normalized, seat.id).battle!.strongholdEffects[fixture.owner], 'arrakeen');
|
||||||
|
const sole = createStrongholdFactionsFixture({ copyChoices: 1 });
|
||||||
|
assert.equal(sole.game.battle!.strongholdCopy, 'arrakeen');
|
||||||
|
assert.notEqual(sole.game.decision?.kind, 'strongholdCopy');
|
||||||
|
custody(normalized);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native HMS copies each existing advantage on its interior without relocating the printed battle site', () => {
|
||||||
|
for (const copy of ['arrakeen', 'carthag', 'habbanya_ridge_sietch', 'sietch_tabr', 'tueks_sietch'] as const) {
|
||||||
|
const options: StrongholdFactionsFixtureOptions = { copy, copyChoices: 1 };
|
||||||
|
if (copy === 'arrakeen') { options.ownerDial = 6; options.support = 3; }
|
||||||
|
if (copy === 'carthag') { options.ownerDefense = 'shield'; options.opponentWeapon = 'poison'; }
|
||||||
|
if (copy === 'sietch_tabr') { options.ownerDial = 6; options.support = 3; options.opponentDial = 1.5; }
|
||||||
|
if (copy === 'tueks_sietch') { options.ownerWeapon = 'worthless'; options.opponentDial = 2; options.opponentSupport = 2; }
|
||||||
|
const { fixture, revealed, quote } = effect(options);
|
||||||
|
assert.equal(revealed.battle!.territory, MOBILE_STRONGHOLD);
|
||||||
|
assert.equal(revealed.battle!.strongholdCopy, copy);
|
||||||
|
assert.equal(viewGame(revealed, fixture.owner).battle!.strongholdEffects[fixture.owner], copy);
|
||||||
|
assert.equal(revealed.mobileStronghold!.location, 'polar_sink:0');
|
||||||
|
assert.equal(player(revealed, fixture.owner).forces['polar_sink:0'], undefined, 'interior army does not leak into the pointing territory');
|
||||||
|
if (copy === 'arrakeen') assert.equal(quote.payments.find(p => p.player === fixture.owner)!.bankSupport, 2);
|
||||||
|
if (copy === 'carthag') assert.equal(revealed.battle!.attacker === fixture.owner ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender, false);
|
||||||
|
if (copy === 'habbanya_ridge_sietch') assert.equal(quote.winner, fixture.owner);
|
||||||
|
if (copy === 'sietch_tabr') assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: 1 }]);
|
||||||
|
if (copy === 'tueks_sietch') assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: 2 }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('real HMS relocation, native cyborg casualty/substitution decisions and sealed plans survive JSON without revealing foreign private plans', () => {
|
||||||
|
const original = createStrongholdFactionsFixture();
|
||||||
|
const route = mobileRoutes(original.afterFirstMentat, 3).find(route => {
|
||||||
|
const target = splitLocation(route.at(-1)!);
|
||||||
|
return route.length > 1 && target.sector >= 8 && territory(target.territory).type !== 'stronghold';
|
||||||
|
});
|
||||||
|
assert.ok(route, 'An actual connected native mobile route is available outside this next Storm band');
|
||||||
|
const fixture = createStrongholdFactionsFixture({ initial: original.initial, mobileRoute: route, ownerDial: 6, support: 3, opponentDial: 1.5 });
|
||||||
|
assert.equal(fixture.game.mobileStronghold!.location, route.at(-1));
|
||||||
|
let game = prepared(fixture);
|
||||||
|
const first = fixture.planActions[0]; game = applyAction(game, first.actor, first.action);
|
||||||
|
const ownView = viewGame(reload(game), fixture.owner), otherView = viewGame(reload(game), fixture.opponent);
|
||||||
|
assert.equal(ownView.battle!.plans[fixture.owner].dial, 6);
|
||||||
|
assert.deepEqual(otherView.battle!.plans, {});
|
||||||
|
assert.equal(otherView.battle!.revealed, false);
|
||||||
|
const second = fixture.planActions[1]; game = applyAction(reload(game), second.actor, second.action);
|
||||||
|
const quote = quoteStrongholdFactionsBattle(game);
|
||||||
|
assert.equal(quote.casualties!.forces.normalFixedHalf, true);
|
||||||
|
assert.ok(quote.casualties!.options.every(loss => loss.elite === 3 && loss.normal === 0));
|
||||||
|
for (let count = 0; game.decision?.kind !== 'ixSubstitution' && count < 100; count++) {
|
||||||
|
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
|
||||||
|
game = applyAction(reload(game), next.actor, next.action);
|
||||||
|
}
|
||||||
|
assert.equal(game.decision?.kind, 'ixSubstitution');
|
||||||
|
assert.equal(player(game, fixture.owner).elites!.tanks, 3);
|
||||||
|
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||||
|
const resolved = finish(fixture, game);
|
||||||
|
custody(resolved);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('all four existing projected policies choose legal native current-control copies and affordable native plans without bank cash', () => {
|
||||||
|
const fixture = createStrongholdFactionsFixture({ ownerSpice: 0, ownerDefense: 'shield' });
|
||||||
|
for (const difficulty of DIFFICULTIES) {
|
||||||
|
const copyView = viewGame(fixture.game, fixture.owner);
|
||||||
|
copyView.players.find(p => p.id === fixture.owner)!.bot = difficulty;
|
||||||
|
const copy = botActions(copyView)[0]; assert.ok(copy);
|
||||||
|
let game = applyAction(reload(fixture.game), fixture.owner, copy);
|
||||||
|
while (nextStrongholdFactionsNativeStep(game)) {
|
||||||
|
const next = nextStrongholdFactionsNativeStep(game)!;
|
||||||
|
game = applyAction(game, next.actor, next.action);
|
||||||
|
}
|
||||||
|
const view = viewGame(game, fixture.owner);
|
||||||
|
view.players.find(p => p.id === fixture.owner)!.bot = difficulty;
|
||||||
|
const plan = botActions(view).find(action => action.type === 'battlePlan'); assert.ok(plan);
|
||||||
|
const accepted = applyAction(game, fixture.owner, plan);
|
||||||
|
assert.equal(player(accepted, fixture.owner).spice, 0);
|
||||||
|
assert.ok((accepted.battle!.plans[fixture.owner].support ?? 0) <= 2, 'free bank support is capped, not unlimited personal cash');
|
||||||
|
custody(accepted);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('free native Fremen support neither spends Arrakeen allowance nor mints CHOAM income', () => {
|
||||||
|
const { fixture, quote } = effect({ kind: 'arrakeen', seatIds: ['i', 'c', 'g', 'e', 'f'], ownerFaction: 'fremen', ownerDial: 3, support: 0, ownerSpice: 0 });
|
||||||
|
assert.deepEqual(quote.payments.find(payment => payment.player === fixture.owner), { player: fixture.owner, cost: 0, ownPayment: 0, allyPayment: 0, donor: null, bankSupport: 0, freeByTraitor: false });
|
||||||
|
assert.equal(quote.choamIncome, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native CHOAM aid opens a real Karama response only for an actual conserved eligible hand; cancellation cannot leave an affordable phantom ally payment', () => {
|
||||||
|
const fixture = createStrongholdFactionsFixture({ ownerFaction: 'guild', copy: 'arrakeen', choamFunding: 2, allyPayment: 2, ownerDial: 4, support: 4, ownerSpice: 0, counter: true });
|
||||||
|
let game = applyAction(reload(fixture.game), fixture.owner, fixture.copyAction!);
|
||||||
|
assert.equal(game.decision?.kind, 'choamBattleFunding');
|
||||||
|
game = applyAction(game, fixture.fundingAction!.actor, fixture.fundingAction!.action);
|
||||||
|
assert.equal(game.response?.owner, fixture.choam);
|
||||||
|
const counter = player(game, fixture.opponent).hand.find(card => card.effect === 'karama'); assert.ok(counter);
|
||||||
|
const before = reload(game);
|
||||||
|
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
|
||||||
|
assert.equal(next.actor, fixture.opponent);
|
||||||
|
assert.equal(next.action.type, 'passResponse');
|
||||||
|
game = applyAction(game, fixture.opponent, { type: 'card', card: counter.id, mode: 'cancel' });
|
||||||
|
for (let count = 0; nextStrongholdFactionsNativeStep(game) && count < 120; count++) {
|
||||||
|
const step = nextStrongholdFactionsNativeStep(game)!; game = applyAction(game, step.actor, step.action);
|
||||||
|
}
|
||||||
|
assert.ok(game.battle!.choamAidBlocked);
|
||||||
|
assert.ok(game.discard.some(card => card.id === counter.id));
|
||||||
|
assert.ok(before.players.find(p => p.id === fixture.opponent)!.hand.some(card => card.id === counter.id), 'original counter custody remains immutable');
|
||||||
|
const preserved = reload(game);
|
||||||
|
assert.throws(() => applyAction(game, fixture.owner, fixture.planActions[0].action));
|
||||||
|
assert.deepEqual(game, preserved);
|
||||||
|
const accepted = applyAction(game, fixture.owner, { ...fixture.planActions[0].action, dial: 2, support: 2, allyPayment: 0 });
|
||||||
|
assert.equal(player(accepted, fixture.owner).spice, 0);
|
||||||
|
custody(accepted);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('actual native battle, Collection and final Mentat settle current custody and terminal victory once, including retained departure custody until that checkpoint', () => {
|
||||||
|
const fixture = createStrongholdFactionsFixture({ ownerDial: 6, support: 3, opponentDial: 1.5 });
|
||||||
|
let game = finish(fixture);
|
||||||
|
assert.deepEqual(game.strongholdCards, fixture.afterFirstMentat.strongholdCards);
|
||||||
|
let finalSource: Game | undefined;
|
||||||
|
for (let count = 0; game.status !== 'finished' && count < 300; count++) {
|
||||||
|
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
|
||||||
|
const before = reload(game);
|
||||||
|
game = applyAction(reload(game), next.actor, next.action);
|
||||||
|
if (game.status === 'finished') finalSource = before;
|
||||||
|
custody(game);
|
||||||
|
}
|
||||||
|
assert.equal(game.status, 'finished');
|
||||||
|
assert.equal(finalSource!.phase, 8);
|
||||||
|
assert.equal(finalSource!.strongholdCards!.claimedTurn, 1);
|
||||||
|
assert.equal(game.strongholdCards!.claimedTurn, 2);
|
||||||
|
assert.deepEqual(game.strongholdCards!.owners, strongholdControllers(finalSource!.players, true));
|
||||||
|
assert.equal(game.lastBattleContext!.winner, fixture.owner);
|
||||||
|
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||||
|
assert.equal(nextStrongholdFactionsNativeStep(game), null);
|
||||||
|
});
|
||||||
+167
-20
@@ -1,9 +1,17 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
import {
|
||||||
|
cpSync,
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
rmSync,
|
||||||
|
symlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
classifyTest,
|
classifyTest,
|
||||||
discoverTests,
|
discoverTests,
|
||||||
@@ -67,6 +75,7 @@ void test('an unmarked HTTP test cannot silently enter the offline suite', (t) =
|
|||||||
void test('each filename filter must match within its suite, including combined filters', () => {
|
void test('each filename filter must match within its suite, including combined filters', () => {
|
||||||
const files = [{ path: 'tests/rules.test.ts', kind: 'unit' as const }];
|
const files = [{ path: 'tests/rules.test.ts', kind: 'unit' as const }];
|
||||||
assert.equal(selectTests(files, 'offline', ['rules']).length, 1);
|
assert.equal(selectTests(files, 'offline', ['rules']).length, 1);
|
||||||
|
assert.throws(() => selectTests(files, 'offline', ['']));
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => selectTests(files, 'offline', ['rules', 'typo']),
|
() => selectTests(files, 'offline', ['rules', 'typo']),
|
||||||
/No offline test files match/,
|
/No offline test files match/,
|
||||||
@@ -78,28 +87,166 @@ void test('each filename filter must match within its suite, including combined
|
|||||||
assert.throws(() => selectTests([], 'all'), /No test files/);
|
assert.throws(() => selectTests([], 'all'), /No test files/);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('the TypeScript test subprocess returns failure for a failing assertion', (t) => {
|
function runnerFixture(t: test.TestContext, sources: Record<string, string>) {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'dune-test-failure-'));
|
const root = mkdtempSync(join(tmpdir(), 'dune-test-runner-'));
|
||||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||||
const file = join(root, 'failure.test.mts');
|
mkdirSync(join(root, 'tools'));
|
||||||
writeFileSync(
|
mkdirSync(join(root, 'tests'));
|
||||||
file,
|
writeFileSync(join(root, 'package.json'), '{"type":"module"}\n');
|
||||||
"import test from 'node:test';\ntest('failure sentinel', () => { throw new Error('expected failure'); });\n",
|
for (const tool of ['test.ts', 'test-discovery.ts', 'test-reporter.ts'])
|
||||||
|
cpSync(new URL(`../tools/${tool}`, import.meta.url), join(root, 'tools', tool));
|
||||||
|
symlinkSync(
|
||||||
|
fileURLToPath(new URL('../node_modules', import.meta.url)),
|
||||||
|
join(root, 'node_modules'),
|
||||||
|
'dir',
|
||||||
);
|
);
|
||||||
|
for (const [file, source] of Object.entries(sources))
|
||||||
|
writeFileSync(join(root, 'tests', file), source);
|
||||||
// A nested runner must not inherit the parent test worker's IPC context.
|
// A nested runner must not inherit the parent test worker's IPC context.
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
delete env.NODE_TEST_CONTEXT;
|
delete env.NODE_TEST_CONTEXT;
|
||||||
const result = spawnSync(
|
const args = ['--import', import.meta.resolve('tsx'), 'tools/test.ts'];
|
||||||
process.execPath,
|
return {
|
||||||
['--import', 'tsx', '--test', file],
|
root,
|
||||||
{
|
env,
|
||||||
cwd: new URL('../', import.meta.url),
|
args,
|
||||||
env,
|
run: (...options: string[]) =>
|
||||||
timeout: 15_000,
|
spawnSync(process.execPath, [...args, ...options], {
|
||||||
encoding: 'utf8',
|
cwd: root,
|
||||||
},
|
env,
|
||||||
);
|
timeout: 15_000,
|
||||||
|
encoding: 'utf8',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('concise CLI preserves nested assertion details, output and failure exit', (t) => {
|
||||||
|
const fixture = runnerFixture(t, {
|
||||||
|
'failure.test.mjs': `
|
||||||
|
import { test, describe } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
describe('parent suite', () => {
|
||||||
|
test('passing sentinel', () => {});
|
||||||
|
test('failure sentinel', t => {
|
||||||
|
console.log('stdout sentinel');
|
||||||
|
console.error('stderr sentinel');
|
||||||
|
t.diagnostic('diagnostic sentinel');
|
||||||
|
assert.deepEqual({ payment: 7 }, { payment: 11 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
for (const options of [[], ['--verbose']]) {
|
||||||
|
const result = fixture.run(...options);
|
||||||
|
assert.ifError(result.error);
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
const output = result.stdout + result.stderr;
|
||||||
|
assert.match(output, /failure sentinel/);
|
||||||
|
assert.match(output, /failure\.test\.mjs:\d+:\d+/);
|
||||||
|
assert.match(output, /payment: 7/);
|
||||||
|
assert.match(output, /payment: 11/);
|
||||||
|
for (const channel of ['stdout', 'stderr', 'diagnostic'])
|
||||||
|
assert.match(output, new RegExp(`${channel} sentinel`));
|
||||||
|
if (options.length) assert.match(output, /passing sentinel/);
|
||||||
|
else assert.doesNotMatch(output, /passing sentinel/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('CLI applies union filename selection and name intersection without green empty runs', (t) => {
|
||||||
|
const fixture = runnerFixture(t, {
|
||||||
|
'empty.test.mjs': '',
|
||||||
|
'skipped.test.mjs': `
|
||||||
|
import test from 'node:test';
|
||||||
|
test('pending case', { skip: true }, () => {});
|
||||||
|
`,
|
||||||
|
'alpha.test.mjs': `
|
||||||
|
import test from 'node:test';
|
||||||
|
test('chosen alpha', () => {});
|
||||||
|
test('excluded failure', () => { throw new Error('must not execute'); });
|
||||||
|
`,
|
||||||
|
'beta.test.mjs': `
|
||||||
|
import test from 'node:test';
|
||||||
|
test('chosen beta', () => {});
|
||||||
|
`,
|
||||||
|
'other.test.mjs': `
|
||||||
|
import test from 'node:test';
|
||||||
|
test('chosen outside selection', () => { throw new Error('must not execute'); });
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
const result = fixture.run('alpha', 'beta', '--name', '^chosen', '--verbose');
|
||||||
assert.ifError(result.error);
|
assert.ifError(result.error);
|
||||||
assert.equal(result.status, 1);
|
assert.equal(result.status, 0);
|
||||||
assert.match(result.stdout + result.stderr, /expected failure/);
|
assert.match(result.stdout, /chosen alpha/);
|
||||||
|
assert.match(result.stdout, /chosen beta/);
|
||||||
|
assert.doesNotMatch(result.stdout + result.stderr, /must not execute/);
|
||||||
|
const listed = fixture.run('alpha', 'beta', '--list');
|
||||||
|
assert.equal(listed.status, 0);
|
||||||
|
assert.match(listed.stdout, /tests\/alpha\.test\.mjs/);
|
||||||
|
assert.match(listed.stdout, /tests\/beta\.test\.mjs/);
|
||||||
|
assert.doesNotMatch(listed.stdout, /tests\/other\.test\.mjs/);
|
||||||
|
for (const options of [
|
||||||
|
['empty'],
|
||||||
|
['skipped'],
|
||||||
|
['alpha', '--name', '^no matching tests$'],
|
||||||
|
['alpha', '--name', '^no matching tests$', '--verbose'],
|
||||||
|
['alpha', '--name', '['],
|
||||||
|
['alpha', '--name', ''],
|
||||||
|
['', '--name', '^chosen'],
|
||||||
|
['alpha', 'missing'],
|
||||||
|
['--suite', 'unknown'],
|
||||||
|
]) {
|
||||||
|
const invalid = fixture.run(...options);
|
||||||
|
assert.ifError(invalid.error);
|
||||||
|
assert.equal(invalid.status, 1, JSON.stringify(options));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('CLI cancellation remains failure when its test driver handles the signal with exit zero', { timeout: 30_000 }, async (t) => {
|
||||||
|
for (const [signal, expected] of [
|
||||||
|
['SIGINT', 130],
|
||||||
|
['SIGTERM', 143],
|
||||||
|
] as const) {
|
||||||
|
const fixture = runnerFixture(t, {
|
||||||
|
'waiting.test.mjs': `
|
||||||
|
import test from 'node:test';
|
||||||
|
test('waiting', () => {});
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
// A real Node test driver whose reporter gracefully handles cancellation.
|
||||||
|
writeFileSync(
|
||||||
|
join(fixture.root, 'tools/test-reporter.ts'),
|
||||||
|
`import { MessageChannel } from 'node:worker_threads';
|
||||||
|
export default async function* reporter(source) {
|
||||||
|
// Keep the driver alive until the real OS signal, without timer guesses.
|
||||||
|
const { port1, port2 } = new MessageChannel();
|
||||||
|
port1.on('message', () => {});
|
||||||
|
process.on('${signal}', () => {
|
||||||
|
port1.close();
|
||||||
|
port2.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
console.log('cancellation-ready');
|
||||||
|
for await (const event of source) yield '';
|
||||||
|
await Promise.withResolvers().promise;
|
||||||
|
}\n`,
|
||||||
|
);
|
||||||
|
const child = spawn(process.execPath, fixture.args, {
|
||||||
|
cwd: fixture.root,
|
||||||
|
env: fixture.env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
t.after(() => child.kill('SIGKILL'));
|
||||||
|
let output = '';
|
||||||
|
child.stdout.on('data', (chunk: Buffer) => {
|
||||||
|
output += chunk.toString();
|
||||||
|
if (output.includes('cancellation-ready')) {
|
||||||
|
output = '';
|
||||||
|
child.kill(signal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { promise, resolve, reject } = Promise.withResolvers<number | null>();
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('close', resolve);
|
||||||
|
assert.equal(await promise, expected);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { execFileSync, spawnSync } from 'node:child_process';
|
import { execFileSync, spawnSync } from 'node:child_process';
|
||||||
import {
|
import {
|
||||||
|
cpSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
privateOutputDirectory,
|
privateOutputDirectory,
|
||||||
runVerification,
|
runVerification,
|
||||||
@@ -39,6 +41,70 @@ void test('focused wrapper cannot turn a filename into a list-only or test-skipp
|
|||||||
assert.equal(existsSync(out), false);
|
assert.equal(existsSync(out), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('focused verification executes only matching names and cannot certify an empty selection', (t) => {
|
||||||
|
const { root, area } = fixture(t);
|
||||||
|
mkdirSync(join(root, 'tools'));
|
||||||
|
mkdirSync(join(root, 'tests'));
|
||||||
|
for (const tool of [
|
||||||
|
'verify.ts',
|
||||||
|
'verification.ts',
|
||||||
|
'test.ts',
|
||||||
|
'test-discovery.ts',
|
||||||
|
'test-reporter.ts',
|
||||||
|
])
|
||||||
|
cpSync(new URL(`../tools/${tool}`, import.meta.url), join(root, 'tools', tool));
|
||||||
|
symlinkSync(
|
||||||
|
fileURLToPath(new URL('../node_modules', import.meta.url)),
|
||||||
|
join(root, 'node_modules'),
|
||||||
|
'dir',
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(root, 'package.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'module',
|
||||||
|
scripts: { test: 'node --import tsx tools/test.ts' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(root, 'tests/rules.test.mjs'),
|
||||||
|
`import test from 'node:test';
|
||||||
|
test('chosen case', () => console.log('selected case executed'));
|
||||||
|
test('excluded case', () => { throw new Error('excluded failure'); });\n`,
|
||||||
|
);
|
||||||
|
const env = { ...process.env };
|
||||||
|
delete env.NODE_TEST_CONTEXT;
|
||||||
|
for (const [name, status] of [
|
||||||
|
['chosen case', 'passed'],
|
||||||
|
['no matching case', 'failed'],
|
||||||
|
]) {
|
||||||
|
const out = join(area, status);
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'--import',
|
||||||
|
import.meta.resolve('tsx'),
|
||||||
|
'tools/verify.ts',
|
||||||
|
'--out',
|
||||||
|
out,
|
||||||
|
'focused',
|
||||||
|
'--focus',
|
||||||
|
'rules',
|
||||||
|
'--name',
|
||||||
|
name,
|
||||||
|
],
|
||||||
|
{ cwd: root, env, encoding: 'utf8', timeout: 15_000 },
|
||||||
|
);
|
||||||
|
assert.ifError(result.error);
|
||||||
|
assert.equal(result.status, status === 'passed' ? 0 : 1);
|
||||||
|
const report = JSON.parse(readFileSync(join(out, 'report.json'), 'utf8'));
|
||||||
|
assert.equal(report.status, status);
|
||||||
|
const log = readFileSync(join(out, 'focused.log'), 'utf8');
|
||||||
|
if (status === 'passed') assert.match(log, /selected case executed/);
|
||||||
|
else assert.doesNotMatch(log, /selected case executed/);
|
||||||
|
assert.doesNotMatch(log, /excluded failure/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function fixture(t: test.TestContext) {
|
function fixture(t: test.TestContext) {
|
||||||
const area = mkdtempSync(join(tmpdir(), 'dune-verification-'));
|
const area = mkdtempSync(join(tmpdir(), 'dune-verification-'));
|
||||||
t.after(() => rmSync(area, { recursive: true, force: true }));
|
t.after(() => rmSync(area, { recursive: true, force: true }));
|
||||||
|
|||||||
+44
-10
@@ -12,6 +12,7 @@ import {
|
|||||||
initializeBaseGameForAudit,
|
initializeBaseGameForAudit,
|
||||||
initializeFactionExpansionsGameForAudit,
|
initializeFactionExpansionsGameForAudit,
|
||||||
initializeHomeworldGameForAudit,
|
initializeHomeworldGameForAudit,
|
||||||
|
initializeStrongholdFactionsGameForAudit,
|
||||||
initializeNexusGameForAudit,
|
initializeNexusGameForAudit,
|
||||||
initializePairedNexusGameForAudit,
|
initializePairedNexusGameForAudit,
|
||||||
initializeEcazTreacheryGameForAudit,
|
initializeEcazTreacheryGameForAudit,
|
||||||
@@ -32,7 +33,7 @@ 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' | 'ecaz-treachery' | 'moritani-assassinate' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills';
|
type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills';
|
||||||
type Rules = 'basic' | 'advanced';
|
type Rules = 'basic' | 'advanced';
|
||||||
|
|
||||||
type Scenario = {
|
type Scenario = {
|
||||||
@@ -216,6 +217,16 @@ const CHOAM_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players
|
|||||||
expansions: ['choam'],
|
expansions: ['choam'],
|
||||||
roster: (['choam', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
roster: (['choam', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
||||||
}));
|
}));
|
||||||
|
const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [
|
||||||
|
'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit',
|
||||||
|
];
|
||||||
|
const STRONGHOLD_FACTIONS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({
|
||||||
|
ordinal: 151 + players - 2,
|
||||||
|
profile: 'stronghold-factions',
|
||||||
|
rules: 'advanced',
|
||||||
|
expansions: ['ix', 'choam'],
|
||||||
|
roster: STRONGHOLD_FACTIONS_ROSTER.slice(0, players),
|
||||||
|
}));
|
||||||
const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills';
|
const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills';
|
||||||
|
|
||||||
type TraceEntry = {
|
type TraceEntry = {
|
||||||
@@ -254,10 +265,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|ecaz-treachery|moritani-assassinate|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills] ' +
|
'[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|moritani-assassinate|stronghold-factions|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills] ' +
|
||||||
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
|
'[--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; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. --players requires a supported profile. Output must be a new private directory outside the checkout.'
|
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: native Ixians + CHOAM with classic opponents, two through six seats, the canonical 47-card Ix+CHOAM deck and six separate Stronghold Cards, without other modules. --players requires a supported profile. Output must be a new private directory outside the checkout.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,15 +300,15 @@ function positive(value: string | undefined) {
|
|||||||
|
|
||||||
function scenarioName(scenario: Scenario) {
|
function scenarioName(scenario: Scenario) {
|
||||||
return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus' ||
|
return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus' ||
|
||||||
scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile)
|
scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'stronghold-factions' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile)
|
||||||
? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}`
|
? `${scenario.profile}-${scenario.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', 'ecaz-treachery', 'moritani-assassinate', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile))
|
if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile))
|
||||||
throw new Error('--profile must be all, base, choam, ecaz, ecaz-treachery, moritani-assassinate, combined, combined-nexus, combined-homeworld-nexus, homeworld, nexus, homeworld-nexus, choam-roster, ecaz-roster, ix-roster, choam-nexus, ecaz-nexus, ix-nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.');
|
throw new Error('--profile must be all, base, choam, ecaz, ecaz-treachery, moritani-assassinate, stronghold-factions, combined, combined-nexus, combined-homeworld-nexus, homeworld, nexus, homeworld-nexus, choam-roster, ecaz-roster, ix-roster, choam-nexus, ecaz-nexus, ix-nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.');
|
||||||
return profile as Profile | 'all';
|
return profile as Profile | 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +364,8 @@ function freshGame(scenario: Scenario) {
|
|||||||
return initializeEcazTreacheryGameForAudit(game);
|
return initializeEcazTreacheryGameForAudit(game);
|
||||||
if (scenario.profile === 'moritani-assassinate')
|
if (scenario.profile === 'moritani-assassinate')
|
||||||
return initializeMoritaniAssassinateGameForAudit(game);
|
return initializeMoritaniAssassinateGameForAudit(game);
|
||||||
|
if (scenario.profile === 'stronghold-factions')
|
||||||
|
return initializeStrongholdFactionsGameForAudit(game);
|
||||||
return skillProfile(scenario.profile)
|
return skillProfile(scenario.profile)
|
||||||
? initializeLeaderSkillsGameForAudit(game)
|
? initializeLeaderSkillsGameForAudit(game)
|
||||||
: scenario.profile === 'homeworld'
|
: scenario.profile === 'homeworld'
|
||||||
@@ -402,15 +415,15 @@ function resumedGame(path: string) {
|
|||||||
game.discoveryStash ||
|
game.discoveryStash ||
|
||||||
game.greatMaker ||
|
game.greatMaker ||
|
||||||
game.techTokens ||
|
game.techTokens ||
|
||||||
game.strongholdCards ||
|
|
||||||
game.mentatQuestionPreview
|
game.mentatQuestionPreview
|
||||||
)
|
)
|
||||||
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
|
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
|
||||||
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find(
|
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
|
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
|
||||||
(candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
|
(candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
|
||||||
skillProfile(candidate.profile) === !!game.leaderSkills &&
|
skillProfile(candidate.profile) === !!game.leaderSkills &&
|
||||||
|
(candidate.profile === 'stronghold-factions') === !!game.strongholdCards &&
|
||||||
(candidate.profile === 'ecaz-treachery') === !!game.ecazTreachery &&
|
(candidate.profile === 'ecaz-treachery') === !!game.ecazTreachery &&
|
||||||
(candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinatePreview &&
|
(candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinatePreview &&
|
||||||
(candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinate &&
|
(candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinate &&
|
||||||
@@ -426,6 +439,8 @@ function resumedGame(path: string) {
|
|||||||
);
|
);
|
||||||
// Projection validates the engine-facing shape and every private seat boundary.
|
// Projection validates the engine-facing shape and every private seat boundary.
|
||||||
for (const player of game.players) viewGame(game, player.id);
|
for (const player of game.players) viewGame(game, player.id);
|
||||||
|
if (scenario.profile === 'stronghold-factions')
|
||||||
|
verifySampleCustody(game, sampleInventory(game));
|
||||||
return { game, scenario, hash, path: resolve(path) };
|
return { game, scenario, hash, path: resolve(path) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,6 +453,11 @@ function simulate(
|
|||||||
) {
|
) {
|
||||||
let game = initial;
|
let game = initial;
|
||||||
const expected = sampleInventory(game);
|
const expected = sampleInventory(game);
|
||||||
|
if (scenario.profile === 'stronghold-factions') {
|
||||||
|
assert.equal(expected.cards.length, 47, 'canonical Ix+CHOAM Treachery census');
|
||||||
|
assert.equal(new Set(expected.cards).size, 47, 'unique Ix+CHOAM Treachery identities');
|
||||||
|
assert.equal(expected.strongholds?.length, 6, 'six physical Stronghold Cards');
|
||||||
|
}
|
||||||
const trace: TraceEntry[] = [];
|
const trace: TraceEntry[] = [];
|
||||||
const used: Record<string, number> = {};
|
const used: Record<string, number> = {};
|
||||||
const rejected: Record<string, number> = {};
|
const rejected: Record<string, number> = {};
|
||||||
@@ -498,8 +518,9 @@ function simulate(
|
|||||||
preparation: game.battle?.preparation,
|
preparation: game.battle?.preparation,
|
||||||
})}`,
|
})}`,
|
||||||
);
|
);
|
||||||
|
const previous = game;
|
||||||
game = next;
|
game = next;
|
||||||
verifySampleCustody(game, expected);
|
verifySampleCustody(game, expected, previous);
|
||||||
if (actions % 37 === 0) {
|
if (actions % 37 === 0) {
|
||||||
const restored = JSON.parse(JSON.stringify(game)) as Game;
|
const restored = JSON.parse(JSON.stringify(game)) as Game;
|
||||||
for (const player of game.players)
|
for (const player of game.players)
|
||||||
@@ -520,6 +541,7 @@ function simulate(
|
|||||||
`private rival field ${field}`,
|
`private rival field ${field}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
verifySampleCustody(restored, expected, game);
|
||||||
game = restored;
|
game = restored;
|
||||||
restores++;
|
restores++;
|
||||||
}
|
}
|
||||||
@@ -606,7 +628,7 @@ 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') && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'moritani-assassinate', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile))
|
if (supplied('players') && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'moritani-assassinate', 'stronghold-factions', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile))
|
||||||
throw new Error('--players requires a roster or optional-module profile.');
|
throw new Error('--players requires a roster or optional-module profile.');
|
||||||
if ((profile === 'combined-nexus' || profile === 'combined-homeworld-nexus') && players !== 'all' && ![5, 6].includes(players))
|
if ((profile === 'combined-nexus' || profile === 'combined-homeworld-nexus') && players !== 'all' && ![5, 6].includes(players))
|
||||||
throw new Error(`--profile ${profile} supports only five or six players.`);
|
throw new Error(`--profile ${profile} supports only five or six players.`);
|
||||||
@@ -614,12 +636,15 @@ async function main() {
|
|||||||
throw new Error('Expansion Leader Skills profiles currently support only Basic samples.');
|
throw new Error('Expansion Leader Skills profiles currently support only Basic samples.');
|
||||||
if (profile === 'moritani-assassinate' && rules === 'basic')
|
if (profile === 'moritani-assassinate' && rules === 'basic')
|
||||||
throw new Error('Moritani assassination samples require Advanced rules.');
|
throw new Error('Moritani assassination samples require Advanced rules.');
|
||||||
|
if (profile === 'stronghold-factions' && rules === 'basic')
|
||||||
|
throw new Error('Stronghold factions samples require Advanced rules.');
|
||||||
const resume = values.resume ? resumedGame(values.resume) : null;
|
const resume = values.resume ? resumedGame(values.resume) : null;
|
||||||
const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
|
const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
|
||||||
? MODULE_SCENARIOS
|
? MODULE_SCENARIOS
|
||||||
: profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS
|
: profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS
|
||||||
: profile === 'ecaz-treachery' ? ECAZ_TREACHERY_SCENARIOS
|
: profile === 'ecaz-treachery' ? ECAZ_TREACHERY_SCENARIOS
|
||||||
: profile === 'moritani-assassinate' ? MORITANI_ASSASSINATE_SCENARIOS
|
: profile === 'moritani-assassinate' ? MORITANI_ASSASSINATE_SCENARIOS
|
||||||
|
: profile === 'stronghold-factions' ? STRONGHOLD_FACTIONS_SCENARIOS
|
||||||
: profile === 'choam-nexus' || profile === 'ecaz-nexus' || profile === 'ix-nexus' ? PAIRED_NEXUS_SCENARIOS
|
: profile === 'choam-nexus' || profile === 'ecaz-nexus' || profile === 'ix-nexus' ? PAIRED_NEXUS_SCENARIOS
|
||||||
: profile === 'combined-nexus' ? COMBINED_NEXUS_SCENARIOS
|
: profile === 'combined-nexus' ? COMBINED_NEXUS_SCENARIOS
|
||||||
: profile === 'combined-homeworld-nexus' ? COMBINED_HOMEWORLD_NEXUS_SCENARIOS
|
: profile === 'combined-homeworld-nexus' ? COMBINED_HOMEWORLD_NEXUS_SCENARIOS
|
||||||
@@ -721,6 +746,15 @@ async function main() {
|
|||||||
randomness: resume
|
randomness: resume
|
||||||
? 'Continuation restarts the random stream at seed plus scenario ordinal; it does not reconstruct the pre-snapshot random stream.'
|
? 'Continuation restarts the random stream at seed plus scenario ordinal; it does not reconstruct the pre-snapshot random stream.'
|
||||||
: 'Each scenario starts its random stream at seed plus scenario ordinal.',
|
: 'Each scenario starts its random stream at seed plus scenario ordinal.',
|
||||||
|
...(selected.some(scenario => scenario.profile === 'stronghold-factions') ? {
|
||||||
|
strongholdFactions: {
|
||||||
|
expansions: ['ix', 'choam'],
|
||||||
|
treacheryCards: 47,
|
||||||
|
strongholdCards: 6,
|
||||||
|
custody: 'Six separate physical cards checked after every accepted action and JSON restore; end-Mentat holders persist during the next turn, independently of current board control.',
|
||||||
|
scope: 'Advanced native Ixians and CHOAM with classic opponents; Stronghold Cards alone. This sample does not certify every Treachery effect, optional-module combination or deployed readiness.',
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
options: {
|
options: {
|
||||||
seed,
|
seed,
|
||||||
profile: resume ? resume.scenario.profile : profile,
|
profile: resume ? resume.scenario.profile : profile,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
initializeLeaderSkillsGameForAudit,
|
initializeLeaderSkillsGameForAudit,
|
||||||
initializeSpiceBankerIncomeGameForAudit,
|
initializeSpiceBankerIncomeGameForAudit,
|
||||||
initializeFactionExpansionsGameForAudit,
|
initializeFactionExpansionsGameForAudit,
|
||||||
|
initializeStrongholdFactionsGameForAudit,
|
||||||
initializeNexusGameForAudit,
|
initializeNexusGameForAudit,
|
||||||
initializeMoritaniAssassinateGameForAudit,
|
initializeMoritaniAssassinateGameForAudit,
|
||||||
initializeEcazTreacheryGameForAudit,
|
initializeEcazTreacheryGameForAudit,
|
||||||
@@ -26,6 +27,7 @@ export const PROTOTYPE_PROFILES = [
|
|||||||
'leader-skills',
|
'leader-skills',
|
||||||
'banker-income',
|
'banker-income',
|
||||||
'factions',
|
'factions',
|
||||||
|
'stronghold-factions',
|
||||||
'nexus',
|
'nexus',
|
||||||
'moritani-assassinate',
|
'moritani-assassinate',
|
||||||
'ecaz-treachery',
|
'ecaz-treachery',
|
||||||
@@ -79,6 +81,8 @@ export function startPrototypeRoom(
|
|||||||
const game =
|
const game =
|
||||||
profile === 'ix'
|
profile === 'ix'
|
||||||
? initializeIxGameForAudit(initial)
|
? initializeIxGameForAudit(initial)
|
||||||
|
: profile === 'stronghold-factions'
|
||||||
|
? initializeStrongholdFactionsGameForAudit(initial)
|
||||||
: profile === 'ecaz-treachery'
|
: profile === 'ecaz-treachery'
|
||||||
? initializeEcazTreacheryGameForAudit(initial)
|
? initializeEcazTreacheryGameForAudit(initial)
|
||||||
: profile === 'moritani-assassinate'
|
: profile === 'moritani-assassinate'
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import { ecazTreacheryCards } from '../game/ecaz-cards';
|
|||||||
import { traitorDeck } from '../game/traitors';
|
import { traitorDeck } from '../game/traitors';
|
||||||
import { basicExpansionLeaderSkillsProfile } from '../game/leader-skill-profile';
|
import { basicExpansionLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||||
import { validateLeaderSkills } from '../game/leader-skills';
|
import { validateLeaderSkills } from '../game/leader-skills';
|
||||||
|
import { ownedStrongholdCards, STRONGHOLD_CARDS } from '../game/stronghold-cards';
|
||||||
|
|
||||||
/** 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,
|
leaderSkills: !!game.leaderSkills,
|
||||||
|
strongholds: game.strongholdCards
|
||||||
|
? STRONGHOLD_CARDS.map(card => card.id).sort()
|
||||||
|
: null,
|
||||||
cards: [
|
cards: [
|
||||||
...treacheryDeck(game.expansions),
|
...treacheryDeck(game.expansions),
|
||||||
...(game.ecazTreachery ? ecazTreacheryCards() : []),
|
...(game.ecazTreachery ? ecazTreacheryCards() : []),
|
||||||
@@ -34,9 +38,35 @@ export function sampleInventory(game: Game) {
|
|||||||
export function verifySampleCustody(
|
export function verifySampleCustody(
|
||||||
game: Game,
|
game: Game,
|
||||||
inventory: ReturnType<typeof sampleInventory>,
|
inventory: ReturnType<typeof sampleInventory>,
|
||||||
|
previous?: Game,
|
||||||
) {
|
) {
|
||||||
assert.equal(!!game.leaderSkills, inventory.leaderSkills, 'Leader Skills module custody');
|
assert.equal(!!game.leaderSkills, inventory.leaderSkills, 'Leader Skills module custody');
|
||||||
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
|
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
|
||||||
|
assert.equal(!!game.strongholdCards, !!inventory.strongholds, 'Stronghold Cards module custody');
|
||||||
|
if (game.strongholdCards) {
|
||||||
|
const state = game.strongholdCards;
|
||||||
|
// Reuse the module's physical map and acquisition-turn validation. Custody
|
||||||
|
// is retained during play; current board control is not a custody census.
|
||||||
|
ownedStrongholdCards(state, game.players[0].id);
|
||||||
|
assert.deepEqual(Object.keys(state.owners).sort(), inventory.strongholds,
|
||||||
|
'physical Stronghold Card custody');
|
||||||
|
assert.ok(state.claimedTurn <= game.turn, 'Stronghold acquisition cannot be in the future');
|
||||||
|
for (const owner of Object.values(state.owners))
|
||||||
|
assert.ok(owner === null || game.players.some(player => player.id === owner),
|
||||||
|
'Stronghold owner must be a current seat');
|
||||||
|
if (state.claimedTurn === 0)
|
||||||
|
assert.ok(Object.values(state.owners).every(owner => owner === null),
|
||||||
|
'unclaimed Stronghold Cards must be unowned');
|
||||||
|
if (game.status === 'setup')
|
||||||
|
assert.equal(state.claimedTurn, 0, 'Stronghold setup cannot have settled custody');
|
||||||
|
if (previous?.strongholdCards) {
|
||||||
|
assert.ok(state.claimedTurn >= previous.strongholdCards.claimedTurn,
|
||||||
|
'Stronghold acquisition cannot move backward');
|
||||||
|
if (state.claimedTurn === previous.strongholdCards.claimedTurn)
|
||||||
|
assert.deepEqual(state.owners, previous.strongholdCards.owners,
|
||||||
|
'Stronghold holders must persist until the next end-Mentat settlement');
|
||||||
|
}
|
||||||
|
}
|
||||||
const cards = [
|
const cards = [
|
||||||
...game.players.flatMap((player) => player.hand),
|
...game.players.flatMap((player) => player.hand),
|
||||||
...game.deck,
|
...game.deck,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
if (values.help) {
|
if (values.help) {
|
||||||
console.log(
|
console.log(
|
||||||
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Banker-income opts a fresh 2–6-seat classic Basic/Advanced roster with base 33-card Treachery and all fourteen Leader Skills alone into automatic deferred normal-band income and native Mentat collection; already-earned spice stays with its original faction under this explicit local policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
|
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced native Ixians plus CHOAM and otherwise classic roster into Stronghold Cards alone, with exactly the Ix and CHOAM decks and all 47 canonical physical Treachery Cards. Native Ix setup choices, HMS placement and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced roster with base 33-card Treachery and all fourteen Leader Skills alone into automatic deferred normal-band income and native Mentat collection; already-earned spice stays with its original faction under this explicit local policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export function selectTests(
|
|||||||
suite: TestSuite,
|
suite: TestSuite,
|
||||||
filters: string[] = [],
|
filters: string[] = [],
|
||||||
): TestFile[] {
|
): TestFile[] {
|
||||||
|
if (filters.some((filter) => !filter))
|
||||||
|
throw new Error('Filename filters must not be empty.');
|
||||||
const selected = files.filter(({ path, kind }) => {
|
const selected = files.filter(({ path, kind }) => {
|
||||||
const included =
|
const included =
|
||||||
suite === 'all' ||
|
suite === 'all' ||
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { spec, type TestEvent } from 'node:test/reporters';
|
||||||
|
|
||||||
|
// Keep Node's assertion diffs, locations, diagnostics, counts and duration output.
|
||||||
|
// Omitting starts as well as passes keeps the spec reporter's nesting stack valid.
|
||||||
|
export default async function* reporter(source: AsyncIterable<TestEvent>) {
|
||||||
|
async function* events(): AsyncGenerator<TestEvent> {
|
||||||
|
let executed = 0;
|
||||||
|
for await (const event of source) {
|
||||||
|
// Aggregate counters can include a synthetic pass for an empty test file.
|
||||||
|
// Worker summaries distinguish registered cases from that file-level result.
|
||||||
|
if (event.type === 'test:summary' && event.data.file !== undefined)
|
||||||
|
executed += event.data.counts.tests - event.data.counts.skipped;
|
||||||
|
if (
|
||||||
|
event.type === 'test:summary' &&
|
||||||
|
event.data.file === undefined &&
|
||||||
|
event.data.success &&
|
||||||
|
executed === 0
|
||||||
|
) {
|
||||||
|
// Node otherwise exits successfully when a name pattern matches no tests.
|
||||||
|
process.exitCode = 1;
|
||||||
|
yield {
|
||||||
|
type: 'test:diagnostic' as const,
|
||||||
|
data: {
|
||||||
|
nesting: 0,
|
||||||
|
level: 'error',
|
||||||
|
message: 'No tests executed. Check --name and skipped tests.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
process.env.DUNE_TEST_VERBOSE !== '1' &&
|
||||||
|
(event.type === 'test:pass' || event.type === 'test:start')
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
yield event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield* Readable.from(events()).pipe(new spec());
|
||||||
|
}
|
||||||
+34
-11
@@ -14,6 +14,7 @@ async function main() {
|
|||||||
list: { type: 'boolean', default: false },
|
list: { type: 'boolean', default: false },
|
||||||
name: { type: 'string' },
|
name: { type: 'string' },
|
||||||
concurrency: { type: 'string' },
|
concurrency: { type: 'string' },
|
||||||
|
verbose: { type: 'boolean', default: false },
|
||||||
help: { type: 'boolean', short: 'h' },
|
help: { type: 'boolean', short: 'h' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -24,6 +25,7 @@ async function main() {
|
|||||||
--list Print selected files without running tests
|
--list Print selected files without running tests
|
||||||
--name REGEX Run matching test names within selected files
|
--name REGEX Run matching test names within selected files
|
||||||
--concurrency NUMBER Parallel test files (default: up to 4; HTTP: 1)
|
--concurrency NUMBER Parallel test files (default: up to 4; HTTP: 1)
|
||||||
|
--verbose Include passing test rows (default: failures + summary)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
npm test -- ecaz-collection ecaz-spice
|
npm test -- ecaz-collection ecaz-spice
|
||||||
@@ -46,12 +48,13 @@ Examples:
|
|||||||
values.suite as TestSuite,
|
values.suite as TestSuite,
|
||||||
positionals,
|
positionals,
|
||||||
);
|
);
|
||||||
|
if (values.name !== undefined) new RegExp(values.name);
|
||||||
|
if (values.name === '') throw new Error('--name must not be empty.');
|
||||||
if (values.list) {
|
if (values.list) {
|
||||||
for (const file of selected) console.log(`${file.kind}\t${file.path}`);
|
for (const file of selected) console.log(`${file.kind}\t${file.path}`);
|
||||||
console.log(`${selected.length} test files`);
|
console.log(`${selected.length} test files`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (values.name) new RegExp(values.name);
|
|
||||||
const network = selected.some(({ kind }) => kind === 'integration');
|
const network = selected.some(({ kind }) => kind === 'integration');
|
||||||
const concurrency =
|
const concurrency =
|
||||||
values.concurrency === undefined
|
values.concurrency === undefined
|
||||||
@@ -85,23 +88,43 @@ Examples:
|
|||||||
'tsx',
|
'tsx',
|
||||||
'--test',
|
'--test',
|
||||||
`--test-concurrency=${concurrency}`,
|
`--test-concurrency=${concurrency}`,
|
||||||
...(values.name ? [`--test-name-pattern=${values.name}`] : []),
|
'--test-reporter=./tools/test-reporter.ts',
|
||||||
|
...(values.name !== undefined
|
||||||
|
? [`--test-name-pattern=${values.name}`]
|
||||||
|
: []),
|
||||||
...selected.map(({ path }) => path),
|
...selected.map(({ path }) => path),
|
||||||
],
|
],
|
||||||
{ cwd: root, stdio: 'inherit' },
|
{
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DUNE_TEST_VERBOSE: values.verbose ? '1' : '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
// Forward cancellation so a stopped check does not leave test workers behind.
|
// Forward cancellation so a stopped check does not leave test workers behind.
|
||||||
const interrupt = () => child.kill('SIGINT');
|
let interrupted = 0;
|
||||||
const terminate = () => child.kill('SIGTERM');
|
const interrupt = () => {
|
||||||
|
interrupted = 130;
|
||||||
|
child.kill('SIGINT');
|
||||||
|
};
|
||||||
|
const terminate = () => {
|
||||||
|
interrupted = 143;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
};
|
||||||
process.on('SIGINT', interrupt);
|
process.on('SIGINT', interrupt);
|
||||||
process.on('SIGTERM', terminate);
|
process.on('SIGTERM', terminate);
|
||||||
try {
|
try {
|
||||||
process.exitCode = await new Promise<number>((resolve, reject) => {
|
const { promise, resolve, reject } = Promise.withResolvers<number>();
|
||||||
child.once('error', reject);
|
child.once('error', reject);
|
||||||
child.once('exit', (code, signal) =>
|
child.once('close', (code, signal) =>
|
||||||
resolve(code ?? (signal === 'SIGINT' ? 130 : 1)),
|
resolve(
|
||||||
);
|
interrupted ||
|
||||||
});
|
(code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
process.exitCode = await promise;
|
||||||
} finally {
|
} finally {
|
||||||
process.off('SIGINT', interrupt);
|
process.off('SIGINT', interrupt);
|
||||||
process.off('SIGTERM', terminate);
|
process.off('SIGTERM', terminate);
|
||||||
|
|||||||
+18
-2
@@ -9,6 +9,7 @@ async function main() {
|
|||||||
options: {
|
options: {
|
||||||
out: { type: 'string' },
|
out: { type: 'string' },
|
||||||
focus: { type: 'string', multiple: true },
|
focus: { type: 'string', multiple: true },
|
||||||
|
name: { type: 'string' },
|
||||||
games: { type: 'string' },
|
games: { type: 'string' },
|
||||||
seed: { type: 'string' },
|
seed: { type: 'string' },
|
||||||
help: { type: 'boolean' },
|
help: { type: 'boolean' },
|
||||||
@@ -16,7 +17,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
if (values.help) {
|
if (values.help) {
|
||||||
console.log(
|
console.log(
|
||||||
'Usage: node --import tsx tools/verify.ts --out /tmp/new-run check [build] [integration]\nOr: --out /tmp/new-run focused --focus fragment [--focus fragment]\nOr: --out /tmp/new-run base-games --games 20 --seed 20260913\nLogs and source-bound report.json stay outside the checkout. Commands stop on failure; changed source invalidates success.',
|
'Usage: node --import tsx tools/verify.ts --out /tmp/new-run check [build] [integration]\nOr: --out /tmp/new-run focused --focus fragment [--focus fragment] [--name REGEX]\nOr: --out /tmp/new-run base-games --games 20 --seed 20260913\nLogs and source-bound report.json stay outside the checkout. Commands stop on failure; changed source invalidates success.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -24,6 +25,12 @@ async function main() {
|
|||||||
throw new Error('Provide --out and verification steps; see --help.');
|
throw new Error('Provide --out and verification steps; see --help.');
|
||||||
if (values.focus?.some((fragment) => !fragment || fragment.startsWith('-')))
|
if (values.focus?.some((fragment) => !fragment || fragment.startsWith('-')))
|
||||||
throw new Error('--focus accepts filename fragments, not runner options.');
|
throw new Error('--focus accepts filename fragments, not runner options.');
|
||||||
|
if (values.name !== undefined) {
|
||||||
|
if (!values.name) throw new Error('--name must not be empty.');
|
||||||
|
new RegExp(values.name);
|
||||||
|
if (!positionals.includes('focused'))
|
||||||
|
throw new Error('--name requires the focused step.');
|
||||||
|
}
|
||||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||||
const steps: VerificationStep[] = positionals.map((name) => {
|
const steps: VerificationStep[] = positionals.map((name) => {
|
||||||
if (['check', 'build', 'integration'].includes(name))
|
if (['check', 'build', 'integration'].includes(name))
|
||||||
@@ -33,7 +40,16 @@ async function main() {
|
|||||||
args: ['run', name === 'integration' ? 'test:integration' : name],
|
args: ['run', name === 'integration' ? 'test:integration' : name],
|
||||||
};
|
};
|
||||||
if (name === 'focused' && values.focus?.length)
|
if (name === 'focused' && values.focus?.length)
|
||||||
return { name, command: 'npm', args: ['test', '--', ...values.focus] };
|
return {
|
||||||
|
name,
|
||||||
|
command: 'npm',
|
||||||
|
args: [
|
||||||
|
'test',
|
||||||
|
'--',
|
||||||
|
...values.focus,
|
||||||
|
...(values.name !== undefined ? ['--name', values.name] : []),
|
||||||
|
],
|
||||||
|
};
|
||||||
if (name === 'base-games') {
|
if (name === 'base-games') {
|
||||||
const games = Number(values.games),
|
const games = Number(values.games),
|
||||||
seed = Number(values.seed);
|
seed = Number(values.seed);
|
||||||
|
|||||||
Reference in new issue
Block a user