From e9830b6ced80c95253cfa112bb8ed2eb6e50b74c Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:30:52 +1000 Subject: [PATCH] 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. --- AGENTS.md | 92 ++--- README.md | 47 ++- docs/CURRENT_STATUS.md | 79 +++- docs/DEVELOPMENT.md | 179 ++++++--- docs/IMPLEMENTATION_STATUS.md | 68 ++++ docs/RULE_DECISIONS.md | 1 + docs/STRONGHOLD_CARDS.md | 45 ++- docs/VERIFICATION_WORKFLOW.md | 86 +++-- game/engine.ts | 36 +- game/reference.ts | 26 ++ package.json | 3 +- tests/faction-games-cli.test.ts | 82 +++++ tests/fixture-stronghold-factions.ts | 407 +++++++++++++++++++++ tests/prototype-room.test.ts | 151 ++++++++ tests/sample-custody.test.ts | 43 +++ tests/stronghold-factions-recovery.test.ts | 365 ++++++++++++++++++ tests/stronghold-factions-runtime.test.ts | 373 +++++++++++++++++++ tests/test-workflow.test.ts | 187 +++++++++- tests/verification-tools.test.ts | 66 ++++ tools/faction-games.ts | 54 ++- tools/prototype-room.ts | 4 + tools/sample-custody.ts | 30 ++ tools/start-prototype.ts | 2 +- tools/test-discovery.ts | 2 + tools/test-reporter.ts | 40 ++ tools/test.ts | 45 ++- tools/verify.ts | 20 +- 27 files changed, 2339 insertions(+), 194 deletions(-) create mode 100644 tests/fixture-stronghold-factions.ts create mode 100644 tests/stronghold-factions-recovery.test.ts create mode 100644 tests/stronghold-factions-runtime.test.ts create mode 100644 tools/test-reporter.ts diff --git a/AGENTS.md b/AGENTS.md index f794107b..39f601a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,16 +6,18 @@ for the in-memory SQLite tests. `tsx` is a pinned development dependency. ## Start small -- Read `README.md` and this guide first. See `docs/DEVELOPMENT.md` for the - architecture map and focused verification commands. +- Start here; use `docs/DEVELOPMENT.md#context-index` to open only the task's + canonical paths, then the relevant README instructions if needed. - When continuing the complete project goal in this checkout, explicitly read `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 request. Never recreate it from credential-bearing handovers or commit it. -- Read `docs/CURRENT_STATUS.md` for current gates and `docs/RULE_DECISIONS.md` - for existing rulings before reopening research. +- Read the opening of `docs/CURRENT_STATUS.md` for active work; use the bounded + 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. -- 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 `read` for `game/engine.ts` and `components/game-table.tsx`; both are large 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 Basic, Advanced and every expansion, followed by integration, refinement and polish. Reuse existing capabilities; batch related functions by dependency. -- The 26 September 2026 priority amendment puts game content first. Current - playable continuation and blocking save fixes remain required; historical-save - compatibility, exhaustive restore/seat audits, backup refinement and routine - whole-database comparisons move to final polish after game content. Retain - privacy, authorization, custody, no-reset safety, complete administration, - relevant checkpoint checks and final recovery acceptance. +- The 2 October 2026 amendment supersedes the 26 September sequencing: + implement all rules first; save/recovery, privacy and custody assurance plus + comprehensive review follow afterward, not as per-prototype gates. Do not + repeat seat-restoration audits, whole-database comparisons or preservation + loops at each feature. This defers assurance, not printed card/force/payment + mechanics, existing authorization, no-reset/no-deletion or secret protection. - Track missing, prototyped, integrated, verified and polished work in the - existing checklist. Prototypes need usable controls, a legal AI path and saved - continuation; placeholders and disconnected helpers are not completed features. + existing checklist. Prototypes need usable controls, connected rule behavior, + 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, lifecycle/removal, participant support, backups/restoration and operations. Follow `docs/ADMIN_PANEL.md`; retain authorization, privacy, audit history and saved-game safeguards. Adding this scope does not authorize a game reset. -- Keep focused checks for crashes, deadlocks, legality, custody, privacy and save - integrity. Defer exhaustive combinations and visual polish until broader - functional coverage, unless current failures require them. +- Iterate on the smallest failing case first. Keep legality, crashes, deadlocks, + usable controls and minimal legal AI feedback; batch related rules by dependency + before integration proof. Do not add privacy/custody/save audits to that loop. - Defer full AI implementation, strategic refinement and difficulty calibration until all non-AI game features are complete. Until then, maintain only minimal legal participation and fix critical playability/correctness issues. Afterward, 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. -- Preserve required checkpoint checks, selective subagents and independent - review for complex rules/privacy/persistence. Commit and push verified - checkpoints under standing authorization. No user-imposed usage cutoff or - routine account-usage check remains. +- Keep one shared integration owner and selectively delegate disjoint substantial + slices. Rule-specific decisions may need bounded review now; comprehensive + rules/privacy/persistence review follows all rules. Commit and push verified + checkpoints under standing authorization. No usage cutoff or routine + account-usage check remains. - Keep material rulings explicit and prototype independent work while pending. The user-authorized Advanced preview permits unfinished base-faction starts 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 -See [the OMP harness workflow](docs/DEVELOPMENT.md#omp-harness-workflow). -Live tool schemas take precedence. Use native planning, delegation and browser -tools, keep one integration owner and retain selective independent review for -complex rules, hidden information and persistence. +See [the OMP harness workflow](docs/DEVELOPMENT.md#omp-harness-workflow) and +[compact handoff](docs/DEVELOPMENT.md#compact-agent-handoff). Live tool schemas +take precedence. Use native planning, delegation and browser tools; one +integration owner freezes related work before running shared checks. ## Implement and verify - Keep rule calculations/validation in focused `game/` modules; integrate them through the engine, player view, bots and UI as the feature requires. -- Preserve rejected-action immutability, physical card/force custody, private - information boundaries, and JSON save/restore behavior. Reuse shared rule - quotes instead of independently duplicating legality in bots or components. +- Reuse shared rule quotes rather than duplicating legality in bots or components. + Preserve existing rejected-action immutability, physical card/force mechanics, + 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. HTTP tests must start with `// @dune-suite integration`. Tests importing `node:sqlite` are classified as in-memory recovery tests automatically. -- During iteration: `npm test -- `; multiple fragments form - a union. Use `--list` to inspect selection and `--name ` to narrow - cases. A filename typo fails rather than silently passing an empty suite. -- Before handing off code changes: `npm run check` (types, lint, offline tests). - Run `npm run build` for app/build/dependency changes. Run - `npm run test:integration` against a development server for HTTP, auth or - persistence changes. Documentation-only edits need link/command review. -- Extend testing when failures or changed behavior justify it. Do not run AI - calibration or browser acceptance for unrelated maintenance changes. +- During iteration: `npm test -- --name ''`. + Inspect with `--list`; verify the intended case actually ran. Once repaired, + run the related file union once, not after every tiny fixture edit. Filename + typos fail instead of passing an empty selection. +- At a frozen related-rule batch, the integration owner runs `npm run check:quick` + (types/lint), affected tests and the changed runtime path once. Build + app/build/dependency changes; + run relevant HTTP tests for actual boundary changes, not routine assurance. + 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 with approved subprocess permissions. Do not disable isolation or skip tests 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. - Reuse a running dev server. Restart only when a code/configuration change or observed server condition warrants it; there is no hourly restart schedule. - Warn before interrupting human play, defer to a safe point, and verify - restoration after a necessary restart. No recurring maintenance automation - is installed; do not recreate it without a request. + Warn before interrupting human play and defer to a safe point. Observe server + health after a necessary restart; comprehensive restoration assurance follows + all rules. No recurring maintenance automation is installed. - `.openai/hosting.json` identifies the existing Sites project. Preserve it. Follow the applicable Sites skills for site work; publication has existing completion and verification gates documented in `README.md`. @@ -117,8 +127,8 @@ complex rules, hidden information and persistence. ## Efficient checkpoints -- Use `docs/VERIFICATION_WORKFLOW.md` for compact source-bound check reports - and private saved-game/seat checks. Keep artifacts outside the checkout. +- Use `docs/VERIFICATION_WORKFLOW.md` for compact source-bound reports and the + deferred final saved-game/seat assurance commands. Keep artifacts outside Git. - Use subagents selectively for bounded independent tasks and complex reviews; they remain explicitly authorized. Share concise briefs and file ownership. - Review, verify, commit and push completed checkpoints automatically under diff --git a/README.md b/README.md index d4aa9e6d..d910792f 100644 --- a/README.md +++ b/README.md @@ -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. 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 counts separately from hands, auction cards and discards. See [draw-pile counts](docs/DRAW_PILES.md). @@ -258,20 +269,40 @@ completes automatically; see [private exchange history](docs/HARKONNEN_EXCHANGE. ## Verify changes -Use the [development guide](docs/DEVELOPMENT.md) for architecture, focused tests -and the [repeatable verification tools](docs/VERIFICATION_WORKFLOW.md). Tests are discovered automatically; the default -suite includes in-memory persistence recovery and needs no server: +Use the [bounded context index](docs/DEVELOPMENT.md#context-index) for canonical +feature/source paths and the [feedback/proof schedule](docs/DEVELOPMENT.md#feedback-and-proof-schedule). +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 +# 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 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 +# App/build/dependency changes also need a build. npm run build ``` -Run `npm run test:integration` against a development server for HTTP/session -changes. `npm run test:multiplayer` remains available for recovery plus HTTP -checks; `npm run test:recovery` runs only in-memory persistence tests. All test -commands accept filename fragments and `--list` after `--`. +Tests are discovered automatically. The offline suite includes in-memory +persistence recovery and needs no server. Run relevant `test:integration` cases +against a development server for changed HTTP/session behavior, not repeated +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. @@ -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. -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. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 3b5bbe97..86cda91e 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,49 @@ open mode gates. ## 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 auction, shipment, paid revival and resolved support source in fresh local `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. - [Rule decision index](RULE_DECISIONS.md): settled contracts, source precedence and pending material interpretations. Consult it before reopening research. -- [Development guide](DEVELOPMENT.md) and [verification tools](VERIFICATION_WORKFLOW.md): - architecture, focused checks, source-bound reports, reusable faction samples, - private backup and saved-seat restoration. +- [Development context index](DEVELOPMENT.md#context-index), [canonical lookup](DEVELOPMENT.md#canonical-feature-lookup) + and [verification tools](VERIFICATION_WORKFLOW.md): bounded paths, narrow + feedback, frozen-batch proof and deferred final assurance. - [Implementation history](IMPLEMENTATION_STATUS.md): newest first. Historical evidence and outage records stay there and in feature documents; read bounded 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 -Prototype every remaining function in dependency order, using existing controls, -legal AI and the saved-state model. Continue independent functions while material -rulings are pending. Then run integrated games, repair failures, deepen combination -and recovery coverage, and complete all non-AI features. Only then fully implement -and refine AI strategy and calibrate the adjacent difficulty pairs. Keep focused -checks for crashes, deadlocks, legality, custody and privacy; complete required -broad checkpoint checks. -Use selective subagents and independent review for complex rules/privacy/persistence. +Prototype all remaining rules in dependency-related batches using existing +controls and minimal legal AI. Continue independent functions while material +rulings are pending. The integration owner freezes each batch, repairs the +smallest failing case first, then runs affected tests/types/lint and changed-path +smoke once. Do not run the full offline suite or repeat HTTP/seat/save/privacy/ +custody audits at every tiny prototype. + +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 success. The user removed the usage cutoff and routine account-usage checks; efficient work, Git delivery and selective subagents remain required. The user-authorized 13 September reset happened once; preserve all games created 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, -with a fresh backup, safe human-play boundary and verified restoration. No hourly -restart or recurring maintenance automation is installed; do not recreate one. +Git. Reuse a healthy server; restart only for a relevant change or observed +problem, warn before interrupting human play and use a safe point. Observe health; +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. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0132f9d6..945ae29f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,19 +1,51 @@ # Development workflow -Start with [README](../README.md) for installation and saved-game behavior and -[AGENTS.md](../AGENTS.md) for project invariants. This page is a navigation and -verification guide, not a second implementation-status log. +Start with [AGENTS.md](../AGENTS.md), then choose the bounded read set below. +This page is a context/navigation and workflow guide, not a second status or +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). -Use [repeatable verification](VERIFICATION_WORKFLOW.md) for compact source-bound -logs, private saved-game backups/comparisons and HTTP seat restoration. -The [three-milestone pilot](EFFICIENCY_PILOT.md) records measurement limits. -Use the [Ix prototype workflow](IX_PROTOTYPE.md) to connect a fresh local ready -lobby to genuine expansion setup with a private backup and preserved sessions. -It does not open normal mode gates or require restarting the server. -The [expansion-faction profile](EXPANSION_FACTIONS_PROTOTYPE.md) extends genuine -setup to selected faction sets without optional modules, including Ecaz starting -placement. Remaining combined-play guards still apply. +## Context index + +| Task | Open first | Open only as needed | +| --- | --- | --- | +| 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 | +| Find a rule/feature | [Canonical lookup](#canonical-feature-lookup) | That feature's linked helper, engine action, view, UI, bot and tests | +| 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 | +| Iterate/integrate | [Fast feedback](#fast-feedback), [proof schedule](#feedback-and-proof-schedule) | [Source-bound reports](VERIFICATION_WORKFLOW.md#compact-checks-and-source-evidence) | +| 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 | +| 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). 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 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 -read-only, write a self-contained spec at `local://-plan.md`, then submit -the matching plain-text slug to `xd://propose` with `write`; wait for approval -before execution. Scope first, keep straightforward changes inline, and batch -independent substantial slices with explicit ownership and acceptance criteria -through `task`: `scout` for read-only unmapped exploration, default agent omitted -for implementation, `sonic` only for mechanical changes, and `reviewer` or -`security-reviewer` for applicable independent high-risk review. Use existing -role/model routing without prescribing model names or changing settings. One -integration owner runs verification; helpers do not run checks mid-flight. -Consume delivered results without polling; use `wait` only when blocked. +Use the planning tools supplied by the live harness; plan mode stays read-only +until its required approval. Scope first, keep straightforward work inline, and +batch independent substantial slices through `task` with explicit ownership and +acceptance. Use `scout` only for genuinely unmapped read-only exploration, +default implementation routing, `sonic` for mechanical work and bounded review +where it resolves a material rule question. Comprehensive review follows all +rules. Do not prescribe model names or change settings. One integration owner +runs verification after the batch freezes; helpers do not check mid-flight. +Consume delivered results without polling; `wait` only when blocked. 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 @@ -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 actions. Keep production evidence tied to the deployed revision. -The 26 September 2026 priority amendment puts game content first. Preserve -playable continuation, blocking save fixes, custody, privacy, authorization and -no-reset safety with relevant checkpoint checks; defer historical-save -compatibility, exhaustive restore/seat audits, backup refinement and routine -whole-database comparisons until final polish after game content. Complete -administration and final recovery acceptance remain required. Use the -[verification guide](VERIFICATION_WORKFLOW.md) for commands and final acceptance. +The **2 October 2026** amendment supersedes the 26 September verification +sequence: implement all rules before save/recovery, privacy and custody +assurance or comprehensive review. Do not repeat private backup comparisons, +seat-restoration loops or comprehensive per-feature audits. Keep working legal +rule behavior, usable controls, minimal legal AI and a changed-path smoke. +Deferral does not remove printed card/force/payment mechanics, existing access +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 @@ -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 view, bot policy, UI and existing tests before editing. Keep server legality -authoritative and reuse helpers across those consumers. Test rejection without -mutation, physical resource conservation, interrupted decisions and saved JSON -restoration where relevant. See `tests/fixture-hand.ts` for physical card fixtures. +authoritative and reuse helpers across those consumers. Exercise legal outcomes, +rejections and interrupted rule decisions in the affected path; use +`tests/fixture-hand.ts` for physical card fixtures. Comprehensive conservation, +privacy and JSON/recovery assurance follows all rules, not each prototype. ## 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. npm test -- ecaz-collection ecaz-spice --list -# Run related files, optionally narrowing test names. -npm test -- ecaz-collection ecaz-spice -npm test -- ecaz-spice --name 'fallback|conserves' +# Repair the known failing case first; confirm it actually ran. +npm test -- ecaz-spice --name 'fallback' -# 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 # 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:multiplayer` | Recovery plus HTTP tests; preserves the previous command's scope | 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 | -All test commands accept filename fragments, `--list`, `--name REGEX`, and -`--concurrency NUMBER` after npm's `--`. Filename filters are literal substring -matches, combined with OR; every supplied filter must match its selected suite. -Name patterns are Node test-runner regular expressions and may skip every case, -so check the reported results when using them. +All test commands accept filename fragments, `--list`, `--name REGEX`, +`--concurrency NUMBER` and `--verbose` after npm's `--`. Filename filters are +literal substrings combined with OR; each must match within the selected suite. +`--name` is a Node test-runner regular expression intersected with that file +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 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 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 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 patch backups are ignored. Git history preserves prior source versions. -Use feature docs for rule decisions and evidence, and keep new evidence concise. -The large [implementation status](IMPLEMENTATION_STATUS.md) log contains historical -checkpoints; a previous green count is not proof that the current source passes. -Full AI studies and browser acceptance belong to changes in their respective -behavior, not every edit. Runtime optimizations should follow a measured bottleneck -and retain legality, secrecy and saved-game invariants. +Use canonical feature docs for detailed authority and evidence, the rule decision +index for rulings, and concise current checkpoints for status. The large +[implementation history](IMPLEMENTATION_STATUS.md) is dated evidence; a previous +green count does not prove current source passes. Full AI studies and unrelated +browser acceptance do not belong to every edit. Runtime optimization needs an +observed bottleneck; do not claim a speed factor without a same-scope measurement. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 3ff2d6e7..cd366e8b 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,73 @@ # 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 Three exclusive implementation owners connect the fresh `banker-income` diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 72e3fcb1..25c28f28 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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) | | 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) | +| 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) | | 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) | diff --git a/docs/STRONGHOLD_CARDS.md b/docs/STRONGHOLD_CARDS.md index 37eb95d4..8729f098 100644 --- a/docs/STRONGHOLD_CARDS.md +++ b/docs/STRONGHOLD_CARDS.md @@ -1,12 +1,53 @@ # 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 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`. 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. diff --git a/docs/VERIFICATION_WORKFLOW.md b/docs/VERIFICATION_WORKFLOW.md index 8cb752e6..9e1dd92c 100644 --- a/docs/VERIFICATION_WORKFLOW.md +++ b/docs/VERIFICATION_WORKFLOW.md @@ -6,15 +6,26 @@ they do not replace rules, browser or release acceptance. ## Current verification priority -The 26 September 2026 amendment prioritizes remaining game content before -extensive saved-game polish. Keep focused current-feature playable continuation, -custody, privacy, authorization, no-reset and blocking save checks and required -checkpoint commands. Historical-save compatibility, exhaustive restore/seat -audits, backup refinement and routine whole-database comparisons wait until -final polish after game content; final recovery acceptance is still required. -The tools below remain available, not a mandate to run every preservation -command for each content checkpoint. See the -[OMP harness workflow](DEVELOPMENT.md#omp-harness-workflow) for execution mechanics. +The **2 October 2026** amendment supersedes the 26 September sequence: implement +all rules first; save/recovery, privacy and custody assurance and comprehensive +review follow afterward. They are not per-prototype gates. Keep legal rule +transitions, usable controls, minimal legal AI and changed-path runtime smoke. +Printed card/force/payment mechanics, existing authorization, secret protection, +no reset/deletion of existing games and deployment safeguards remain in force. + +Use the [feedback/proof schedule](DEVELOPMENT.md#feedback-and-proof-schedule): +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 @@ -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 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. -Each pushed application checkpoint also needs a separate live deployment; a -successful GitHub container publication alone is not a deployment. Follow -[the manual TrueNAS update](TRUENAS.md#applying-an-update) only after the image -passes CI, the persistent volume is backed up and play reaches a safe point. +A pushed image is not a deployment. Do not make a separate live rollout a +per-prototype gate; deployed acceptance remains required after all rules and for +actual deployments. Follow [the manual TrueNAS update](TRUENAS.md#applying-an-update) +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 -to a dedicated deployment agent while the integration owner continues other -work. Supply the exact successful immutable image tag; the agent changes only -the `dune` app in Apps, retains its `/data` volume, and reports the displayed -revision and saved-room continuity before the checkpoint is called deployed. +For an authorized deployment, use a dedicated deployment agent if useful while +the integration owner continues other work. Supply the exact successful immutable +image tag; the agent changes only the `dune` app in Apps, retains its `/data` +volume, and reports the displayed revision and deployment-required continuity +before the checkpoint is called deployed. 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 @@ -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 or restart the server merely to run a routine smoke test. -When a deployment or necessary restart occurs, verify saved-game and seat -continuity afterward, distinguishing legitimate gameplay changes from data loss. -Use the existing hosting/deployment procedure and additive migrations; testing -requirements do not authorize bypassing completion or publication gates. +Actual production deployments retain their existing continuity safeguards. +A routine local content checkpoint or dev-server reload is not a mandate to +reaudit every saved seat. Comprehensive save/recovery/privacy/custody assurance +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 production gameplay run. Record production evidence separately from the local 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 ```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 +# Full offline proof after all rules or a justified broad integration checkpoint. 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 -# 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 ``` +`focused` accepts repeated `--focus` filename fragments and optional `--name +REGEX`, using the test runner's union/file-plus-name semantics. Use +`npm test -- --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. 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 @@ -93,6 +117,11 @@ An interrupted or incomplete report is never evidence of success. ## 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 ```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 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 -targeted storage-risk maintenance operation warrants a baseline, snapshot the -current set rather than reusing historical room counts; routine content-only -checkpoints do not require full-database comparisons. +targeted authorized storage operation warrants a baseline, snapshot the current +set rather than reusing historical room counts. Routine rule checkpoints do not +require these comparisons; comprehensive assurance follows all rules. 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 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, 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. -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. diff --git a/game/engine.ts b/game/engine.ts index 53c26082..fb82434f 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -8065,7 +8065,7 @@ function finishMandatorySkillVisibility(g: Game) { } } -function initializeSetup(g: Game) { +function initializeSetup(g: Game, strongholds = !!g.strongholdCards) { const ecaz = byFaction(g, 'ecaz'); if (ecaz && g.advanced) g.ecazLoyalty = { player: ecaz.id, card: null }; if (g.discoveryEnabled) g.discoveries = createDiscoveryState(random); @@ -8073,7 +8073,7 @@ function initializeSetup(g: Game) { const choam = byFaction(g, 'choam'); if (choam && g.advanced && !choam.leaders.some(isAuditorLeader)) choam.leaders.push(createAuditorLeader()); - if (g.strongholdCards) { + if (strongholds) { requireRule(g.advanced, 'Stronghold Cards require Advanced rules.'); g.strongholdCards = createStrongholdCards(); } @@ -9077,6 +9077,27 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game { !state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker && !state.techTokens && !state.strongholdCards, '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 && state.ecazAmbassadors === undefined && state.moritaniTerror === undefined && state.dukeVidal === 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); }); }), '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. */ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game { @@ -9182,7 +9202,7 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game { g.ecazTreachery = 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); homeworldRule(() => homeworldGameIntegrity(state)); homeworldBattleLossIntegrity(state); @@ -9200,14 +9220,16 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f (nexus || !g.nexusCards) && (ecazTreachery || !g.ecazTreachery) && !g.techTokens && - !g.strongholdCards && + (strongholdFactions || !g.strongholdCards) && (homeworlds || !g.homeworlds) && g.players.every((p) => 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))), ), ), - 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.' : factions ? '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 audit initializer supports base factions without expansions or optional modules.', ); - initializeSetup(g); + initializeSetup(g, strongholdFactions || !!g.strongholdCards); return normalizeAutomaticGame(g); } function ecazStartingForcesComplete(p: Player): boolean { diff --git a/game/reference.ts b/game/reference.ts index 0af35d8b..25a1e785 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3157,6 +3157,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'mentat', 'choam-combat', 'mobile-stronghold', + 'stronghold-factions', 'choam-modules', ], 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', title: 'Tech tokens: income, conquest and victory', diff --git a/package.json b/package.json index a221019c..e38c1b0e 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "test:recovery": "node --import tsx tools/test.ts --suite recovery", "test:integration": "node --import tsx tools/test.ts --suite integration", "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": { "react": "19.2.6", diff --git a/tests/faction-games-cli.test.ts b/tests/faction-games-cli.test.ts index 385a44d5..20f3baf9 100644 --- a/tests/faction-games-cli.test.ts +++ b/tests/faction-games-cli.test.ts @@ -13,6 +13,11 @@ import { import { tmpdir } from 'node:os'; 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 cli = new URL('../tools/faction-games.ts', import.meta.url).pathname; type CliResult = { @@ -94,6 +99,83 @@ function assertFailedEvidence( return report; } +void test('native Stronghold samples admit every two-to-six-seat roster and preserve canonical physical inventories', (t) => { + const area = temporary(t); + const out = join(area, 'native'); + assert.equal(run(out, '--profile', 'stronghold-factions', + '--players', 'all', '--seed', '1000', '--max-actions', '1').status, 1); + const results = json<{ results: CliResult[] }>(join(out, 'results.json')).results; + 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(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(join(continued, 'failed-stronghold-factions-6-advanced.json')), + sampleInventory(json(snapshot)), json(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(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) => { const area = temporary(t); const out = join(area, 'ecaz-cards'); diff --git a/tests/fixture-stronghold-factions.ts b/tests/fixture-stronghold-factions.ts new file mode 100644 index 00000000..d6969b45 --- /dev/null +++ b/tests/fixture-stronghold-factions.ts @@ -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; + currentControllers: Record; + physicalForces: { player: string; forces: Record; eliteForces: Record }[]; +}; +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 = (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; diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts index 8a3a3010..ea76b910 100644 --- a/tests/prototype-room.test.ts +++ b/tests/prototype-room.test.ts @@ -20,6 +20,8 @@ import { } from '../tools/prototype-room'; import { NEXUS_FACTIONS } from '../game/nexus-cards'; import { createLeaderSkills } from '../game/leader-skills'; +import { createStrongholdCards } from '../game/stronghold-cards'; +import { treacheryDeck } from '../game/cards'; function fixture(t: test.TestContext) { 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 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); +}); diff --git a/tests/sample-custody.test.ts b/tests/sample-custody.test.ts index 640ff3dc..4c979b89 100644 --- a/tests/sample-custody.test.ts +++ b/tests/sample-custody.test.ts @@ -19,6 +19,7 @@ import { resolveAssassinationBattle, stageAssassinationBattle, } 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', () => { const game = completedMoritaniSkillsGame(); @@ -52,6 +53,48 @@ void test('Moritani assassination samples conserve retired Traitors after real b duplicated.traitorReserve!.push('guild-1'); 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) { let game = createGame( diff --git a/tests/stronghold-factions-recovery.test.ts b/tests/stronghold-factions-recovery.test.ts new file mode 100644 index 00000000..505a0865 --- /dev/null +++ b/tests/stronghold-factions-recovery.test.ts @@ -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; +interface ProtectedRows { + seats: SqlRow[]; + entries: SqlRow[]; + keys: SqlRow[]; + receipts: SqlRow[]; +} +interface Fixture extends StrongholdFactionsFixture { + sqlite: DatabaseSync; + restart(): typeof Rooms; + hooks: { beforeWrite?: () => Promise }; + 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 { + 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(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); +}); diff --git a/tests/stronghold-factions-runtime.test.ts b/tests/stronghold-factions-runtime.test.ts new file mode 100644 index 00000000..1c36e30a --- /dev/null +++ b/tests/stronghold-factions-runtime.test.ts @@ -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); +}); diff --git a/tests/test-workflow.test.ts b/tests/test-workflow.test.ts index 6dd2dfd8..8307262e 100644 --- a/tests/test-workflow.test.ts +++ b/tests/test-workflow.test.ts @@ -1,9 +1,17 @@ import test from 'node:test'; 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 { join } from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { classifyTest, 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', () => { const files = [{ path: 'tests/rules.test.ts', kind: 'unit' as const }]; assert.equal(selectTests(files, 'offline', ['rules']).length, 1); + assert.throws(() => selectTests(files, 'offline', [''])); assert.throws( () => selectTests(files, 'offline', ['rules', 'typo']), /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/); }); -void test('the TypeScript test subprocess returns failure for a failing assertion', (t) => { - const root = mkdtempSync(join(tmpdir(), 'dune-test-failure-')); +function runnerFixture(t: test.TestContext, sources: Record) { + const root = mkdtempSync(join(tmpdir(), 'dune-test-runner-')); t.after(() => rmSync(root, { recursive: true, force: true })); - const file = join(root, 'failure.test.mts'); - writeFileSync( - file, - "import test from 'node:test';\ntest('failure sentinel', () => { throw new Error('expected failure'); });\n", + mkdirSync(join(root, 'tools')); + mkdirSync(join(root, 'tests')); + writeFileSync(join(root, 'package.json'), '{"type":"module"}\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. const env = { ...process.env }; delete env.NODE_TEST_CONTEXT; - const result = spawnSync( - process.execPath, - ['--import', 'tsx', '--test', file], - { - cwd: new URL('../', import.meta.url), - env, - timeout: 15_000, - encoding: 'utf8', - }, - ); + const args = ['--import', import.meta.resolve('tsx'), 'tools/test.ts']; + return { + root, + env, + args, + run: (...options: string[]) => + spawnSync(process.execPath, [...args, ...options], { + 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.equal(result.status, 1); - assert.match(result.stdout + result.stderr, /expected failure/); + assert.equal(result.status, 0); + 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(); + child.once('error', reject); + child.once('close', resolve); + assert.equal(await promise, expected); + } }); diff --git a/tests/verification-tools.test.ts b/tests/verification-tools.test.ts index b58b7a5c..e107cae6 100644 --- a/tests/verification-tools.test.ts +++ b/tests/verification-tools.test.ts @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import { + cpSync, mkdtempSync, mkdirSync, writeFileSync, @@ -13,6 +14,7 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { privateOutputDirectory, 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); }); +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) { const area = mkdtempSync(join(tmpdir(), 'dune-verification-')); t.after(() => rmSync(area, { recursive: true, force: true })); diff --git a/tools/faction-games.ts b/tools/faction-games.ts index 6498df62..11b8b83c 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -12,6 +12,7 @@ import { initializeBaseGameForAudit, initializeFactionExpansionsGameForAudit, initializeHomeworldGameForAudit, + initializeStrongholdFactionsGameForAudit, initializeNexusGameForAudit, initializePairedNexusGameForAudit, initializeEcazTreacheryGameForAudit, @@ -32,7 +33,7 @@ import { privateOutputDirectory, sourceSnapshot } from './verification'; const DEFAULT_SEED = 20_260_926; const DEFAULT_MAX_ACTIONS = 3_500; 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 Scenario = { @@ -216,6 +217,16 @@ const CHOAM_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players expansions: ['choam'], 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'; type TraceEntry = { @@ -254,10 +265,10 @@ type Result = { function usage() { return ( '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] ' + '[--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) { 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.rules}`; } function parseProfile(value: string | undefined) { 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)) - 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.'); + 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, 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'; } @@ -353,6 +364,8 @@ function freshGame(scenario: Scenario) { return initializeEcazTreacheryGameForAudit(game); if (scenario.profile === 'moritani-assassinate') return initializeMoritaniAssassinateGameForAudit(game); + if (scenario.profile === 'stronghold-factions') + return initializeStrongholdFactionsGameForAudit(game); return skillProfile(scenario.profile) ? initializeLeaderSkillsGameForAudit(game) : scenario.profile === 'homeworld' @@ -402,15 +415,15 @@ function resumedGame(path: string) { game.discoveryStash || game.greatMaker || game.techTokens || - game.strongholdCards || game.mentatQuestionPreview ) throw new Error('--resume sample scenarios exclude unsupported optional modules.'); - const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...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.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 && skillProfile(candidate.profile) === !!game.leaderSkills && + (candidate.profile === 'stronghold-factions') === !!game.strongholdCards && (candidate.profile === 'ecaz-treachery') === !!game.ecazTreachery && (candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinatePreview && (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. 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) }; } @@ -438,6 +453,11 @@ function simulate( ) { let game = initial; 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 used: Record = {}; const rejected: Record = {}; @@ -498,8 +518,9 @@ function simulate( preparation: game.battle?.preparation, })}`, ); + const previous = game; game = next; - verifySampleCustody(game, expected); + verifySampleCustody(game, expected, previous); if (actions % 37 === 0) { const restored = JSON.parse(JSON.stringify(game)) as Game; for (const player of game.players) @@ -520,6 +541,7 @@ function simulate( `private rival field ${field}`, ); } + verifySampleCustody(restored, expected, game); game = restored; restores++; } @@ -606,7 +628,7 @@ async function main() { const profile = parseProfile(values.profile); const rules = parseRules(values.rules); 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.'); 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.`); @@ -614,12 +636,15 @@ async function main() { throw new Error('Expansion Leader Skills profiles currently support only Basic samples.'); if (profile === 'moritani-assassinate' && rules === 'basic') 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 requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus' ? MODULE_SCENARIOS : profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS : profile === 'ecaz-treachery' ? ECAZ_TREACHERY_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 === 'combined-nexus' ? COMBINED_NEXUS_SCENARIOS : profile === 'combined-homeworld-nexus' ? COMBINED_HOMEWORLD_NEXUS_SCENARIOS @@ -721,6 +746,15 @@ async function main() { randomness: resume ? '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.', + ...(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: { seed, profile: resume ? resume.scenario.profile : profile, diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index f2e95843..b2685321 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -5,6 +5,7 @@ import { initializeLeaderSkillsGameForAudit, initializeSpiceBankerIncomeGameForAudit, initializeFactionExpansionsGameForAudit, + initializeStrongholdFactionsGameForAudit, initializeNexusGameForAudit, initializeMoritaniAssassinateGameForAudit, initializeEcazTreacheryGameForAudit, @@ -26,6 +27,7 @@ export const PROTOTYPE_PROFILES = [ 'leader-skills', 'banker-income', 'factions', + 'stronghold-factions', 'nexus', 'moritani-assassinate', 'ecaz-treachery', @@ -79,6 +81,8 @@ export function startPrototypeRoom( const game = profile === 'ix' ? initializeIxGameForAudit(initial) + : profile === 'stronghold-factions' + ? initializeStrongholdFactionsGameForAudit(initial) : profile === 'ecaz-treachery' ? initializeEcazTreacheryGameForAudit(initial) : profile === 'moritani-assassinate' diff --git a/tools/sample-custody.ts b/tools/sample-custody.ts index 7e3e7255..b78f1ea2 100644 --- a/tools/sample-custody.ts +++ b/tools/sample-custody.ts @@ -6,11 +6,15 @@ import { ecazTreacheryCards } from '../game/ecaz-cards'; import { traitorDeck } from '../game/traitors'; import { basicExpansionLeaderSkillsProfile } from '../game/leader-skill-profile'; import { validateLeaderSkills } from '../game/leader-skills'; +import { ownedStrongholdCards, STRONGHOLD_CARDS } from '../game/stronghold-cards'; /** Fixed inventory captured after genuine setup initialization, before any choices. */ export function sampleInventory(game: Game) { return { leaderSkills: !!game.leaderSkills, + strongholds: game.strongholdCards + ? STRONGHOLD_CARDS.map(card => card.id).sort() + : null, cards: [ ...treacheryDeck(game.expansions), ...(game.ecazTreachery ? ecazTreacheryCards() : []), @@ -34,9 +38,35 @@ export function sampleInventory(game: Game) { export function verifySampleCustody( game: Game, inventory: ReturnType, + previous?: Game, ) { assert.equal(!!game.leaderSkills, inventory.leaderSkills, 'Leader Skills module custody'); 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 = [ ...game.players.flatMap((player) => player.hand), ...game.deck, diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 6faaf6f2..441637d7 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -20,7 +20,7 @@ async function main() { }); if (values.help) { 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; } diff --git a/tools/test-discovery.ts b/tools/test-discovery.ts index fbef2bc3..d5371fce 100644 --- a/tools/test-discovery.ts +++ b/tools/test-discovery.ts @@ -37,6 +37,8 @@ export function selectTests( suite: TestSuite, filters: string[] = [], ): TestFile[] { + if (filters.some((filter) => !filter)) + throw new Error('Filename filters must not be empty.'); const selected = files.filter(({ path, kind }) => { const included = suite === 'all' || diff --git a/tools/test-reporter.ts b/tools/test-reporter.ts new file mode 100644 index 00000000..da30bc0a --- /dev/null +++ b/tools/test-reporter.ts @@ -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) { + async function* events(): AsyncGenerator { + 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()); +} diff --git a/tools/test.ts b/tools/test.ts index 24bb3460..ad23fcfe 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -14,6 +14,7 @@ async function main() { list: { type: 'boolean', default: false }, name: { type: 'string' }, concurrency: { type: 'string' }, + verbose: { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h' }, }, }); @@ -24,6 +25,7 @@ async function main() { --list Print selected files without running tests --name REGEX Run matching test names within selected files --concurrency NUMBER Parallel test files (default: up to 4; HTTP: 1) + --verbose Include passing test rows (default: failures + summary) Examples: npm test -- ecaz-collection ecaz-spice @@ -46,12 +48,13 @@ Examples: values.suite as TestSuite, positionals, ); + if (values.name !== undefined) new RegExp(values.name); + if (values.name === '') throw new Error('--name must not be empty.'); if (values.list) { for (const file of selected) console.log(`${file.kind}\t${file.path}`); console.log(`${selected.length} test files`); return; } - if (values.name) new RegExp(values.name); const network = selected.some(({ kind }) => kind === 'integration'); const concurrency = values.concurrency === undefined @@ -85,23 +88,43 @@ Examples: 'tsx', '--test', `--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), ], - { 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. - const interrupt = () => child.kill('SIGINT'); - const terminate = () => child.kill('SIGTERM'); + let interrupted = 0; + const interrupt = () => { + interrupted = 130; + child.kill('SIGINT'); + }; + const terminate = () => { + interrupted = 143; + child.kill('SIGTERM'); + }; process.on('SIGINT', interrupt); process.on('SIGTERM', terminate); try { - process.exitCode = await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => - resolve(code ?? (signal === 'SIGINT' ? 130 : 1)), - ); - }); + const { promise, resolve, reject } = Promise.withResolvers(); + child.once('error', reject); + child.once('close', (code, signal) => + resolve( + interrupted || + (code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1)), + ), + ); + process.exitCode = await promise; } finally { process.off('SIGINT', interrupt); process.off('SIGTERM', terminate); diff --git a/tools/verify.ts b/tools/verify.ts index f3d7c9d0..29c95252 100644 --- a/tools/verify.ts +++ b/tools/verify.ts @@ -9,6 +9,7 @@ async function main() { options: { out: { type: 'string' }, focus: { type: 'string', multiple: true }, + name: { type: 'string' }, games: { type: 'string' }, seed: { type: 'string' }, help: { type: 'boolean' }, @@ -16,7 +17,7 @@ async function main() { }); if (values.help) { 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; } @@ -24,6 +25,12 @@ async function main() { throw new Error('Provide --out and verification steps; see --help.'); if (values.focus?.some((fragment) => !fragment || fragment.startsWith('-'))) 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 steps: VerificationStep[] = positionals.map((name) => { if (['check', 'build', 'integration'].includes(name)) @@ -33,7 +40,16 @@ async function main() { args: ['run', name === 'integration' ? 'test:integration' : name], }; 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') { const games = Number(values.games), seed = Number(values.seed);