diff --git a/AGENTS.md b/AGENTS.md index 84c0a09b..00698748 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 16aaa4c2..13a6d281 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md new file mode 100644 index 00000000..5bd983ee --- /dev/null +++ b/docs/CURRENT_STATUS.md @@ -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). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f5c7b3df..03097d5e 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.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 | diff --git a/docs/EFFICIENCY_PILOT.md b/docs/EFFICIENCY_PILOT.md new file mode 100644 index 00000000..ab3d3e54 --- /dev/null +++ b/docs/EFFICIENCY_PILOT.md @@ -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. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 2d140681..465fc1fb 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md new file mode 100644 index 00000000..41040bd3 --- /dev/null +++ b/docs/RULE_DECISIONS.md @@ -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. diff --git a/docs/VERIFICATION_WORKFLOW.md b/docs/VERIFICATION_WORKFLOW.md new file mode 100644 index 00000000..28c51c77 --- /dev/null +++ b/docs/VERIFICATION_WORKFLOW.md @@ -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. diff --git a/tests/saved-game-verification.test.ts b/tests/saved-game-verification.test.ts new file mode 100644 index 00000000..84fc8399 --- /dev/null +++ b/tests/saved-game-verification.test.ts @@ -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); + }, +); diff --git a/tests/seat-restoration-tools.test.ts b/tests/seat-restoration-tools.test.ts new file mode 100644 index 00000000..22d45959 --- /dev/null +++ b/tests/seat-restoration-tools.test.ts @@ -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/, + ); +}); diff --git a/tests/verification-tools.test.ts b/tests/verification-tools.test.ts new file mode 100644 index 00000000..b58b7a5c --- /dev/null +++ b/tests/verification-tools.test.ts @@ -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'); +}); diff --git a/tools/restore-seats.ts b/tools/restore-seats.ts new file mode 100644 index 00000000..30973ac3 --- /dev/null +++ b/tools/restore-seats.ts @@ -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; +}); diff --git a/tools/saved-game-verification.ts b/tools/saved-game-verification.ts new file mode 100644 index 00000000..1c654041 --- /dev/null +++ b/tools/saved-game-verification.ts @@ -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; +} diff --git a/tools/saved-games.ts b/tools/saved-games.ts new file mode 100644 index 00000000..f5ba2312 --- /dev/null +++ b/tools/saved-games.ts @@ -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; +}); diff --git a/tools/seat-restoration.ts b/tools/seat-restoration.ts new file mode 100644 index 00000000..c78c01fe --- /dev/null +++ b/tools/seat-restoration.ts @@ -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) => + 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 }; +} diff --git a/tools/verification.ts b/tools/verification.ts new file mode 100644 index 00000000..0f7ecd5a --- /dev/null +++ b/tools/verification.ts @@ -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 | 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((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; +} diff --git a/tools/verify.ts b/tools/verify.ts new file mode 100644 index 00000000..f3d7c9d0 --- /dev/null +++ b/tools/verify.ts @@ -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; +});