Add repeatable verification and establish fresh save checkpoint
Consolidate current readiness and existing rule decisions, preserve selective subagents and automatic verified Git pushes, and record the efficiency pilot. Add source-bound compact checks, private saved-game snapshots/online backups, and bounded HTTP seat-restoration checks with 15 meaningful regressions. At the user's explicit request, archive and clear 3,644 old local rooms and related records while retaining schema/migrations; preserve future games from a fresh empty baseline. Private backups and credentials are outside Git. Validation: npm run check passes types, lint and all 4,177 offline tests. Full report: 20260913-efficiency-check-3, 130.147 seconds; source fingerprint 344ce1d44475b0cd4ff10a915f28331cb4e2fcacbc7595636fbaa645c0568f64. Two earlier broad attempts stopped at new type/lint errors before tests; both were fixed. Independent review found cancellation falsely passing; SIGINT/SIGTERM regressions and review confirm the fix. Documentation links, example test selection, private backup integrity and empty baseline verified. No app/build/dependency/HTTP implementation changed; no production build, HTTP suite, browser acceptance or AI calibration claimed for this checkpoint. The full game goal and existing release gates remain unfinished.
This commit is contained in:
1 parent
06d0a66fc5
commit
6e26ba5daa
17 files changed
+1242
-2
No files matched your search
@@ -8,6 +8,8 @@ for the in-memory SQLite tests. `tsx` is a pinned development dependency.
|
||||
|
||||
- Read `README.md` and this guide first. See `docs/DEVELOPMENT.md` for the
|
||||
architecture map and focused verification commands.
|
||||
- Read `docs/CURRENT_STATUS.md` for current gates and `docs/RULE_DECISIONS.md`
|
||||
for existing rulings before reopening research.
|
||||
- Run `git status --short` before editing; preserve other work.
|
||||
- Search the relevant feature in `game/`, `components/`, `tests/`, and `docs/`.
|
||||
Use `rg -n` and bounded reads for `game/engine.ts` and
|
||||
@@ -45,6 +47,8 @@ for the in-memory SQLite tests. `tsx` is a pinned development dependency.
|
||||
|
||||
## Preserve development state
|
||||
|
||||
- The user-authorized 2026-09-13 reset cleared old local games once; see
|
||||
`docs/VERIFICATION_WORKFLOW.md`. Preserve all games created afterward.
|
||||
- Keep `.wrangler/state` and all saved games. Never reset a database to fix a
|
||||
test, migration, connection or gameplay problem. Migrations are additive.
|
||||
- Reuse a running dev server. Warn before interrupting human play, defer to a
|
||||
@@ -55,3 +59,12 @@ for the in-memory SQLite tests. `tsx` is a pinned development dependency.
|
||||
completion and verification gates documented in `README.md`.
|
||||
- Keep generated builds, caches and editor backups out of version control.
|
||||
Avoid mass formatting, unrelated dependency updates and broad engine rewrites.
|
||||
|
||||
## 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 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
|
||||
“Push all from now on.” Confirm push success; never force-push or expose secrets.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dune — Arrakis Table
|
||||
|
||||
An unofficial multiplayer adaptation of Gale Force Nine’s classic 2019 Dune, built with React, TypeScript, Vinext, and Cloudflare Workers/D1. **Development is ongoing; this is not yet a complete rules implementation.** Advanced and unfinished expansion starts remain disabled. See [implementation status](docs/IMPLEMENTATION_STATUS.md) for implemented systems, evidence, outstanding rules questions and release gates.
|
||||
An unofficial multiplayer adaptation of Gale Force Nine’s classic 2019 Dune, built with React, TypeScript, Vinext, and Cloudflare Workers/D1. **Development is ongoing; this is not yet a complete rules implementation.** Advanced and unfinished expansion starts remain disabled. Start with [current status](docs/CURRENT_STATUS.md) for implemented systems, remaining requirements and release gates; detailed historical evidence remains in the [implementation log](docs/IMPLEMENTATION_STATUS.md).
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -31,7 +31,7 @@ An uncertain saved-kit recovery keeps its exact retry details in memory and prev
|
||||
## Verify changes
|
||||
|
||||
Use the [development guide](docs/DEVELOPMENT.md) for architecture, focused tests
|
||||
and the verification workflow. Tests are discovered automatically; the default
|
||||
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:
|
||||
|
||||
```sh
|
||||
@@ -57,6 +57,8 @@ See [AI calibration](docs/AI_CALIBRATION.md), [multiplayer audit](docs/MULTIPLAY
|
||||
|
||||
## Development maintenance
|
||||
|
||||
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.
|
||||
|
||||
During active development, preserve database state and perform a controlled server restart approximately hourly. Warn before interrupting human play and defer until a safe decision point. Verify server health and restoration afterward. No recurring automation is installed; the user requested leaving it removed. Fix causes of crashes and lost progress 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.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Current development status
|
||||
|
||||
Updated 13 September 2026. This is the short navigation dashboard; detailed evidence remains in the linked records. **The complete twelve-faction goal is unfinished. Basic is playable; Advanced and unfinished expansion starts remain gated.** No completion or publication gate is opened by this document.
|
||||
|
||||
## Evidence and source of truth
|
||||
|
||||
- [Rules implementation checklist](../game/reference.ts): `RULE_TOPICS`, topic `implementation-checklist`, and each feature's implementation, controls, AI, documentation and verification entries. Coverage is per stated boundary, not an implicit whole-game certificate.
|
||||
- [Latest historical checkpoint](IMPLEMENTATION_STATUS.md): Guild Nexus Secret Ally, 10 September 2026 (checkpoint `06d0a66`), records types, lint, **4,162 offline tests**, production build and **40 HTTP/session tests** passing, two browser rooms and preserved opening saved games. These are recorded results, not checks rerun for this dashboard.
|
||||
- [Development guide](DEVELOPMENT.md) defines focused verification; [rule decisions](RULE_DECISIONS.md) locates settled contracts and pending interpretations. Read the newest checkpoint and relevant feature document before consulting older log entries.
|
||||
|
||||
The user authorized a one-time local reset on 13 September: the 3,644 historical
|
||||
rooms were archived privately and cleared. [Fresh checkpoint and preservation
|
||||
policy](VERIFICATION_WORKFLOW.md#authorized-reset-13-september-2026) now apply to
|
||||
new games; historical room IDs are no longer active local fixtures.
|
||||
|
||||
## Remaining readiness
|
||||
|
||||
| Area | Established evidence | Remaining gate |
|
||||
| --- | --- | --- |
|
||||
| Basic core | Playable loop; shared setup, Fremen movement cancellation, structured Truthtrance spice/card facts and bounded reserve-shipment promises. | Ordinary Guild rate/transport cancellation; wider truthful questions/action commitments; integrated rules and human/AI acceptance. |
|
||||
| Six-faction Advanced | Genuine offline setup across 456 configurations; four-profile full-game simulations and JSON continuations. | Base gaps plus provisional special-Karama outcomes, remaining timing audits and authentic multiplayer/browser acceptance. Public starts remain disabled. |
|
||||
| Twelve factions and optional modules | Many bounded faction, card, Homeworld and Nexus slices have controls, bots and recovery evidence. | Every unfinished effect and interaction, occupation entitlement, Discoveries and complete combined games. Inventory or fixture coverage does not enable a module. |
|
||||
| AI difficulty | Four profiles consume private legal views; recorded Basic and Advanced studies complete. | Demonstrate a consistent Medium → Hard → Brutal strength ordering, improve strategic gaps, and repeat targeted studies after relevant policy/rule changes. |
|
||||
| Multiplayer | Versioned writes, saved seats, owner recovery kits, uncertain-request retry and voluntary own-seat AI control. | Intentional transfer, unprepared abandoned-seat recovery, unattended continuation and remaining network-failure acceptance. Preserve privacy and existing games. |
|
||||
| Player experience | Internal component inspectors and many desktop/phone feature journeys verified. | Complete end-to-end games, accessible controls, responsive layout, clear rules/errors and recovery across all supported combinations. |
|
||||
|
||||
The [base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md) includes historical missing-feature rows. Its follow-ups supersede the Fremen cancellation and BG setup findings; [genuine setup](ADVANCED_SETUP_TEST_SEAM.md) and the [Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md) supersede the old Basic-then-flip testing concern. They do not settle the remaining rules.
|
||||
|
||||
Likewise, the [Truthtrance readiness audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) predates [spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card-count facts](TRUTHTRANCE_CARD_COUNT.md) and [Basic shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md). Current `TruthFact` already includes these facts; do not rebuild them. Broader facts, earlier-phase promises, Advanced/expansion commitment scope and freeform interpretation remain incomplete.
|
||||
|
||||
## Next milestone: core readiness
|
||||
|
||||
1. Reconcile the remaining base-only gaps against current helpers, consumers and tests, using the existing [Guild cancellation contract](GUILD_RATE_KARAMA_RULES.md) and [special-Karama findings](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations). Keep already-asked interpretations pending; tests are not rulings.
|
||||
2. First gameplay batch: ordinary Guild cancellation around an exact saved shipment intent. Keep discount, cross/return permission and income separate; preserve contributors, independent Karama rates and original typed forces. Establish the unresolved affordability/settlement contract before implementing those outcomes. Verify sufficient-funds paths, nested responses, rejected-action immutability, private controls, all four bots and saved continuations together.
|
||||
3. Close remaining core Truthtrance and Advanced timing gaps, then run genuine Basic/Advanced setup-to-finish multiplayer and browser acceptance before reconsidering mode gates. Expansion completion remains a subsequent part of the same overall goal.
|
||||
|
||||
Consult [AI calibration](AI_CALIBRATION.md), [multiplayer audit](MULTIPLAYER_AUDIT.md), [autopilot](AUTOPILOT.md), [component inventory](COMPONENT_INVENTORY.md) and [visual playtest](VISUAL_PLAYTEST.md) for their measured scope and follow-ups. Use current code and later checkpoints to interpret historical results. Publication still requires the completion and integrated verification gates in [README](../README.md).
|
||||
@@ -4,6 +4,11 @@ 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 [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.
|
||||
|
||||
## Architecture map
|
||||
|
||||
| Concern | Entry points |
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Three-milestone efficiency pilot
|
||||
|
||||
Preserve the complete game scope and required checks. Use small relevant reads,
|
||||
coherent feature batches, selective subagents and compact verification artifacts.
|
||||
Git remains mandatory: review, verify, commit, push and confirm remote success at
|
||||
each finished implementation checkpoint. Never force-push or commit credentials.
|
||||
The removed recurring automation stays removed.
|
||||
|
||||
| Milestone | Deliverable | Measurement |
|
||||
| --- | --- | --- |
|
||||
| 1: setup | Current status, decision index, reusable checks, saved-game snapshots/backups and HTTP seat restoration | Start aggregate goal counter: 57,957,554 tokens. Latest observed pre-continuation counter: 57,986,221; partial delta 28,667 includes planning/context and is not a finished-milestone cost. Two bounded helpers: documentation and independent tooling review. Final checkpoint records actual checks. |
|
||||
| 2: core readiness batch | Remaining Basic/Advanced capabilities with rules, controls, bots and recovery together | Record start/end aggregate counters, accepted capability, focused/broad runs, reviewer effort and reopened defects. Pending material rulings remain explicit. |
|
||||
| 3: next dependency batch | Next core or connected expansion capability selected from current evidence | Repeat measurements; compare cost per accepted capability and investigate repeated work. |
|
||||
|
||||
The aggregate counter may update only between turns. Record a confirmed endpoint
|
||||
when available rather than estimating missing usage. It includes coordination and
|
||||
conversation overhead and is not a dollar-cost meter. Different capability sizes
|
||||
limit direct comparisons; do not promise a savings percentage from this setup.
|
||||
Do not count tests alone as accepted faction or mode completion. Scripts consolidate
|
||||
existing checks; broad checks still run once source/review is stable and repeat if
|
||||
subsequent changes or failures justify them. Browser fixtures remain feature-specific;
|
||||
the reusable HTTP restoration helper does not automate browser setup or visual QA.
|
||||
|
||||
Use [current status](CURRENT_STATUS.md), [rule decisions](RULE_DECISIONS.md) and
|
||||
[verification workflow](VERIFICATION_WORKFLOW.md) as short entry points. Historical
|
||||
logs remain references, not mandatory full-context input on every turn.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Dune implementation status
|
||||
|
||||
**Efficiency setup and fresh local save checkpoint, 13 September 2026:** [Current status](CURRENT_STATUS.md) and the [rule decision index](RULE_DECISIONS.md) consolidate existing evidence without changing mode gates. [Reusable verification](VERIFICATION_WORKFLOW.md) adds private source-bound check logs, read-only saved-game snapshots/online backups and bounded HTTP seat-restoration checks. Focused regressions cover child failure/cancellation, stale source, output protection, WAL backup and seat privacy; the required full-check result is recorded in this checkpoint’s Git commit and external verification report. The user explicitly authorized clearing old local saves: a verified private backup preceded removal of 3,644 rooms and related records, preserving schema/migrations and creating a fresh empty baseline. Future games remain protected. [Pilot measurements](EFFICIENCY_PILOT.md) retain selective subagents, mandatory verified Git pushes and the full game scope; no gameplay or release gate is enabled. Browser setup remains feature-specific, and no live browser acceptance is claimed for this tooling change.
|
||||
|
||||
**Guild Nexus Secret Ally checkpoint, 10 September 2026:** [NEXUS_GUILD_SECRET_ALLY_RUNTIME.md](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) records one explicitly selected Guild-priced shipment for an unallied holder with Guild absent. Reserve, physical cross/abstract-return and independently legal world-to-world routes preserve actual faction identity, typed custody, ordinary movement and bank payment. Fremen retain their free reinforcement choice; the separate paid southern route reaches beyond its range without storm privileges or off-planet triggers. Private controls and all four bot profiles share source-aware price/permission; binding shipment promises can rely on the card. Exact committed records and an independent outcome marker preserve arrival alliances and later movement without inventing native Guild responses. **Final types, lint, all 4,162 offline tests, production build and 40 HTTP/session tests pass.** Twenty-six new cases include real Moritani arrival alliances and production SQLite races, corruption rejection and private recovery. Review also repaired an older Cunning fixture's post-auction card-availability assumption; ten repeated runs passed 190 cases. Two three-seat browser rooms passed paid Fremen shipment/movement, mixed Emperor Homeworld shipment, desktop/phone controls and six private refreshes. The 03:46 UTC backed-up restart preserved all **3,612** opening games and restored twelve earlier private seats; final comparison preserves every opening version/hash, with two browser and thirty HTTP rooms added. Homeworld-return interpretation was asked of the user; that route, concealed transport, Betrayal and complete module/mode acceptance remain gated. Automation remains removed.
|
||||
|
||||
**Guild Nexus Cunning checkpoint, 10 September 2026:** [NEXUS_GUILD_CUNNING_RUNTIME.md](NEXUS_GUILD_CUNNING_RUNTIME.md) records a second paid native Guild shipment within the original combined turn, followed only by unused Hajr movement. Original shipment/movement history, typed Homeworld sources, cross/return routes, interception and later entry effects retain their rules. Private controls and all four bot profiles share availability; new binding shipment promises recognize the extra opportunity. Native Karama and paid BG conversion preserve the original source, and countering conversion restores it. Independent review fixed a later Moritani alliance freezing the remaining Hajr continuation; transport validation also rejects a same-territory cross-shipment. **Final types, lint, all 4,136 offline tests, production build and 40 HTTP/session tests pass.** Thirty-seven new cases include genuine nested interruptions, pending-frame corruption and competing production SQLite requests. Two three-seat browser rooms passed second shipment/Hajr and cancellation, desktop/phone controls and six private refreshes. A raw internal event notice found visually was corrected and verified by final rendering tests; earlier pending screenshots precede that text correction. All **3,580** opening room versions/hashes remain unchanged, with two browser and thirty HTTP rooms added. Guild Secret Ally/Betrayal, the physical Ornithopter combination, remaining native cancellation/arrival gaps and complete module/mode acceptance remain gated. Existing server reused, no restart due; automation remains removed.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Rule decision index
|
||||
|
||||
Updated 13 September 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
|
||||
|
||||
## Recorded contracts and implementation boundaries
|
||||
|
||||
| Topic | Decision to preserve | Detailed authority and evidence |
|
||||
| --- | --- | --- |
|
||||
| Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) |
|
||||
| Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) |
|
||||
| Guild payment | Rate, transport permission and income are distinct; power owner, shipper, contributors and independent rate-card beneficiary remain distinct. | [Rate/cancellation sources](GUILD_RATE_KARAMA_RULES.md), [implemented payment routing](GUILD_SHIPMENT_PAYMENTS.md) |
|
||||
| Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Structured Basic active-opportunity shipment promises bind within their documented scope; prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) |
|
||||
| Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) |
|
||||
| Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) |
|
||||
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
|
||||
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
|
||||
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
|
||||
| Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) |
|
||||
|
||||
## Pending interpretations: preserve existing questions
|
||||
|
||||
These are unresolved in the cited records. Do not infer an answer from elapsed time, a fixture, a related card or this index. Check subsequent recorded user/source decisions before proceeding; do not send duplicate questions.
|
||||
|
||||
| Boundary | Still unresolved | Existing record |
|
||||
| --- | --- | --- |
|
||||
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
|
||||
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional or require interaction audit. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations) |
|
||||
| Guild Secret Ally/Homeworlds | Whether the reserve-return grant overrides the Homeworld return restriction. The question was already sent; that route stays guarded. | [Homeworld return](NEXUS_GUILD_SECRET_ALLY_RULES.md#homeworld-prices-and-the-unresolved-return), [checkpoint](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
|
||||
| Occupied Homeworlds | Entitlement/expiry after departure, replacement or contest; unique powers and shared income. The first-occupier/current-occupier question was already sent. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md) |
|
||||
| Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries) and the individual faction source/runtime documents above |
|
||||
| Other Homeworld combinations | Population/force splitting, removal custody, replacement shortages, income accounting and placement ordering remain local to their detailed contracts. | [Homeworld rules](HOMEWORLD_RULES.md), [occupation](HOMEWORLD_OCCUPATION_RULES.md) and the effect documents above |
|
||||
|
||||
When a decision changes, record the exact source or user ruling in its detailed feature document, update affected implementation guidance/tests, and adjust this index and [current status](CURRENT_STATUS.md). Retain historical evidence without presenting a superseded proposal as current behavior.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Repeatable verification
|
||||
|
||||
Use the [development guide](DEVELOPMENT.md) to choose checks appropriate to the
|
||||
change. These wrappers retain the existing test runner and simulation harness;
|
||||
they do not replace rules, browser or release acceptance.
|
||||
|
||||
## Compact checks and source evidence
|
||||
|
||||
```sh
|
||||
node --import tsx tools/verify.ts --out /tmp/dune-new-focused focused --focus guild-payment
|
||||
node --import tsx tools/verify.ts --out /tmp/dune-new-check check
|
||||
# Add build for app/build/dependency changes, 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.
|
||||
node --import tsx tools/verify.ts --out /tmp/dune-new-study base-games --games 20 --seed 20260913
|
||||
```
|
||||
|
||||
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
|
||||
of tracked and nonignored untracked files before and after the run. Ignored local
|
||||
credentials, saved games and build caches are excluded. Do not supply secrets in
|
||||
command arguments. A failed child stops subsequent steps. Changed source or HEAD
|
||||
invalidates a successful run; finish all source/doc edits before broad checks.
|
||||
This checks the two observed snapshots, not changes made and reverted between them,
|
||||
nor the contents of dependencies, ignored files or an external HTTP server.
|
||||
An interrupted or incomplete report is never evidence of success.
|
||||
|
||||
## Saved games and online backups
|
||||
|
||||
```sh
|
||||
node --import tsx tools/saved-games.ts snapshot --db /absolute/games.sqlite --out /private/new-baseline --backup
|
||||
node --import tsx tools/saved-games.ts compare --db /absolute/games.sqlite --baseline /private/new-baseline/snapshot.json
|
||||
```
|
||||
|
||||
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. Snapshot the
|
||||
current set before each maintenance operation so newly created rooms are protected.
|
||||
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.
|
||||
This tool does not delete, reset or restore the live database.
|
||||
|
||||
### Authorized reset, 13 September 2026
|
||||
|
||||
The user requested clearing all old local games and starting a new checkpoint.
|
||||
With no development server running, a verified online backup preceded a single
|
||||
transaction deleting **3,644 rooms** and cascading their seat/recovery/entry records.
|
||||
Schema, migrations and Wrangler state files were preserved. All five application
|
||||
tables were empty afterward; foreign-key validation passed.
|
||||
|
||||
Private archive: `/home/quintine/.local/share/dune/checkpoints/20260913-before-reset/`.
|
||||
Fresh empty database backup and snapshot:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20260913-fresh-baseline/`.
|
||||
These machine-local paths are operational records, not portable repo artifacts.
|
||||
Old room IDs in historical browser evidence no longer identify active local games.
|
||||
Future games must be preserved. This was a one-time user-authorized reset, not a
|
||||
maintenance strategy or permission to clear future games.
|
||||
|
||||
## Private-seat restoration
|
||||
|
||||
Create a private JSON file (mode 0600) outside the checkout with this structure;
|
||||
substitute credentials only in that private file, never in a command or tracked doc:
|
||||
|
||||
```json
|
||||
{"baseUrl":"http://127.0.0.1:3000","isolatedQa":true,"seats":[{"code":"ABCDEFGH","playerId":"fixture-seat-id","version":4,"token":"fixture_session_token"}]}
|
||||
```
|
||||
|
||||
```sh
|
||||
node --import tsx tools/restore-seats.ts --config /private/qa-seats.json
|
||||
```
|
||||
|
||||
Use deliberately isolated, stable human QA rooms. The server's GET route can resume
|
||||
bots or automatic work, so this is not a generally side-effect-free read and must
|
||||
not probe arbitrary human games. Requests use loopback origins only, reject redirects
|
||||
and time out. Checks require the expected room, seat and version, the owning seat's
|
||||
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.
|
||||
@@ -0,0 +1,102 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as sqlite from 'node:sqlite';
|
||||
import { mkdtempSync, mkdirSync, rmSync, existsSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
captureSavedGames,
|
||||
compareSavedGames,
|
||||
loadSavedGameSnapshot,
|
||||
readSavedGames,
|
||||
} from '../tools/saved-game-verification';
|
||||
|
||||
function fixture(t: test.TestContext) {
|
||||
const area = mkdtempSync(join(tmpdir(), 'dune-preservation-'));
|
||||
const root = join(area, 'repo');
|
||||
mkdirSync(root);
|
||||
const path = join(root, 'games.sqlite'),
|
||||
db = new sqlite.DatabaseSync(path);
|
||||
db.exec(
|
||||
"PRAGMA journal_mode=WAL; CREATE TABLE rooms(code TEXT PRIMARY KEY, version INTEGER, state TEXT); INSERT INTO rooms VALUES('ABCDEFGH', 2, '{\"privateHand\":[1]}')",
|
||||
);
|
||||
t.after(() => {
|
||||
db.close();
|
||||
rmSync(area, { recursive: true, force: true });
|
||||
});
|
||||
return { area, root, path, db };
|
||||
}
|
||||
|
||||
void test('preservation allows new rooms but detects changed state, versions and missing originals', (t) => {
|
||||
const { path, db } = fixture(t),
|
||||
baseline = readSavedGames(path);
|
||||
db.exec("INSERT INTO rooms VALUES('BCDEFGHJ', 0, '{}')");
|
||||
const added = compareSavedGames(baseline, readSavedGames(path));
|
||||
assert.equal(added.preserved, true);
|
||||
assert.equal(added.added, 1);
|
||||
db.exec("UPDATE rooms SET version=3 WHERE code='ABCDEFGH'");
|
||||
assert.equal(
|
||||
compareSavedGames(baseline, readSavedGames(path)).preserved,
|
||||
false,
|
||||
);
|
||||
db.exec("UPDATE rooms SET version=2, state='{}' WHERE code='ABCDEFGH'");
|
||||
assert.deepEqual(compareSavedGames(baseline, readSavedGames(path)).changed, [
|
||||
'ABCDEFGH',
|
||||
]);
|
||||
db.exec("DELETE FROM rooms WHERE code='ABCDEFGH'");
|
||||
assert.deepEqual(compareSavedGames(baseline, readSavedGames(path)).missing, [
|
||||
'ABCDEFGH',
|
||||
]);
|
||||
});
|
||||
|
||||
void test('private snapshot stores hashes and cannot overwrite an earlier baseline', async (t) => {
|
||||
const { area, root, path } = fixture(t),
|
||||
out = join(area, 'snapshot');
|
||||
const result = await captureSavedGames(root, path, out);
|
||||
assert.deepEqual(loadSavedGameSnapshot(join(out, 'snapshot.json')), result);
|
||||
assert.equal(JSON.stringify(result).includes('privateHand'), false);
|
||||
assert.equal(statSync(join(out, 'snapshot.json')).mode & 0o777, 0o600);
|
||||
await assert.rejects(captureSavedGames(root, path, out));
|
||||
assert.deepEqual(loadSavedGameSnapshot(join(out, 'snapshot.json')), result);
|
||||
assert.throws(
|
||||
() =>
|
||||
compareSavedGames(
|
||||
{ ...result, rooms: [...result.rooms, ...result.rooms] },
|
||||
result,
|
||||
),
|
||||
/Invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('missing source never creates a database or output', async (t) => {
|
||||
const { area, root } = fixture(t),
|
||||
missing = join(area, 'missing.sqlite'),
|
||||
out = join(area, 'out');
|
||||
await assert.rejects(captureSavedGames(root, missing, out));
|
||||
assert.equal(existsSync(missing), false);
|
||||
assert.equal(existsSync(out), false);
|
||||
});
|
||||
|
||||
void test(
|
||||
'online backup includes WAL state and leaves the live database untouched',
|
||||
{ skip: typeof sqlite.backup !== 'function' },
|
||||
async (t) => {
|
||||
const { area, root, path, db } = fixture(t),
|
||||
out = join(area, 'backup');
|
||||
db.exec("INSERT INTO rooms VALUES('CDEFGHJK', 4, '{\"pending\":true}')");
|
||||
const before = readSavedGames(path),
|
||||
snapshot = await captureSavedGames(root, path, out, true);
|
||||
assert.equal(compareSavedGames(before, snapshot).preserved, true);
|
||||
assert.equal(snapshot.rooms.length, 2);
|
||||
assert.equal(
|
||||
compareSavedGames(snapshot, readSavedGames(join(out, 'games.sqlite')))
|
||||
.preserved,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
compareSavedGames(before, readSavedGames(path)).preserved,
|
||||
true,
|
||||
);
|
||||
assert.equal(statSync(join(out, 'games.sqlite')).mode & 0o777, 0o600);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,130 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
chmodSync,
|
||||
rmSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
checkSeatRestoration,
|
||||
loadRestorationConfig,
|
||||
type RestorationConfig,
|
||||
} from '../tools/seat-restoration';
|
||||
|
||||
const config: RestorationConfig = {
|
||||
baseUrl: 'http://127.0.0.1:3000',
|
||||
isolatedQa: true,
|
||||
seats: [
|
||||
{ code: 'ABCDEFGH', playerId: 'p1', version: 4, token: 'private_token' },
|
||||
],
|
||||
};
|
||||
const view = () => ({
|
||||
code: 'ABCDEFGH',
|
||||
me: 'p1',
|
||||
version: 4,
|
||||
players: [{ id: 'p1', hand: [], traitors: [], spice: 10 }, { id: 'p2' }],
|
||||
});
|
||||
const respond =
|
||||
(body: unknown, status = 200): typeof fetch =>
|
||||
async () =>
|
||||
Response.json(body, { status });
|
||||
|
||||
void test('restoration authenticates the expected seat and prevents cookie redirects', async () => {
|
||||
const request: typeof fetch = async (url, options) => {
|
||||
assert.ok(url instanceof URL);
|
||||
assert.equal(url.href, 'http://127.0.0.1:3000/api/rooms/ABCDEFGH');
|
||||
assert.equal(
|
||||
new Headers(options?.headers).get('cookie'),
|
||||
'dune_ABCDEFGH=private_token',
|
||||
);
|
||||
assert.equal(options?.redirect, 'error');
|
||||
assert.ok(options?.signal);
|
||||
return Response.json(view());
|
||||
};
|
||||
assert.deepEqual(await checkSeatRestoration(config, request), {
|
||||
checked: 1,
|
||||
passed: true,
|
||||
});
|
||||
});
|
||||
|
||||
void test('restoration refuses wrong identity, stale versions, missing private state and rival secrets', async () => {
|
||||
for (const body of [
|
||||
{ ...view(), me: 'p2' },
|
||||
{ ...view(), code: 'BCDEFGHJ' },
|
||||
{ ...view(), version: 5 },
|
||||
{ ...view(), players: [{ id: 'p1' }] },
|
||||
{ ...view(), botsPending: true },
|
||||
{
|
||||
...view(),
|
||||
players: [...view().players, { id: 'p3', traitors: ['secret'] }],
|
||||
},
|
||||
])
|
||||
await assert.rejects(
|
||||
checkSeatRestoration(config, respond(body)),
|
||||
/^Error: Seat 1 failed/,
|
||||
);
|
||||
await assert.rejects(
|
||||
checkSeatRestoration(
|
||||
config,
|
||||
respond({ error: 'private_token secret response' }, 403),
|
||||
),
|
||||
(error) => {
|
||||
assert.equal(String(error).includes('private_token'), false);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
void test('invalid origins and unsafe credentials cause no network requests', async () => {
|
||||
let calls = 0;
|
||||
const request: typeof fetch = async () => {
|
||||
calls++;
|
||||
return Response.json(view());
|
||||
};
|
||||
for (const baseUrl of [
|
||||
'https://example.com',
|
||||
'http://127.0.0.1.evil.test',
|
||||
'http://user:password@localhost',
|
||||
'http://localhost/other',
|
||||
'http://localhost/?secret=token',
|
||||
])
|
||||
await assert.rejects(checkSeatRestoration({ ...config, baseUrl }, request));
|
||||
await assert.rejects(
|
||||
checkSeatRestoration(
|
||||
{ ...config, seats: [{ ...config.seats[0], token: 'a; other=cookie' }] },
|
||||
request,
|
||||
),
|
||||
);
|
||||
await assert.rejects(
|
||||
checkSeatRestoration(
|
||||
{ ...config, isolatedQa: false } as unknown as RestorationConfig,
|
||||
request,
|
||||
),
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
void test('seat manifests must be private files outside the checkout', (t) => {
|
||||
const area = mkdtempSync(join(tmpdir(), 'dune-seat-manifest-'));
|
||||
t.after(() => rmSync(area, { recursive: true, force: true }));
|
||||
const root = join(area, 'repo');
|
||||
mkdirSync(root);
|
||||
const outside = join(area, 'seats.json'),
|
||||
inside = join(root, 'seats.json');
|
||||
writeFileSync(outside, JSON.stringify(config), { mode: 0o600 });
|
||||
writeFileSync(inside, JSON.stringify(config), { mode: 0o600 });
|
||||
assert.deepEqual(loadRestorationConfig(outside, root), config);
|
||||
assert.throws(
|
||||
() => loadRestorationConfig(inside, root),
|
||||
/private file outside/,
|
||||
);
|
||||
chmodSync(outside, 0o644);
|
||||
assert.throws(
|
||||
() => loadRestorationConfig(outside, root),
|
||||
/private file outside/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
statSync,
|
||||
existsSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
privateOutputDirectory,
|
||||
runVerification,
|
||||
sourceSnapshot,
|
||||
} from '../tools/verification';
|
||||
|
||||
void test('focused wrapper cannot turn a filename into a list-only or test-skipping option', (t) => {
|
||||
const { root, area } = fixture(t),
|
||||
out = join(area, 'run');
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'--import',
|
||||
import.meta.resolve('tsx'),
|
||||
new URL('../tools/verify.ts', import.meta.url).pathname,
|
||||
'--out',
|
||||
out,
|
||||
'focused',
|
||||
'--focus=--list',
|
||||
],
|
||||
{ cwd: root, encoding: 'utf8', timeout: 15000 },
|
||||
);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(existsSync(out), false);
|
||||
});
|
||||
|
||||
function fixture(t: test.TestContext) {
|
||||
const area = mkdtempSync(join(tmpdir(), 'dune-verification-'));
|
||||
t.after(() => rmSync(area, { recursive: true, force: true }));
|
||||
const root = join(area, 'repo');
|
||||
mkdirSync(root);
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync('git', args, { cwd: root, stdio: 'pipe' });
|
||||
git('init', '-q');
|
||||
writeFileSync(join(root, '.gitignore'), 'private.txt\n');
|
||||
writeFileSync(join(root, 'source.txt'), 'original');
|
||||
git('add', '.');
|
||||
git(
|
||||
'-c',
|
||||
'user.name=Verification',
|
||||
'-c',
|
||||
'user.email=verification@example.invalid',
|
||||
'commit',
|
||||
'-qm',
|
||||
'fixture',
|
||||
);
|
||||
return { area, root };
|
||||
}
|
||||
|
||||
void test('interrupted checks fail even when a child handles the signal with exit zero', (t) => {
|
||||
const { root, area } = fixture(t);
|
||||
for (const [signal, exitCode] of [
|
||||
['SIGINT', 130],
|
||||
['SIGTERM', 143],
|
||||
] as const) {
|
||||
const out = join(area, signal),
|
||||
script = join(area, `${signal}.mts`);
|
||||
const steps = [
|
||||
{
|
||||
name: 'interrupted',
|
||||
command: process.execPath,
|
||||
args: [
|
||||
'-e',
|
||||
`process.on('${signal}',()=>process.exit(0));process.kill(process.ppid,'${signal}');setInterval(()=>{},1000);`,
|
||||
],
|
||||
},
|
||||
{ name: 'must-not-run', command: process.execPath, args: ['-e', ''] },
|
||||
];
|
||||
writeFileSync(
|
||||
script,
|
||||
`import { runVerification } from ${JSON.stringify(new URL('../tools/verification.ts', import.meta.url).href)};\nawait runVerification(${JSON.stringify(root)}, ${JSON.stringify(out)}, ${JSON.stringify(steps)});\n`,
|
||||
);
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
['--import', import.meta.resolve('tsx'), script],
|
||||
{ cwd: root, stdio: 'pipe', timeout: 15000 },
|
||||
);
|
||||
const report = JSON.parse(readFileSync(join(out, 'report.json'), 'utf8'));
|
||||
assert.equal(report.status, 'failed');
|
||||
assert.equal(report.steps.length, 1);
|
||||
assert.equal(report.steps[0].exitCode, exitCode);
|
||||
}
|
||||
});
|
||||
|
||||
void test('fingerprints include untracked source and ignore private local material', (t) => {
|
||||
const { root } = fixture(t),
|
||||
before = sourceSnapshot(root);
|
||||
writeFileSync(join(root, 'private.txt'), 'never include this');
|
||||
assert.deepEqual(sourceSnapshot(root), before);
|
||||
writeFileSync(join(root, 'new.ts'), 'new source');
|
||||
assert.notEqual(sourceSnapshot(root).tree, before.tree);
|
||||
rmSync(join(root, 'new.ts'));
|
||||
rmSync(join(root, 'source.txt'));
|
||||
assert.notEqual(sourceSnapshot(root).tree, before.tree);
|
||||
});
|
||||
|
||||
void test('verification preserves real child failures, stops later work and stores private logs', async (t) => {
|
||||
const { root, area } = fixture(t),
|
||||
out = join(area, 'run');
|
||||
const result = await runVerification(root, out, [
|
||||
{
|
||||
name: 'first',
|
||||
command: process.execPath,
|
||||
args: ['-e', 'console.log("first complete")'],
|
||||
},
|
||||
{
|
||||
name: 'failure',
|
||||
command: process.execPath,
|
||||
args: ['-e', 'console.error("failure detail");process.exit(7)'],
|
||||
},
|
||||
{
|
||||
name: 'must-not-run',
|
||||
command: process.execPath,
|
||||
args: ['-e', 'process.exit(0)'],
|
||||
},
|
||||
]);
|
||||
assert.equal(result.status, 'failed');
|
||||
assert.deepEqual(
|
||||
result.steps.map((s) => s.exitCode),
|
||||
[0, 7],
|
||||
);
|
||||
assert.match(
|
||||
readFileSync(join(out, 'failure.log'), 'utf8'),
|
||||
/failure detail/,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(readFileSync(join(out, 'report.json'), 'utf8')).status,
|
||||
'failed',
|
||||
);
|
||||
assert.equal(statSync(out).mode & 0o777, 0o700);
|
||||
assert.equal(statSync(join(out, 'report.json')).mode & 0o777, 0o600);
|
||||
await assert.rejects(
|
||||
runVerification(root, out, [
|
||||
{ name: 'repeat', command: process.execPath, args: ['-e', ''] },
|
||||
]),
|
||||
);
|
||||
assert.match(
|
||||
readFileSync(join(out, 'failure.log'), 'utf8'),
|
||||
/failure detail/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('green processes cannot certify source changed during verification', async (t) => {
|
||||
const { root, area } = fixture(t);
|
||||
const result = await runVerification(root, join(area, 'run'), [
|
||||
{
|
||||
name: 'edit',
|
||||
command: process.execPath,
|
||||
args: ['-e', 'require("node:fs").writeFileSync("source.txt", "changed")'],
|
||||
},
|
||||
]);
|
||||
assert.equal(result.steps[0].exitCode, 0);
|
||||
assert.equal(result.status, 'source-changed');
|
||||
assert.notEqual(result.before.tree, result.after?.tree);
|
||||
});
|
||||
|
||||
void test('unchanged successful checks pass and missing executables fail', async (t) => {
|
||||
const { root, area } = fixture(t);
|
||||
assert.equal(
|
||||
(
|
||||
await runVerification(root, join(area, 'good'), [
|
||||
{ name: 'ok', command: process.execPath, args: ['-e', ''] },
|
||||
])
|
||||
).status,
|
||||
'passed',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await runVerification(root, join(area, 'bad'), [
|
||||
{ name: 'missing', command: join(area, 'missing-command'), args: [] },
|
||||
])
|
||||
).status,
|
||||
'failed',
|
||||
);
|
||||
});
|
||||
|
||||
void test('output refuses checkout paths, symlink redirects and existing results', (t) => {
|
||||
const { root, area } = fixture(t);
|
||||
assert.throws(
|
||||
() => privateOutputDirectory(root, join(root, 'results')),
|
||||
/outside/,
|
||||
);
|
||||
symlinkSync(root, join(area, 'redirect'), 'dir');
|
||||
assert.throws(
|
||||
() => privateOutputDirectory(root, join(area, 'redirect/results')),
|
||||
/outside/,
|
||||
);
|
||||
const out = join(area, 'out');
|
||||
privateOutputDirectory(root, out);
|
||||
writeFileSync(join(out, 'sentinel'), 'keep');
|
||||
assert.throws(() => privateOutputDirectory(root, out));
|
||||
assert.equal(readFileSync(join(out, 'sentinel'), 'utf8'), 'keep');
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
import {
|
||||
checkSeatRestoration,
|
||||
loadRestorationConfig,
|
||||
} from './seat-restoration';
|
||||
|
||||
async function main() {
|
||||
const { values } = parseArgs({
|
||||
options: { config: { type: 'string' }, help: { type: 'boolean' } },
|
||||
});
|
||||
if (values.help) {
|
||||
console.log(
|
||||
'Usage: node --import tsx tools/restore-seats.ts --config /private/qa-seats.json\nRequires a private manifest outside the checkout and isolated, stable human QA rooms on a loopback server. GET can resume automatic game work. This checks HTTP restoration, not browser rendering.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!values.config) throw new Error();
|
||||
const config = loadRestorationConfig(
|
||||
values.config,
|
||||
fileURLToPath(new URL('../', import.meta.url)),
|
||||
);
|
||||
console.log(JSON.stringify(await checkSeatRestoration(config)));
|
||||
}
|
||||
main().catch(() => {
|
||||
console.error(
|
||||
'Private-seat restoration failed. Check the private manifest, stable QA versions and local server; see --help.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as sqlite from 'node:sqlite';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { chmodSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { privateOutputDirectory } from './verification';
|
||||
|
||||
type RoomProof = { code: string; version: number; hash: string };
|
||||
export type SavedGameSnapshot = {
|
||||
format: 1;
|
||||
capturedAt: string;
|
||||
rooms: RoomProof[];
|
||||
};
|
||||
export function readSavedGames(path: string): SavedGameSnapshot {
|
||||
const db = new sqlite.DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
const rows = db
|
||||
.prepare('SELECT code, version, state FROM rooms ORDER BY code')
|
||||
.all();
|
||||
const rooms = rows.map((row) => {
|
||||
if (
|
||||
typeof row.code !== 'string' ||
|
||||
!Number.isSafeInteger(row.version) ||
|
||||
typeof row.state !== 'string'
|
||||
)
|
||||
throw new Error('Unexpected saved-game schema.');
|
||||
return {
|
||||
code: row.code,
|
||||
version: row.version as number,
|
||||
hash: createHash('sha256').update(row.state).digest('hex'),
|
||||
};
|
||||
});
|
||||
return { format: 1, capturedAt: new Date().toISOString(), rooms };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
export function compareSavedGames(
|
||||
baseline: SavedGameSnapshot,
|
||||
current: SavedGameSnapshot,
|
||||
) {
|
||||
for (const snapshot of [baseline, current]) {
|
||||
if (
|
||||
snapshot?.format !== 1 ||
|
||||
!Array.isArray(snapshot.rooms) ||
|
||||
snapshot.rooms.some(
|
||||
(r) =>
|
||||
!r ||
|
||||
typeof r.code !== 'string' ||
|
||||
!Number.isSafeInteger(r.version) ||
|
||||
!/^[0-9a-f]{64}$/.test(r.hash),
|
||||
) ||
|
||||
new Set(snapshot.rooms.map((r) => r.code)).size !== snapshot.rooms.length
|
||||
)
|
||||
throw new Error('Invalid saved-game baseline.');
|
||||
}
|
||||
const now = new Map(current.rooms.map((r) => [r.code, r])),
|
||||
before = new Set(baseline.rooms.map((r) => r.code));
|
||||
const missing = baseline.rooms
|
||||
.filter((r) => !now.has(r.code))
|
||||
.map((r) => r.code);
|
||||
const changed = baseline.rooms
|
||||
.filter(
|
||||
(r) =>
|
||||
now.has(r.code) &&
|
||||
(now.get(r.code)!.version !== r.version ||
|
||||
now.get(r.code)!.hash !== r.hash),
|
||||
)
|
||||
.map((r) => r.code);
|
||||
return {
|
||||
original: baseline.rooms.length,
|
||||
current: current.rooms.length,
|
||||
added: current.rooms.filter((r) => !before.has(r.code)).length,
|
||||
missing,
|
||||
changed,
|
||||
preserved: !missing.length && !changed.length,
|
||||
};
|
||||
}
|
||||
export async function captureSavedGames(
|
||||
root: string,
|
||||
database: string,
|
||||
output: string,
|
||||
withBackup = false,
|
||||
) {
|
||||
// Validate without creating a missing database or destination directory.
|
||||
readSavedGames(database);
|
||||
if (withBackup && typeof sqlite.backup !== 'function')
|
||||
throw new Error('Online backup requires Node.js 22.16 or newer.');
|
||||
const directory = privateOutputDirectory(root, output);
|
||||
let snapshot: SavedGameSnapshot;
|
||||
if (withBackup) {
|
||||
const db = new sqlite.DatabaseSync(database, { readOnly: true });
|
||||
const destination = resolve(directory, 'games.sqlite');
|
||||
try {
|
||||
await sqlite.backup(db, destination);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
chmodSync(destination, 0o600);
|
||||
snapshot = readSavedGames(destination); // Bind the baseline to the actual consistent backup.
|
||||
} else snapshot = readSavedGames(database);
|
||||
writeFileSync(
|
||||
resolve(directory, 'snapshot.json'),
|
||||
JSON.stringify(snapshot, null, 2) + '\n',
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
return snapshot;
|
||||
}
|
||||
export function loadSavedGameSnapshot(path: string): SavedGameSnapshot {
|
||||
const snapshot = JSON.parse(readFileSync(path, 'utf8')) as SavedGameSnapshot;
|
||||
compareSavedGames(snapshot, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
import {
|
||||
captureSavedGames,
|
||||
compareSavedGames,
|
||||
loadSavedGameSnapshot,
|
||||
readSavedGames,
|
||||
} from './saved-game-verification';
|
||||
|
||||
async function main() {
|
||||
const { values, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
db: { type: 'string' },
|
||||
out: { type: 'string' },
|
||||
baseline: { type: 'string' },
|
||||
backup: { type: 'boolean' },
|
||||
help: { type: 'boolean' },
|
||||
},
|
||||
});
|
||||
if (values.help) {
|
||||
console.log(
|
||||
'Usage: node --import tsx tools/saved-games.ts snapshot --db PATH --out /tmp/new-snapshot [--backup]\nOr: compare --db PATH --baseline /tmp/new-snapshot/snapshot.json\nRead-only source access. New rooms are allowed; any changed or missing original room fails. Backup requires Node >=22.16.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!values.db || positionals.length !== 1)
|
||||
throw new Error('Provide a database and operation.');
|
||||
if (positionals[0] === 'snapshot' && values.out && !values.baseline) {
|
||||
const result = await captureSavedGames(
|
||||
fileURLToPath(new URL('../', import.meta.url)),
|
||||
values.db,
|
||||
values.out,
|
||||
values.backup,
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
captured: result.rooms.length,
|
||||
backup: !!values.backup,
|
||||
}),
|
||||
);
|
||||
} else if (
|
||||
positionals[0] === 'compare' &&
|
||||
values.baseline &&
|
||||
!values.out &&
|
||||
!values.backup
|
||||
) {
|
||||
const result = compareSavedGames(
|
||||
loadSavedGameSnapshot(values.baseline),
|
||||
readSavedGames(values.db),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
...result,
|
||||
missing: result.missing.length,
|
||||
changed: result.changed.length,
|
||||
}),
|
||||
);
|
||||
process.exitCode = result.preserved ? 0 : 1;
|
||||
} else throw new Error('Invalid operation.');
|
||||
}
|
||||
main().catch(() => {
|
||||
console.error(
|
||||
'Saved-game verification failed. Check arguments, schema and file access; see --help.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { isAbsolute, relative, sep } from 'node:path';
|
||||
|
||||
type Seat = { code: string; playerId: string; version: number; token: string };
|
||||
export type RestorationConfig = {
|
||||
baseUrl: string;
|
||||
isolatedQa: true;
|
||||
seats: Seat[];
|
||||
};
|
||||
|
||||
export function loadRestorationConfig(
|
||||
path: string,
|
||||
root: string,
|
||||
): RestorationConfig {
|
||||
const stat = lstatSync(path),
|
||||
rel = relative(realpathSync(root), realpathSync(path));
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
(stat.mode & 0o077) !== 0 ||
|
||||
(!rel.startsWith('..' + sep) && rel !== '..' && !isAbsolute(rel))
|
||||
)
|
||||
throw new Error(
|
||||
'Seat credentials require a private file outside the checkout.',
|
||||
);
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as RestorationConfig;
|
||||
}
|
||||
|
||||
export async function checkSeatRestoration(
|
||||
config: RestorationConfig,
|
||||
request: typeof fetch = fetch,
|
||||
) {
|
||||
// These GETs can resume automatic server work. Only deliberately isolated QA
|
||||
// fixtures are appropriate; callers must never probe arbitrary human games.
|
||||
const url = new URL(config.baseUrl);
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.pathname !== '/' ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
config.isolatedQa !== true
|
||||
)
|
||||
throw new Error('Use an explicit loopback origin and isolated QA seats.');
|
||||
if (
|
||||
!Array.isArray(config.seats) ||
|
||||
!config.seats.length ||
|
||||
config.seats.some(
|
||||
(s) =>
|
||||
!s ||
|
||||
!/^[A-Z2-9]{8}$/.test(s.code) ||
|
||||
typeof s.playerId !== 'string' ||
|
||||
!s.playerId ||
|
||||
!Number.isSafeInteger(s.version) ||
|
||||
s.version < 0 ||
|
||||
typeof s.token !== 'string' ||
|
||||
!/^[A-Za-z0-9_-]+$/.test(s.token),
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid seat manifest.');
|
||||
for (const [index, seat] of config.seats.entries()) {
|
||||
try {
|
||||
const response = await request(new URL(`/api/rooms/${seat.code}`, url), {
|
||||
method: 'GET',
|
||||
headers: { cookie: `dune_${seat.code}=${seat.token}` },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!response.ok) throw new Error();
|
||||
const view = await response.json();
|
||||
if (
|
||||
!view ||
|
||||
typeof view !== 'object' ||
|
||||
!('players' in view) ||
|
||||
!('code' in view) ||
|
||||
!('me' in view) ||
|
||||
!('version' in view) ||
|
||||
view.code !== seat.code ||
|
||||
view.me !== seat.playerId ||
|
||||
view.version !== seat.version ||
|
||||
!Array.isArray(view.players) ||
|
||||
('botsPending' in view && view.botsPending)
|
||||
)
|
||||
throw new Error();
|
||||
if (
|
||||
view.players.some(
|
||||
(p: unknown) =>
|
||||
!p ||
|
||||
typeof p !== 'object' ||
|
||||
!('id' in p) ||
|
||||
typeof p.id !== 'string',
|
||||
)
|
||||
)
|
||||
throw new Error();
|
||||
const own = view.players.filter(
|
||||
(p: { id?: string }) => p.id === seat.playerId,
|
||||
);
|
||||
if (
|
||||
own.length !== 1 ||
|
||||
!Array.isArray(own[0].hand) ||
|
||||
!Array.isArray(own[0].traitors) ||
|
||||
!Number.isFinite(own[0].spice)
|
||||
)
|
||||
throw new Error();
|
||||
const privateFields = [
|
||||
'hand',
|
||||
'traitors',
|
||||
'traitorChoices',
|
||||
'spice',
|
||||
'faceDancers',
|
||||
'prediction',
|
||||
'bribes',
|
||||
];
|
||||
if (
|
||||
view.players.some(
|
||||
(p: Record<string, unknown>) =>
|
||||
p.id !== seat.playerId && privateFields.some((key) => key in p),
|
||||
)
|
||||
)
|
||||
throw new Error();
|
||||
} catch {
|
||||
// Never echo response bodies, session cookies or private hands.
|
||||
throw new Error(
|
||||
`Seat ${index + 1} failed restoration, version or privacy checks.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { checked: config.seats.length, passed: true };
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
closeSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import {
|
||||
resolve,
|
||||
relative,
|
||||
isAbsolute,
|
||||
dirname,
|
||||
basename,
|
||||
sep,
|
||||
} from 'node:path';
|
||||
|
||||
export function sourceSnapshot(root: string) {
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync('git', args, {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
const files = [
|
||||
...new Set(
|
||||
git('ls-files', '-z', '--cached', '--others', '--exclude-standard')
|
||||
.split('\0')
|
||||
.filter(Boolean),
|
||||
),
|
||||
].sort();
|
||||
const digest = createHash('sha256');
|
||||
for (const file of files) {
|
||||
const path = resolve(root, file);
|
||||
digest.update(JSON.stringify(file));
|
||||
try {
|
||||
const stat = lstatSync(path);
|
||||
digest.update(
|
||||
JSON.stringify([
|
||||
stat.mode,
|
||||
createHash('sha256')
|
||||
.update(
|
||||
stat.isSymbolicLink() ? readlinkSync(path) : readFileSync(path),
|
||||
)
|
||||
.digest('hex'),
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
digest.update('deleted');
|
||||
}
|
||||
}
|
||||
return {
|
||||
commit: git('rev-parse', 'HEAD').trim(),
|
||||
tree: digest.digest('hex'),
|
||||
files: files.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function privateOutputDirectory(root: string, output: string) {
|
||||
// Resolve existing ancestors so a symlink cannot redirect artifacts into source.
|
||||
const path = resolve(
|
||||
realpathSync(dirname(resolve(output))),
|
||||
basename(output),
|
||||
);
|
||||
const rel = relative(realpathSync(root), path);
|
||||
if (!rel || (!rel.startsWith('..' + sep) && rel !== '..' && !isAbsolute(rel)))
|
||||
throw new Error('Write verification artifacts outside the checkout.');
|
||||
mkdirSync(path, { mode: 0o700 }); // Exclusive: existing results are never overwritten.
|
||||
return path;
|
||||
}
|
||||
|
||||
export type VerificationStep = {
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
export async function runVerification(
|
||||
root: string,
|
||||
output: string,
|
||||
steps: VerificationStep[],
|
||||
) {
|
||||
if (!steps.length) throw new Error('Choose at least one verification step.');
|
||||
if (
|
||||
steps.some((s) => !/^[a-z0-9-]+$/.test(s.name)) ||
|
||||
new Set(steps.map((s) => s.name)).size !== steps.length
|
||||
)
|
||||
throw new Error('Verification steps need unique simple names.');
|
||||
const directory = privateOutputDirectory(root, output);
|
||||
const report = {
|
||||
format: 1,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: '',
|
||||
before: sourceSnapshot(root),
|
||||
after: null as ReturnType<typeof sourceSnapshot> | null,
|
||||
status: 'running' as 'running' | 'passed' | 'failed' | 'source-changed',
|
||||
steps: [] as {
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
log: string;
|
||||
exitCode: number;
|
||||
durationMs: number;
|
||||
}[],
|
||||
};
|
||||
const save = () =>
|
||||
writeFileSync(
|
||||
resolve(directory, 'report.json'),
|
||||
JSON.stringify(report, null, 2) + '\n',
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
save();
|
||||
for (const step of steps) {
|
||||
const log = `${step.name}.log`,
|
||||
fd = openSync(resolve(directory, log), 'wx', 0o600),
|
||||
start = Date.now();
|
||||
let exitCode = 1;
|
||||
try {
|
||||
const child = spawn(step.command, step.args, {
|
||||
cwd: root,
|
||||
stdio: ['ignore', fd, fd],
|
||||
shell: false,
|
||||
});
|
||||
let interrupted = 0;
|
||||
const interrupt = () => {
|
||||
interrupted = 130;
|
||||
child.kill('SIGINT');
|
||||
},
|
||||
terminate = () => {
|
||||
interrupted = 143;
|
||||
child.kill('SIGTERM');
|
||||
};
|
||||
process.on('SIGINT', interrupt);
|
||||
process.on('SIGTERM', terminate);
|
||||
try {
|
||||
exitCode = await new Promise<number>((resolveExit) => {
|
||||
child.once('error', () => resolveExit(1));
|
||||
child.once('close', (code, signal) =>
|
||||
resolveExit(
|
||||
interrupted || (code ?? (signal === 'SIGINT' ? 130 : 1)),
|
||||
),
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
process.off('SIGINT', interrupt);
|
||||
process.off('SIGTERM', terminate);
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
report.steps.push({
|
||||
...step,
|
||||
log,
|
||||
exitCode,
|
||||
durationMs: Date.now() - start,
|
||||
});
|
||||
save();
|
||||
console.log(
|
||||
`${step.name}: ${exitCode === 0 ? 'passed' : 'FAILED'} (${report.steps.at(-1)!.durationMs} ms); ${resolve(directory, log)}`,
|
||||
);
|
||||
if (exitCode !== 0) break;
|
||||
}
|
||||
report.after = sourceSnapshot(root);
|
||||
report.finishedAt = new Date().toISOString();
|
||||
report.status = report.steps.some((s) => s.exitCode !== 0)
|
||||
? 'failed'
|
||||
: report.before.tree !== report.after.tree ||
|
||||
report.before.commit !== report.after.commit
|
||||
? 'source-changed'
|
||||
: 'passed';
|
||||
save();
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { resolve } from 'node:path';
|
||||
import { runVerification, type VerificationStep } from './verification';
|
||||
|
||||
async function main() {
|
||||
const { values, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
out: { type: 'string' },
|
||||
focus: { type: 'string', multiple: true },
|
||||
games: { type: 'string' },
|
||||
seed: { type: 'string' },
|
||||
help: { type: 'boolean' },
|
||||
},
|
||||
});
|
||||
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.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!values.out || !positionals.length)
|
||||
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.');
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const steps: VerificationStep[] = positionals.map((name) => {
|
||||
if (['check', 'build', 'integration'].includes(name))
|
||||
return {
|
||||
name,
|
||||
command: 'npm',
|
||||
args: ['run', name === 'integration' ? 'test:integration' : name],
|
||||
};
|
||||
if (name === 'focused' && values.focus?.length)
|
||||
return { name, command: 'npm', args: ['test', '--', ...values.focus] };
|
||||
if (name === 'base-games') {
|
||||
const games = Number(values.games),
|
||||
seed = Number(values.seed);
|
||||
if (
|
||||
!Number.isSafeInteger(games) ||
|
||||
games < 1 ||
|
||||
games > 120 ||
|
||||
!Number.isSafeInteger(seed) ||
|
||||
seed < 1 ||
|
||||
seed > 0xffffffff
|
||||
)
|
||||
throw new Error(
|
||||
'base-games requires --games 1..120 and --seed 1..4294967295.',
|
||||
);
|
||||
return {
|
||||
name,
|
||||
command: process.execPath,
|
||||
args: [
|
||||
'--import',
|
||||
'tsx',
|
||||
'tools/ai-calibration.ts',
|
||||
'--games',
|
||||
String(games),
|
||||
'--seed',
|
||||
String(seed),
|
||||
'--out',
|
||||
resolve(values.out!, 'base-games.json'),
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`Unknown or incomplete step: ${name}`);
|
||||
});
|
||||
if (values.focus && !positionals.includes('focused'))
|
||||
throw new Error('--focus requires the focused step.');
|
||||
if ((values.games || values.seed) && !positionals.includes('base-games'))
|
||||
throw new Error('--games/--seed require base-games.');
|
||||
const report = await runVerification(root, values.out, steps);
|
||||
console.log(
|
||||
`Verification ${report.status}; ${resolve(values.out, 'report.json')}`,
|
||||
);
|
||||
process.exitCode = report.status === 'passed' ? 0 : 1;
|
||||
}
|
||||
main().catch(() => {
|
||||
console.error(
|
||||
'Verification could not complete. Check arguments, repository and output location; see --help.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in new issue
Block a user