From c257ec0c28af23078a8fe0f94b2d1dd6860c88b3 Mon Sep 17 00:00:00 2001 From: Ael <183124839+ael-dev3@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:05:42 +0200 Subject: [PATCH 01/10] Alpha 0.3.14 signed source (1/10) GitHub-signed reconstruction of the exact reviewed Alpha 0.3.14 tree. --- .github/workflows/verify.yml | 5 +- .gitignore | 22 ++++- ASSETS-LICENSE.md | 21 +++++ CHANGELOG.md | 17 +++- README.md | 6 +- docs/design/realm-renderer-recovery.md | 39 +++++++++ docs/design/roadmap.md | 2 +- docs/farcaster-integration.md | 15 +++- docs/legal/license-inventory.md | 1 + docs/operations/alpha-activation.md | 36 ++++++--- .../reconstruction/credential-rotation.md | 7 +- .../reconstruction/deployment-recovery.md | 6 +- .../reconstruction/service-inventory.md | 6 +- .../record-art/manifest.json | 80 +++++++++++++++++++ docs/releases/versioning.md | 2 +- docs/security/threat-model.md | 14 +++- package-lock.json | 4 +- package.json | 4 +- 18 files changed, 253 insertions(+), 34 deletions(-) create mode 100644 docs/design/realm-renderer-recovery.md create mode 100644 docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index bb240426..7d41d3b7 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -98,7 +98,7 @@ jobs: run: pnpm --dir services/auth-bridge run check - name: Audit bridge dependencies - run: pnpm --dir services/auth-bridge audit --audit-level high + run: pnpm --dir services/auth-bridge audit --audit-level low spacetimedb-module: runs-on: ubuntu-latest @@ -147,7 +147,8 @@ jobs: run: | pnpm --dir spacetimedb run verify npm run stdb:verify-bindings + npm run stdb:verify-worker-migration npm run stdb:verify-additive-migration - name: Audit module dependencies - run: pnpm --dir spacetimedb audit --audit-level high + run: pnpm --dir spacetimedb audit --audit-level low diff --git a/.gitignore b/.gitignore index b153080f..ad3fbacd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,14 @@ dist .DS_Store coverage *.local -.cache/warpkeep-assets/ -.cache/warpkeep-tools/ +/pnpm-lock.yaml +/pnpm-workspace.yaml + +# Machine-local caches and deployment state. +.cache/ +.wrangler/ +.dev.vars* +!.dev.vars.example # Local credentials, operator output, and recovery residue must never be staged. credentials.json @@ -17,6 +23,18 @@ credentials.json *.key *.p12 *.pfx +*.jks +*.keystore +*.crt +*.cer +*.jwk +*.token +id_rsa* +id_ed25519* +admin-secret* +secret.json +secrets.json +.secrets/ *.log *.har *.trace diff --git a/ASSETS-LICENSE.md b/ASSETS-LICENSE.md index 3dcdf0b8..bb567272 100644 --- a/ASSETS-LICENSE.md +++ b/ASSETS-LICENSE.md @@ -292,6 +292,27 @@ The live integration mounts the card as decorative inspection art only; it grants no resource, currency, reward, entitlement, map-placement, or Wood authority. No Pages deployment is authorized by this record. +## Hegemony Worker inspection artwork + +On 2026-07-19, the Warpkeep project owner supplied a transparent Worker +illustration for the reviewed Worker UI slice. That authorization covers this +exact checked-in derivative in the public Warpkeep repository and an eventual +official `warpkeep.com` Pages runtime only after separately approved deployment. +It is use authorization only: it does not establish ownership, grant a public +open-content licence or general redistribution rights, or create worker, +resource, route, cargo, reward, settlement, or SpacetimeDB authority. + +| Intended use | Repository file | Technical record | +| --- | --- | --- | +| Decorative Worker inspection artwork | `public/images/realm/hegemony-worker-record.webp` | 1024×1024 transparent WebP, 86,984 bytes, SHA-256 `ff758ecbf520b05ccf0a2fa490bcafa6c564514de5ee56ef5a720fd6da24193e`; prepared from the supplied transparent PNG through the exact recorded Sharp 0.35.3 encoding. | + +The supplied source is not committed. Its exact hash, the runtime decoded-RGBA +hash, alpha profile, visible bounds, processing settings, and narrow UI-only +boundary are recorded in the dated [Worker inspection-art record](docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json). +The runtime file remains `LicenseRef-Warpkeep-Provenance-Required` and is loaded +only as same-origin decorative art by `WorkerInspectionPanel`; it does not +provide or imply identity, ownership, balance, command, or gameplay authority. + ## Hegemony Logging Camp runtime assets On 2026-07-18, the Warpkeep project owner supplied the named Logging Camp diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e2db587..1b848908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ full engineering record. ## [Unreleased] +## [0.3.14] — 2026-07-22 + +- Made the Realm recover from temporary graphics interruptions while preserving + selection and camera intent, and let castles continue at compact detail when + optional richer models cannot load. +- Gave river and ocean surfaces gentle motion and selectable, read-only public + records, including source-to-mouth river navigation. Reduced-motion play + keeps the water still. +- Refined the Lowlands toward a clearer green palette and denser grass coverage + without changing authoritative terrain, ownership, or resource rules. +- Staged a server-authoritative four-worker foundation behind inactive migration + and activation gates. Workers are not live in Alpha 0.3.14; the existing + expedition flow remains in place. + ## [0.3.13] — 2026-07-19 - Let the old scattered lakes return to lowland while preserving twelve @@ -111,7 +125,8 @@ full engineering record. Lowlands, a first keep, Farcaster sign-in, and an admission-gated shared-world foundation. -[Unreleased]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.13...HEAD +[Unreleased]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.14...HEAD +[0.3.14]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.13...v0.3.14 [0.3.13]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.12...v0.3.13 [0.3.12]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.11...v0.3.12 [0.3.11]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.8...v0.3.11 diff --git a/README.md b/README.md index 486cb73a..5a4865e5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## What is this? -Genesis 001 is a persistent, invite-only 10,000-cell Lowlands realm with 100 permanent castle sites kept close to its founding district. Each founder signs in with a verified Farcaster identity, receives one durable keep, and privately holds Food / Wood / Stone / Gold. Food, Wood, and Stone are governed by authoritative terrain yield, while dedicated expeditions can gather all four resources. Alpha 0.3.13 is live but early; founders can explore its coast, twelve rivers, clustered forests, and resource sites, follow their supply wagons, and return to a world that remembers them, while the intended core strategy loop is not playable yet. Warpkeep is a one-person experiment—not a finished MMO or financial product; there are no token rewards, no financial promises, and joining does not earn an airdrop or financial return or guarantee a reward or future value. +Genesis 001 is a persistent, invite-only 10,000-cell Lowlands realm with 100 permanent castle sites kept close to its founding district. Each founder signs in with a verified Farcaster identity, receives one durable keep, and privately holds Food / Wood / Stone / Gold. Food, Wood, and Stone are governed by authoritative terrain yield, while dedicated expeditions can gather all four resources. Alpha 0.3.14 is live but early; founders can explore its coast, twelve rivers, clustered forests, and resource sites, follow their supply wagons, and return to a world that remembers them, while the intended core strategy loop is not playable yet. Warpkeep is a one-person experiment—not a finished MMO or financial product; there are no token rewards, no financial promises, and joining does not earn an airdrop or financial return or guarantee a reward or future value. ![Development preview of Genesis 001 showing the Lowlands and an open Wheat Farm inspection panel.](docs/reference/screenshots/2026-07-22-realm-wheat-farm-preview/warpkeep-realm-wheat-farm-preview-f3b1f7e598c543d6.png) @@ -27,7 +27,7 @@ Open the local URL Vite prints; shared Alpha access stays off by default. Contri | State | Today | | --- | --- | -| ✅ Live | Alpha 0.3.13 is live and invite-only. | +| ✅ Live | Alpha 0.3.14 is live and invite-only. | | ✅ World | Genesis 001 persists 10,000 cells, a coastline, twelve one-cell rivers, and 100 permanent castle sites near the founding district. Founders return to one durable keep, explore the Lowlands up to its fog, and inspect nearby founders through their public username / portrait / castle. The same authoritative world waits across sessions. | | ✅ Authority | FID is the durable identity; handles and portraits are bounded presentation metadata. Farcaster sign-in uses a browser-bound, least-privilege bridge. The browser presents. The server decides admission and ownership. It also owns resources, timers, and saved state. | | ✅ Resources | Each keep privately holds Food / Wood / Stone / Gold. Food, Wood, and Stone come from authoritative terrain yield and can also be gathered at Wheat Farms, Logging Camps, and Stone Quarries; Gold comes from Gold Mines. The resource rail shows stored and ready amounts, and hover, focus, or tap explains current behavior. The browser never invents balances. | @@ -52,7 +52,7 @@ Open the local URL Vite prints; shared Alpha access stays off by default. Contri - **Architecture:** The [technical architecture](docs/technical-architecture.md) explains what the browser shows and what the server decides. - **Roadmap:** The [roadmap](docs/design/roadmap.md) and [game direction](docs/design/warpkeep-direction.md) separate today's game from later plans. - **Authentication:** The [Farcaster integration](docs/farcaster-integration.md) guide covers sign-in, privacy, and public configuration. -- **Release:** The [Alpha 0.3.13 release notes](CHANGELOG.md#0313--2026-07-19) record exactly what is live. +- **Release:** The [Alpha 0.3.14 release notes](CHANGELOG.md#0314--2026-07-22) record exactly what is live. - **Licensing:** [LICENSING.md](LICENSING.md) explains release rules; [asset provenance](ASSETS-LICENSE.md) records where media came from and what permissions apply. - **Contributing:** [CONTRIBUTING.md](CONTRIBUTING.md) covers checks and provenance; the [Realm Council issue forms](https://github.com/ael-dev3/Warpkeep/issues/new/choose) accept privacy-safe bugs and ideas. - **Security:** Report sensitive issues privately through [SECURITY.md](SECURITY.md), never through a public issue. diff --git a/docs/design/realm-renderer-recovery.md b/docs/design/realm-renderer-recovery.md new file mode 100644 index 00000000..8754c939 --- /dev/null +++ b/docs/design/realm-renderer-recovery.md @@ -0,0 +1,39 @@ +# Realm renderer recovery + +The Realm keeps a real WebGL scene as the source of truth once it has become +ready. The renderer lifecycle is explicit: `probing`, `loading`, `ready`, +`recovering`, `static-unsupported`, and `failed`. + +`static-unsupported` is reserved for a device that cannot create WebGL before +the first successful scene. It is an accessible, bounded illustrated view; it +is never a post-ready error surface. A renderer construction error, failed +castle assembly, castle-count mismatch, or synchronization failure remains an +explicit loading/recovery/failed state instead of silently replacing a real +world with a full-world SVG. + +Context loss calls `preventDefault`, pauses ambient work and rendering, and +retains React selection, camera intent, and the scene attestation. Pointer, +wheel, label-click, and camera input are synchronously suspended while the +context is lost so a partially disposed scene cannot consume a gesture. The +restored event starts a bounded scene rebuild and records loss/restore counts on +the canvas for DOM diagnostics. If the browser does not restore the context in +time, the user sees an explicit retry surface. All renderer surfaces share one +cached, non-destructive WebGL2 capability probe. No capability check calls +`WEBGL_lose_context` or otherwise tears down a context; a probe only reads the +optional texture-size limit. + +Castle loading is staged: Compact is mandatory and retried once for transient +transport failures after a deterministic short yield; Balanced and High are +optional upgrades. A missing optional LOD records the active quality in +`data-realm-castle-active-lod` and continues with Compact. Pairing, integrity, +and Compact failures are reported with stable failure codes for telemetry and +QA. Each controlled load is assigned a monotonic renderer generation. Scene +callbacks carry that generation and stale callbacks from a disposed scene are +ignored by both the React boundary and the pure lifecycle reducer. The DOM +exposes the active generation and the last generation that rendered a +successful frame, making recovery assertions deterministic. A ready renderer +can never transition into static compatibility mode. + +The recovery contract is intentionally frontend-only. Durable world state, +authorization, and SpacetimeDB subscriptions remain outside the renderer and +are never mutated by recovery code. diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md index e4ec225e..5eef97f0 100644 --- a/docs/design/roadmap.md +++ b/docs/design/roadmap.md @@ -3,7 +3,7 @@ Warpkeep is building a persistent strategy world one playable loop at a time. Dates and feature order may change as the Alpha is tested. -## Live now — Alpha 0.3.13 +## Live now — Alpha 0.3.14 - Farcaster-gated entry to the persistent Genesis 001 realm - 10,000 world cells and 100 permanent castle sites near the founding district diff --git a/docs/farcaster-integration.md b/docs/farcaster-integration.md index de8a1494..a778f71c 100644 --- a/docs/farcaster-integration.md +++ b/docs/farcaster-integration.md @@ -3,7 +3,7 @@ Warpkeep uses standard website Sign In with Farcaster (SIWF). It is not a Mini App, Quick Auth, wallet connection, or a client-only permanent identity system. -Alpha 0.3.13 uses backend protocol 3 and authentication contract v2; admission +Alpha 0.3.14 uses backend protocol 3 and authentication contract v2; admission remains gated. Production configuration and founder identities belong in the private operator record, not this guide. This document describes the contract but does not authorize admission or a production change. @@ -30,6 +30,12 @@ that boundary and never enter the session family or player JWT. The bridge accepts only the configured `FARCASTER_DOMAIN` and exact `FARCASTER_SIWE_URI`. +Production proof verification uses two official Farcaster verifier instances +backed by distinct public HTTPS RPC origins. Both must succeed with the same +canonical FID. A provider outage, partial result, or disagreement fails closed +as temporarily unavailable. A single RPC endpoint is permitted only for an +explicit development profile and must be loopback-local. + The intended production coordinates remain: ```txt @@ -82,6 +88,11 @@ URLs, or logs: - player/admin/resolver JWTs; - signing keys, session-cookie key, RPC credential, or admin secret. +The private admin configuration attestation exposes only domain-separated +SHA-256 fingerprints of the normalized RPC URLs and the active signing public +key's RFC 7638 thumbprint. Those values make endpoint or key drift detectable +without returning an RPC URL, credential, or private scalar. + After a fresh signature and an exchange whose bridge-verified FID exactly matches it, the browser may write a tab-scoped `sessionStorage` presentation cache. It contains only the sanitized public FID, username, display name, and @@ -238,7 +249,7 @@ VITE_WARPKEEP_OIDC_AUDIENCE=warpkeep-spacetimedb The Worker configuration is documented in [`services/auth-bridge/README.md`](../services/auth-bridge/README.md). Its -checked-in `PUBLIC_AUTH_ENABLED` remains false, while the recorded Alpha 0.3.13 +checked-in `PUBLIC_AUTH_ENABLED` remains false, while the recorded Alpha 0.3.14 production override is true. Before any future enable, the server-only v2 configuration attestation must match the reviewed issuer, origins, SIWF coordinates, key ID, Maincloud coordinates, S256 binding, 600-second access diff --git a/docs/legal/license-inventory.md b/docs/legal/license-inventory.md index 9d806d7c..b34d5351 100644 --- a/docs/legal/license-inventory.md +++ b/docs/legal/license-inventory.md @@ -34,6 +34,7 @@ public license or expand the recorded reuse and redistribution terms. | `/public/images/realm/hegemony-gold-mine-record.webp` | Transparent decorative Gold Mine inspection-card artwork | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-18, the project owner authorized the exact background-cleaned derivative for PR #49 and the reviewed Gold Wagon integration in the public Warpkeep GitHub repository, plus an eventual official `warpkeep.com` Pages runtime only after separately approved deployment; this is not deployment approval or Gold Mine economic authority | The dated Gold Mine inspection-art record pins the supplied input, generated/chroma inputs, exact output, alpha audit, UI-only boundary, and authorization scope | Preserve the exact hash and decorative-only use; do not infer a node placement, gathering action, balance, reward, Marks linkage, public relicensing, or general redistribution authority | | `/public/images/realm/hegemony-wheat-farm-record.webp` | Transparent decorative Wheat Farm inspection-card artwork | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-18, the project owner authorized the exact background-cleaned derivative for PR #57 in the public Warpkeep GitHub repository, plus an eventual official `warpkeep.com` Pages runtime only after separately approved deployment; this is not deployment approval or Food economic authority | The dated Wheat Farm inspection-art record pins the supplied input, generated/chroma inputs, exact output, alpha audit, UI-only boundary, and authorization scope | Preserve the exact hash and decorative-only use; do not infer a Food-site placement, gathering action, balance, reward, Marks linkage, public relicensing, or general redistribution authority | | `/public/images/realm/hegemony-logging-camp-record.webp` | Transparent decorative Logging Camp inspection-card artwork | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-19, the project owner authorized the exact background-cleaned derivative for PR #62 in the public Warpkeep GitHub repository, plus an eventual official `warpkeep.com` Pages runtime only after separately approved deployment; this is not merge or deployment approval or Wood economic authority | The dated Logging Camp inspection-art record pins the supplied RGB preview, local alpha-matte input, exact output, alpha/spill audit, UI-only boundary, and authorization scope | Preserve the exact hash and decorative-only use; do not infer a Wood-site placement, gathering action, balance, reward, Marks linkage, public relicensing, or general redistribution authority | +| `/public/images/realm/hegemony-worker-record.webp` | Transparent decorative Worker inspection artwork | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-19, the project owner supplied the exact transparent Worker illustration for the reviewed Worker UI slice in this public repository and an eventual official `warpkeep.com` Pages runtime only after separately approved deployment; this is not merge, deployment, worker activation, or economic authority | The dated Worker inspection-art record pins the supplied source hash, exact runtime hash, decoded-RGBA hash, alpha audit, visible bounds, same-origin UI-only boundary, and authorization scope | Preserve the exact hash and decorative-only use; do not infer worker identity, ownership, command, route, cargo, balance, reward, settlement, public relicensing, or general redistribution authority | | `/public/images/realm/hegemony-stone-quarry-record.webp` | Transparent decorative Stone Quarry inspection-card artwork | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-19, the project owner authorized the exact background-cleaned derivative for draft PR #65 in the public Warpkeep GitHub repository, plus an eventual official `warpkeep.com` Pages runtime only after separately approved deployment; this is not deployment approval or Stone-site/economic authority | The dated Stone Quarry inspection-art record pins the supplied input, generated/chroma inputs, exact output, alpha audit, UI-only boundary, and authorization scope | Preserve the exact hash and decorative-only use; do not infer a node placement, gathering action, balance, reward, Marks linkage, public relicensing, or general redistribution authority | | `/docs/reference/resources/2026-07-18-hegemony-gold-mine/runtime-candidates/**` | Historical Hegemony Gold Mine technical-review candidates | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | The owner supplied the exact three source inputs; their historical candidate bytes themselves do not authorize browser delivery, gameplay, deployment, or public relicensing | The candidate record pins supplied hashes and the known atlas-metadata discrepancy; the separate runtime record documents the reviewed promotion | Keep outside `public/` and never import candidate paths; preserve them as audit evidence for the separately named digest-bearing runtime outputs | | `/public/models/hegemony/gathering-nodes/gold-mine/hegemony-gold-mine-*.glb` | Active Hegemony Gold Mine visual runtime LODs | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-18, the project owner authorized the named reviewed outputs for the Gold Wagon integration in this public Warpkeep GitHub repository and an eventual official `warpkeep.com` Pages runtime after separately approved deployment; this is not deployment approval | The separate runtime record pins source inputs, exact output hashes, bounded Balanced/Compact atlas metadata normalization, orientation, and visual-only boundary | Preserve immutable hash-bearing paths; do not infer site placement, occupation, dispatch, travel, balance, reward, Marks linkage, public relicensing, or general redistribution authority | diff --git a/docs/operations/alpha-activation.md b/docs/operations/alpha-activation.md index 67578b20..5fb443f5 100644 --- a/docs/operations/alpha-activation.md +++ b/docs/operations/alpha-activation.md @@ -68,6 +68,16 @@ For releases after the Water/Stone suffix has been published, also run: npm run stdb:inspect-alpha-v10 -- --json ``` +After the Worker v12 suffix exists, its separate aggregate inspection is: + +```sh +npm run stdb:inspect-alpha-v12 -- --json +``` + +The first v12 publication cannot run that procedure beforehand. Its guarded +publisher instead requires an anonymous schema description of the immutable +database identity to match the exact 47-table v11 predecessor. + The first additive publication that introduces v8 cannot use it as a pre-publication check. Record counts privately. The v8 status contains only schema/backend versions, resource/forest policy identifiers and digests, and @@ -83,7 +93,8 @@ SpacetimeDB with deletion disabled. ```sh npm run stdb:publish:dev -- --dry-run \ --resource-rollout-stage=ready \ - --genesis-world-stage=expanded + --genesis-world-stage=expanded \ + --worker-rollout-stage=empty ``` Those stage values describe the current production predecessor; do not copy @@ -93,13 +104,20 @@ it does not inspect Maincloud or publish. Review the result, then use the same explicit stage arguments without `--dry-run` and with the publisher's exact confirmation variable set through the private operator environment. Do not substitute raw `spacetime publish` commands. If publication times out or -returns an ambiguous result, do not retry until fresh read-only inspection -establishes the live schema and counts. - -After publication, the publisher reruns v3, v4, v8, and v10 aggregate checks. -A failed check blocks component setup. Previously deployed counts must remain -unchanged; new component tables should be empty unless they were activated in -an earlier release. +returns an ambiguous result, do not republish. A fresh read-only inspection must +establish the live schema and counts before any further release decision. + +For the one-time v11-to-v12 boundary, the publisher anonymously describes the +same immutable identity before and after publication. It requires all 47 v11 +table signatures to remain unchanged and exactly six reviewed Worker tables to +be appended. The local proof receipt pins SHA-256 digests of the complete v11 +and v12 table descriptors, row types, indexes, constraints, and every reachable +typespace reference; reducer- and procedure-only schema is excluded. The live +anonymous pre- and post-publication descriptions must match those exact proven +boundaries. The publisher then reruns v3, v4, v8, v10, and v12 aggregate checks. +The v12 checkpoint must prove those tables are empty and the Worker system +remains absent and fail-closed. Worker seeding, backfill, or activation needs separate +approval and is not performed by publication. ## 4. Activate reviewed components @@ -169,7 +187,7 @@ to create evidence. tables inert, stop component setup, and restore service compatibility through a reviewed forward change. - Ambiguous operator result: disconnect, obtain fresh credentials, and inspect - counts before deciding whether any retry is safe. + schema and counts before deciding any next step. - Suspected credential exposure: stop and use the private credential-rotation procedure in [reconstruction/credential-rotation.md](reconstruction/credential-rotation.md). diff --git a/docs/operations/reconstruction/credential-rotation.md b/docs/operations/reconstruction/credential-rotation.md index 2751d5cc..f2668852 100644 --- a/docs/operations/reconstruction/credential-rotation.md +++ b/docs/operations/reconstruction/credential-rotation.md @@ -14,7 +14,8 @@ Credential values live only in their authorized platform or local secret store. - `ADMIN_TOKEN_SECRET`: Worker/operator Hermes authentication boundary. - `SESSION_COOKIE_KEY`: independent Worker HMAC boundary for the `__Host-warpkeep_session` rotating family reference. -- `FARCASTER_RPC_URL`: server-only Farcaster verifier/provider endpoint. +- `FARCASTER_RPC_URL` and `FARCASTER_RPC_URL_SECONDARY`: independent server-only + Farcaster verifier/provider endpoints on distinct public HTTPS origins. - Maincloud CLI authorization: inspect/build/generate/publish access. Never put values in a recovery manifest, `.env.example`, command-line argument, shell history, issue, screenshot, log, or chat transcript. @@ -30,7 +31,9 @@ For every rotation record only timestamp, affected service, public key ID or dep Verify exact `__Host-`, Secure, HttpOnly, SameSite=Strict attributes, tokenless pending behavior, generation rotation/replay revocation, and configuration attestation before any auth enable. -- **FARCASTER_RPC_URL:** update the managed secret; verify normal SIWF resolution and fail-closed provider outage behavior. +- **Farcaster RPC pair:** rotate one managed endpoint at a time; verify its + privacy-safe attestation fingerprint, matching dual-provider SIWF resolution, + and fail-closed outage/disagreement behavior before rotating the other. - **SIGNING_KEY_JWK:** update the private key and matching public key ID, deploy, confirm JWKS contains no private `d`, and re-verify module/browser behavior. The current bridge publishes one JWKS key. Do not claim seamless overlapping signing-key rotation; compromise rotation intentionally invalidates old tokens. Planned overlap requires a separately reviewed multi-key implementation. The module normally does not require republishing when only the issuer's signing key changes, but verify deployed runtime behavior. Access tokens are maximum 600 seconds and memory-only in the auth-v2 target; the separate session family remains server-revocable. diff --git a/docs/operations/reconstruction/deployment-recovery.md b/docs/operations/reconstruction/deployment-recovery.md index 654e5910..1916c9d6 100644 --- a/docs/operations/reconstruction/deployment-recovery.md +++ b/docs/operations/reconstruction/deployment-recovery.md @@ -64,8 +64,10 @@ pnpm --dir services/auth-bridge exec wrangler deploy --dry-run Restore secret values only through managed secret prompts or an approved non-logging secret-manager pipe. The required names are `SIGNING_KEY_JWK`, -`ADMIN_TOKEN_SECRET`, `SESSION_COOKIE_KEY`, and `FARCASTER_RPC_URL`; never -record their values. The signing, admin, and session secrets must be distinct. +`ADMIN_TOKEN_SECRET`, `SESSION_COOKIE_KEY`, `FARCASTER_RPC_URL`, and +`FARCASTER_RPC_URL_SECONDARY`; never record their values. The two production RPC +endpoints must use distinct public HTTPS origins. The signing, admin, and +session secrets must be distinct. Recovery begins with `PUBLIC_AUTH_ENABLED=false`. Deploy the Worker only when its reviewed source or required binding changed. Any Durable Object migration diff --git a/docs/operations/reconstruction/service-inventory.md b/docs/operations/reconstruction/service-inventory.md index f117f506..fcddc41f 100644 --- a/docs/operations/reconstruction/service-inventory.md +++ b/docs/operations/reconstruction/service-inventory.md @@ -97,13 +97,15 @@ Those public routes are active only while `PUBLIC_AUTH_ENABLED=true` and return the paused profile when it is false. Public v1 challenge/exchange are retired with `410`; admin `/v1` routes are a separate server-only namespace. Secret names are `SIGNING_KEY_JWK`, -`ADMIN_TOKEN_SECRET`, `SESSION_COOKIE_KEY`, and `FARCASTER_RPC_URL`; see +`ADMIN_TOKEN_SECRET`, `SESSION_COOKIE_KEY`, `FARCASTER_RPC_URL`, and +`FARCASTER_RPC_URL_SECONDARY`; see [`credential-rotation.md`](credential-rotation.md). Never record their values. The server-only config attestation profile is `warpkeep-auth-v2`. Its fail-closed recovery target has `publicAuthEnabled: false`; the current Alpha 0.3.13 active target has `publicAuthEnabled: true`. It covers -issuer/origins/SIWF coordinates, gameplay key/Maincloud coordinates, the +issuer/origins/SIWF coordinates, both privacy-safe RPC endpoint fingerprints, +the active signing-public-key thumbprint, gameplay key/Maincloud coordinates, the observer URI/database/audience tuple and gate, S256, the 600-second access TTL, 15-second resolver TTL, five-second resolver timeout, five-minute challenge TTL, maximum-30-day family, and exact `__Host-` cookie attributes. Record only the diff --git a/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json b/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json new file mode 100644 index 00000000..b47ff3e3 --- /dev/null +++ b/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json @@ -0,0 +1,80 @@ +{ + "schemaVersion": 1, + "id": "hegemony-worker-record-art-v1", + "recordedAt": "2026-07-19", + "purpose": "Transparent decorative Worker art for the Worker inspection panel", + "projectAuthorization": { + "authorizedBy": "Warpkeep project owner", + "instructionDate": "2026-07-19", + "scope": "Use the supplied transparent Worker illustration in the Warpkeep Worker UI slice stacked on the stable Realm PR. This authorizes repository/runtime integration only after review; it does not authorize merge, deployment, production activation, worker seeding, or generic worker authority.", + "notGranted": [ + "proof of underlying copyright ownership", + "a public open-content licence", + "general third-party derivative or redistribution permission", + "trademark or canonical-identity rights", + "worker ownership, status, route, cargo, reward, or settlement authority" + ] + }, + "sourceInputs": [ + { + "role": "owner-supplied transparent Worker illustration", + "repositoryRetained": false, + "originalName": "codex-clipboard-1312042e-175b-466f-adda-9bef7b12c1f0.png", + "bytes": 478174, + "sha256": "f6ae700affb5ce981074c7952bc81f90b60e2dab947b94867c5394d3e23b4d6d", + "image": { + "format": "png", + "width": 1024, + "height": 1024, + "channels": 4, + "alpha": true + } + } + ], + "processing": { + "tool": "sharp 0.35.3", + "operation": "lossy WebP derivative with alpha preserved", + "outputEncoding": "quality 92, alphaQuality 100, effort 6, smartSubsample true", + "runtimeAsset": { + "path": "public/images/realm/hegemony-worker-record.webp", + "format": "webp", + "width": 1024, + "height": 1024, + "bytes": 86984, + "sha256": "ff758ecbf520b05ccf0a2fa490bcafa6c564514de5ee56ef5a720fd6da24193e", + "decodedRgbaSha256": "2e77492f76801576adcc9cfe660fa15494123bc43fcd767e005cd5ad95d8b047", + "alpha": { + "transparentPixels": 858605, + "partiallyTransparentPixels": 39551, + "opaquePixels": 150420 + }, + "visibleBoundsAlpha16": { + "minX": 256, + "minY": 51, + "maxX": 863, + "maxY": 730 + } + } + }, + "presentationBoundary": { + "component": "src/components/realm/WorkerInspectionPanel.tsx", + "runtimeUse": "same-origin decorative hero art in a focus-safe Worker inspector", + "forbiddenClaims": [ + "private FID", + "private cargo or account balance", + "browser-invented worker ownership", + "browser-derived dispatch or recall authority", + "reward or Marks linkage" + ] + }, + "licence": { + "spdx": "LicenseRef-Warpkeep-Provenance-Required", + "policy": "The exact project instruction authorizes this Warpkeep runtime use but does not establish a public relicensing grant. See ASSETS-LICENSE.md." + }, + "visualQa": { + "transparentSourceVerified": true, + "visibleBoundsRecorded": true, + "runtimeDerivativeViewedOnTransparentViewer": true, + "externalRuntimeUrl": false + } +} diff --git a/docs/releases/versioning.md b/docs/releases/versioning.md index 50e20077..d225bb40 100644 --- a/docs/releases/versioning.md +++ b/docs/releases/versioning.md @@ -1,7 +1,7 @@ # Versioning and releases Warpkeep uses semantic versions for the product and Git commit SHAs for builds. -The current Alpha is `0.3.13`. +The current Alpha is `0.3.14`. ## Version numbers diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index e7942e7f..bba3bc10 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -2,7 +2,7 @@ ## Status and scope -This document describes the current security model for Warpkeep Alpha 0.3.13. It +This document describes the current security model for Warpkeep Alpha 0.3.14. It covers the browser application, Farcaster Sign In with Farcaster (SIWF), the Cloudflare authentication bridge, SpacetimeDB game authority, local operator tools, GitHub Actions, and GitHub Pages delivery. @@ -65,7 +65,9 @@ operation. Anonymous visitors do not connect to the game database. 1. **Browser to Farcaster.** Relay responses are untrusted until the bridge verifies the completed proof. 2. **Browser to authentication bridge.** Request bodies, headers, origins, and proof material are hostile input. The bridge applies strict schemas, size limits, deadlines, origin checks, and replay protection. -3. **Bridge to Farcaster verifier.** Provider failures or malformed responses must not produce a token. +3. **Bridge to Farcaster verifier.** Two independent production RPC origins + must both validate the proof to the same FID. Failure, partial success, + disagreement, or malformed responses must not produce a token. 4. **Bridge to SpacetimeDB.** Admission lookup uses a short-lived resolver principal bound to one FID and fixed service coordinates. 5. **Browser to SpacetimeDB.** The module repeats issuer, audience, subject, role, epoch, expiry, admission, @@ -95,6 +97,9 @@ operation. Anonymous visitors do not connect to the game database. wallet links, and browser parameters are presentation data only. - SIWF validation binds the proof to the configured domain, URI, nonce, request, and expiry. The proof FID and requested FID must agree. +- Production runs the official verifier against two distinct public HTTPS RPC + origins and requires matching successful FIDs. One loopback endpoint is + accepted only in an explicit development profile. - Challenges are random, expire, and are claimed atomically. Successful or definitively invalid exchanges consume them. - Missing, disabled, malformed, or epoch-mismatched admission returns no access token or game state. @@ -180,7 +185,7 @@ operation. Anonymous visitors do not connect to the game database. | Risk | Treatment and remaining exposure | | --- | --- | -| Client substitutes another FID | Independent proof verification and module-side caller derivation prevent browser choice; verifier compromise remains an external incident. | +| Client substitutes another FID | Dual-RPC proof consensus and module-side caller derivation prevent browser choice; correlated provider or verifier-library compromise remains an external incident. | | Proof or session replay | Expiring challenges, atomic claim, rotation, epoch checks, and revocation limit replay; host compromise can still defeat the boundary. | | Access token stolen by script or extension | Memory-only storage and short lifetime limit exposure, but a compromised origin or device can use the token until expiry. | | Resolver token stolen while fresh | One-FID binding and least-privilege guards limit access; a fresh token may still expose that FID's admission status and public subscriptions until disconnect. | @@ -210,7 +215,8 @@ operation. Anonymous visitors do not connect to the game database. - Per-client rate limits do not prevent distributed traffic from reaching provider or account quotas. Monitoring, alerting, and incident drills remain areas for improvement. -- Third-party services—including Farcaster, Cloudflare, GitHub, package +- Third-party services—including both configured Farcaster RPC providers, + Farcaster, Cloudflare, GitHub, package registries, and SpacetimeDB—are trusted dependencies and are not audited by this project. - Branch protection and automated checks reduce supply-chain risk but do not diff --git a/package-lock.json b/package-lock.json index cdb7e0ac..fdd7d6da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "warpkeep", - "version": "0.3.13", + "version": "0.3.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "warpkeep", - "version": "0.3.13", + "version": "0.3.14", "license": "Apache-2.0", "dependencies": { "@farcaster/auth-client": "0.7.1", diff --git a/package.json b/package.json index 556acada..de0bc4dc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "warpkeep", "private": true, - "version": "0.3.13", + "version": "0.3.14", "description": "A Farcaster-connected persistent strategy world in active Alpha development.", "license": "Apache-2.0", "homepage": "https://warpkeep.com", @@ -69,6 +69,7 @@ "stdb:generate": "node scripts/generate-spacetime-bindings.mjs", "stdb:verify-bindings": "node scripts/verify-spacetime-bindings.mjs", "stdb:verify-additive-migration": "node scripts/verify-spacetime-additive-migration.mjs", + "stdb:verify-worker-migration": "node scripts/verify-castle-worker-additive-migration.mjs", "stdb:publish:dev": "node scripts/publish-spacetime-dev.mjs", "stdb:seed-world": "tsx scripts/hermes-admin.ts seed-world", "stdb:expand-world-v3": "tsx scripts/hermes-admin.ts expand-world-v3", @@ -82,6 +83,7 @@ "stdb:inspect-alpha-v4": "tsx scripts/hermes-admin.ts inspect-alpha-v4", "stdb:inspect-alpha-v8": "tsx scripts/hermes-admin.ts inspect-alpha-v8", "stdb:inspect-alpha-v10": "tsx scripts/hermes-admin.ts inspect-alpha-v10", + "stdb:inspect-alpha-v12": "tsx scripts/hermes-admin.ts inspect-alpha-v12", "stdb:seed-alpha-component": "tsx scripts/hermes-admin.ts seed-alpha-component", "stdb:activate-alpha-water": "tsx scripts/hermes-admin.ts activate-alpha-water", "stdb:inspect-water-revision": "tsx scripts/water-revision-operator.ts inspect", From 1901ec44c208d213a2c1a8fdda139f5d1813c0ac Mon Sep 17 00:00:00 2001 From: Ael <183124839+ael-dev3@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:05:43 +0200 Subject: [PATCH 02/10] Alpha 0.3.14 signed source (2/10) GitHub-signed reconstruction of the exact reviewed Alpha 0.3.14 tree. --- .../images/realm/hegemony-worker-record.webp | Bin 0 -> 86984 bytes scripts/hermes-admin.ts | 113 +- scripts/publish-spacetime-dev.d.mts | 19 + scripts/publish-spacetime-dev.mjs | 1008 +++++++++++++++-- scripts/qa-observer/local-vite-fs-deny.d.mts | 5 + scripts/qa-observer/local-vite-fs-deny.mjs | 201 ++++ .../rendered-webgl-browser-probe.d.mts | 2 + .../rendered-webgl-browser-probe.mjs | 65 +- .../spacetime-additive-migration-proof.mjs | 55 +- .../spacetime-table-schema-attestation.mjs | 179 +++ ...erify-castle-worker-additive-migration.mjs | 61 + 11 files changed, 1561 insertions(+), 147 deletions(-) create mode 100644 public/images/realm/hegemony-worker-record.webp create mode 100644 scripts/qa-observer/local-vite-fs-deny.d.mts create mode 100644 scripts/qa-observer/local-vite-fs-deny.mjs create mode 100644 scripts/spacetime-table-schema-attestation.mjs create mode 100644 scripts/verify-castle-worker-additive-migration.mjs diff --git a/public/images/realm/hegemony-worker-record.webp b/public/images/realm/hegemony-worker-record.webp new file mode 100644 index 0000000000000000000000000000000000000000..7a8bfa3ab6f0d6411cc6ee53b9cb25e47a755fd6 GIT binary patch literal 86984 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 +*lsVZ=1k29F} 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 ' + + ' ' + '[...args] [--dry-run] [--confirm]. admit-founder requires private stdin: ' + '--input-stdin --dry-run creates a reviewed plan; --input-stdin --confirm consumes it; ' + 'allow-fid only re-enables an existing complete founder.', @@ -302,7 +355,8 @@ export function parseHermesArguments(arguments_: readonly string[] = process.arg || command === 'inspect-alpha-v3' || command === 'inspect-alpha-v4' || command === 'inspect-alpha-v8' - || command === 'inspect-alpha-v10'; + || command === 'inspect-alpha-v10' + || command === 'inspect-alpha-v12'; const expectedPositionals = command === 'allow-fid' || command === 'disable-fid' || command === 'bump-auth-epoch' @@ -411,6 +465,51 @@ function printable(value: unknown): unknown { return value; } +/** + * Keep the v12 operator surface aggregate-only and fail closed if the generated + * procedure contract changes. Raw u64 values must still be canonical SDK + * bigints here; decimal-string conversion happens only at the JSON boundary. + */ +export function projectWorkerSystemStatusV12(value: unknown) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail('Worker procedure-v12 returned an invalid status object.'); + } + const status = value as Record; + const actualKeys = Object.keys(status).sort(); + if ( + actualKeys.length !== WORKER_STATUS_V12_KEYS.length + || actualKeys.some((key, index) => key !== WORKER_STATUS_V12_KEYS[index]) + ) { + fail('Worker procedure-v12 returned unexpected fields.'); + } + for (const field of WORKER_STATUS_V12_U64_FIELDS) { + const count = status[field]; + if (typeof count !== 'bigint' || count < 0n || count > U64_MAXIMUM) { + fail('Worker procedure-v12 returned an invalid aggregate count.'); + } + } + for (const field of WORKER_STATUS_V12_BOOLEAN_FIELDS) { + if (typeof status[field] !== 'boolean') { + fail('Worker procedure-v12 returned an invalid status flag.'); + } + } + if ( + (status.mode !== 'absent' && status.mode !== 'staged' && status.mode !== 'active') + || (status.rosterDigest !== '' + && (typeof status.rosterDigest !== 'string' + || !/^[0-9a-f]{16}$/.test(status.rosterDigest))) + || typeof status.rosterDigestExpected !== 'string' + || !/^[0-9a-f]{16}$/.test(status.rosterDigestExpected) + ) { + fail('Worker procedure-v12 returned invalid worker metadata.'); + } + return Object.freeze(Object.fromEntries( + [...WORKER_STATUS_V12_U64_FIELDS, ...WORKER_STATUS_V12_BOOLEAN_FIELDS, + ...WORKER_STATUS_V12_STRING_FIELDS] + .map(field => [field, status[field]]), + )); +} + type ResourceAggregateV4 = Readonly<{ allowedFids: bigint; castles: bigint; @@ -988,6 +1087,12 @@ export async function readStatus( expectedResourceFounderCount?: bigint, emit = true, ) { + if (version === 'v12') { + const status = await withOperationTimeout(connection.procedures.adminGetWorkerSystemStatusV1({})); + const verified = projectWorkerSystemStatusV12(status); + if (emit) console.log(JSON.stringify(printable(verified))); + return verified; + } if (version === 'v10') { const status = await withOperationTimeout(connection.procedures.adminGetAlphaStatusV10({})); const verified = projectAlphaStatusV10(status); @@ -1466,6 +1571,8 @@ async function main() { ? 'v8' : command === 'inspect-alpha-v10' ? 'v10' + : command === 'inspect-alpha-v12' + ? 'v12' : 'v1'; if (!mutationStatusHandled) { await readStatus( diff --git a/scripts/publish-spacetime-dev.d.mts b/scripts/publish-spacetime-dev.d.mts index a1a417ed..560722e7 100644 --- a/scripts/publish-spacetime-dev.d.mts +++ b/scripts/publish-spacetime-dev.d.mts @@ -1,8 +1,19 @@ export const GENESIS_WORLD_PUBLISH_STAGE: Readonly>; +export const PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS: Readonly>; export const RESOURCE_PUBLISH_ROLLOUT_STAGE: Readonly>; +export const WORKER_PUBLISH_ROLLOUT_STAGE: Readonly>; +export const WORKER_V12_TABLE_CONTRACTS: Readonly>>; export function alphaV8AggregateChildArguments(...args: any[]): any; export function alphaV10AggregateChildArguments(...args: any[]): any; +export function alphaV12AggregateChildArguments(...args: any[]): any; +export function canonicalSchemaDescribeChildArguments(...args: any[]): any; +export function createPrivatePublishSnapshot(...args: any[]): any; +export function parseCanonicalSchemaDescription(...args: any[]): any; export function parseMigrationProofReceipt(...args: any[]): any; export function parsePublishArguments(...args: any[]): any; export function publishChildEnvironment(...args: any[]): any; @@ -14,6 +25,8 @@ export function validateIssuerDeployment(...args: any[]): any; export function verifyCanonicalDatabaseList(...args: any[]): any; export function verifyFreshAlphaStatusV8Aggregate(...args: any[]): any; export function verifyFreshAlphaStatusV10Aggregate(...args: any[]): any; +export function verifyFreshAlphaStatusV12Aggregate(...args: any[]): any; +export function verifyFreshProductionV11Schema(...args: any[]): any; export function verifyFreshFoundedProtocolV3Aggregate(...args: any[]): any; export function verifyFreshResourceProtocolV4PrebackfillAggregate(...args: any[]): any; export function verifyFreshResourceProtocolV4ReadyAggregate(...args: any[]): any; @@ -21,9 +34,15 @@ export function verifyMigrationArtifactReceipt(...args: any[]): any; export function verifyPinnedCliAttestation(...args: any[]): any; export function verifyPostPublishAlphaStatusV8Aggregate(...args: any[]): any; export function verifyPostPublishAlphaStatusV10Aggregate(...args: any[]): any; +export function verifyPostPublishAlphaStatusV12Aggregate(...args: any[]): any; export function verifyPostPublishFoundedProtocolV3Aggregate(...args: any[]): any; +export function verifyPostPublishProductionV12Schema(...args: any[]): any; export function verifyPostPublishResourceProtocolV4PrebackfillAggregate(...args: any[]): any; export function verifyPostPublishResourceProtocolV4ReadyAggregate(...args: any[]): any; export function verifyPostPublishResourcePublicationCheckpoints(...args: any[]): any; export function verifyPrivacySafeAlphaStatusV8Output(...args: any[]): any; export function verifyPrivacySafeAlphaStatusV10Output(...args: any[]): any; +export function verifyPrivacySafeAlphaStatusV12Output(...args: any[]): any; +export function verifyEmptyAlphaStatusV12(...args: any[]): any; +export function verifyExactProductionV11Schema(...args: any[]): any; +export function verifyExactProductionV12Schema(...args: any[]): any; diff --git a/scripts/publish-spacetime-dev.mjs b/scripts/publish-spacetime-dev.mjs index 16ffde22..471c7ead 100644 --- a/scripts/publish-spacetime-dev.mjs +++ b/scripts/publish-spacetime-dev.mjs @@ -1,7 +1,23 @@ import { spawn, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { constants, accessSync, closeSync, fstatSync, openSync, readFileSync, realpathSync } from 'node:fs'; +import { + constants, + accessSync, + chmodSync, + closeSync, + fchmodSync, + fstatSync, + fsyncSync, + mkdtempSync, + openSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; import { readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -18,6 +34,9 @@ import { ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION, parseAdditiveMigrationProofReceipt, } from './spacetime-additive-migration-proof.mjs'; +import { + canonicalTableSchemaBoundaryDigest, +} from './spacetime-table-schema-attestation.mjs'; import { WARPKEEP_ENTRY_AGREEMENT_ACCEPTANCE_RECORDS_PER_FID_MAXIMUM, } from './entry-agreement-policy.mjs'; @@ -56,6 +75,7 @@ const MAX_ENTRY_AGREEMENT_ACCEPTANCE_ROWS_PER_PLAYER = WARPKEEP_ENTRY_AGREEMENT_ACCEPTANCE_RECORDS_PER_FID_MAXIMUM; const MAX_ENTRY_AGREEMENT_ACCEPTANCE_COUNT = 100 * MAX_ENTRY_AGREEMENT_ACCEPTANCE_ROWS_PER_PLAYER; +const SHA256_DIGEST = /^[0-9a-f]{64}$/; export const RESOURCE_PUBLISH_ROLLOUT_STAGE = Object.freeze({ PREBACKFILL: 'prebackfill', @@ -65,6 +85,118 @@ export const GENESIS_WORLD_PUBLISH_STAGE = Object.freeze({ PRE_EXPANSION: 'pre-expansion', EXPANDED: 'expanded', }); +export const WORKER_PUBLISH_ROLLOUT_STAGE = Object.freeze({ + EMPTY: 'empty', +}); + +export const PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS = Object.freeze({ + allowed_fid: 0, + world_tile: 1, + player: 2, + castle: 3, + admin_audit: 4, + player_v2: 5, + player_ownership_v2: 6, + realm_v1: 7, + world_tile_meta_v1: 8, + castle_slot_v1: 9, + castle_slot_claim_v1: 10, + realm_profile_v1: 11, + mark_account_v1: 12, + snap_burn_credit_v1: 13, + fid_wallet_attribution_v1: 14, + wallet_attribution_snapshot_v1: 15, + snap_scan_cursor_v1: 16, + snap_scan_batch_v1: 17, + alpha_terms_acceptance_v1: 18, + resource_account_v1: 19, + gold_site_v1: 20, + gold_node_occupation_v1: 21, + gold_expedition_v1: 22, + gold_expedition_idempotency_v1: 23, + gold_expedition_schedule_v_1: 24, + realm_forest_layout_v1: 25, + realm_forest_instance_v1: 26, + food_site_v1: 27, + food_node_occupation_v1: 28, + food_expedition_v1: 29, + food_expedition_idempotency_v1: 30, + food_expedition_schedule_v_1: 31, + wood_site_v1: 32, + wood_node_occupation_v1: 33, + wood_expedition_v1: 34, + wood_expedition_idempotency_v1: 35, + wood_expedition_schedule_v_1: 36, + realm_water_layout_v1: 37, + realm_water_body_v1: 38, + realm_water_cell_v1: 39, + realm_environment_v1: 40, + stone_site_v1: 41, + stone_node_occupation_v1: 42, + stone_expedition_v1: 43, + stone_expedition_idempotency_v1: 44, + stone_expedition_schedule_v_1: 45, + realm_water_revision_v1: 46, +}); +export const WORKER_V12_TABLE_CONTRACTS = Object.freeze({ + realm_worker_system_v1: Object.freeze({ + productTypeRef: 47, + access: 'Public', + fields: Object.freeze([ + 'realm_id', 'policy_version', 'workers_per_castle', 'expected_castle_count', + 'expected_worker_count', 'roster_digest', 'mode', 'legacy_drain_required', + 'created_at', 'activated_at', + ]), + }), + castle_worker_v1: Object.freeze({ + productTypeRef: 48, + access: 'Public', + fields: Object.freeze([ + 'worker_id', 'origin_castle_id', 'ordinal', 'status', 'resource_kind', + 'site_id', 'started_at_micros', 'arrives_at_micros', + 'gathering_ends_at_micros', 'return_started_at_micros', + 'returns_at_micros', 'route_steps', 'return_start_progress_basis_points', + 'timeline_revision', 'revision', + ]), + }), + worker_assignment_v1: Object.freeze({ + productTypeRef: 49, + access: 'Private', + fields: Object.freeze([ + 'assignment_id', 'worker_id', 'fid', 'origin_castle_id', 'resource_kind', + 'site_id', 'phase', 'started_at_micros', 'arrives_at_micros', + 'gathering_ends_at_micros', 'return_started_at_micros', + 'returns_at_micros', 'route_steps', 'return_start_progress_basis_points', + 'settled_through_micros', 'accrued_amount', 'materialized_amount', + 'timeline_revision', 'policy_version', 'created_at', 'updated_at', + ]), + }), + worker_node_occupation_v1: Object.freeze({ + productTypeRef: 50, + access: 'Public', + fields: Object.freeze([ + 'node_key', 'resource_kind', 'site_id', 'worker_id', 'worker_ordinal', + 'origin_castle_id', 'phase', 'started_at_micros', 'arrives_at_micros', + 'gathering_ends_at_micros', 'timeline_revision', + ]), + }), + worker_command_idempotency_v1: Object.freeze({ + productTypeRef: 51, + access: 'Private', + fields: Object.freeze([ + 'request_key', 'fid', 'worker_id', 'command_kind', 'resource_kind', + 'site_id', 'assignment_id', 'result_revision', 'created_at', + ]), + }), + worker_assignment_schedule_v_1: Object.freeze({ + productTypeRef: 52, + access: 'Private', + fields: Object.freeze([ + 'schedule_id', 'scheduled_at', 'assignment_id', 'worker_id', + 'timeline_revision', 'stage', + ]), + }), +}); const ALPHA_V8_COUNT_FIELDS = Object.freeze([ 'goldSites', @@ -148,6 +280,86 @@ const ALPHA_V10_STATUS_KEYS = Object.freeze([ ...ALPHA_V10_DIGEST_FIELDS, ...ALPHA_V10_COUNT_FIELDS, ].sort()); +const ALPHA_V12_U64_FIELDS = Object.freeze([ + 'systemRows', + 'expectedCastleCount', + 'expectedWorkerCount', + 'actualWorkerCount', + 'castlesMissingWorkers', + 'castlesWithExtraWorkers', + 'duplicateOrdinals', + 'malformedWorkerIds', + 'invalidWorkerStates', + 'idleWorkers', + 'outboundWorkers', + 'gatheringWorkers', + 'returningWorkers', + 'assignments', + 'occupations', + 'schedules', + 'orphanWorkers', + 'orphanAssignments', + 'assignmentsMissingOccupation', + 'assignmentsWithoutSingleSchedule', + 'orphanOccupations', + 'orphanSchedules', + 'invalidSchedules', + 'assignmentPublicMismatches', + 'occupationSiteMismatches', + 'invalidAssignments', + 'idempotencyReceipts', + 'invalidIdempotencyReceipts', + 'idempotencyOverflowFids', + 'legacyExpeditions', + 'legacyOccupations', + 'legacySchedules', +]); +const ALPHA_V12_BOOLEAN_FIELDS = Object.freeze([ + 'systemConfigValid', + 'legacyDrainRequired', + 'expectedCountsMatch', + 'rosterDigestMatches', +]); +const ALPHA_V12_STRING_FIELDS = Object.freeze([ + 'mode', + 'rosterDigest', + 'rosterDigestExpected', +]); +const ALPHA_V12_STATUS_KEYS = Object.freeze([ + ...ALPHA_V12_U64_FIELDS, + ...ALPHA_V12_BOOLEAN_FIELDS, + ...ALPHA_V12_STRING_FIELDS, +].sort()); +const EMPTY_WORKER_V12_ZERO_FIELDS = Object.freeze([ + 'systemRows', + 'expectedCastleCount', + 'expectedWorkerCount', + 'actualWorkerCount', + 'castlesWithExtraWorkers', + 'duplicateOrdinals', + 'malformedWorkerIds', + 'invalidWorkerStates', + 'idleWorkers', + 'outboundWorkers', + 'gatheringWorkers', + 'returningWorkers', + 'assignments', + 'occupations', + 'schedules', + 'orphanWorkers', + 'orphanAssignments', + 'assignmentsMissingOccupation', + 'assignmentsWithoutSingleSchedule', + 'orphanOccupations', + 'orphanSchedules', + 'invalidSchedules', + 'assignmentPublicMismatches', + 'occupationSiteMismatches', + 'invalidAssignments', + 'idempotencyReceipts', + 'invalidIdempotencyReceipts', + 'idempotencyOverflowFids', +]); const U64_MAXIMUM = (1n << 64n) - 1n; class SafePublishError extends Error {} @@ -156,6 +368,140 @@ function fail(message) { throw new SafePublishError(message); } +const PRIVATE_SNAPSHOT_DIRECTORY_MODE = 0o700; +const PRIVATE_SNAPSHOT_ARTIFACT_MODE = 0o400; +const PRIVATE_SNAPSHOT_EXECUTABLE_MODE = 0o500; +const MAX_PRIVATE_SNAPSHOT_BYTES = 128 * 1_024 * 1_024; +const PRIVATE_SNAPSHOT_KINDS = Object.freeze({ + ARTIFACT: 'artifact', + EXECUTABLE: 'executable', +}); + +function readExactVerifiedSourceBytes(sourcePath, expectedDigest, kind) { + if ( + typeof sourcePath !== 'string' + || !isAbsolute(sourcePath) + || typeof expectedDigest !== 'string' + || !SHA256_DIGEST.test(expectedDigest) + || typeof kind !== 'string' + || !Object.values(PRIVATE_SNAPSHOT_KINDS).includes(kind) + ) { + fail('The private publication snapshot request was invalid.'); + } + + let descriptor; + try { + descriptor = openSync(sourcePath, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = fstatSync(descriptor); + if ( + !before.isFile() + || before.size < 1 + || before.size > MAX_PRIVATE_SNAPSHOT_BYTES + ) { + fail('The private publication snapshot source was not a regular file.'); + } + const bytes = readFileSync(descriptor); + const after = fstatSync(descriptor); + if ( + before.dev !== after.dev + || before.ino !== after.ino + || before.size !== after.size + || before.mtimeMs !== after.mtimeMs + || before.ctimeMs !== after.ctimeMs + || bytes.byteLength !== after.size + ) { + fail('The private publication snapshot source changed while it was read.'); + } + const digest = createHash('sha256').update(bytes).digest('hex'); + if (digest !== expectedDigest) { + fail(kind === PRIVATE_SNAPSHOT_KINDS.ARTIFACT + ? 'The proven SpacetimeDB artifact changed after migration verification.' + : 'The exact reviewed SpacetimeDB CLI binary was not active on this platform.'); + } + // The caller copies this exact verified buffer. It never reopens the + // mutable source path between attestation and snapshot creation. + return Object.freeze({ bytes, digest }); + } catch (error) { + if (error instanceof SafePublishError) throw error; + fail('The private publication snapshot source could not be read safely.'); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +export function createPrivatePublishSnapshot(sourcePath, expectedDigest, kind) { + const verified = readExactVerifiedSourceBytes(sourcePath, expectedDigest, kind); + let directory; + let descriptor; + try { + directory = mkdtempSync(join(tmpdir(), 'warpkeep-publish-snapshot-')); + chmodSync(directory, PRIVATE_SNAPSHOT_DIRECTORY_MODE); + const directoryMetadata = statSync(directory); + if ( + !directoryMetadata.isDirectory() + || (directoryMetadata.mode & 0o777) !== PRIVATE_SNAPSHOT_DIRECTORY_MODE + ) { + fail('The private publication snapshot directory permissions were not exact.'); + } + + const snapshotPath = join( + directory, + // The pinned CLI is a multicall binary and dispatches from argv[0]. Keep + // its reviewed command name while changing only the private directory. + kind === PRIVATE_SNAPSHOT_KINDS.EXECUTABLE ? 'spacetime' : 'module.js', + ); + const snapshotMode = kind === PRIVATE_SNAPSHOT_KINDS.EXECUTABLE + ? PRIVATE_SNAPSHOT_EXECUTABLE_MODE + : PRIVATE_SNAPSHOT_ARTIFACT_MODE; + descriptor = openSync( + snapshotPath, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, + PRIVATE_SNAPSHOT_DIRECTORY_MODE, + ); + writeFileSync(descriptor, verified.bytes); + fchmodSync(descriptor, snapshotMode); + fsyncSync(descriptor); + const snapshotMetadata = fstatSync(descriptor); + if ( + !snapshotMetadata.isFile() + || snapshotMetadata.size !== verified.bytes.byteLength + || (snapshotMetadata.mode & 0o777) !== snapshotMode + ) { + fail('The private publication snapshot was not created exactly.'); + } + closeSync(descriptor); + descriptor = undefined; + + let cleaned = false; + const cleanup = () => { + if (cleaned) return; + try { + rmSync(directory, { recursive: true, force: true }); + cleaned = true; + } catch { + fail('Private publication snapshot cleanup failed; no further publication is safe.'); + } + }; + return Object.freeze({ + path: snapshotPath, + directory, + digest: verified.digest, + cleanup, + }); + } catch (error) { + if (descriptor !== undefined) { + try { closeSync(descriptor); } catch { /* Cleanup below remains mandatory. */ } + } + if (directory !== undefined) { + try { rmSync(directory, { recursive: true, force: true }); } catch { + fail('Private publication snapshot cleanup failed; no further publication is safe.'); + } + } + if (error instanceof SafePublishError) throw error; + fail('The private publication snapshot could not be created safely.'); + } +} + function requireHttpsOrigin(value, label) { if (typeof value !== 'string' || value.length === 0) { fail(`${label} is required.`); @@ -294,6 +640,7 @@ export function parsePublishArguments(arguments_ = process.argv.slice(2)) { let dryRun = false; let resourceRolloutStage; let genesisWorldRolloutStage; + let workerRolloutStage; for (const argument of arguments_) { if (argument === '--dry-run' && !dryRun) { dryRun = true; @@ -319,7 +666,17 @@ export function parsePublishArguments(arguments_ = process.argv.slice(2)) { continue; } } - fail('Usage: publish-spacetime-dev.mjs [--dry-run] --resource-rollout-stage= --genesis-world-stage=. Unknown or duplicate arguments are rejected.'); + if ( + argument.startsWith('--worker-rollout-stage=') + && workerRolloutStage === undefined + ) { + const value = argument.slice('--worker-rollout-stage='.length); + if (Object.values(WORKER_PUBLISH_ROLLOUT_STAGE).includes(value)) { + workerRolloutStage = value; + continue; + } + } + fail('Usage: publish-spacetime-dev.mjs [--dry-run] --resource-rollout-stage= --genesis-world-stage= --worker-rollout-stage=empty. Unknown or duplicate arguments are rejected.'); } if (resourceRolloutStage === undefined) { fail('An explicit resource rollout stage is required: prebackfill for the first additive publication or ready for an already-backfilled republish.'); @@ -327,7 +684,15 @@ export function parsePublishArguments(arguments_ = process.argv.slice(2)) { if (genesisWorldRolloutStage === undefined) { fail('An explicit Genesis world stage is required: pre-expansion for the exact 1,261-cell predecessor or expanded for the exact 10,000-cell target.'); } - return Object.freeze({ dryRun, resourceRolloutStage, genesisWorldRolloutStage }); + if (workerRolloutStage === undefined) { + fail('An explicit empty Worker rollout stage is required for the one-time additive v12 publication.'); + } + return Object.freeze({ + dryRun, + resourceRolloutStage, + genesisWorldRolloutStage, + workerRolloutStage, + }); } export function requireCanonicalPublishCoordinates(source = process.env) { @@ -464,15 +829,28 @@ export function attestPinnedSpacetimeCli( ) { const environment = publishChildEnvironment(sourceEnvironment); const executablePath = resolveExecutablePath(executable, environment); - const digest = createHash('sha256').update(readFileSync(executablePath)).digest('hex'); - const result = runBoundedSync( + const expectedDigest = EXPECTED_CLI_BINARY_SHA256[`${process.platform}-${process.arch}`]; + if (typeof expectedDigest !== 'string') { + fail('The exact reviewed SpacetimeDB CLI binary was not active on this platform.'); + } + const snapshot = createPrivatePublishSnapshot( executablePath, - ['--version'], - { env: environment, timeout: 10_000 }, - spawnSyncProcess, + expectedDigest, + PRIVATE_SNAPSHOT_KINDS.EXECUTABLE, ); - verifyPinnedCliAttestation(result.stdout, digest); - return executablePath; + try { + const result = runBoundedSync( + snapshot.path, + ['--version'], + { env: environment, timeout: 10_000 }, + spawnSyncProcess, + ); + verifyPinnedCliAttestation(result.stdout, snapshot.digest); + return snapshot; + } catch (error) { + snapshot.cleanup(); + throw error; + } } export function verifyCanonicalDatabaseList(output) { @@ -498,6 +876,237 @@ export function attestCanonicalDatabase(executable, spawnSyncProcess = spawnSync verifyCanonicalDatabaseList(result.stdout); } +export function canonicalSchemaDescribeChildArguments() { + return [ + 'describe', + '--json', + '--anonymous', + '--server', CANONICAL_MAINCLOUD_URI, + '--no-config', + CANONICAL_DATABASE_IDENTITY, + ]; +} + +export function parseCanonicalSchemaDescription(output) { + let description; + try { + description = JSON.parse(output); + } catch { + fail('The canonical schema inspection did not return machine-readable JSON.'); + } + if ( + !description + || typeof description !== 'object' + || Array.isArray(description) + || !Array.isArray(description.tables) + || !description.typespace + || typeof description.typespace !== 'object' + || !Array.isArray(description.typespace.types) + ) { + fail('The canonical schema inspection returned an invalid description.'); + } + return description; +} + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map(key => ( + `${JSON.stringify(key)}:${canonicalJson(value[key])}` + )).join(',')}}`; + } + return JSON.stringify(value); +} + +function schemaTableSignature(description, name) { + const matches = description.tables.filter(candidate => candidate?.name === name); + if (matches.length !== 1 || !Number.isSafeInteger(matches[0].product_type_ref)) { + fail('The canonical schema did not contain one exact required table.'); + } + const table = matches[0]; + const rowType = description.typespace.types[table.product_type_ref]; + if (!rowType || typeof rowType !== 'object' || Array.isArray(rowType)) { + fail('The canonical schema did not contain one exact required row type.'); + } + return { ...table, rowType }; +} + +function verifyExactTableIdentities(description, expectedRefs) { + const expectedNames = Object.keys(expectedRefs).sort(); + const actualNames = description.tables.map(table => table?.name).sort(); + if ( + actualNames.length !== expectedNames.length + || actualNames.some((name, index) => name !== expectedNames[index]) + ) { + fail('The canonical schema table set did not match the exact publication boundary.'); + } + for (const [name, expectedRef] of Object.entries(expectedRefs)) { + const signature = schemaTableSignature(description, name); + if (signature.product_type_ref !== expectedRef) { + fail('The canonical schema product-type references did not match the exact publication boundary.'); + } + } +} + +function schemaFieldNames(description, name) { + const elements = schemaTableSignature(description, name).rowType?.Product?.elements; + if (!Array.isArray(elements)) { + fail('The canonical Worker schema row fields were absent.'); + } + const fields = elements.map(element => element?.name?.some); + if (fields.some(field => typeof field !== 'string')) { + fail('The canonical Worker schema row fields were invalid.'); + } + return fields; +} + +function schemaTableAccess(description, name) { + const access = schemaTableSignature(description, name).table_access; + if (!access || typeof access !== 'object' || Array.isArray(access)) { + fail('The canonical Worker schema table access was invalid.'); + } + const keys = Object.keys(access); + if (keys.length !== 1) fail('The canonical Worker schema table access was invalid.'); + return keys[0]; +} + +/** + * Require the live predecessor to be exactly the deployed v11 table boundary. + * The returned canonical signatures are retained in memory and compared after + * publication so no pre-existing table can drift unnoticed. + */ +export function verifyExactProductionV11Schema(description, expectedTableSchemaDigest) { + verifyExactTableIdentities(description, PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS); + try { + if ( + typeof expectedTableSchemaDigest !== 'string' + || !SHA256_DIGEST.test(expectedTableSchemaDigest) + || canonicalTableSchemaBoundaryDigest( + description, + Object.keys(PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS), + ) !== expectedTableSchemaDigest + ) { + fail('The canonical v11 table schema did not match the proven publication boundary.'); + } + } catch (error) { + if ( + error instanceof SafePublishError + && error.message === 'The canonical v11 table schema did not match the proven publication boundary.' + ) throw error; + fail('The canonical v11 table schema did not match the proven publication boundary.'); + } + return Object.freeze(Object.fromEntries( + Object.keys(PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS).map(name => [ + name, + canonicalJson(schemaTableSignature(description, name)), + ]), + )); +} + +/** Require an exact v12 suffix while preserving every captured v11 signature. */ +export function verifyExactProductionV12Schema( + predecessorSignatures, + description, + expectedTableSchemaDigest, +) { + if ( + !predecessorSignatures + || typeof predecessorSignatures !== 'object' + || Array.isArray(predecessorSignatures) + || Object.keys(predecessorSignatures).sort().join(',') + !== Object.keys(PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS).sort().join(',') + || Object.values(predecessorSignatures).some(value => typeof value !== 'string') + ) { + fail('The captured production v11 schema boundary was invalid.'); + } + const v12Refs = Object.freeze({ + ...PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS, + ...Object.fromEntries(Object.entries(WORKER_V12_TABLE_CONTRACTS) + .map(([name, contract]) => [name, contract.productTypeRef])), + }); + verifyExactTableIdentities(description, v12Refs); + for (const name of Object.keys(PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS)) { + if (canonicalJson(schemaTableSignature(description, name)) !== predecessorSignatures[name]) { + fail('A pre-existing production table changed during the v12 publication.'); + } + } + for (const [name, contract] of Object.entries(WORKER_V12_TABLE_CONTRACTS)) { + if ( + schemaTableAccess(description, name) !== contract.access + || canonicalJson(schemaFieldNames(description, name)) !== canonicalJson(contract.fields) + ) { + fail('The appended Worker schema did not match the exact v12 contract.'); + } + } + try { + if ( + typeof expectedTableSchemaDigest !== 'string' + || !SHA256_DIGEST.test(expectedTableSchemaDigest) + || canonicalTableSchemaBoundaryDigest( + description, + Object.keys(v12Refs), + ) !== expectedTableSchemaDigest + ) { + fail('The canonical v12 table schema did not match the proven publication boundary.'); + } + } catch (error) { + if ( + error instanceof SafePublishError + && error.message === 'The canonical v12 table schema did not match the proven publication boundary.' + ) throw error; + fail('The canonical v12 table schema did not match the proven publication boundary.'); + } + return Object.freeze({ + predecessorTableCount: Object.keys(PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS).length, + appendedWorkerTableCount: Object.keys(WORKER_V12_TABLE_CONTRACTS).length, + totalTableCount: Object.keys(v12Refs).length, + }); +} + +export function verifyFreshProductionV11Schema( + executable, + expectedTableSchemaDigest, + spawnSyncProcess = spawnSync, +) { + try { + const result = runBoundedSync( + executable, + canonicalSchemaDescribeChildArguments(), + { timeout: 30_000 }, + spawnSyncProcess, + ); + return verifyExactProductionV11Schema( + parseCanonicalSchemaDescription(result.stdout), + expectedTableSchemaDigest, + ); + } catch { + fail('Exact production v11 schema preflight failed. No publish was attempted.'); + } +} + +export function verifyPostPublishProductionV12Schema( + executable, + predecessorSignatures, + expectedTableSchemaDigest, + spawnSyncProcess = spawnSync, +) { + try { + const result = runBoundedSync( + executable, + canonicalSchemaDescribeChildArguments(), + { timeout: 30_000 }, + spawnSyncProcess, + ); + return verifyExactProductionV12Schema( + predecessorSignatures, + parseCanonicalSchemaDescription(result.stdout), + expectedTableSchemaDigest, + ); + } catch { + fail('Post-publication v12 schema checkpoint is indeterminate; a fresh anonymous read-only schema inspection is required before any merge, client deployment, Worker seed, backfill, activation, or further publication decision.'); + } +} + function digestArtifact(artifactPath) { let descriptor; try { @@ -515,26 +1124,36 @@ function digestArtifact(artifactPath) { } } -export function verifyMigrationArtifactReceipt(receipt) { +function validateMigrationArtifactReceiptShape(receipt) { if ( receipt === null || typeof receipt !== 'object' - || Object.keys(receipt).sort().join(',') !== 'artifactDigest,artifactPath' + || Object.keys(receipt).sort().join(',') + !== 'artifactDigest,artifactPath,v11TableSchemaDigest,v12TableSchemaDigest' || receipt.artifactPath !== PROVEN_ARTIFACT_PATH - || !/^[0-9a-f]{64}$/.test(receipt.artifactDigest ?? '') + || !SHA256_DIGEST.test(receipt.v11TableSchemaDigest ?? '') + || !SHA256_DIGEST.test(receipt.v12TableSchemaDigest ?? '') + || !SHA256_DIGEST.test(receipt.artifactDigest ?? '') ) { fail('The additive migration proof artifact receipt was invalid.'); } - const currentDigest = digestArtifact(receipt.artifactPath); - if (currentDigest !== receipt.artifactDigest) { - fail('The proven SpacetimeDB artifact changed after migration verification.'); - } return Object.freeze({ artifactPath: receipt.artifactPath, + v11TableSchemaDigest: receipt.v11TableSchemaDigest, + v12TableSchemaDigest: receipt.v12TableSchemaDigest, artifactDigest: receipt.artifactDigest, }); } +export function verifyMigrationArtifactReceipt(receipt) { + const validated = validateMigrationArtifactReceiptShape(receipt); + const currentDigest = digestArtifact(validated.artifactPath); + if (currentDigest !== validated.artifactDigest) { + fail('The proven SpacetimeDB artifact changed after migration verification.'); + } + return validated; +} + export function parseMigrationProofReceipt(output) { let proofReceipt; try { @@ -544,6 +1163,8 @@ export function parseMigrationProofReceipt(output) { } return verifyMigrationArtifactReceipt({ artifactPath: PROVEN_ARTIFACT_PATH, + v11TableSchemaDigest: proofReceipt.v11TableSchemaDigest, + v12TableSchemaDigest: proofReceipt.v12TableSchemaDigest, artifactDigest: proofReceipt.artifactDigest, }); } @@ -681,6 +1302,15 @@ export function alphaV10AggregateChildArguments(tsxCli) { ]; } +export function alphaV12AggregateChildArguments(tsxCli) { + return [ + tsxCli, + 'scripts/hermes-admin.ts', + 'inspect-alpha-v12', + '--json', + ]; +} + /** * The Hermes child already verifies exact v8 policy identities and catalog * shape. This second boundary accepts only its closed, aggregate-only JSON @@ -838,6 +1468,104 @@ export function verifyFreshAlphaStatusV10Aggregate( return verifyPrivacySafeAlphaStatusV10Output(result.stdout); } +/** Accept only the exact aggregate-only Worker v12 JSON envelope from Hermes. */ +export function verifyPrivacySafeAlphaStatusV12Output(output) { + let status; + try { + status = JSON.parse(output); + } catch { + fail('Alpha procedure-v12 inspection did not return machine-readable JSON.'); + } + if (!status || typeof status !== 'object' || Array.isArray(status)) { + fail('Alpha procedure-v12 inspection returned an invalid status object.'); + } + const actualKeys = Object.keys(status).sort(); + if ( + actualKeys.length !== ALPHA_V12_STATUS_KEYS.length + || actualKeys.some((key, index) => key !== ALPHA_V12_STATUS_KEYS[index]) + ) fail('Alpha procedure-v12 inspection returned unexpected fields.'); + for (const field of ALPHA_V12_U64_FIELDS) { + const value = status[field]; + if ( + typeof value !== 'string' + || !/^(?:0|[1-9]\d*)$/.test(value) + || value.length > 20 + || BigInt(value) > U64_MAXIMUM + ) fail('Alpha procedure-v12 inspection returned an invalid aggregate count.'); + } + for (const field of ALPHA_V12_BOOLEAN_FIELDS) { + if (typeof status[field] !== 'boolean') { + fail('Alpha procedure-v12 inspection returned an invalid status flag.'); + } + } + if ( + (status.mode !== 'absent' && status.mode !== 'staged' && status.mode !== 'active') + || (status.rosterDigest !== '' + && (typeof status.rosterDigest !== 'string' + || !/^[0-9a-f]{16}$/.test(status.rosterDigest))) + || typeof status.rosterDigestExpected !== 'string' + || !/^[0-9a-f]{16}$/.test(status.rosterDigestExpected) + ) fail('Alpha procedure-v12 inspection returned invalid Worker metadata.'); + return Object.freeze({ ...status }); +} + +export function verifyEmptyAlphaStatusV12(status, expectedFounderCount) { + if ( + !Number.isSafeInteger(expectedFounderCount) + || expectedFounderCount < 1 + || expectedFounderCount > 100 + ) fail('The empty Worker checkpoint expected founder count was invalid.'); + if ( + status.mode !== 'absent' + || status.systemConfigValid !== false + || status.legacyDrainRequired !== true + || status.expectedCountsMatch !== false + || status.rosterDigestMatches !== false + || status.castlesMissingWorkers !== String(expectedFounderCount) + || status.rosterDigest !== '' + || EMPTY_WORKER_V12_ZERO_FIELDS.some(field => status[field] !== '0') + ) { + fail('Alpha procedure-v12 did not prove an empty, inert Worker suffix.'); + } + return status; +} + +export function verifyFreshAlphaStatusV12Aggregate( + secret, + expectedFounderCount, + spawnSyncProcess = spawnSync, +) { + const secretBytes = typeof secret === 'string' ? new TextEncoder().encode(secret).byteLength : 0; + if (secretBytes < 32 || secretBytes > 512) { + fail('A local 32-to-512-byte Hermes credential is required for the fresh Alpha v12 checkpoint.'); + } + if ( + !Number.isSafeInteger(expectedFounderCount) + || expectedFounderCount < 1 + || expectedFounderCount > 100 + ) fail('The Alpha v12 checkpoint expected founder count was invalid.'); + const tsxCli = resolve(repositoryRoot, 'node_modules/tsx/dist/cli.mjs'); + const result = runBoundedSync( + process.execPath, + alphaV12AggregateChildArguments(tsxCli), + { + env: { + WARPKEEP_SPACETIMEDB_URI: CANONICAL_MAINCLOUD_URI, + WARPKEEP_SPACETIMEDB_DATABASE: CANONICAL_DATABASE_IDENTITY, + WARPKEEP_AUTH_BRIDGE_URL: CANONICAL_BRIDGE, + WARPKEEP_ADMIN_TOKEN_SECRET_STDIN: '1', + }, + input: secret, + timeout: 30_000, + }, + spawnSyncProcess, + ); + return verifyEmptyAlphaStatusV12( + verifyPrivacySafeAlphaStatusV12Output(result.stdout), + expectedFounderCount, + ); +} + export function verifyPostPublishFoundedProtocolV3Aggregate( secret, expectations, @@ -853,8 +1581,8 @@ export function verifyPostPublishFoundedProtocolV3Aggregate( ); } catch { // Publication has already returned success. Never surface a preflight-style - // "no publish attempted" message or invite an unsafe retry when only the - // bounded post-publication inspection failed. + // "no publish attempted" message or invite another publication when only + // the bounded post-publication inspection failed. fail('Post-publication protocol-v3 verification is indeterminate; a fresh read-only inspection is required before any backfill or further publication decision.'); } } @@ -916,10 +1644,27 @@ export function verifyPostPublishAlphaStatusV10Aggregate( } } +export function verifyPostPublishAlphaStatusV12Aggregate( + secret, + expectedFounderCount, + spawnSyncProcess = spawnSync, +) { + try { + return verifyFreshAlphaStatusV12Aggregate( + secret, + expectedFounderCount, + spawnSyncProcess, + ); + } catch { + fail('Post-publication Alpha procedure-v12 checkpoint is indeterminate; a fresh read-only v12 inspection is required before any merge, client deployment, Worker seed, backfill, activation, or further publication decision.'); + } +} + export function verifyPostPublishResourcePublicationCheckpoints( secret, expectations, resourceRolloutStage, + workerRolloutStage, spawnSyncProcess = spawnSync, genesisWorldRolloutStage = GENESIS_WORLD_PUBLISH_STAGE.PRE_EXPANSION, ) { @@ -927,6 +1672,9 @@ export function verifyPostPublishResourcePublicationCheckpoints( if (!Object.values(RESOURCE_PUBLISH_ROLLOUT_STAGE).includes(resourceRolloutStage)) { fail('The post-publication resource rollout stage was invalid.'); } + if (workerRolloutStage !== WORKER_PUBLISH_ROLLOUT_STAGE.EMPTY) { + fail('The post-publication Worker rollout stage was invalid.'); + } verifyPostPublishFoundedProtocolV3Aggregate( secret, exactExpectations, @@ -948,6 +1696,11 @@ export function verifyPostPublishResourcePublicationCheckpoints( } verifyPostPublishAlphaStatusV8Aggregate(secret, spawnSyncProcess); verifyPostPublishAlphaStatusV10Aggregate(secret, spawnSyncProcess); + verifyPostPublishAlphaStatusV12Aggregate( + secret, + exactExpectations.expectedFounderCount, + spawnSyncProcess, + ); } export async function publishModule( @@ -959,84 +1712,93 @@ export async function publishModule( if (targetDatabase !== CANONICAL_DATABASE_IDENTITY) { fail('The production publish target was not the pinned canonical database identity.'); } - const artifact = verifyMigrationArtifactReceipt(artifactReceipt); + const artifact = validateMigrationArtifactReceiptShape(artifactReceipt); + const artifactSnapshot = createPrivatePublishSnapshot( + artifact.artifactPath, + artifact.artifactDigest, + PRIVATE_SNAPSHOT_KINDS.ARTIFACT, + ); const arguments_ = [ 'publish', '--server', CANONICAL_MAINCLOUD_URI, - '--js-path', artifact.artifactPath, + '--js-path', artifactSnapshot.path, '--delete-data=never', '--yes=remote', '--no-config', targetDatabase, ]; - await new Promise((resolvePromise, rejectPromise) => { - let settled = false; - let timedOut = false; - let outputExceeded = false; - let outputBytes = 0; - let deadline; - let forcedKill; - const settle = (callback) => { - if (settled) return; - settled = true; - if (deadline !== undefined) clearTimeout(deadline); - if (forcedKill !== undefined) clearTimeout(forcedKill); - callback(); - }; + try { + await new Promise((resolvePromise, rejectPromise) => { + let settled = false; + let timedOut = false; + let outputExceeded = false; + let outputBytes = 0; + let deadline; + let forcedKill; + const settle = (callback) => { + if (settled) return; + settled = true; + if (deadline !== undefined) clearTimeout(deadline); + if (forcedKill !== undefined) clearTimeout(forcedKill); + callback(); + }; - let child; - try { - child = spawnProcess(spacetimeCommand, arguments_, { - cwd: repositoryRoot, - // A compatibility or break-clients prompt must see EOF and abort. The - // bounded output is consumed without mirroring private process detail. - stdio: ['ignore', 'pipe', 'pipe'], - // The CLI uses local config/Home and standard network settings. It - // never receives ambient Warpkeep signing, admin, RPC, or review data. - env: publishChildEnvironment(), + let child; + try { + child = spawnProcess(spacetimeCommand, arguments_, { + cwd: repositoryRoot, + // A compatibility or break-clients prompt must see EOF and abort. The + // bounded output is consumed without mirroring private process detail. + stdio: ['ignore', 'pipe', 'pipe'], + // The CLI uses local config/Home and standard network settings. It + // never receives ambient Warpkeep signing, admin, RPC, or review data. + env: publishChildEnvironment(), + }); + } catch (error) { + settle(() => rejectPromise(error)); + return; + } + const observeOutput = (stream) => { + if (!stream || typeof stream.on !== 'function') return; + stream.on('data', chunk => { + outputBytes += chunk.byteLength; + if (outputBytes <= MAX_CHILD_OUTPUT_BYTES || outputExceeded) return; + outputExceeded = true; + try { child.kill('SIGKILL'); } catch { /* The bounded failure remains generic. */ } + forcedKill = setTimeout(() => { + settle(() => rejectPromise(new Error('SpacetimeDB publish output exceeded its fixed bound.'))); + }, PUBLISH_KILL_GRACE_MILLISECONDS); + }); + }; + observeOutput(child.stdout); + observeOutput(child.stderr); + child.on('error', (error) => { + // A signal-delivery error can arrive after the deadline. Keep the forced + // SIGKILL timer alive in that case instead of abandoning the child. Keep + // this listener installed so a second kill-delivery error is not emitted + // as an unhandled EventEmitter error after forced settlement. + if (!timedOut) settle(() => rejectPromise(error)); }); - } catch (error) { - settle(() => rejectPromise(error)); - return; - } - const observeOutput = (stream) => { - if (!stream || typeof stream.on !== 'function') return; - stream.on('data', chunk => { - outputBytes += chunk.byteLength; - if (outputBytes <= MAX_CHILD_OUTPUT_BYTES || outputExceeded) return; - outputExceeded = true; - try { child.kill('SIGKILL'); } catch { /* The bounded failure remains generic. */ } + child.once('close', (code) => settle(() => { + if (!timedOut && !outputExceeded && code === 0) resolvePromise(); + else rejectPromise(new Error('SpacetimeDB publish did not complete successfully.')); + })); + + deadline = setTimeout(() => { + timedOut = true; + try { child.kill('SIGTERM'); } catch { /* Fall through to the forced deadline. */ } forcedKill = setTimeout(() => { - settle(() => rejectPromise(new Error('SpacetimeDB publish output exceeded its fixed bound.'))); + try { child.kill('SIGKILL'); } catch { /* The outcome remains indeterminate. */ } + // Do not wait indefinitely for a child that ignores termination or + // withholds its close event. Treat the publication outcome as + // indeterminate and require a fresh read-only inspection. + settle(() => rejectPromise(new Error('SpacetimeDB publish exceeded its hard deadline.'))); }, PUBLISH_KILL_GRACE_MILLISECONDS); - }); - }; - observeOutput(child.stdout); - observeOutput(child.stderr); - child.on('error', (error) => { - // A signal-delivery error can arrive after the deadline. Keep the forced - // SIGKILL timer alive in that case instead of abandoning the child. Keep - // this listener installed so a second kill-delivery error is not emitted - // as an unhandled EventEmitter error after forced settlement. - if (!timedOut) settle(() => rejectPromise(error)); + }, PUBLISH_TIMEOUT_MILLISECONDS); }); - child.once('close', (code) => settle(() => { - if (!timedOut && !outputExceeded && code === 0) resolvePromise(); - else rejectPromise(new Error('SpacetimeDB publish did not complete successfully.')); - })); - - deadline = setTimeout(() => { - timedOut = true; - try { child.kill('SIGTERM'); } catch { /* Fall through to the forced deadline. */ } - forcedKill = setTimeout(() => { - try { child.kill('SIGKILL'); } catch { /* The outcome remains indeterminate. */ } - // Do not wait indefinitely for a child that ignores termination or - // withholds its close event. The CLI outcome must be inspected before - // any operator retries the publish. - settle(() => rejectPromise(new Error('SpacetimeDB publish exceeded its hard deadline.'))); - }, PUBLISH_KILL_GRACE_MILLISECONDS); - }, PUBLISH_TIMEOUT_MILLISECONDS); - }); + } finally { + artifactSnapshot.cleanup(); + } } async function main() { @@ -1044,6 +1806,7 @@ async function main() { dryRun, resourceRolloutStage, genesisWorldRolloutStage, + workerRolloutStage, } = parsePublishArguments(); requireCanonicalPublishCoordinates(); if (database !== CANONICAL_DATABASE) fail('The production publisher target was not canonical.'); @@ -1058,42 +1821,65 @@ async function main() { fail(`Set WARPKEEP_PUBLISH_CONFIRM=${database} after reviewing the target database; publish was not attempted.`); } const foundedExpectations = readFoundedPublishExpectations(); - const executable = attestPinnedSpacetimeCli(command); - const artifactReceipt = runCurrentAdditiveMigrationProof(executable); - if (dryRun) { + // Remove the Hermes credential from the ambient environment before the + // long-running proof spawns any children. The bounded aggregate helpers + // receive it only through stdin and every child environment stays allowlisted. + let adminTokenSecret = process.env.WARPKEEP_ADMIN_TOKEN_SECRET; + delete process.env.WARPKEEP_ADMIN_TOKEN_SECRET; + const executableSnapshot = attestPinnedSpacetimeCli(command); + try { + // Keep every proof, inspection, publish, and checkpoint bound to the one + // attested CLI copy for this complete publication lifecycle. + const executable = executableSnapshot.path; + const artifactReceipt = runCurrentAdditiveMigrationProof(executable); + if (dryRun) { + await validateIssuerDeployment(issuer); + console.log(`Dry run: verified the pinned CLI, current additive migration, founded-state expectation contract, explicit ${resourceRolloutStage} resource stage, explicit ${genesisWorldRolloutStage} Genesis world stage, explicit ${workerRolloutStage} Worker stage, and ${issuer}; would update the canonical existing database without deleting data.`); + return; + } await validateIssuerDeployment(issuer); - console.log(`Dry run: verified the pinned CLI, current additive migration, founded-state expectation contract, explicit ${resourceRolloutStage} resource stage, explicit ${genesisWorldRolloutStage} Genesis world stage, and ${issuer}; would update the canonical existing database without deleting data.`); - return; - } - await validateIssuerDeployment(issuer); - attestCanonicalDatabase(executable); - verifyFreshFoundedProtocolV3Aggregate( - process.env.WARPKEEP_ADMIN_TOKEN_SECRET, - foundedExpectations, - spawnSync, - genesisWorldRolloutStage, - ); - if (resourceRolloutStage === RESOURCE_PUBLISH_ROLLOUT_STAGE.READY) { - verifyFreshResourceProtocolV4ReadyAggregate( - process.env.WARPKEEP_ADMIN_TOKEN_SECRET, - foundedExpectations.expectedFounderCount, + attestCanonicalDatabase(executable); + const predecessorSchema = verifyFreshProductionV11Schema( + executable, + artifactReceipt.v11TableSchemaDigest, + ); + verifyFreshFoundedProtocolV3Aggregate( + adminTokenSecret, + foundedExpectations, + spawnSync, + genesisWorldRolloutStage, + ); + if (resourceRolloutStage === RESOURCE_PUBLISH_ROLLOUT_STAGE.READY) { + verifyFreshResourceProtocolV4ReadyAggregate( + adminTokenSecret, + foundedExpectations.expectedFounderCount, + ); + } + await publishModule(executable, CANONICAL_DATABASE_IDENTITY, artifactReceipt); + verifyPostPublishProductionV12Schema( + executable, + predecessorSchema, + artifactReceipt.v12TableSchemaDigest, + ); + verifyPostPublishResourcePublicationCheckpoints( + adminTokenSecret, + foundedExpectations, + resourceRolloutStage, + workerRolloutStage, + spawnSync, + genesisWorldRolloutStage, ); + } finally { + adminTokenSecret = undefined; + executableSnapshot.cleanup(); } - await publishModule(executable, CANONICAL_DATABASE_IDENTITY, artifactReceipt); - verifyPostPublishResourcePublicationCheckpoints( - process.env.WARPKEEP_ADMIN_TOKEN_SECRET, - foundedExpectations, - resourceRolloutStage, - spawnSync, - genesisWorldRolloutStage, - ); } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { main().catch((error) => { console.error(error instanceof SafePublishError ? error.message - : 'Non-destructive publish did not complete. The outcome may be indeterminate; inspect Maincloud before retrying.'); + : 'Non-destructive publish did not complete. The outcome may be indeterminate; perform a fresh read-only Maincloud inspection before any further publication decision.'); process.exitCode = 1; }); } diff --git a/scripts/qa-observer/local-vite-fs-deny.d.mts b/scripts/qa-observer/local-vite-fs-deny.d.mts new file mode 100644 index 00000000..70261731 --- /dev/null +++ b/scripts/qa-observer/local-vite-fs-deny.d.mts @@ -0,0 +1,5 @@ +import type { Plugin } from 'vite'; + +export declare const WARPKEEP_LOCAL_VITE_FS_DENY: readonly string[]; + +export declare function warpkeepLocalPublicBoundaryPlugin(): Plugin; diff --git a/scripts/qa-observer/local-vite-fs-deny.mjs b/scripts/qa-observer/local-vite-fs-deny.mjs new file mode 100644 index 00000000..ad1e5eaa --- /dev/null +++ b/scripts/qa-observer/local-vite-fs-deny.mjs @@ -0,0 +1,201 @@ +import { lstatSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Vite replaces, rather than extends, its default deny list when `server.fs.deny` + * is configured. Keep one shared contract for manual and automated local QA. + */ +export const WARPKEEP_LOCAL_VITE_FS_DENY = Object.freeze([ + '.env', + '.env.*', + '.dev.vars*', + '.envrc', + '.npmrc', + 'credentials.json', + 'admin-secret*', + 'secret.json', + 'secrets.json', + 'id_rsa*', + 'id_ed25519*', + '*.{crt,pem}', + '*.{cer,key,p12,pfx,jks,keystore,jwk,token}', + '*.local', + '*.{log,har,trace}', + '*.{bak,backup,tmp}', + '*.{sqlite,sqlite3,db,dump}', + '*.{zip,tar,tar.gz,tgz,7z}', + '**/.git/**', + '**/.cache/**', + '**/.wrangler/**', + '**/.secrets/**', +]); + +const SENSITIVE_PUBLIC_EXACT_NAMES = new Set([ + '.env', + '.envrc', + '.npmrc', + 'credentials.json', + 'secret.json', + 'secrets.json', +]); +const SENSITIVE_PUBLIC_DIRECTORIES = new Set([ + '.git', + '.cache', + '.wrangler', + '.secrets', +]); +const SENSITIVE_PUBLIC_SUFFIXES = Object.freeze([ + '.crt', + '.pem', + '.cer', + '.key', + '.p12', + '.pfx', + '.jks', + '.keystore', + '.jwk', + '.token', + '.local', + '.log', + '.har', + '.trace', + '.bak', + '.backup', + '.tmp', + '.sqlite', + '.sqlite3', + '.db', + '.dump', + '.zip', + '.tar', + '.tar.gz', + '.tgz', + '.7z', +]); + +function sensitivePublicEntryName(name) { + const lower = name.toLowerCase(); + return SENSITIVE_PUBLIC_EXACT_NAMES.has(lower) + || SENSITIVE_PUBLIC_DIRECTORIES.has(lower) + || lower.startsWith('.env.') + || lower.startsWith('.dev.vars') + || lower.startsWith('admin-secret') + || lower.startsWith('id_rsa') + || lower.startsWith('id_ed25519') + || SENSITIVE_PUBLIC_SUFFIXES.some((suffix) => lower.endsWith(suffix)); +} + +function readPublicEntryStats(path, allowMissing) { + try { + return lstatSync(path); + } catch (error) { + if ( + allowMissing + && error + && typeof error === 'object' + && 'code' in error + && error.code === 'ENOENT' + ) return undefined; + throw new Error('Warpkeep could not attest the public directory boundary.'); + } +} + +function unsafePublicEntry(stats) { + return stats.isSymbolicLink() || (!stats.isDirectory() && !stats.isFile()); +} + +function assertSafePublicTree(directory, allowMissing = true) { + const directoryStats = readPublicEntryStats(directory, allowMissing); + if (directoryStats === undefined) return; + if (!directoryStats.isDirectory() || directoryStats.isSymbolicLink()) { + throw new Error('Warpkeep public directory contains a prohibited local artifact.'); + } + let entries; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch { + throw new Error('Warpkeep could not attest the public directory boundary.'); + } + for (const entry of entries) { + const entryPath = join(directory, entry.name); + const entryStats = readPublicEntryStats(entryPath, false); + if (sensitivePublicEntryName(entry.name) || unsafePublicEntry(entryStats)) { + throw new Error('Warpkeep public directory contains a prohibited local artifact.'); + } + if (entryStats.isDirectory()) assertSafePublicTree(entryPath, false); + } +} + +function requestPublicSegments(requestUrl, base) { + try { + let pathname = decodeURIComponent(new URL(requestUrl, 'http://warpkeep.local').pathname) + .replaceAll('\\', '/'); + const decodedBase = decodeURIComponent(new URL(base, 'http://warpkeep.local').pathname); + if (decodedBase !== '/' && pathname.startsWith(decodedBase)) { + pathname = `/${pathname.slice(decodedBase.length)}`; + } + const segments = pathname.split('/').filter(Boolean); + if (segments.some((segment) => segment === '.' || segment === '..' || segment.includes('\0'))) { + return undefined; + } + return segments; + } catch { + return undefined; + } +} + +function requestTargetsUnsafePublicEntry(publicDirectory, requestUrl, base) { + try { + const segments = requestPublicSegments(requestUrl, base); + if (segments === undefined || segments.some(sensitivePublicEntryName)) return true; + const rootStats = readPublicEntryStats(publicDirectory, true); + if (rootStats === undefined) return false; + if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) return true; + + let current = publicDirectory; + for (const [index, segment] of segments.entries()) { + current = join(current, segment); + const stats = readPublicEntryStats(current, true); + if (stats === undefined) return false; + if (unsafePublicEntry(stats)) return true; + if (index < segments.length - 1 && !stats.isDirectory()) return false; + } + return false; + } catch { + return true; + } +} + +/** + * Vite's public middleware bypasses `server.fs.deny`. Refuse startup/build if + * that copy-through surface contains a credential, private capture, local + * database, recovery archive, special file, or symlink. + */ +export function warpkeepLocalPublicBoundaryPlugin() { + let publicDirectory; + let base = '/'; + return { + name: 'warpkeep-local-public-boundary', + enforce: 'pre', + configResolved(config) { + publicDirectory = config.publicDir || undefined; + base = config.base; + if (publicDirectory) assertSafePublicTree(publicDirectory); + }, + configureServer(server) { + server.middlewares.use((request, response, next) => { + if ( + request.url + && (!publicDirectory || !requestTargetsUnsafePublicEntry(publicDirectory, request.url, base)) + ) { + next(); + return; + } + response.statusCode = 404; + response.setHeader('cache-control', 'no-store'); + response.setHeader('content-type', 'text/plain; charset=utf-8'); + response.end('Not Found\n'); + }); + }, + }; +} diff --git a/scripts/qa-observer/rendered-webgl-browser-probe.d.mts b/scripts/qa-observer/rendered-webgl-browser-probe.d.mts index 742aea21..fcb5c6e5 100644 --- a/scripts/qa-observer/rendered-webgl-browser-probe.d.mts +++ b/scripts/qa-observer/rendered-webgl-browser-probe.d.mts @@ -7,6 +7,8 @@ export const RENDERED_WEBGL_QA_CHROME_TEAM_ID: 'EQHXZ8M8AV'; export const RENDERED_WEBGL_QA_CASE_COUNT: 14; /** Exact authoritative terrain count for the Genesis generation-v3 render target. */ export const RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_CELL_COUNT: 10000; +/** Exact visible terrain-kind count after canonical no-lake revision activation. */ +export const RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_KIND_COUNT: 6; export const RENDERED_WEBGL_QA_LABEL_MAX_ANCHOR_DISPLACEMENT_PIXELS: 0; export const RENDERED_WEBGL_QA_LABEL_COORDINATE_SERIALIZATION_EPSILON_PIXELS: 0.015; /** Vite 8 default deny patterns plus the local asset-cache boundary. */ diff --git a/scripts/qa-observer/rendered-webgl-browser-probe.mjs b/scripts/qa-observer/rendered-webgl-browser-probe.mjs index a676c50c..8bc5a192 100644 --- a/scripts/qa-observer/rendered-webgl-browser-probe.mjs +++ b/scripts/qa-observer/rendered-webgl-browser-probe.mjs @@ -14,6 +14,10 @@ import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { analyzeRenderedWebglPngScreenshot } from './png-visual-aggregate.mjs'; +import { + WARPKEEP_LOCAL_VITE_FS_DENY, + warpkeepLocalPublicBoundaryPlugin, +} from './local-vite-fs-deny.mjs'; import { parseRenderedWebglQaObservation, RENDERED_WEBGL_QA_MAX_READY_MILLISECONDS, @@ -57,22 +61,17 @@ export const RENDERED_WEBGL_QA_CASE_COUNT = 14; // count prevents a complete generation-v2 surface (1,261 cells), a partial // expansion, or a mixed snapshot from being accepted as current render proof. export const RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_CELL_COUNT = 10_000; +// The synthetic observer activates canonical Water revision v1. Its 409 +// former lake rows are presented as lowland, leaving exactly six live terrain +// kinds while the immutable authority metadata remains seven-kind. +export const RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_KIND_COUNT = 6; // Every projection-visible keeper name is locked to its castle foundation. // Dense overviews may overlap, but camera motion cannot aggregate, displace, // or hide founded identities. export const RENDERED_WEBGL_QA_LABEL_MAX_ANCHOR_DISPLACEMENT_PIXELS = 0; const RENDERED_WEBGL_QA_CLUSTER_MAX_ANCHOR_DISPLACEMENT_PIXELS = 112; export const RENDERED_WEBGL_QA_LABEL_COORDINATE_SERIALIZATION_EPSILON_PIXELS = 0.015; -// Supplying `server.fs.deny` replaces Vite's defaults instead of appending to -// them. Keep Vite 8's four reviewed defaults explicitly, then add the source -// cache boundary required by the local LOD evidence route. -export const RENDERED_WEBGL_QA_VITE_FS_DENY = Object.freeze([ - '.env', - '.env.*', - '*.{crt,pem}', - '**/.git/**', - '**/.cache/**', -]); +export const RENDERED_WEBGL_QA_VITE_FS_DENY = WARPKEEP_LOCAL_VITE_FS_DENY; const RENDERED_WEBGL_QA_LABEL_ANGLE_TOLERANCE_RADIANS = 0.002; const RENDERED_WEBGL_QA_CASTLE_POINTER_ACTIVATION_CASE_ID = 'desktop-balanced'; const RENDERED_WEBGL_QA_MAP_GESTURE_CASE_ID = 'desktop-balanced-player'; @@ -103,22 +102,34 @@ const RENDERED_WEBGL_QA_MAP_DRAG_OFFSETS = Object.freeze([ Object.freeze({ x: 52, y: 14 }), ]); const RENDERED_WEBGL_QA_MAX_POINTER_COORDINATE_PIXELS = 10_000; -// The canonical shared forest is a separate static world layer, not part of -// the quality-scaled procedural feature budget. Presentation telemetry includes -// it, so the browser ceiling must include its exact 210 instances as well. +// Shared forest and the reviewed outer-Realm infill are separate real-tree +// layers, not part of the quality-scaled procedural feature budget. Telemetry +// includes both; mirror their exact source budgets rather than widening the +// browser gate with an arbitrary allowance. const RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT = 210; +const RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS = Object.freeze({ + high: 240, + balanced: 90, + reduced: 0, +}); const TERRAIN_PRESENTATION_BUDGETS = Object.freeze({ high: Object.freeze({ - semanticFeatureCount: 1_100 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT, - totalDetailInstanceCount: 7_000 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT, + semanticFeatureCount: 1_100 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT + + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.high, + totalDetailInstanceCount: 7_000 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT + + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.high, }), balanced: Object.freeze({ - semanticFeatureCount: 800 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT, - totalDetailInstanceCount: 5_500 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT, + semanticFeatureCount: 800 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT + + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.balanced, + totalDetailInstanceCount: 5_500 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT + + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.balanced, }), reduced: Object.freeze({ - semanticFeatureCount: 400 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT, - totalDetailInstanceCount: 3_000 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT, + semanticFeatureCount: 400 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT + + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.reduced, + totalDetailInstanceCount: 3_000 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT + + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.reduced, }), }); const LABEL_CULL_REASONS = new Set([ @@ -876,7 +887,8 @@ export function parseRenderedWebglBrowserDom(value, expected) { candidate.environmentLighting !== 'procedural' ? 'environment-lighting' : '', candidate.semanticTerrainCellCount !== RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_CELL_COUNT ? 'semantic-terrain-cell-count' : '', - candidate.semanticTerrainKindCount !== 7 ? 'semantic-terrain-kind-count' : '', + candidate.semanticTerrainKindCount !== RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_KIND_COUNT + ? 'semantic-terrain-kind-count' : '', !terrainBudgets || !Number.isSafeInteger(candidate.semanticTerrainFeatureCount) || candidate.semanticTerrainFeatureCount < 1 @@ -1778,7 +1790,7 @@ async function createLoopbackViteServer(runtimeDirectory, localQaPlugins = []) { cacheDir: join(privateRuntime, 'vite-cache'), configFile: false, envFile: false, - plugins: [reactPlugin(), ...localQaPlugins], + plugins: [warpkeepLocalPublicBoundaryPlugin(), reactPlugin(), ...localQaPlugins], define: { __WARPKEEP_LOCAL_QA__: 'true', __WARPKEEP_PRODUCT_VERSION__: JSON.stringify(packageJson.version), @@ -2149,8 +2161,11 @@ const READ_DOM_EXPRESSION = `(() => { const inspectorProfileImage = inspector?.querySelector( 'canvas[data-profile-image-state]' ); + // Water endpoint controls intentionally share some list styling. Count only + // the semantically named castle list so source/mouth buttons cannot inflate + // or invalidate the exact 100-castle accessibility gate. const exploreCastleButtons = [...document.querySelectorAll( - '.realm-cell-navigator__castles button' + '.realm-cell-navigator__castles[aria-label="Founded castles"] > li > button' )].filter(visible); const exploreAccessibleCastleButtons = exploreCastleButtons.filter((button) => ( button instanceof HTMLButtonElement @@ -2362,7 +2377,11 @@ async function waitForAcceptedRenderedDom(session, expected, state) { `overflow=${String(value.labelClusterOverflowCount)}`, `portrait=${String(value.inspectorProfileImageState)}`, `models=${String(value.presentedModelCount)}`, - `bases=${String(value.presentedLandscapeBaseCount)}` + `bases=${String(value.presentedLandscapeBaseCount)}`, + `terrainKinds=${String(value.semanticTerrainKindCount)}`, + `terrainFeatures=${String(value.semanticTerrainFeatureCount)}`, + `exploreCastles=${String(value.exploreCastleCount)}`, + `exploreAccessible=${String(value.exploreAccessibleCastleCount)}` ].join(','); try { parseRenderedWebglBrowserDom(value, expected); diff --git a/scripts/spacetime-additive-migration-proof.mjs b/scripts/spacetime-additive-migration-proof.mjs index dffdb1e6..30041c02 100644 --- a/scripts/spacetime-additive-migration-proof.mjs +++ b/scripts/spacetime-additive-migration-proof.mjs @@ -1,9 +1,16 @@ const SHA256_DIGEST = /^[0-9a-f]{64}$/; -const RECEIPT_FIELD = 'artifact_sha256'; +const V11_TABLE_SCHEMA_RECEIPT_FIELD = 'v11_table_schema_sha256'; +const V12_TABLE_SCHEMA_RECEIPT_FIELD = 'v12_table_schema_sha256'; +const ARTIFACT_RECEIPT_FIELD = 'artifact_sha256'; +const RECEIPT_FIELDS = Object.freeze([ + V11_TABLE_SCHEMA_RECEIPT_FIELD, + V12_TABLE_SCHEMA_RECEIPT_FIELD, + ARTIFACT_RECEIPT_FIELD, +]); const INVALID_RECEIPT_MESSAGE = 'The current additive migration proof did not produce its exact success receipt.'; -export const ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 11; +export const ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 12; export const ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION = '2.6.1'; // The compiled lifecycle lane includes a nine-minute route and one complete // gathering minute. Keep a bounded margin for server startup and cleanup. @@ -16,19 +23,31 @@ function rejectReceipt() { throw new Error(INVALID_RECEIPT_MESSAGE); } -export function formatAdditiveMigrationProofReceipt({ summary, artifactDigest }) { +export function formatAdditiveMigrationProofReceipt({ + summary, + v11TableSchemaDigest, + v12TableSchemaDigest, + artifactDigest, +}) { if ( typeof summary !== 'string' || summary.length === 0 || summary.trim() !== summary || /[\r\n]/.test(summary) - || summary.includes(`${RECEIPT_FIELD}=`) + || RECEIPT_FIELDS.some(field => summary.includes(`${field}=`)) + || typeof v11TableSchemaDigest !== 'string' + || !SHA256_DIGEST.test(v11TableSchemaDigest) + || typeof v12TableSchemaDigest !== 'string' + || !SHA256_DIGEST.test(v12TableSchemaDigest) || typeof artifactDigest !== 'string' || !SHA256_DIGEST.test(artifactDigest) ) { rejectReceipt(); } - return `${SUCCESS_PREFIX} ${summary} ${RECEIPT_FIELD}=${artifactDigest}`; + return `${SUCCESS_PREFIX} ${summary} ` + + `${V11_TABLE_SCHEMA_RECEIPT_FIELD}=${v11TableSchemaDigest} ` + + `${V12_TABLE_SCHEMA_RECEIPT_FIELD}=${v12TableSchemaDigest} ` + + `${ARTIFACT_RECEIPT_FIELD}=${artifactDigest}`; } export function parseAdditiveMigrationProofReceipt(output) { @@ -37,14 +56,26 @@ export function parseAdditiveMigrationProofReceipt(output) { const proofLines = output.split(/\r?\n/).filter(line => ( /^Additive protocol-v\d+ migration proof passed with SpacetimeDB /.test(line) )); - const digestFields = [...output.matchAll(/\bartifact_sha256=([^\s]*)/g)]; - if (proofLines.length !== 1 || digestFields.length !== 1) rejectReceipt(); + const digestFields = Object.fromEntries(RECEIPT_FIELDS.map(field => [ + field, + [...output.matchAll(new RegExp(`\\b${field}=([^\\s]*)`, 'g'))], + ])); + if ( + proofLines.length !== 1 + || RECEIPT_FIELDS.some(field => digestFields[field].length !== 1) + ) rejectReceipt(); const proofLine = proofLines[0]; - const artifactDigest = digestFields[0][1]; - const receiptSuffix = ` ${RECEIPT_FIELD}=${artifactDigest}`; + const v11TableSchemaDigest = digestFields[V11_TABLE_SCHEMA_RECEIPT_FIELD][0][1]; + const v12TableSchemaDigest = digestFields[V12_TABLE_SCHEMA_RECEIPT_FIELD][0][1]; + const artifactDigest = digestFields[ARTIFACT_RECEIPT_FIELD][0][1]; + const receiptSuffix = ` ${V11_TABLE_SCHEMA_RECEIPT_FIELD}=${v11TableSchemaDigest}` + + ` ${V12_TABLE_SCHEMA_RECEIPT_FIELD}=${v12TableSchemaDigest}` + + ` ${ARTIFACT_RECEIPT_FIELD}=${artifactDigest}`; if ( !proofLine.startsWith(`${SUCCESS_PREFIX} `) + || !SHA256_DIGEST.test(v11TableSchemaDigest) + || !SHA256_DIGEST.test(v12TableSchemaDigest) || !SHA256_DIGEST.test(artifactDigest) || !proofLine.endsWith(receiptSuffix) || proofLine.slice(SUCCESS_PREFIX.length + 1, -receiptSuffix.length).length === 0 @@ -52,5 +83,9 @@ export function parseAdditiveMigrationProofReceipt(output) { rejectReceipt(); } - return Object.freeze({ artifactDigest }); + return Object.freeze({ + v11TableSchemaDigest, + v12TableSchemaDigest, + artifactDigest, + }); } diff --git a/scripts/spacetime-table-schema-attestation.mjs b/scripts/spacetime-table-schema-attestation.mjs new file mode 100644 index 00000000..bca95b2b --- /dev/null +++ b/scripts/spacetime-table-schema-attestation.mjs @@ -0,0 +1,179 @@ +import { createHash } from 'node:crypto'; + +const MAX_CANONICAL_DEPTH = 128; + +export class TableSchemaAttestationError extends Error { + constructor(message) { + super(message); + this.name = 'TableSchemaAttestationError'; + } +} + +function fail(message) { + throw new TableSchemaAttestationError(message); +} + +function record(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function canonicalJson(value, depth = 0, ancestors = new Set()) { + if (depth > MAX_CANONICAL_DEPTH) fail('The table schema boundary exceeded its canonical depth limit.'); + if (value === null || typeof value === 'boolean' || typeof value === 'string') { + return JSON.stringify(value); + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) fail('The table schema boundary contained a non-finite number.'); + return JSON.stringify(value); + } + if (typeof value !== 'object') { + fail('The table schema boundary contained a non-JSON value.'); + } + if (ancestors.has(value)) fail('The table schema boundary contained a cycle.'); + ancestors.add(value); + try { + if (Array.isArray(value)) { + return `[${value.map(entry => canonicalJson(entry, depth + 1, ancestors)).join(',')}]`; + } + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some(key => typeof key !== 'string')) { + fail('The table schema boundary contained a non-JSON object key.'); + } + const keys = ownKeys.sort(); + return `{${keys.map(key => { + const entry = value[key]; + if (entry === undefined) fail('The table schema boundary contained an undefined field.'); + return `${JSON.stringify(key)}:${canonicalJson(entry, depth + 1, ancestors)}`; + }).join(',')}}`; + } finally { + ancestors.delete(value); + } +} + +function collectReferencedTypeRefs(value, refs, depth = 0, ancestors = new Set()) { + if (depth > MAX_CANONICAL_DEPTH) fail('The table schema type graph exceeded its depth limit.'); + if (value === null || typeof value !== 'object') return; + if (ancestors.has(value)) fail('The table schema type graph contained an object cycle.'); + ancestors.add(value); + try { + if (Array.isArray(value)) { + for (const entry of value) collectReferencedTypeRefs(entry, refs, depth + 1, ancestors); + return; + } + const keys = Object.keys(value); + if (Object.hasOwn(value, 'Ref')) { + if (keys.length !== 1 || !Number.isSafeInteger(value.Ref) || value.Ref < 0) { + fail('The table schema type graph contained an invalid type reference.'); + } + refs.add(value.Ref); + return; + } + // The CLI JSON uses externally tagged `{ Ref: n }` values. Supporting the + // SDK's equivalent tagged shape as well keeps this helper usable in strict + // local fixtures without weakening the accepted reference envelope. + if (value.tag === 'Ref') { + if ( + keys.length !== 2 + || !keys.includes('tag') + || !keys.includes('value') + || !Number.isSafeInteger(value.value) + || value.value < 0 + ) fail('The table schema type graph contained an invalid type reference.'); + refs.add(value.value); + return; + } + for (const key of keys) { + collectReferencedTypeRefs(value[key], refs, depth + 1, ancestors); + } + } finally { + ancestors.delete(value); + } +} + +function exactTableNames(value) { + if (!Array.isArray(value) || value.length === 0) { + fail('The table schema boundary requires one exact non-empty table set.'); + } + const names = value.map(name => { + if (typeof name !== 'string' || !/^[a-z][a-z0-9_]*$/.test(name)) { + fail('The table schema boundary contained an invalid expected table name.'); + } + return name; + }); + if (new Set(names).size !== names.length) { + fail('The table schema boundary contained duplicate expected table names.'); + } + return Object.freeze([...names].sort()); +} + +/** + * Select the complete table schema and only the typespace closure reachable + * from those table row roots. Reducer/procedure-only types are deliberately + * excluded so an operational code change cannot perturb the data boundary. + */ +export function canonicalTableSchemaBoundary(description, expectedTableNames) { + const names = exactTableNames(expectedTableNames); + if ( + !record(description) + || !Array.isArray(description.tables) + || !record(description.typespace) + || !Array.isArray(description.typespace.types) + ) fail('The table schema description was invalid.'); + + const tablesByName = new Map(); + for (const table of description.tables) { + if ( + !record(table) + || typeof table.name !== 'string' + || tablesByName.has(table.name) + ) fail('The table schema description contained an invalid table descriptor.'); + tablesByName.set(table.name, table); + } + const actualNames = [...tablesByName.keys()].sort(); + if ( + actualNames.length !== names.length + || actualNames.some((name, index) => name !== names[index]) + ) fail('The table schema description did not match the exact table set.'); + + const tableDescriptors = []; + const pendingRefs = []; + for (const name of names) { + const table = tablesByName.get(name); + if (!Number.isSafeInteger(table.product_type_ref) || table.product_type_ref < 0) { + fail('The table schema description contained an invalid row-type reference.'); + } + tableDescriptors.push(table); + pendingRefs.push(table.product_type_ref); + } + + const reachableRefs = new Set(); + while (pendingRefs.length > 0) { + const ref = pendingRefs.pop(); + if (reachableRefs.has(ref)) continue; + const type = description.typespace.types[ref]; + if (!record(type)) fail('The table schema description omitted a reachable row type.'); + reachableRefs.add(ref); + const discovered = new Set(); + collectReferencedTypeRefs(type, discovered); + for (const referencedRef of discovered) { + if (referencedRef >= description.typespace.types.length) { + fail('The table schema type graph referenced an absent type.'); + } + pendingRefs.push(referencedRef); + } + } + + const reachableTypes = [...reachableRefs] + .sort((left, right) => left - right) + .map(ref => Object.freeze({ ref, type: description.typespace.types[ref] })); + return Object.freeze({ + protocol: 'warpkeep-table-schema-boundary-v1', + tables: Object.freeze(tableDescriptors), + reachableTypes: Object.freeze(reachableTypes), + }); +} + +export function canonicalTableSchemaBoundaryDigest(description, expectedTableNames) { + const boundary = canonicalTableSchemaBoundary(description, expectedTableNames); + return createHash('sha256').update(canonicalJson(boundary)).digest('hex'); +} diff --git a/scripts/verify-castle-worker-additive-migration.mjs b/scripts/verify-castle-worker-additive-migration.mjs new file mode 100644 index 00000000..8a03fca7 --- /dev/null +++ b/scripts/verify-castle-worker-additive-migration.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const schemaPath = resolve(root, 'spacetimedb/src/schema.ts'); +const previousFixturePath = resolve(root, 'spacetimedb/migration-fixtures/additive-v11-schema/src/index.ts'); +const fixturePath = resolve(root, 'spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts'); + +function registrations(source, marker) { + const start = source.indexOf(marker); + const end = source.indexOf('\n});', start); + assert.ok(start >= 0 && end > start, `missing schema marker: ${marker}`); + return source.slice(start + marker.length, end) + .split(/[,\n]/) + .map(value => value.trim()) + .filter(value => /^[A-Za-z][A-Za-z0-9]*$/.test(value)); +} + +function table(source, name) { + const start = source.indexOf(`const ${name} = table(`); + const end = [source.indexOf('\n);', start), source.indexOf('\n});', start)] + .filter(candidate => candidate > start) + .sort((left, right) => left - right)[0] ?? -1; + assert.ok(start >= 0 && end > start, `missing table: ${name}`); + return source.slice(start, end); +} + +const [schema, previousFixture, fixture] = await Promise.all([ + readFile(schemaPath, 'utf8'), + readFile(previousFixturePath, 'utf8'), + readFile(fixturePath, 'utf8'), +]); +const current = registrations(schema, 'const warpkeep = schema({'); +const previous = registrations(previousFixture, 'const db = schema({'); +const candidate = registrations(fixture, 'const db = schema({'); +assert.equal(previous.length, 47, 'v11 fixture must end at ref 46'); +assert.deepEqual(current.slice(0, 47), previous, 'current schema changed before the v12 suffix'); +assert.deepEqual(candidate.slice(0, 47), previous, 'v12 fixture changed the deployed prefix'); +assert.deepEqual(candidate.slice(47), [ + 'realmWorkerSystemV1', + 'castleWorkerV1', + 'workerAssignmentV1', + 'workerNodeOccupationV1', + 'workerCommandIdempotencyV1', + 'workerAssignmentScheduleV1', +]); +assert.deepEqual(current.slice(47), candidate.slice(47), 'module and fixture suffix differ'); +for (const name of ['realmWorkerSystemV1', 'castleWorkerV1', 'workerNodeOccupationV1']) { + const definition = table(schema, name); + assert.match(definition, /public: true/); + assert.doesNotMatch(definition, /\bfid\b|assignmentId|accruedAmount|materializedAmount|balance|requestKey|auth/i); +} +assert.doesNotMatch(table(schema, 'castleWorkerV1'), /createdAt|updatedAt/); +assert.doesNotMatch(table(fixture, 'castleWorkerV1'), /createdAt|updatedAt/); +for (const name of ['workerAssignmentV1', 'workerCommandIdempotencyV1', 'workerAssignmentScheduleV1']) { + assert.doesNotMatch(table(schema, name), /public: true/); +} +assert.match(fixture, /fixture_seed_generic_worker_sentinel_v12/); +console.log('generic worker additive migration proof passed: refs 0–46 preserved, refs 47–52 append-only, populated fixture present, assignment correlation remains private'); From 951154fb056440598cc19259a518b54567d20d76 Mon Sep 17 00:00:00 2001 From: Ael <183124839+ael-dev3@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:05:45 +0200 Subject: [PATCH 03/10] Alpha 0.3.14 signed source (3/10) GitHub-signed reconstruction of the exact reviewed Alpha 0.3.14 tree. --- scripts/verify-runtime-assets.mjs | 52 +- .../verify-spacetime-additive-migration.mjs | 463 +++++++++++++++--- services/auth-bridge/.dev.vars.example | 8 +- services/auth-bridge/README.md | 33 +- services/auth-bridge/src/app.ts | 64 ++- services/auth-bridge/src/config.ts | 83 +++- services/auth-bridge/src/farcaster.ts | 56 ++- services/auth-bridge/src/types.ts | 4 +- .../test-workerd/authBridge.workerd.test.ts | 5 +- 9 files changed, 659 insertions(+), 109 deletions(-) diff --git a/scripts/verify-runtime-assets.mjs b/scripts/verify-runtime-assets.mjs index 4370c9a6..99395f72 100644 --- a/scripts/verify-runtime-assets.mjs +++ b/scripts/verify-runtime-assets.mjs @@ -42,7 +42,25 @@ const assets = Object.freeze([ ['public/images/realm/hegemony-gold-mine-record.webp', 218_736, 'a2c52a5e1536860ce3ad778c1719e354637fe473495c45ee927c99f468c60fa3', false], ['public/images/realm/hegemony-wheat-farm-record.webp', 224_806, '466c80380a8d23de043731a7c386e78c9b36a2d2e69fa175db4b87efc3f43eb0', false], ['public/images/realm/hegemony-logging-camp-record.webp', 177_622, 'fb9d171e423a7bd4bfcce1e68cd3faecb38b4904bc528f720e4283522fca1293', false], - ['public/images/realm/hegemony-stone-quarry-record.webp', 186_736, '86b13c14a0eda7403c3583d886be3242e04d7ef9e442fcfdbcc054642421a70a', false] + ['public/images/realm/hegemony-stone-quarry-record.webp', 186_736, '86b13c14a0eda7403c3583d886be3242e04d7ef9e442fcfdbcc054642421a70a', false], + ['public/images/realm/hegemony-worker-record.webp', 86_984, 'ff758ecbf520b05ccf0a2fa490bcafa6c564514de5ee56ef5a720fd6da24193e', false] +]); + +const decorativeInspectionImageAssets = Object.freeze([ + Object.freeze({ + path: 'public/images/realm/hegemony-worker-record.webp', + width: 1_024, + height: 1_024, + format: 'webp', + bytes: 86_984, + sha256: 'ff758ecbf520b05ccf0a2fa490bcafa6c564514de5ee56ef5a720fd6da24193e', + decodedRgbaSha256: '2e77492f76801576adcc9cfe660fa15494123bc43fcd767e005cd5ad95d8b047', + alpha: Object.freeze({ + transparentPixels: 858_605, + partiallyTransparentPixels: 39_551, + opaquePixels: 150_420 + }) + }) ]); const referenceImageAssets = Object.freeze([ @@ -601,6 +619,38 @@ if (resourcePixels.size !== 4) { throw new Error('The reviewed resource runtime family must contain exactly four pixel-equivalent PNG/WebP pairs.'); } +for (const asset of decorativeInspectionImageAssets) { + const bytes = readContainedRegularFile({ + root, + relativePath: asset.path, + label: `${asset.path} decorative inspection image`, + expectedBytes: asset.bytes + }); + const hash = createHash('sha256').update(bytes).digest('hex'); + if (hash !== asset.sha256) throw new Error(`${asset.path} hash changed: ${hash}.`); + const image = sharp(bytes, { + failOn: 'warning', + limitInputPixels: asset.width * asset.height + }); + const metadata = await image.metadata(); + if ( + metadata.format !== asset.format + || metadata.width !== asset.width + || metadata.height !== asset.height + || metadata.channels !== 4 + || metadata.depth !== 'uchar' + || metadata.hasAlpha !== true + ) throw new Error(`${asset.path} decoder metadata changed: ${JSON.stringify(metadata)}.`); + const raw = await image.ensureAlpha().raw().toBuffer(); + const decodedHash = createHash('sha256').update(raw).digest('hex'); + if (decodedHash !== asset.decodedRgbaSha256) { + throw new Error(`${asset.path} decoded RGBA hash changed: ${decodedHash}.`); + } + if (!exactRecord(alphaProfile(raw), asset.alpha)) { + throw new Error(`${asset.path} alpha profile changed.`); + } +} + console.log( `Verified ${assets.length + imageAssets.length + resourceImageAssets.length} exact runtime assets and ` + `${referenceImageAssets.length} exact provenance reference masters.` diff --git a/scripts/verify-spacetime-additive-migration.mjs b/scripts/verify-spacetime-additive-migration.mjs index 29286a25..6a840cad 100644 --- a/scripts/verify-spacetime-additive-migration.mjs +++ b/scripts/verify-spacetime-additive-migration.mjs @@ -6,7 +6,8 @@ import { randomBytes, sign as signBytes, } from 'node:crypto'; -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { readFile, rm, stat, writeFile } from 'node:fs/promises'; import { createServer } from 'node:net'; import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; @@ -17,6 +18,9 @@ import { ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION, formatAdditiveMigrationProofReceipt, } from './spacetime-additive-migration-proof.mjs'; +import { + canonicalTableSchemaBoundaryDigest, +} from './spacetime-table-schema-attestation.mjs'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const fixtureModule = resolve( @@ -63,6 +67,10 @@ const additiveV11SchemaFixture = resolve( repositoryRoot, 'spacetimedb/migration-fixtures/additive-v11-schema', ); +const additiveV12SchemaFixture = resolve( + repositoryRoot, + 'spacetimedb/migration-fixtures/additive-v12-schema', +); const additiveModule = resolve(repositoryRoot, 'spacetimedb'); const command = process.env.SPACETIME_BIN || 'spacetime'; const expectedCliVersion = ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION; @@ -243,6 +251,14 @@ const additiveV10Tables = Object.freeze([ const additiveV11Tables = Object.freeze([ 'realm_water_revision_v1', ]); +const additiveV12Tables = Object.freeze([ + 'realm_worker_system_v1', + 'castle_worker_v1', + 'worker_assignment_v1', + 'worker_node_occupation_v1', + 'worker_command_idempotency_v1', + 'worker_assignment_schedule_v_1', +]); const deployedV3Tables = Object.freeze([ ...existingTables, ...additiveV3Tables, @@ -279,6 +295,10 @@ const deployedV11Tables = Object.freeze([ ...deployedV10Tables, ...additiveV11Tables, ]); +const deployedV12Tables = Object.freeze([ + ...deployedV11Tables, + ...additiveV12Tables, +]); const expectedProductTypeRefs = Object.freeze({ allowed_fid: 0, world_tile: 1, @@ -327,6 +347,12 @@ const expectedProductTypeRefs = Object.freeze({ stone_expedition_idempotency_v1: 44, stone_expedition_schedule_v_1: 45, realm_water_revision_v1: 46, + realm_worker_system_v1: 47, + castle_worker_v1: 48, + worker_assignment_v1: 49, + worker_node_occupation_v1: 50, + worker_command_idempotency_v1: 51, + worker_assignment_schedule_v_1: 52, }); const childEnvironmentKeys = Object.freeze([ 'PATH', 'HOME', 'USER', 'LOGNAME', 'TMPDIR', 'TMP', 'TEMP', @@ -1194,6 +1220,87 @@ function assertAdditiveV11Schema(before, after) { ); } +function assertDeployedV11TablesUnchanged(before, after) { + for (const name of deployedV11Tables) { + assert.deepEqual(tableSignature(after, name), tableSignature(before, name)); + assert.equal( + tableSignature(after, name).product_type_ref, + expectedProductTypeRefs[name], + ); + } +} + +function assertAdditiveV12Schema(before, after) { + assertDeployedV11TablesUnchanged(before, after); + const beforeNames = new Set(before.tables.map(table => table.name)); + const added = after.tables + .map(table => table.name) + .filter(name => !beforeNames.has(name)) + .sort(); + assert.deepEqual(added, [...additiveV12Tables].sort()); + const contracts = { + realm_worker_system_v1: { + access: 'Public', + fields: [ + 'realm_id', 'policy_version', 'workers_per_castle', 'expected_castle_count', + 'expected_worker_count', 'roster_digest', 'mode', 'legacy_drain_required', + 'created_at', 'activated_at', + ], + }, + castle_worker_v1: { + access: 'Public', + fields: [ + 'worker_id', 'origin_castle_id', 'ordinal', 'status', 'resource_kind', + 'site_id', 'started_at_micros', 'arrives_at_micros', + 'gathering_ends_at_micros', 'return_started_at_micros', + 'returns_at_micros', 'route_steps', 'return_start_progress_basis_points', + 'timeline_revision', 'revision', + ], + }, + worker_assignment_v1: { + access: 'Private', + fields: [ + 'assignment_id', 'worker_id', 'fid', 'origin_castle_id', 'resource_kind', + 'site_id', 'phase', 'started_at_micros', 'arrives_at_micros', + 'gathering_ends_at_micros', 'return_started_at_micros', + 'returns_at_micros', 'route_steps', 'return_start_progress_basis_points', + 'settled_through_micros', 'accrued_amount', 'materialized_amount', + 'timeline_revision', 'policy_version', 'created_at', 'updated_at', + ], + }, + worker_node_occupation_v1: { + access: 'Public', + fields: [ + 'node_key', 'resource_kind', 'site_id', 'worker_id', 'worker_ordinal', + 'origin_castle_id', 'phase', 'started_at_micros', 'arrives_at_micros', + 'gathering_ends_at_micros', 'timeline_revision', + ], + }, + worker_command_idempotency_v1: { + access: 'Private', + fields: [ + 'request_key', 'fid', 'worker_id', 'command_kind', 'resource_kind', + 'site_id', 'assignment_id', 'result_revision', 'created_at', + ], + }, + worker_assignment_schedule_v_1: { + access: 'Private', + fields: [ + 'schedule_id', 'scheduled_at', 'assignment_id', 'worker_id', + 'timeline_revision', 'stage', + ], + }, + }; + for (const [name, contract] of Object.entries(contracts)) { + assert.deepEqual(fieldNames(after, name), contract.fields); + assert.equal(access(after, name), contract.access); + assert.equal( + tableSignature(after, name).product_type_ref, + expectedProductTypeRefs[name], + ); + } +} + async function freeLoopbackPort() { return new Promise((resolvePromise, rejectPromise) => { const server = createServer(); @@ -2029,9 +2136,9 @@ async function verifyActualModuleResourceLifecycle(server, database, privateKey, let stage = 'seed'; let activeModule = 'actual'; const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js'); - // Keep inspection on the complete v11 candidate schema. Reverting to a + // Keep inspection on the complete v12 candidate schema. Reverting to a // predecessor fixture after Stone is appended would be destructive. - const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js'); const useActualModule = async () => { if (activeModule === 'actual') return; await publishBuiltArtifact(server, ownerToken, actualArtifactPath, database); @@ -2846,9 +2953,9 @@ async function verifyActualModuleExpeditionLifecycles( let stage = 'seed-world'; let activeModule = 'actual'; const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js'); - // Reusing the candidate fixture preserves the complete v11 suffix during + // Reusing the candidate fixture preserves the complete v12 suffix during // SQL inspection; publishing any predecessor would request a downgrade. - const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js'); const useActualModule = async () => { if (activeModule === 'actual') return; await publishBuiltArtifact(server, ownerToken, actualArtifactPath, database); @@ -3286,7 +3393,7 @@ async function verifyActualModuleWaterLifecycle(server, database, privateKey, ow let stage = 'publish'; let activeModule = 'actual'; const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js'); - const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js'); const adminCredential = () => createEphemeralJwt(privateKey, adminServiceClaims()); const useActualModule = async () => { if (activeModule === 'actual') return; @@ -3757,7 +3864,7 @@ async function verifyGenesisWorldExpansionLifecycle( // Wood append. Reverting to an earlier protocol after publishing the // candidate would correctly be rejected as a destructive schema downgrade. const fixtureArtifactPath = join(additiveV8SchemaFixture, 'dist', 'bundle.js'); - const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js'); const adminCredential = () => createEphemeralJwt(privateKey, adminServiceClaims()); await publishBuiltArtifact(server, ownerToken, fixtureArtifactPath, database); @@ -3944,6 +4051,44 @@ export async function cleanupMigrationProofResources( if (stopFailure !== undefined) throw stopFailure; } +export function installMigrationProofSignalCleanup( + cleanup, + processTarget = process, +) { + if ( + typeof cleanup !== 'function' + || typeof processTarget?.on !== 'function' + || typeof processTarget?.removeListener !== 'function' + || typeof processTarget?.exit !== 'function' + ) fail('Migration proof signal cleanup setup was invalid.'); + + let handled = false; + const handlers = { + SIGINT: () => handleSignal('SIGINT'), + SIGTERM: () => handleSignal('SIGTERM'), + }; + const remove = () => { + processTarget.removeListener('SIGINT', handlers.SIGINT); + processTarget.removeListener('SIGTERM', handlers.SIGTERM); + }; + const handleSignal = signal => { + if (handled) return; + handled = true; + let exitCode = signal === 'SIGINT' ? 130 : 143; + try { + cleanup(); + } catch { + exitCode = 1; + } + remove(); + processTarget.exit(exitCode); + }; + + processTarget.on('SIGINT', handlers.SIGINT); + processTarget.on('SIGTERM', handlers.SIGTERM); + return remove; +} + async function verifyCliVersion() { const result = await runCommand(['--version'], { timeout: 10_000 }); if ( @@ -3958,7 +4103,40 @@ async function main() { const port = await freeLoopbackPort(); const server = `http://127.0.0.1:${port}`; if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(server)) fail('Migration proof was not loopback-only.'); - const dataDirectory = await mkdtemp(join(tmpdir(), 'warpkeep-stdb-migration-')); + let dataDirectory; + let serverProcess; + const removeSignalCleanup = installMigrationProofSignalCleanup(() => { + disposableCliCredential = null; + let stopFailed = false; + if ( + serverProcess !== undefined + && serverProcess.exitCode === null + && serverProcess.signalCode === null + ) { + try { + if (!serverProcess.kill('SIGKILL')) stopFailed = true; + } catch { + stopFailed = true; + } + } + let removalFailed = false; + try { + if (typeof dataDirectory === 'string') { + rmSync(dataDirectory, { recursive: true, force: true }); + } + } catch { + removalFailed = true; + } + if (stopFailed || removalFailed) { + fail('Interrupted migration proof cleanup failed.'); + } + }); + try { + dataDirectory = mkdtempSync(join(tmpdir(), 'warpkeep-stdb-migration-')); + } catch { + removeSignalCleanup(); + fail('Private migration proof directory setup failed.'); + } const publicKeyPath = join(dataDirectory, 'jwt-public.pem'); const privateKeyPath = join(dataDirectory, 'jwt-private.pem'); let privateKey; @@ -3979,15 +4157,18 @@ async function main() { } } catch (error) { try { - await rm(dataDirectory, { recursive: true, force: true }); - } catch { - fail('Ephemeral loopback signing-key cleanup failed.'); + try { + await rm(dataDirectory, { recursive: true, force: true }); + } catch { + fail('Ephemeral loopback signing-key cleanup failed.'); + } + } finally { + removeSignalCleanup(); } if (error instanceof MigrationProofError) throw error; fail('Ephemeral loopback signing-key setup failed.'); } - let serverProcess; try { serverProcess = containServerProcessErrors(spawn(command, [ 'start', @@ -4004,9 +4185,13 @@ async function main() { })); } catch { try { - await rm(dataDirectory, { recursive: true, force: true }); - } catch { - fail('Loopback server startup cleanup failed.'); + try { + await rm(dataDirectory, { recursive: true, force: true }); + } catch { + fail('Loopback server startup cleanup failed.'); + } + } finally { + removeSignalCleanup(); } fail('Loopback server could not start.'); } @@ -4488,6 +4673,20 @@ async function main() { populatedWaterStoneV10, populatedWaterStoneV11, ); + const provenV11TableSchemaDigest = canonicalTableSchemaBoundaryDigest( + emptyV11, + deployedV11Tables, + ); + for (const description of [ + nonemptyV11, + actualModuleV11, + populatedWaterStoneV11, + ]) { + assert.equal( + canonicalTableSchemaBoundaryDigest(description, deployedV11Tables), + provenV11TableSchemaDigest, + ); + } for (const name of deployedV11Tables) { assert.deepEqual( tableSignature(actualModuleV11, name), @@ -4548,28 +4747,144 @@ async function main() { populatedWaterStoneV11Rows, ); - // Advance every database to the real v11 candidate so the implementation - // is exercised against the exact v11 table contract without production. + // Freeze v12 independently, then prove a populated canonical v11 -> v12 + // migration before exercising the real candidate artifact. + await publish(server, owner.token, additiveV12SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, actualModuleDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, resourceLifecycleDatabase); + await publish( + server, + owner.token, + additiveV12SchemaFixture, + populatedWaterStoneMigrationDatabase, + ); + const emptyV12 = await describe(server, owner.token, emptyDatabase); + const nonemptyV12 = await describe(server, owner.token, nonemptyDatabase); + const actualModuleV12 = await describe(server, owner.token, actualModuleDatabase); + const populatedWaterStoneV12 = await describe( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + ); + assertAdditiveV12Schema(emptyV11, emptyV12); + assertAdditiveV12Schema(nonemptyV11, nonemptyV12); + assertAdditiveV12Schema(actualModuleV11, actualModuleV12); + assertAdditiveV12Schema(populatedWaterStoneV11, populatedWaterStoneV12); + const fixtureV12TableSchemaDigest = canonicalTableSchemaBoundaryDigest( + emptyV12, + deployedV12Tables, + ); + for (const description of [ + nonemptyV12, + actualModuleV12, + populatedWaterStoneV12, + ]) { + assert.equal( + canonicalTableSchemaBoundaryDigest(description, deployedV12Tables), + fixtureV12TableSchemaDigest, + ); + } + for (const name of deployedV12Tables) { + assert.deepEqual( + tableSignature(actualModuleV12, name), + tableSignature(emptyV12, name), + ); + } + assert.deepEqual( + await tableRowDigests( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + deployedV11Tables, + ), + populatedWaterStoneV11Rows, + ); + for (const table of additiveV12Tables) { + assert.equal(await count( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + table, + ), 0n); + } + await callLoopbackReducer( + server, + populatedWaterStoneMigrationDatabase, + 'fixture_seed_generic_worker_sentinel_v12', + owner.token, + '[]', + 200, + ); + const expectedPopulatedV12Counts = new Map([ + ['realm_worker_system_v1', 1n], + ['castle_worker_v1', 4n], + ['worker_assignment_v1', 1n], + ['worker_node_occupation_v1', 1n], + ['worker_command_idempotency_v1', 1n], + ['worker_assignment_schedule_v_1', 1n], + ]); + for (const [table, expectedCount] of expectedPopulatedV12Counts) { + assert.equal(await count( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + table, + ), expectedCount); + } + const populatedWaterStoneV12Rows = await tableRowDigests( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + deployedV12Tables, + ); + const populatedWaterStoneV12SchemaDigest = schemaDigest( + await describe(server, owner.token, populatedWaterStoneMigrationDatabase), + ); + await publish( + server, + owner.token, + additiveV11SchemaFixture, + populatedWaterStoneMigrationDatabase, + false, + /break|delete|remove|migration|incompatible|data loss|table/i, + ); + assert.equal( + schemaDigest(await describe(server, owner.token, populatedWaterStoneMigrationDatabase)), + populatedWaterStoneV12SchemaDigest, + ); + assert.deepEqual( + await tableRowDigests( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + deployedV12Tables, + ), + populatedWaterStoneV12Rows, + ); + + // Advance every database to the real v12 candidate so the implementation + // is exercised against the exact v12 table contract without production. await publish(server, owner.token, additiveModule, emptyDatabase); await publish(server, owner.token, additiveModule, nonemptyDatabase); await publish(server, owner.token, additiveModule, actualModuleDatabase); await publish(server, owner.token, additiveModule, resourceLifecycleDatabase); await publish(server, owner.token, additiveModule, populatedWaterStoneMigrationDatabase); - const populatedWaterStoneCandidateV11 = await describe( + const populatedWaterStoneCandidateV12 = await describe( server, owner.token, populatedWaterStoneMigrationDatabase, ); - for (const name of deployedV11Tables) { + for (const name of deployedV12Tables) { assert.deepEqual( - tableSignature(populatedWaterStoneCandidateV11, name), - tableSignature(emptyV11, name), + tableSignature(populatedWaterStoneCandidateV12, name), + tableSignature(emptyV12, name), ); } await publish( server, owner.token, - additiveV11SchemaFixture, + additiveV12SchemaFixture, populatedWaterStoneMigrationDatabase, ); assert.deepEqual( @@ -4577,9 +4892,9 @@ async function main() { server, owner.token, populatedWaterStoneMigrationDatabase, - deployedV11Tables, + deployedV12Tables, ), - populatedWaterStoneV11Rows, + populatedWaterStoneV12Rows, ); await verifyResolverHttpLifecycle(server, actualModuleDatabase, privateKey); const worldExpansionDurationMilliseconds = await verifyGenesisWorldExpansionLifecycle( @@ -4616,30 +4931,41 @@ async function main() { const builtArtifactDigest = createHash('sha256') .update(await readFile(builtArtifactPath)) .digest('hex'); - const emptyCandidateV11 = await describe(server, owner.token, emptyDatabase); - const nonemptyCandidateV11 = await describe(server, owner.token, nonemptyDatabase); - const actualCandidateV11 = await describe(server, owner.token, actualModuleDatabase); - for (const name of deployedV11Tables) { + const emptyCandidateV12 = await describe(server, owner.token, emptyDatabase); + const nonemptyCandidateV12 = await describe(server, owner.token, nonemptyDatabase); + const actualCandidateV12 = await describe(server, owner.token, actualModuleDatabase); + const provenV12TableSchemaDigest = canonicalTableSchemaBoundaryDigest( + emptyCandidateV12, + deployedV12Tables, + ); + assert.equal(provenV12TableSchemaDigest, fixtureV12TableSchemaDigest); + for (const description of [nonemptyCandidateV12, actualCandidateV12]) { + assert.equal( + canonicalTableSchemaBoundaryDigest(description, deployedV12Tables), + provenV12TableSchemaDigest, + ); + } + for (const name of deployedV12Tables) { assert.deepEqual( - tableSignature(actualCandidateV11, name), - tableSignature(emptyV11, name), + tableSignature(actualCandidateV12, name), + tableSignature(emptyV12, name), ); assert.deepEqual( - tableSignature(nonemptyCandidateV11, name), - tableSignature(nonemptyV11, name), + tableSignature(nonemptyCandidateV12, name), + tableSignature(nonemptyV12, name), ); assert.deepEqual( - tableSignature(actualCandidateV11, name), - tableSignature(actualModuleV11, name), + tableSignature(actualCandidateV12, name), + tableSignature(actualModuleV12, name), ); } // The candidate's on-connect policy intentionally rejects the disposable - // owner identity. Reuse the table-identical, auth-neutral v11 fixture before + // owner identity. Reuse the table-identical, auth-neutral v12 fixture before // owner SQL reads and never downgrade the schema suffix. - await publish(server, owner.token, additiveV11SchemaFixture, emptyDatabase); - await publish(server, owner.token, additiveV11SchemaFixture, nonemptyDatabase); - await publish(server, owner.token, additiveV11SchemaFixture, actualModuleDatabase); - // SQL preservation reads remain on the complete v11 candidate. No reducer + await publish(server, owner.token, additiveV12SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, actualModuleDatabase); + // SQL preservation reads remain on the complete v12 candidate. No reducer // is invoked by these owner-only queries. for (const [database, beforeRows] of [ [emptyDatabase, emptyV7Rows], @@ -4652,7 +4978,7 @@ async function main() { ); } - const idempotentSchemaBefore = schemaDigest(nonemptyCandidateV11); + const idempotentSchemaBefore = schemaDigest(nonemptyCandidateV12); await publishBuiltArtifact( server, owner.token, @@ -4663,10 +4989,10 @@ async function main() { schemaDigest(await describe(server, owner.token, nonemptyDatabase)), idempotentSchemaBefore, ); - await publish(server, owner.token, additiveV11SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, nonemptyDatabase); // The actual module correctly rejects the disposable local identity at its - // on-connect boundary; owner SQL still reads the unchanged v11 rows. + // on-connect boundary; owner SQL still reads the unchanged v12 rows. assert.equal(await count(server, owner.token, emptyDatabase, 'player'), 0n); assert.equal(await count(server, owner.token, emptyDatabase, 'player_v2'), 0n); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 0); @@ -4691,6 +5017,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, database, table), 0n); } @@ -4721,7 +5048,7 @@ async function main() { )), actualModuleWorldBefore); // Identity columns reject arbitrary SQL literals after the candidate's - // issuer boundary is active. The auth-neutral v11 fixture inserts the + // issuer boundary is active. The auth-neutral v12 fixture inserts the // caller's verified sender identity through a disposable reducer instead. await callLoopbackReducer( server, @@ -4743,10 +5070,11 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } - const populatedV11SchemaDigest = schemaDigest(await describe(server, owner.token, emptyDatabase)); + const populatedV12SchemaDigest = schemaDigest(await describe(server, owner.token, emptyDatabase)); await callLoopbackReducer( server, @@ -4768,7 +5096,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1); assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n); @@ -4781,6 +5109,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -4794,7 +5123,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1); assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n); @@ -4807,6 +5136,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -4820,7 +5150,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4832,7 +5162,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4844,10 +5174,10 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); - // The immediate v11 -> v10 rollback must be refused before it can remove - // the Water revision. Older boundaries continue protecting Stone and Water. + // The v12 boundary must refuse every predecessor before any generic-worker + // or Water table can be removed. await publish( server, owner.token, @@ -4858,7 +5188,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4870,7 +5200,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4882,7 +5212,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); // Older fixture rollbacks remain refused as well. await publish( @@ -4895,17 +5225,17 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish(server, owner.token, additiveModule, emptyDatabase); - assertAdditiveV11Schema( - emptyV10, + assertAdditiveV12Schema( + emptyV11, await describe(server, owner.token, emptyDatabase), ); // Reuse the table-identical auth-neutral fixture for the final bounded // identity assertion; the candidate itself deliberately rejects the // disposable owner issuer before any private identity SQL can run. - await publish(server, owner.token, additiveV11SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV12SchemaFixture, emptyDatabase); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1); assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n); for (const table of [ @@ -4917,6 +5247,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -4939,7 +5270,9 @@ async function main() { + 'public Tier-I Stone sites, identity-minimized occupations, and public-safe lifecycle schedule projection plus private Stone expedition and idempotency ' + 'tables appended at exact refs 41-45, ' + 'public ocean-and-river Water revision policy appended at exact ref 46, ' - + '61-tile empty, synthetic nonempty, and populated v10 Water/Stone fixtures remained preserved through v11, ' + + 'identity-safe generic worker readiness, roster, assignment, occupation, bounded receipt, and private schedule tables appended at exact refs 47-52, ' + + '61-tile empty, synthetic nonempty, and populated Water/Stone/Water-revision fixtures remained preserved through v12, ' + + 'every v12 table was populated, retained through the real candidate, and protected from a v12-to-v11 downgrade, ' + 'exact resolver HTTP lifecycle enforced without mutation, ' + `atomic 1,261-to-10,000 world expansion proved in ${worldExpansionDurationMilliseconds}ms with an idempotent retry, ` + `actual Water administration exercised with ${waterLifecycleProof}, ` @@ -4951,13 +5284,19 @@ async function main() { + 'presentation-independent founder monitoring and bootstrap, ' + 'legacy first-time admission rejection and complete-graph re-enable preservation, ' + 'and guarded backfill rejection/idempotence held, ' - + 'prebuilt-artifact republish idempotent, populated v3-prefix state retained through v11, ' - + 'and guarded v10/v9/v8/v7/v6/v5/v4/v3/v2 rollbacks refused before schema change.', + + 'prebuilt-artifact republish idempotent, populated v3-prefix state retained through v12, ' + + 'and guarded v11/v10/v9/v8/v7/v6/v5/v4/v3/v2 rollbacks refused before schema change.', + v11TableSchemaDigest: provenV11TableSchemaDigest, + v12TableSchemaDigest: provenV12TableSchemaDigest, artifactDigest: builtArtifactDigest, })); } finally { disposableCliCredential = null; - await cleanupMigrationProofResources(serverProcess, dataDirectory); + try { + await cleanupMigrationProofResources(serverProcess, dataDirectory); + } finally { + removeSignalCleanup(); + } } } diff --git a/services/auth-bridge/.dev.vars.example b/services/auth-bridge/.dev.vars.example index da073e47..41c273d0 100644 --- a/services/auth-bridge/.dev.vars.example +++ b/services/auth-bridge/.dev.vars.example @@ -1,11 +1,15 @@ # Development only. Copy to .dev.vars; never commit the copy. # Generate SIGNING_KEY_JWK separately and use a different key from production. ENVIRONMENT=development -ISSUER=http://localhost:8787 +# The bridge itself rejects plaintext requests; use a local TLS endpoint/proxy. +ISSUER=https://localhost:8787 ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 FARCASTER_DOMAIN=localhost:5173 FARCASTER_SIWE_URI=http://localhost:5173/ -FARCASTER_RPC_URL=https://YOUR-OPTIMISM-RPC.example +# A single endpoint is accepted only for explicit development on loopback. +FARCASTER_RPC_URL=http://127.0.0.1:8545 +# Production requires a second HTTPS endpoint on an independent public origin. +# FARCASTER_RPC_URL_SECONDARY=https://YOUR-SECOND-OPTIMISM-RPC.example OIDC_AUDIENCE=warpkeep-spacetimedb OIDC_KEY_ID=warpkeep-dev-2026-01 SPACETIMEDB_URI=http://127.0.0.1:3000 diff --git a/services/auth-bridge/README.md b/services/auth-bridge/README.md index 5c7a42c2..b49976de 100644 --- a/services/auth-bridge/README.md +++ b/services/auth-bridge/README.md @@ -318,11 +318,15 @@ proof, cookie, or profile payload. `wrangler.toml` declares `workers_dev = false`, the `auth.warpkeep.com` custom-domain route, `PUBLIC_AUTH_ENABLED = "false"`, and the non-secret -issuer/origin/database contract. `FARCASTER_RPC_URL`, `SIGNING_KEY_JWK`, -`ADMIN_TOKEN_SECRET`, and the independent `SESSION_COOKIE_KEY` are managed -Worker secrets. Both symmetric secrets require at least 32 random bytes and -all three secret materials must be pairwise distinct, including the private -`d` scalar inside `SIGNING_KEY_JWK`. `CHALLENGE_REPLAY_GUARD`, +issuer/origin/database contract. `FARCASTER_RPC_URL`, +`FARCASTER_RPC_URL_SECONDARY`, `SIGNING_KEY_JWK`, `ADMIN_TOKEN_SECRET`, and the +independent `SESSION_COOKIE_KEY` are managed Worker secrets. Production accepts +only two distinct public HTTPS RPC origins. Both official verifier instances +must independently validate the proof and return the same canonical FID; an +outage, partial result, or disagreement returns no token. Explicit development +may instead use one loopback RPC endpoint. Both symmetric secrets require at +least 32 random bytes and all three secret materials must be pairwise distinct, +including the private `d` scalar inside `SIGNING_KEY_JWK`. `CHALLENGE_REPLAY_GUARD`, `AUTH_RATE_LIMITER`, and `SESSION_FAMILIES` are separate SQLite Durable Object bindings. `QA_CHALLENGE_REPLAY_GUARD` is a fourth isolated SQLite binding; its additive `QaChallengeReplayGuard` migration requires explicit operator approval @@ -344,14 +348,17 @@ same Maincloud/database pair. Development remains explicitly configurable and is not accepted as a production activation profile. The server-only `POST /v1/admin/config-attestation` route additionally returns -the independent QA gate, observer URI/database/audience tuple, registered -public-key fingerprint, canonical registration/expiry timestamps, and maximum -registration lifetime after admin-secret authentication. The SHA-256 digest -covers issuer, origins, SIWF coordinates, gameplay audience/key/Maincloud -coordinates, observer URI/database/audience coordinates, environment, S256 -binding, player/resolver lifetimes, QA scope/procedure/lifetimes, both gates, -the registered QA fingerprint/registration/expiry/lifetime, the 30-day family -ceiling, and exact cookie attributes. Operators must compare it with the +the sorted fingerprints of the exact RPC endpoints, the active signing public +key's RFC 7638 thumbprint, the independent QA gate, observer +URI/database/audience tuple, registered public-key fingerprint, canonical +registration/expiry timestamps, and maximum registration lifetime after +admin-secret authentication. Each endpoint fingerprint is lowercase hex +`SHA-256("warpkeep-farcaster-rpc-endpoint-v1\0" + normalizedExactUrl)`, where +`\0` is one NUL separator; the endpoint URLs themselves are never returned. The +digest covers those values +along with issuer, origins, SIWF coordinates, gameplay audience/key/Maincloud +coordinates, observer coordinates, environment, binding and bounded lifetimes, +both gates, and exact cookie attributes. Operators must compare it with the reviewed expected configuration; it is not a deployment action and reveals no secret material. diff --git a/services/auth-bridge/src/app.ts b/services/auth-bridge/src/app.ts index dec53ba5..17f5c63c 100644 --- a/services/auth-bridge/src/app.ts +++ b/services/auth-bridge/src/app.ts @@ -676,10 +676,41 @@ async function timingSafeSecretMatch(provided: string, expected: string): Promis return difference === 0 } +async function sha256Hex(value: string): Promise { + const bytes = new TextEncoder().encode(value) + try { + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)) + return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('') + } finally { + bytes.fill(0) + } +} + +/** + * Privacy-safe stable identity for an exact configured RPC URL. The URL itself + * can contain provider credentials and must never enter an attestation response. + */ +export function farcasterRpcEndpointFingerprint(rpcUrl: string): Promise { + return sha256Hex(`warpkeep-farcaster-rpc-endpoint-v1\0${rpcUrl}`) +} + async function configurationAttestation( config: BridgeConfig, qaObserverKeyFingerprint: string | null, -): Promise { +): Promise> { + const farcasterRpcEndpointFingerprints = Object.freeze((await Promise.all( + config.farcasterRpcUrls.map(farcasterRpcEndpointFingerprint), + )).sort()) + const signingPublicKeyThumbprint = await qaObserverKeyThumbprint({ + kty: 'EC', + crv: 'P-256', + x: config.privateJwk.x, + y: config.privateJwk.y, + }) const canonical = JSON.stringify({ profile: 'warpkeep-auth-v2', issuer: config.issuer, @@ -688,6 +719,8 @@ async function configurationAttestation( siweUri: config.siweUri, audience: config.audience, keyId: config.keyId, + farcasterRpcEndpointFingerprints, + signingPublicKeyThumbprint, spacetimeDbUri: config.spacetimeDbUri, spacetimeDbDatabase: config.spacetimeDbDatabase, publicAuthEnabled: config.publicAuthEnabled, @@ -717,13 +750,11 @@ async function configurationAttestation( sessionFamilyTtlSeconds: SESSION_FAMILY_TTL_SECONDS, sessionCookie: '__Host-warpkeep_session; Secure; HttpOnly; SameSite=Strict; Path=/', }) - const bytes = new TextEncoder().encode(canonical) - try { - const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)) - return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('') - } finally { - bytes.fill(0) - } + return Object.freeze({ + digest: await sha256Hex(canonical), + farcasterRpcEndpointFingerprints, + signingPublicKeyThumbprint, + }) } function adminCredential(request: Request): string | null { @@ -1308,7 +1339,7 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri logger.event('exchange_rejected') throw new HttpError(401, 'challenge_replayed', 'This sign-in challenge is invalid or already used.') } - const verifier = dependencies.verifier ?? createOfficialFarcasterVerifier(config.farcasterRpcUrl) + const verifier = dependencies.verifier ?? createOfficialFarcasterVerifier(config.farcasterRpcUrls) let verifiedFid: string try { verifiedFid = canonicalFid((await verifyFarcasterWithDeadline(verifier, { @@ -1585,18 +1616,21 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri throw new HttpError(400, 'admin_query_not_allowed', 'This endpoint does not accept query parameters.') } let qaObserverKeyFingerprint: string | null = null - if (config.qaObserverPublicJwk) { - try { + let attestation: Awaited> + try { + if (config.qaObserverPublicJwk) { qaObserverKeyFingerprint = await qaObserverKeyThumbprint(config.qaObserverPublicJwk) - } catch { - throw new ConfigurationError() } + attestation = await configurationAttestation(config, qaObserverKeyFingerprint) + } catch { + throw new ConfigurationError() } - const digest = await configurationAttestation(config, qaObserverKeyFingerprint) logger.event('config_attestation_issued') return json({ profile: 'warpkeep-auth-v2', - digest, + digest: attestation.digest, + farcasterRpcEndpointFingerprints: attestation.farcasterRpcEndpointFingerprints, + signingPublicKeyThumbprint: attestation.signingPublicKeyThumbprint, publicAuthEnabled: config.publicAuthEnabled, qaObserverEnabled: config.qaObserverEnabled, qaObserverSpacetimeDbUri: config.qaObserverSpacetimeDb?.uri ?? null, diff --git a/services/auth-bridge/src/config.ts b/services/auth-bridge/src/config.ts index 0299f18e..d01ca6fb 100644 --- a/services/auth-bridge/src/config.ts +++ b/services/auth-bridge/src/config.ts @@ -29,13 +29,15 @@ export type QaObserverSpacetimeDbConfig = Readonly<{ audience: string }> +export type FarcasterRpcUrls = readonly [string] | readonly [string, string] + export interface BridgeConfig { issuer: string issuerUrl: URL allowedOrigins: ReadonlySet domain: string siweUri: string - farcasterRpcUrl: string + farcasterRpcUrls: FarcasterRpcUrls audience: string keyId: string privateJwk: PrivateEcJwk @@ -140,13 +142,12 @@ function parsePrivateJwk(value: string): PrivateEcJwk { } catch { throw new ConfigurationError() } - const coordinate = /^[A-Za-z0-9_-]{43}$/ if ( jwk.kty !== 'EC' || jwk.crv !== 'P-256' - || !jwk.x || !coordinate.test(jwk.x) - || !jwk.y || !coordinate.test(jwk.y) - || !jwk.d || !coordinate.test(jwk.d) + || !jwk.x || !isCanonicalBase64UrlCoordinate(jwk.x) + || !jwk.y || !isCanonicalBase64UrlCoordinate(jwk.y) + || !jwk.d || !isCanonicalBase64UrlCoordinate(jwk.d) ) { throw new ConfigurationError() } @@ -205,6 +206,72 @@ function parseAudience(value: string): string { return value } +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase() + return normalized === 'localhost' + || normalized.endsWith('.localhost') + || normalized === '127.0.0.1' + || normalized === '[::1]' +} + +function isPublicDnsHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase() + if ( + isLoopbackHostname(normalized) + || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(normalized) + || normalized.includes(':') + || normalized.endsWith('.local') + || normalized.endsWith('.internal') + || normalized.endsWith('.invalid') + || normalized.endsWith('.localhost') + || normalized.endsWith('.test') + || normalized.endsWith('.example') + ) { + return false + } + const label = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?' + return new RegExp(`^${label}(?:\\.${label})+$`).test(normalized) +} + +function parseFarcasterRpcUrls(env: WorkerEnv, production: boolean): FarcasterRpcUrls { + const primary = parseAbsoluteUrl(required(env, 'FARCASTER_RPC_URL')) + const secondaryValue = env.FARCASTER_RPC_URL_SECONDARY?.trim() + const urls = secondaryValue + ? [primary, parseAbsoluteUrl(secondaryValue)] + : [primary] + + for (const url of urls) { + if ( + url.username + || url.password + || url.hash + || (url.protocol === 'http:' && !isLoopbackHostname(url.hostname)) + || (production && ( + url.protocol !== 'https:' + || Boolean(url.port) + || !isPublicDnsHostname(url.hostname) + )) + ) { + throw new ConfigurationError() + } + } + + if (production && urls.length !== 2) { + throw new ConfigurationError() + } + if (!production && urls.length === 1 && !isLoopbackHostname(primary.hostname)) { + throw new ConfigurationError() + } + if (urls.length === 2 && urls[0].origin === urls[1].origin) { + throw new ConfigurationError() + } + + const normalized = urls.map(url => url.toString()) + return normalized.length === 2 + ? Object.freeze([normalized[0], normalized[1]]) + : Object.freeze([normalized[0]]) +} + function parsePublicAuthEnabled(value: string): boolean { if (value !== 'true' && value !== 'false') { throw new ConfigurationError() @@ -321,9 +388,7 @@ export function readBridgeConfig(env: WorkerEnv): BridgeConfig { throw new ConfigurationError() } - const farcasterRpcUrl = required(env, 'FARCASTER_RPC_URL') - const rpcUrl = parseAbsoluteUrl(farcasterRpcUrl) - if (production && rpcUrl.protocol !== 'https:') throw new ConfigurationError() + const farcasterRpcUrls = parseFarcasterRpcUrls(env, production) const spacetimeDbUri = parseSpacetimeDbUri(required(env, 'SPACETIMEDB_URI'), production) const spacetimeDbDatabase = parseSpacetimeDbDatabase(required(env, 'SPACETIMEDB_DATABASE')) @@ -402,7 +467,7 @@ export function readBridgeConfig(env: WorkerEnv): BridgeConfig { allowedOrigins, domain, siweUri, - farcasterRpcUrl, + farcasterRpcUrls, audience, keyId: parseKeyId(configuredKid), privateJwk, diff --git a/services/auth-bridge/src/farcaster.ts b/services/auth-bridge/src/farcaster.ts index 7515a8f0..bcd2268e 100644 --- a/services/auth-bridge/src/farcaster.ts +++ b/services/auth-bridge/src/farcaster.ts @@ -14,11 +14,7 @@ function canonicalFid(value: number | bigint | string): string { return fid.toString(10) } -/** - * Uses Farcaster's official auth client. The client checks the SIWF signature, - * custody/auth address ownership, and FID resource with `acceptAuthAddress`. - */ -export function createOfficialFarcasterVerifier(rpcUrl: string): FarcasterVerifier { +function createSingleOfficialFarcasterVerifier(rpcUrl: string): FarcasterVerifier { const client = createAppClient({ ethereum: viemConnector({ rpcUrl }), }) @@ -48,3 +44,53 @@ export function createOfficialFarcasterVerifier(rpcUrl: string): FarcasterVerifi }, } } + +/** + * Requires every configured verifier to independently return the same FID. + * One verifier is supported only for the explicitly local development profile. + */ +export function createConsensusFarcasterVerifier( + verifiers: readonly FarcasterVerifier[], +): FarcasterVerifier { + if (verifiers.length < 1 || verifiers.length > 2) { + throw new Error('Farcaster verifier configuration is invalid.') + } + + return { + async verify(input: FarcasterProofInput): Promise { + const results = await Promise.allSettled(verifiers.map(async (verifier) => ({ + fid: canonicalFid((await verifier.verify(input)).fid), + }))) + + if (results.length === 1) { + const [result] = results + if (result.status === 'fulfilled') return result.value + throw result.reason + } + + const [first, second] = results + if (first.status === 'fulfilled' && second.status === 'fulfilled') { + if (first.value.fid === second.value.fid) return first.value + throw new FarcasterVerifierUnavailableError() + } + if (first.status === 'fulfilled' || second.status === 'fulfilled') { + throw new FarcasterVerifierUnavailableError() + } + if ( + first.reason instanceof FarcasterVerifierUnavailableError + || second.reason instanceof FarcasterVerifierUnavailableError + ) { + throw new FarcasterVerifierUnavailableError() + } + throw new Error('Farcaster verification failed.') + }, + } +} + +/** + * Uses Farcaster's official auth client. Each client checks the SIWF signature, + * custody/auth address ownership, and FID resource with `acceptAuthAddress`. + */ +export function createOfficialFarcasterVerifier(rpcUrls: readonly string[]): FarcasterVerifier { + return createConsensusFarcasterVerifier(rpcUrls.map(createSingleOfficialFarcasterVerifier)) +} diff --git a/services/auth-bridge/src/types.ts b/services/auth-bridge/src/types.ts index 27ea2ac5..513e943d 100644 --- a/services/auth-bridge/src/types.ts +++ b/services/auth-bridge/src/types.ts @@ -41,8 +41,10 @@ export interface WorkerEnv { FARCASTER_DOMAIN?: string /** Exact SIWF URI expected in every signed message. */ FARCASTER_SIWE_URI?: string - /** A private Optimism RPC URL used by the official Farcaster verifier. */ + /** Primary private Optimism RPC URL used by the official Farcaster verifier. */ FARCASTER_RPC_URL?: string + /** Independent secondary Optimism RPC URL. Required in production. */ + FARCASTER_RPC_URL_SECONDARY?: string OIDC_AUDIENCE?: string /** Stable public JWK key id. May also be supplied inside SIGNING_KEY_JWK. */ OIDC_KEY_ID?: string diff --git a/services/auth-bridge/test-workerd/authBridge.workerd.test.ts b/services/auth-bridge/test-workerd/authBridge.workerd.test.ts index a7932c80..9787dc21 100644 --- a/services/auth-bridge/test-workerd/authBridge.workerd.test.ts +++ b/services/auth-bridge/test-workerd/authBridge.workerd.test.ts @@ -25,7 +25,10 @@ const CONFIG: BridgeConfig = { allowedOrigins: new Set([ORIGIN]), domain: DOMAIN, siweUri: SIWE_URI, - farcasterRpcUrl: 'https://optimism-rpc.warpkeep.test', + farcasterRpcUrls: Object.freeze([ + 'https://optimism-rpc-one.example.com/', + 'https://optimism-rpc-two.example.net/', + ]), audience: 'warpkeep-spacetimedb', keyId: 'workerd-test-key', privateJwk: { From 9888e018d7ddf483bb44c2a82d8369f756e78e62 Mon Sep 17 00:00:00 2001 From: Ael <183124839+ael-dev3@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:05:47 +0200 Subject: [PATCH 04/10] Alpha 0.3.14 signed source (4/10) GitHub-signed reconstruction of the exact reviewed Alpha 0.3.14 tree. --- services/auth-bridge/test/app.test.ts | 77 +- services/auth-bridge/test/farcaster.test.ts | 84 ++ services/auth-bridge/test/qaObserver.test.ts | 3 +- spacetimedb/README.md | 20 +- .../additive-v12-schema/package.json | 13 + .../additive-v12-schema/src/index.ts | 606 +++++++++ .../additive-v12-schema/tsconfig.json | 13 + spacetimedb/pnpm-lock.yaml | 10 + spacetimedb/src/castleWorkerAuthority.ts | 1172 +++++++++++++++++ spacetimedb/src/castleWorkerCommandPolicy.ts | 186 +++ spacetimedb/src/castleWorkerPolicy.ts | 400 ++++++ spacetimedb/src/castleWorkerRoster.ts | 213 +++ spacetimedb/src/foodExpeditionAuthority.ts | 2 + spacetimedb/src/foundingAuthority.ts | 7 + spacetimedb/src/goldExpeditionAuthority.ts | 2 + spacetimedb/src/index.ts | 10 + spacetimedb/src/reducers/castleWorkers.ts | 322 +++++ spacetimedb/src/reducers/resources.ts | 7 + .../resourceExpeditionReservationAuthority.ts | 56 +- spacetimedb/src/schema.ts | 196 +++ spacetimedb/src/stoneExpeditionAuthority.ts | 2 + 21 files changed, 3389 insertions(+), 12 deletions(-) create mode 100644 services/auth-bridge/test/farcaster.test.ts create mode 100644 spacetimedb/migration-fixtures/additive-v12-schema/package.json create mode 100644 spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts create mode 100644 spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json create mode 100644 spacetimedb/src/castleWorkerAuthority.ts create mode 100644 spacetimedb/src/castleWorkerCommandPolicy.ts create mode 100644 spacetimedb/src/castleWorkerPolicy.ts create mode 100644 spacetimedb/src/castleWorkerRoster.ts create mode 100644 spacetimedb/src/reducers/castleWorkers.ts diff --git a/services/auth-bridge/test/app.test.ts b/services/auth-bridge/test/app.test.ts index 3ee94efd..07863dff 100644 --- a/services/auth-bridge/test/app.test.ts +++ b/services/auth-bridge/test/app.test.ts @@ -4,11 +4,13 @@ import { FARCASTER_VERIFICATION_TIMEOUT_MILLISECONDS, REQUEST_BODY_TIMEOUT_MILLISECONDS, createAuthBridge, + farcasterRpcEndpointFingerprint, type AuthBridgeDependencies, } from '../src/app' import { MemoryChallengeStore } from '../src/challengeStore' import { PRODUCTION_SPACETIMEDB_DATABASE } from '../src/config' import { FarcasterVerifierUnavailableError } from '../src/farcaster' +import { qaObserverKeyThumbprint } from '../src/qaObserver' import { MemorySessionFamilyStore } from '../src/sessionFamily' import { AuthEpochResolverFailure, @@ -52,7 +54,8 @@ function env(overrides: Partial = {}): WorkerEnv { ALLOWED_ORIGINS: ORIGIN, FARCASTER_DOMAIN: DOMAIN, FARCASTER_SIWE_URI: SIWE_URI, - FARCASTER_RPC_URL: 'https://optimism-rpc.internal.example', + FARCASTER_RPC_URL: 'https://optimism-rpc-one.example.com', + FARCASTER_RPC_URL_SECONDARY: 'https://optimism-rpc-two.example.net', OIDC_AUDIENCE: 'warpkeep-spacetimedb', OIDC_KEY_ID: 'test-es256-2026', SPACETIMEDB_URI: 'https://maincloud.spacetimedb.com', @@ -1127,8 +1130,20 @@ describe('Warpkeep auth bridge', () => { const firstBody = await json(first) const secondBody = await json(second) expect(firstBody).toEqual(secondBody) + const farcasterRpcEndpointFingerprints = (await Promise.all([ + farcasterRpcEndpointFingerprint('https://optimism-rpc-one.example.com/'), + farcasterRpcEndpointFingerprint('https://optimism-rpc-two.example.net/'), + ])).sort() + const signingPublicKeyThumbprint = await qaObserverKeyThumbprint({ + kty: 'EC', + crv: 'P-256', + x: String(privateJwk.x), + y: String(privateJwk.y), + }) expect(firstBody).toMatchObject({ profile: 'warpkeep-auth-v2', + farcasterRpcEndpointFingerprints, + signingPublicKeyThumbprint, publicAuthEnabled: true, qaObserverEnabled: false, qaObserverSpacetimeDbUri: null, @@ -1147,6 +1162,8 @@ describe('Warpkeep auth bridge', () => { siweUri: 'https://warpkeep.example/Warpkeep/', audience: 'warpkeep-spacetimedb', keyId: 'test-es256-2026', + farcasterRpcEndpointFingerprints, + signingPublicKeyThumbprint, spacetimeDbUri: 'https://maincloud.spacetimedb.com', spacetimeDbDatabase: PRODUCTION_SPACETIMEDB_DATABASE, publicAuthEnabled: true, @@ -1180,9 +1197,27 @@ describe('Warpkeep auth bridge', () => { expect(serialized).not.toContain(ADMIN_SECRET) expect(serialized).not.toContain(SESSION_COOKIE_KEY) expect(serialized).not.toContain(privateJwk.d ?? '') + expect(serialized).not.toContain('https://optimism-rpc-one.example.com') + expect(serialized).not.toContain('https://optimism-rpc-two.example.net') const paused = await json(await call({ PUBLIC_AUTH_ENABLED: 'false' })) expect(paused.digest).not.toBe(reviewedDigest) expect(paused.publicAuthEnabled).toBe(false) + const rpcDrift = await json(await call({ + FARCASTER_RPC_URL_SECONDARY: 'https://optimism-rpc-three.example.org', + })) + expect(rpcDrift.digest).not.toBe(reviewedDigest) + expect(rpcDrift.farcasterRpcEndpointFingerprints).not.toEqual(farcasterRpcEndpointFingerprints) + const replacementPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + ) + const replacementJwk = await crypto.subtle.exportKey('jwk', replacementPair.privateKey) + const signingKeyDrift = await json(await call({ + SIGNING_KEY_JWK: JSON.stringify(replacementJwk), + })) + expect(signingKeyDrift.digest).not.toBe(reviewedDigest) + expect(signingKeyDrift.signingPublicKeyThumbprint).not.toBe(signingPublicKeyThumbprint) expect(first.headers.has('access-control-allow-origin')).toBe(false) expect(h.events).toContain('config_attestation_issued') }) @@ -1550,6 +1585,46 @@ describe('Warpkeep auth bridge', () => { await expect(response.json()).resolves.toMatchObject({ error: { code: 'service_misconfigured' } }) }) + it('requires two independent public HTTPS Farcaster RPC origins in production', async () => { + const h = harness() + const invalidProductionOverrides: readonly Partial[] = [ + { FARCASTER_RPC_URL_SECONDARY: undefined }, + { FARCASTER_RPC_URL: 'http://optimism-rpc-one.example.com' }, + { FARCASTER_RPC_URL_SECONDARY: 'https://optimism-rpc-one.example.com/secondary' }, + { FARCASTER_RPC_URL_SECONDARY: 'https://127.0.0.1' }, + { FARCASTER_RPC_URL_SECONDARY: 'https://10.0.0.1' }, + { FARCASTER_RPC_URL_SECONDARY: 'https://[2001:db8::1]' }, + { FARCASTER_RPC_URL_SECONDARY: 'https://optimism-rpc.internal' }, + { FARCASTER_RPC_URL_SECONDARY: 'https://optimism-rpc-two.example.net/#fragment' }, + ] + for (const overrides of invalidProductionOverrides) { + const response = await h.app.fetch(request('/healthz'), env(overrides)) + expect(response.status).toBe(503) + await expect(response.json()).resolves.toMatchObject({ error: { code: 'service_misconfigured' } }) + } + + const localDevelopment = { + ENVIRONMENT: 'development', + ISSUER: 'https://localhost:8787', + ALLOWED_ORIGINS: 'http://localhost:5173', + FARCASTER_DOMAIN: 'localhost:5173', + FARCASTER_SIWE_URI: 'http://localhost:5173/', + FARCASTER_RPC_URL: 'http://127.0.0.1:8545', + FARCASTER_RPC_URL_SECONDARY: undefined, + SPACETIMEDB_URI: 'http://127.0.0.1:3000', + SPACETIMEDB_DATABASE: 'warpkeep-dev', + } satisfies Partial + const developmentRequest = () => new Request('https://localhost:8787/healthz') + const accepted = await h.app.fetch(developmentRequest(), env(localDevelopment)) + expect(accepted.status).toBe(200) + + const remoteSingle = await h.app.fetch(developmentRequest(), env({ + ...localDevelopment, + FARCASTER_RPC_URL: 'https://optimism-rpc-one.example.com', + })) + expect(remoteSingle.status).toBe(503) + }) + it('fails closed without a public issuer and writes only static safe log events', async () => { const h = harness() const response = await h.app.fetch(request('/healthz'), env({ ISSUER: undefined })) diff --git a/services/auth-bridge/test/farcaster.test.ts b/services/auth-bridge/test/farcaster.test.ts new file mode 100644 index 00000000..2144260c --- /dev/null +++ b/services/auth-bridge/test/farcaster.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest' +import { + FarcasterVerifierUnavailableError, + createConsensusFarcasterVerifier, +} from '../src/farcaster' +import type { FarcasterProofInput, FarcasterVerifier } from '../src/types' + +const PROOF = Object.freeze({ + nonce: '12345678', + domain: 'warpkeep.example', + message: 'test-only SIWF message', + signature: `0x${'00'.repeat(65)}` as `0x${string}`, + acceptAuthAddress: true, +}) satisfies FarcasterProofInput + +function succeeds(fid: string): FarcasterVerifier & { verify: ReturnType } { + return { verify: vi.fn(async () => ({ fid })) } +} + +function fails(error: Error): FarcasterVerifier & { verify: ReturnType } { + return { verify: vi.fn(async () => { throw error }) } +} + +describe('Farcaster verifier consensus', () => { + it('returns only a matching canonical FID from both independent verifiers', async () => { + const first = succeeds('12345') + const second = succeeds('00012345') + const verifier = createConsensusFarcasterVerifier([first, second]) + + await expect(verifier.verify(PROOF)).resolves.toEqual({ fid: '12345' }) + expect(first.verify).toHaveBeenCalledOnce() + expect(second.verify).toHaveBeenCalledOnce() + expect(first.verify).toHaveBeenCalledWith(PROOF) + expect(second.verify).toHaveBeenCalledWith(PROOF) + }) + + it('fails unavailable when successful verifiers disagree', async () => { + const verifier = createConsensusFarcasterVerifier([ + succeeds('12345'), + succeeds('54321'), + ]) + + await expect(verifier.verify(PROOF)).rejects.toBeInstanceOf(FarcasterVerifierUnavailableError) + }) + + it('fails unavailable when only one verifier succeeds', async () => { + for (const failure of [ + new FarcasterVerifierUnavailableError(), + new Error('invalid proof'), + ]) { + const verifier = createConsensusFarcasterVerifier([ + succeeds('12345'), + fails(failure), + ]) + await expect(verifier.verify(PROOF)).rejects.toBeInstanceOf(FarcasterVerifierUnavailableError) + } + }) + + it('preserves provider outage and definitive rejection classifications', async () => { + const unavailable = createConsensusFarcasterVerifier([ + fails(new FarcasterVerifierUnavailableError()), + fails(new Error('invalid proof')), + ]) + await expect(unavailable.verify(PROOF)).rejects.toBeInstanceOf(FarcasterVerifierUnavailableError) + + const invalid = createConsensusFarcasterVerifier([ + fails(new Error('invalid proof one')), + fails(new Error('invalid proof two')), + ]) + await expect(invalid.verify(PROOF)).rejects.toMatchObject({ + name: 'Error', + message: 'Farcaster verification failed.', + }) + }) + + it('supports exactly one verifier for the explicitly local development profile', async () => { + const only = succeeds('12345') + await expect(createConsensusFarcasterVerifier([only]).verify(PROOF)).resolves.toEqual({ fid: '12345' }) + expect(() => createConsensusFarcasterVerifier([])).toThrow('Farcaster verifier configuration is invalid.') + expect(() => createConsensusFarcasterVerifier([only, only, only])).toThrow( + 'Farcaster verifier configuration is invalid.', + ) + }) +}) diff --git a/services/auth-bridge/test/qaObserver.test.ts b/services/auth-bridge/test/qaObserver.test.ts index 9f74fd33..962a1be9 100644 --- a/services/auth-bridge/test/qaObserver.test.ts +++ b/services/auth-bridge/test/qaObserver.test.ts @@ -111,7 +111,8 @@ function environment(overrides: Partial = {}): WorkerEnv { ALLOWED_ORIGINS: ORIGIN, FARCASTER_DOMAIN: 'warpkeep.example', FARCASTER_SIWE_URI: `${ORIGIN}/`, - FARCASTER_RPC_URL: 'https://optimism-rpc.internal.example', + FARCASTER_RPC_URL: 'https://optimism-rpc-one.example.com', + FARCASTER_RPC_URL_SECONDARY: 'https://optimism-rpc-two.example.net', OIDC_AUDIENCE: 'warpkeep-spacetimedb', OIDC_KEY_ID: 'test-key', SIGNING_KEY_JWK: JSON.stringify(signingPrivateJwk), diff --git a/spacetimedb/README.md b/spacetimedb/README.md index 0735acd3..d4da369c 100644 --- a/spacetimedb/README.md +++ b/spacetimedb/README.md @@ -12,8 +12,9 @@ balance, advance a timer, or decide an expedition outcome. | Browser/backend wire protocol | 3 | | Player authentication contract | 2 | | Genesis world generation | 3 | -| Append-only schema generation | 10 | +| Append-only schema generation | 12 (staged suffix) | | Alpha 0.3.12 suffix | Water refs 37–40; Stone refs 41–45 | +| Generic worker suffix | refs 47–52; staged, not activated | Deployed tables retain their original declaration order and shape. Later features append new tables; they do not rename or delete existing data. The @@ -55,6 +56,8 @@ Public subscriptions contain only shared-world presentation: - activated Water layout, body/cell topology, and shared environment data; - identity-minimized site occupations containing a site, phase, public timeline, and origin castle; +- staged four-worker roster and generic node-lease projections; the public + rows contain no FID, cargo, accrual, balance, request, or auth data; - public Community Marks projection only when its policy permits it. Private tables contain admission, ownership, unclaimed-slot decisions, resource @@ -94,6 +97,21 @@ Gold, Food, Wood, and Stone each have an independent expedition: - private reservations prevent passive collection or another lifecycle from truncating a valid Food, Wood, or Stone award. +The additive generic-worker suffix defines four stable workers per founded +castle. Any idle worker can gather Gold, Food, Wood, or Stone, and multiple +workers may gather the same resource at different nodes. Worker assignments +use the same canonical site catalogs, route authority, 60-second quantum, and +30-day cap as the legacy expeditions. The caller's private read projects exact +server-time availability without a write; scheduled expiry and explicit +dispatch/recall commands materialize complete quanta. There is no per-minute +write loop and no `collect` command for generic workers. + +The suffix is intentionally staged. The module does not seed, activate, +backfill, or migrate production workers in this PR. Activation requires an +admin-reviewed singleton, an exact four-worker roster digest, and zero legacy +expedition, occupation, and schedule rows. The browser wire protocol remains 3 +until a later client-capability release opts into the generic worker methods. + ## Entry agreement and Marks Entry and gameplay require the exact current Alpha Terms and Hegemony Social diff --git a/spacetimedb/migration-fixtures/additive-v12-schema/package.json b/spacetimedb/migration-fixtures/additive-v12-schema/package.json new file mode 100644 index 00000000..0caa87d6 --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v12-schema/package.json @@ -0,0 +1,13 @@ +{ + "name": "warpkeep-additive-v12-schema-migration-fixture", + "private": true, + "version": "0.0.0", + "type": "module", + "license": "Apache-2.0", + "dependencies": { + "spacetimedb": "2.6.1" + }, + "devDependencies": { + "typescript": "5.6.3" + } +} diff --git a/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts b/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts new file mode 100644 index 00000000..ae3a4bc1 --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts @@ -0,0 +1,606 @@ +import { schema, table, t } from 'spacetimedb/server'; +import { ScheduleAt, Timestamp } from 'spacetimedb'; +import { SenderError } from 'spacetimedb/server'; +import { + goldExpeditionErrorCode, + runGoldExpeditionSchedule, +} from '../../../src/goldExpeditionAuthority'; +import { + foodExpeditionErrorCode, + runFoodExpeditionSchedule, +} from '../../../src/foodExpeditionAuthority'; +import { + woodExpeditionErrorCode, + runWoodExpeditionSchedule, +} from '../../../src/woodExpeditionAuthority'; +import { + stoneExpeditionErrorCode, + runStoneExpeditionSchedule, +} from '../../../src/stoneExpeditionAuthority'; + +const allowedFid = table({ name: 'allowed_fid' }, { + fid: t.u64().primaryKey(), enabled: t.bool(), authEpoch: t.u32(), + invitedAt: t.timestamp(), invitedBy: t.string(), note: t.string(), +}); +const worldTile = table({ name: 'world_tile', public: true }, { + key: t.string().primaryKey(), q: t.i32(), r: t.i32(), biome: t.string(), + terrainSeed: t.u32(), occupantCastleId: t.option(t.u64()), +}); +const player = table({ name: 'player', public: true }, { + fid: t.u64().primaryKey(), identity: t.identity().unique(), username: t.option(t.string()), + displayName: t.option(t.string()), pfpUrl: t.option(t.string()), joinedAt: t.timestamp(), status: t.string(), +}); +const castle = table({ name: 'castle', public: true }, { + castleId: t.u64().primaryKey().autoInc(), ownerFid: t.u64().unique(), tileKey: t.string().unique(), + q: t.i32(), r: t.i32(), level: t.i32(), name: t.string(), createdAt: t.timestamp(), +}); +const adminAudit = table({ name: 'admin_audit' }, { + id: t.u64().primaryKey().autoInc(), action: t.string(), targetFid: t.option(t.u64()), + actorSubject: t.string(), createdAt: t.timestamp(), note: t.string(), +}); +const playerV2 = table({ name: 'player_v2', public: true }, { + fid: t.u64().primaryKey(), username: t.option(t.string()), displayName: t.option(t.string()), + pfpUrl: t.option(t.string()), joinedAt: t.timestamp(), status: t.string(), +}); +const playerOwnershipV2 = table({ name: 'player_ownership_v2' }, { + fid: t.u64().primaryKey(), identity: t.identity().unique(), +}); +const realmV1 = table({ name: 'realm_v1', public: true }, { + realmId: t.string().primaryKey(), publicName: t.string(), seedName: t.string(), numericSeed: t.u32(), + generationVersion: t.u32(), authoritativeRadius: t.u32(), renderRadius: t.u32(), playerCapacity: t.u32(), + active: t.bool(), createdAt: t.timestamp(), +}); +const worldTileMetaV1 = table({ + name: 'world_tile_meta_v1', public: true, + indexes: [{ accessor: 'byRealmAndRing', algorithm: 'btree', columns: ['realmId', 'ring'] as const }] as const, +}, { + tileKey: t.string().primaryKey(), realmId: t.string().index(), s: t.i32(), ring: t.u32(), sector: t.u32(), + terrainKind: t.string(), passable: t.bool(), movementCost: t.u32(), staticContentKind: t.string(), generationVersion: t.u32(), +}); +const castleSlotV1 = table({ name: 'castle_slot_v1', public: true }, { + slotId: t.u32().primaryKey(), realmId: t.string().index(), tileKey: t.string().unique(), q: t.i32(), r: t.i32(), generationVersion: t.u32(), +}); +const castleSlotClaimV1 = table({ name: 'castle_slot_claim_v1' }, { + slotId: t.u32().primaryKey(), ownerFid: t.u64().unique(), castleId: t.u64().unique(), claimedAt: t.timestamp(), generationVersion: t.u32(), +}); +const realmProfileV1 = table({ name: 'realm_profile_v1', public: true }, { + fid: t.u64().primaryKey(), canonicalUsername: t.option(t.string()), displayName: t.option(t.string()), pfpUrl: t.option(t.string()), publicBio: t.option(t.string()), + admittedAt: t.timestamp(), firstAuthenticatedAt: t.option(t.timestamp()), profileUpdatedAt: t.timestamp(), publicStatus: t.string(), communityStatsVisible: t.bool(), + totalSnapBurnedMicros: t.option(t.u128()), marksEarnedMicros: t.option(t.u128()), marksSpentMicros: t.option(t.u128()), marksBalanceMicros: t.option(t.u128()), marksPolicyVersion: t.option(t.string()), +}); +const markAccountV1 = table({ name: 'mark_account_v1' }, { + fid: t.u64().primaryKey(), totalSnapBurnedMicros: t.u128(), earnedMicros: t.u128(), spentMicros: t.u128(), balanceMicros: t.u128(), policyVersion: t.string(), updatedAt: t.timestamp(), +}); +const snapBurnCreditV1 = table({ name: 'snap_burn_credit_v1' }, { + eventKey: t.string().primaryKey(), batchId: t.string().index(), chainId: t.u32(), tokenContract: t.string(), transactionHash: t.string(), logIndex: t.u32(), burnReference: t.string().unique(), burnMethod: t.string(), senderAddress: t.string(), blockNumber: t.u64(), blockHash: t.string(), amountMicros: t.u128(), attributedFid: t.u64().index(), attributionPolicyVersion: t.string(), contractCodeHash: t.string(), creditedAt: t.timestamp(), +}); +const fidWalletAttributionV1 = table({ + name: 'fid_wallet_attribution_v1', indexes: [{ accessor: 'bySnapshotAndAddress', algorithm: 'btree', columns: ['snapshotGeneration', 'address'] as const }] as const, +}, { + snapshotAttributionKey: t.string().primaryKey(), attributionKey: t.string(), snapshotGeneration: t.u64(), fid: t.u64().index(), address: t.string(), addressType: t.string(), source: t.string(), snapshotAt: t.timestamp(), attributionPolicyVersion: t.string(), active: t.bool(), +}); +const walletAttributionSnapshotV1 = table({ name: 'wallet_attribution_snapshot_v1' }, { + snapshotKey: t.string().primaryKey(), generation: t.u64(), snapshotId: t.string(), policyVersion: t.string(), attributionCount: t.u32(), snapshotAt: t.timestamp(), +}); +const snapScanCursorV1 = table({ name: 'snap_scan_cursor_v1' }, { + cursorKey: t.string().primaryKey(), chainId: t.u32(), tokenContract: t.string(), policyVersion: t.string(), deploymentStartBlock: t.u64(), lastFinalizedBlock: t.u64(), lastFinalizedBlockHash: t.string(), proxyCodeHash: t.string(), implementationAddress: t.string(), implementationCodeHash: t.string(), walletSnapshotGeneration: t.u64(), walletSnapshotId: t.string(), scannedAt: t.timestamp(), +}); +const snapScanBatchV1 = table({ + name: 'snap_scan_batch_v1', indexes: [{ accessor: 'byCursorAndStatus', algorithm: 'btree', columns: ['cursorKey', 'status'] as const }] as const, +}, { + batchId: t.string().primaryKey(), cursorKey: t.string(), status: t.string(), previousFinalizedBlock: t.u64(), previousFinalizedBlockHash: t.string(), throughFinalizedBlock: t.u64(), throughFinalizedBlockHash: t.string(), walletSnapshotGeneration: t.u64(), walletSnapshotId: t.string(), walletAttributionCount: t.u32(), expectedCredits: t.u32(), expectedMicros: t.u128(), appliedCredits: t.u32(), appliedMicros: t.u128(), proxyCodeHash: t.string(), implementationAddress: t.string(), implementationCodeHash: t.string(), startedAt: t.timestamp(), finalizedAt: t.option(t.timestamp()), +}); +const alphaTermsAcceptanceV1 = table({ name: 'alpha_terms_acceptance_v1' }, { + acceptanceKey: t.string().primaryKey(), fid: t.u64().index(), termsVersion: t.string(), acceptedAt: t.timestamp(), +}); +const resourceAccountV1 = table({ name: 'resource_account_v1' }, { + fid: t.u64().primaryKey(), castleId: t.u64().unique(), realmId: t.string().index(), food: t.u64(), wood: t.u64(), stone: t.u64(), gold: t.u64(), settledThroughMicros: t.u64(), revision: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp(), +}); + +const goldSiteV1 = table({ name: 'gold_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const goldNodeOccupationV1 = table({ name: 'gold_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const goldExpeditionV1 = table({ name: 'gold_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedGold: t.u64(), creditedGold: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const goldExpeditionIdempotencyV1 = table({ name: 'gold_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const goldExpeditionScheduleV1 = table({ name: 'gold_expedition_schedule_v_1', public: true, scheduled: (): any => runGoldExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmForestLayoutV1 = table({ name: 'realm_forest_layout_v1', public: true }, { realmId: t.string().primaryKey(), layoutVersion: t.u32(), policyVersion: t.string(), layoutDigest: t.string(), assetCatalogDigest: t.string(), instanceCount: t.u32(), seededAt: t.timestamp() }); +const realmForestInstanceV1 = table({ name: 'realm_forest_instance_v1', public: true }, { treeId: t.string().primaryKey(), realmId: t.string().index(), tileKey: t.string(), q: t.i32(), r: t.i32(), localXMicrounits: t.i64(), localZMicrounits: t.i64(), worldXMicrounits: t.i64(), worldZMicrounits: t.i64(), rotationMilliDegrees: t.u32(), scaleBasisPoints: t.u32(), speciesId: t.string(), habitat: t.string(), layoutVersion: t.u32() }); + +const foodSiteV1 = table({ name: 'food_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const foodNodeOccupationV1 = table({ name: 'food_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const foodExpeditionV1 = table({ name: 'food_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedFood: t.u64(), creditedFood: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const foodExpeditionIdempotencyV1 = table({ name: 'food_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const foodExpeditionScheduleV1 = table({ name: 'food_expedition_schedule_v_1', public: true, scheduled: (): any => runFoodExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const woodSiteV1 = table({ name: 'wood_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const woodNodeOccupationV1 = table({ name: 'wood_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const woodExpeditionV1 = table({ name: 'wood_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedWood: t.u64(), creditedWood: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const woodExpeditionIdempotencyV1 = table({ name: 'wood_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const woodExpeditionScheduleV1 = table({ name: 'wood_expedition_schedule_v_1', public: true, scheduled: (): any => runWoodExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmWaterLayoutV1 = table({ name: 'realm_water_layout_v1', public: true }, { realmId: t.string().primaryKey(), layoutVersion: t.u32(), policyVersion: t.string(), generationVersion: t.u32(), canonicalLandCellCount: t.u32(), oceanCellCount: t.u32(), lakeCellCount: t.u32(), lakeBodyCount: t.u32(), riverCount: t.u32(), riverCellCount: t.u32(), seaLevelMilli: t.i32(), seaLevelPolicyVersion: t.string(), fogStartDepthCells: t.u32(), fogFullDepthCells: t.u32(), hiddenBufferCells: t.u32(), layoutDigest: t.string(), sourceCommit: t.string(), activated: t.bool(), seededAt: t.timestamp(), activatedAt: t.option(t.timestamp()) }); +const realmWaterBodyV1 = table({ name: 'realm_water_body_v1', public: true, indexes: [{ accessor: 'byRealmAndRegime', algorithm: 'btree', columns: ['realmId', 'regime'] as const }] as const }, { bodyId: t.string().primaryKey(), realmId: t.string().index(), regime: t.string(), cellCount: t.u32(), sourceCellKey: t.string(), mouthCellKey: t.string(), surfaceLevelMilli: t.i32(), flowDirectionXQ15: t.i32(), flowDirectionZQ15: t.i32(), wavePreset: t.string(), ordinal: t.u32(), seed: t.u32(), generationVersion: t.u32(), layoutVersion: t.u32() }); +const realmWaterCellV1 = table({ name: 'realm_water_cell_v1', public: true, indexes: [{ accessor: 'byRealmAndRegime', algorithm: 'btree', columns: ['realmId', 'regime'] as const }, { accessor: 'byBody', algorithm: 'btree', columns: ['bodyId'] as const }] as const }, { cellKey: t.string().primaryKey(), realmId: t.string().index(), q: t.i32(), r: t.i32(), regime: t.string(), bodyId: t.string(), depthCells: t.u32(), elevationMilli: t.i32(), surfaceLevelMilli: t.i32(), ring: t.u32(), s: t.i32(), underlyingTileKey: t.option(t.string()), riverOrdinal: t.option(t.u32()), riverOrder: t.option(t.u32()), downstreamWaterCellKey: t.option(t.string()), flowAccumulation: t.u32(), depthClass: t.u32(), oceanDepth: t.u32(), bankSeed: t.u32(), generationVersion: t.u32(), fogBand: t.string(), layoutVersion: t.u32() }); +const realmEnvironmentV1 = table({ name: 'realm_environment_v1', public: true }, { realmId: t.string().primaryKey(), environmentEpoch: t.u64(), waterLayoutVersion: t.u32(), seaLevelMilli: t.i32(), sunDirectionXMicro: t.i32(), sunDirectionYMicro: t.i32(), sunDirectionZMicro: t.i32(), updatedAt: t.timestamp() }); + +const stoneSiteV1 = table({ name: 'stone_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const stoneNodeOccupationV1 = table({ name: 'stone_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const stoneExpeditionV1 = table({ name: 'stone_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedStone: t.u64(), creditedStone: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const stoneExpeditionIdempotencyV1 = table({ name: 'stone_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const stoneExpeditionScheduleV1 = table({ name: 'stone_expedition_schedule_v_1', public: true, scheduled: (): any => runStoneExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmWaterRevisionV1 = table({ name: 'realm_water_revision_v1', public: true }, { + realmId: t.string().primaryKey(), revisionVersion: t.u32(), policyVersion: t.string(), + baseLayoutVersion: t.u32(), baseLayoutDigest: t.string(), oceanBodyCount: t.u32(), + riverBodyCount: t.u32(), enabledBodyCount: t.u32(), oceanCellCount: t.u32(), + riverCellCount: t.u32(), enabledCellCount: t.u32(), lakeBodyCount: t.u32(), + lakeCellCount: t.u32(), riverWidthCells: t.u32(), navigationFogBoundaryDepthCells: t.u32(), + hiddenBufferCells: t.u32(), revisionDigest: t.string(), sourceCommit: t.string(), + activated: t.bool(), seededAt: t.timestamp(), activatedAt: t.option(t.timestamp()), +}); + +/** v12 generic-worker suffix. Public rows contain only identity/lifecycle data. */ +const realmWorkerSystemV1 = table({ name: 'realm_worker_system_v1', public: true }, { + realmId: t.string().primaryKey(), policyVersion: t.string(), workersPerCastle: t.u32(), + expectedCastleCount: t.u32(), expectedWorkerCount: t.u32(), rosterDigest: t.string(), + mode: t.string(), legacyDrainRequired: t.bool(), createdAt: t.timestamp(), + activatedAt: t.option(t.timestamp()), +}); +const castleWorkerV1 = table({ + name: 'castle_worker_v1', public: true, + indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const, +}, { + workerId: t.string().primaryKey(), originCastleId: t.u64(), ordinal: t.u32(), status: t.string(), + resourceKind: t.option(t.string()), siteId: t.option(t.string()), + startedAtMicros: t.option(t.u64()), arrivesAtMicros: t.option(t.u64()), gatheringEndsAtMicros: t.option(t.u64()), + returnStartedAtMicros: t.option(t.u64()), returnsAtMicros: t.option(t.u64()), routeSteps: t.option(t.u32()), + returnStartProgressBasisPoints: t.option(t.u32()), timelineRevision: t.u32(), revision: t.u64(), +}); +const workerAssignmentV1 = table({ + name: 'worker_assignment_v1', + indexes: [ + { accessor: 'byFid', algorithm: 'btree', columns: ['fid'] as const }, + { accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }, + ] as const, +}, { + assignmentId: t.string().primaryKey(), workerId: t.string().unique(), fid: t.u64(), + originCastleId: t.u64(), resourceKind: t.string(), siteId: t.string().index(), phase: t.string(), + startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), + returnStartedAtMicros: t.option(t.u64()), returnsAtMicros: t.u64(), routeSteps: t.u32(), + returnStartProgressBasisPoints: t.u32(), settledThroughMicros: t.u64(), accruedAmount: t.u64(), + materializedAmount: t.u64(), timelineRevision: t.u32(), policyVersion: t.string(), + createdAt: t.timestamp(), updatedAt: t.timestamp(), +}); +const workerNodeOccupationV1 = table({ + name: 'worker_node_occupation_v1', public: true, + indexes: [ + { accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }, + { accessor: 'byWorker', algorithm: 'btree', columns: ['workerId'] as const }, + ] as const, +}, { + nodeKey: t.string().primaryKey(), resourceKind: t.string(), siteId: t.string(), workerId: t.string(), + workerOrdinal: t.u32(), originCastleId: t.u64(), phase: t.string(), + startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), timelineRevision: t.u32(), +}); +const workerCommandIdempotencyV1 = table({ + name: 'worker_command_idempotency_v1', + indexes: [{ accessor: 'byFid', algorithm: 'btree', columns: ['fid'] as const }] as const, +}, { + requestKey: t.string().primaryKey(), fid: t.u64(), workerId: t.option(t.string()), commandKind: t.string(), + resourceKind: t.option(t.string()), siteId: t.option(t.string()), assignmentId: t.option(t.string()), + resultRevision: t.u64(), createdAt: t.timestamp(), +}); +const workerAssignmentScheduleV1 = table({ + name: 'worker_assignment_schedule_v_1', + indexes: [ + { accessor: 'byAssignment', algorithm: 'btree', columns: ['assignmentId'] as const }, + { accessor: 'byWorker', algorithm: 'btree', columns: ['workerId'] as const }, + ] as const, + scheduled: (): any => runWorkerAssignmentScheduleV1, +}, { + scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), assignmentId: t.string(), + workerId: t.string(), timelineRevision: t.u32(), stage: t.string(), +}); + +const db = schema({ + allowedFid, worldTile, player, castle, adminAudit, playerV2, playerOwnershipV2, + realmV1, worldTileMetaV1, castleSlotV1, castleSlotClaimV1, realmProfileV1, markAccountV1, + snapBurnCreditV1, fidWalletAttributionV1, walletAttributionSnapshotV1, snapScanCursorV1, + snapScanBatchV1, alphaTermsAcceptanceV1, resourceAccountV1, goldSiteV1, goldNodeOccupationV1, + goldExpeditionV1, goldExpeditionIdempotencyV1, goldExpeditionScheduleV1, realmForestLayoutV1, + realmForestInstanceV1, foodSiteV1, foodNodeOccupationV1, foodExpeditionV1, + foodExpeditionIdempotencyV1, foodExpeditionScheduleV1, woodSiteV1, woodNodeOccupationV1, + woodExpeditionV1, woodExpeditionIdempotencyV1, woodExpeditionScheduleV1, realmWaterLayoutV1, + realmWaterBodyV1, realmWaterCellV1, realmEnvironmentV1, stoneSiteV1, + stoneNodeOccupationV1, stoneExpeditionV1, stoneExpeditionIdempotencyV1, + stoneExpeditionScheduleV1, realmWaterRevisionV1, realmWorkerSystemV1, castleWorkerV1, + workerAssignmentV1, workerNodeOccupationV1, workerCommandIdempotencyV1, workerAssignmentScheduleV1, +}); + +export const runWorkerAssignmentScheduleV1 = db.reducer( + { name: 'run_worker_assignment_schedule_v_1' }, + { arg: workerAssignmentScheduleV1.rowType }, + () => {}, +); + +export const runGoldExpeditionScheduleV1 = db.reducer( + { name: 'run_gold_expedition_schedule_v_1' }, + { arg: goldExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runGoldExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = goldExpeditionErrorCode(error); throw new SenderError(code ?? 'GOLD_SCHEDULE_ERROR'); } + }, +); +export const runFoodExpeditionScheduleV1 = db.reducer( + { name: 'run_food_expedition_schedule_v_1' }, + { arg: foodExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runFoodExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = foodExpeditionErrorCode(error); throw new SenderError(code ?? 'FOOD_SCHEDULE_ERROR'); } + }, +); +export const runWoodExpeditionScheduleV1 = db.reducer( + { name: 'run_wood_expedition_schedule_v_1' }, + { arg: woodExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runWoodExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = woodExpeditionErrorCode(error); throw new SenderError(code ?? 'WOOD_SCHEDULE_ERROR'); } + }, +); +export const runStoneExpeditionScheduleV1 = db.reducer( + { name: 'run_stone_expedition_schedule_v_1' }, + { arg: stoneExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runStoneExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = stoneExpeditionErrorCode(error); throw new SenderError(code ?? 'STONE_SCHEDULE_ERROR'); } + }, +); + +/** Auth-neutral identity fixture; SQL identity literals are issuer-bound. */ +export const fixtureInsertPlayerOwnershipV9 = db.reducer( + { name: 'fixture_insert_player_ownership_v9' }, + { fid: t.u64() }, + (ctx, { fid }) => { + if (ctx.db.playerOwnershipV2.fid.find(fid) !== null) throw new Error('FIXTURE_OWNERSHIP_EXISTS'); + ctx.db.playerOwnershipV2.insert({ fid, identity: ctx.sender }); + }, +); + +/** Bounded identity-row assertion; SQL cannot read identity columns across issuers. */ +export const fixtureAssertPlayerOwnershipV9 = db.reducer( + { name: 'fixture_assert_player_ownership_v9' }, + { fid: t.u64(), expectedCount: t.u64() }, + (ctx, { fid, expectedCount }) => { + if (ctx.db.playerOwnershipV2.count() !== expectedCount) throw new Error('FIXTURE_OWNERSHIP_COUNT_INVALID'); + if (expectedCount === 0n) { + if (ctx.db.playerOwnershipV2.fid.find(fid) !== null) throw new Error('FIXTURE_OWNERSHIP_UNEXPECTED'); + return; + } + if (expectedCount !== 1n || ctx.db.playerOwnershipV2.fid.find(fid) === null) { + throw new Error('FIXTURE_OWNERSHIP_ROW_INVALID'); + } + }, +); + +/** Preserve the v9 Water sentinel wire unchanged in the v10 fixture. */ +export const fixtureSeedWaterSentinelV9 = db.reducer( + { name: 'fixture_seed_water_sentinel_v9' }, + ctx => { + if ( + ctx.db.realmWaterLayoutV1.count() !== 0n + || ctx.db.realmWaterBodyV1.count() !== 0n + || ctx.db.realmWaterCellV1.count() !== 0n + || ctx.db.realmEnvironmentV1.count() !== 0n + ) throw new Error('FIXTURE_WATER_NOT_EMPTY'); + const realmId = 'MIGRATION_WATER_SENTINEL'; + const bodyId = 'migration-water-body'; + ctx.db.realmWaterLayoutV1.insert({ + realmId, + layoutVersion: 1, + policyVersion: 'migration-water-sentinel-v1', + generationVersion: 3, + canonicalLandCellCount: 10_000, + oceanCellCount: 1, + lakeCellCount: 0, + lakeBodyCount: 0, + riverCount: 0, + riverCellCount: 0, + seaLevelMilli: 0, + seaLevelPolicyVersion: 'migration-water-sentinel-v1', + fogStartDepthCells: 1, + fogFullDepthCells: 2, + hiddenBufferCells: 1, + layoutDigest: '0'.repeat(64), + sourceCommit: '0'.repeat(40), + activated: false, + seededAt: ctx.timestamp, + activatedAt: undefined, + }); + ctx.db.realmWaterBodyV1.insert({ + bodyId, + realmId, + regime: 'ocean', + cellCount: 1, + sourceCellKey: 'migration-water-cell', + mouthCellKey: 'migration-water-cell', + surfaceLevelMilli: 0, + flowDirectionXQ15: 0, + flowDirectionZQ15: 0, + wavePreset: 'migration', + ordinal: 0, + seed: 0, + generationVersion: 3, + layoutVersion: 1, + }); + ctx.db.realmWaterCellV1.insert({ + cellKey: 'migration-water-cell', + realmId, + q: 0, + r: 0, + regime: 'ocean', + bodyId, + depthCells: 1, + elevationMilli: 0, + surfaceLevelMilli: 0, + ring: 0, + s: 0, + underlyingTileKey: undefined, + riverOrdinal: undefined, + riverOrder: undefined, + downstreamWaterCellKey: undefined, + flowAccumulation: 0, + depthClass: 1, + oceanDepth: 1, + bankSeed: 0, + generationVersion: 3, + fogBand: 'clear', + layoutVersion: 1, + }); + ctx.db.realmEnvironmentV1.insert({ + realmId, + environmentEpoch: 1n, + waterLayoutVersion: 1, + seaLevelMilli: 0, + sunDirectionXMicro: 0, + sunDirectionYMicro: 1_000_000, + sunDirectionZMicro: 0, + updatedAt: ctx.timestamp, + }); + }, +); + +/** One typed row per v10 Stone table for the next additive migration. */ +export const fixtureSeedStoneSentinelV10 = db.reducer( + { name: 'fixture_seed_stone_sentinel_v10' }, + ctx => { + if ( + ctx.db.stoneSiteV1.count() !== 0n + || ctx.db.stoneNodeOccupationV1.count() !== 0n + || ctx.db.stoneExpeditionV1.count() !== 0n + || ctx.db.stoneExpeditionIdempotencyV1.count() !== 0n + || ctx.db.stoneExpeditionScheduleV1.count() !== 0n + ) throw new Error('FIXTURE_STONE_NOT_EMPTY'); + const startedAtMicros = ctx.timestamp.microsSinceUnixEpoch; + const arrivesAtMicros = startedAtMicros + 7n * 24n * 60n * 60n * 1_000_000n; + const gatheringEndsAtMicros = arrivesAtMicros + 24n * 60n * 60n * 1_000_000n; + const returnsAtMicros = gatheringEndsAtMicros + 24n * 60n * 60n * 1_000_000n; + const siteId = 'migration-stone-site'; + const expeditionId = 'migration-stone-expedition'; + const originCastleId = 991_001n; + const fid = 991_002n; + ctx.db.stoneSiteV1.insert({ siteId, q: 1, r: -1, tier: 1, active: true }); + ctx.db.stoneNodeOccupationV1.insert({ + siteId, + originCastleId, + phase: 'outbound', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnsAtMicros, + }); + ctx.db.stoneExpeditionV1.insert({ + expeditionId, + fid, + originCastleId, + siteId, + phase: 'outbound', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnsAtMicros, + settledThroughMicros: startedAtMicros, + accruedStone: 0n, + creditedStone: 0n, + policyVersion: 'migration-stone-sentinel-v1', + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.stoneExpeditionIdempotencyV1.insert({ + requestKey: 'migration-stone-sentinel-request-0001', + fid, + siteId, + expeditionId, + createdAt: ctx.timestamp, + }); + ctx.db.stoneExpeditionScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(arrivesAtMicros), + originCastleId, + siteId, + stage: 'arrival', + }); + }, +); + +/** Typed v11 sentinel used only to prove rollback refusal and row survival. */ +export const fixtureSeedWaterRevisionSentinelV11 = db.reducer( + { name: 'fixture_seed_water_revision_sentinel_v11' }, + ctx => { + if (ctx.db.realmWaterRevisionV1.count() !== 0n) { + throw new Error('FIXTURE_WATER_REVISION_NOT_EMPTY'); + } + ctx.db.realmWaterRevisionV1.insert({ + realmId: 'MIGRATION_WATER_SENTINEL', + revisionVersion: 2, + policyVersion: 'migration-water-revision-sentinel-v1', + baseLayoutVersion: 1, + baseLayoutDigest: '0'.repeat(64), + oceanBodyCount: 1, + riverBodyCount: 0, + enabledBodyCount: 1, + oceanCellCount: 1, + riverCellCount: 0, + enabledCellCount: 1, + lakeBodyCount: 0, + lakeCellCount: 0, + riverWidthCells: 1, + navigationFogBoundaryDepthCells: 2, + hiddenBufferCells: 1, + revisionDigest: '1'.repeat(64), + sourceCommit: '1'.repeat(40), + activated: false, + seededAt: ctx.timestamp, + activatedAt: undefined, + }); + }, +); + +const FIXTURE_RESOURCE_QUANTUM_MICROS = 600_000_000n; +const FIXTURE_RESOURCE_POLICY_VERSION = 'genesis-resource-yield-v1'; + +export const fixtureRewindResourceOneQuantum = db.reducer( + { name: 'fixture_rewind_resource_one_quantum' }, + { fid: t.u64() }, + (ctx, { fid }) => { + const row = ctx.db.resourceAccountV1.fid.find(fid); + if ( + row === null + || row.policyVersion !== FIXTURE_RESOURCE_POLICY_VERSION + || row.revision !== 0n + || row.food !== 0n + || row.wood !== 0n + || row.stone !== 0n + || row.gold !== 0n + || row.settledThroughMicros < FIXTURE_RESOURCE_QUANTUM_MICROS + ) throw new Error('FIXTURE_RESOURCE_STATE_INVALID'); + const rewoundMicros = row.settledThroughMicros - FIXTURE_RESOURCE_QUANTUM_MICROS; + ctx.db.resourceAccountV1.fid.update({ + ...row, + settledThroughMicros: rewoundMicros, + createdAt: new Timestamp(rewoundMicros), + updatedAt: ctx.timestamp, + }); + }, +); + +/** Populates every v12 table with bounded, auth-neutral rows for migration proof. */ +export const fixtureSeedGenericWorkerSentinelV12 = db.reducer( + { name: 'fixture_seed_generic_worker_sentinel_v12' }, + ctx => { + if ( + ctx.db.realmWorkerSystemV1.count() !== 0n + || ctx.db.castleWorkerV1.count() !== 0n + || ctx.db.workerAssignmentV1.count() !== 0n + || ctx.db.workerNodeOccupationV1.count() !== 0n + || ctx.db.workerCommandIdempotencyV1.count() !== 0n + || ctx.db.workerAssignmentScheduleV1.count() !== 0n + ) throw new Error('FIXTURE_WORKER_NOT_EMPTY'); + const castleId = 991_101n; + const fid = 991_102n; + const startedAtMicros = ctx.timestamp.microsSinceUnixEpoch; + const arrivesAtMicros = startedAtMicros + 30_000_000n; + const gatheringEndsAtMicros = arrivesAtMicros + 86_400_000_000n; + const returnsAtMicros = gatheringEndsAtMicros + 30_000_000n; + const assignmentId = 'migration-worker-assignment-0001'; + const workerId = 'genesis-001-castle-991101-worker-01'; + const siteId = 'migration-worker-site'; + ctx.db.realmWorkerSystemV1.insert({ + realmId: 'GENESIS_001', + policyVersion: 'genesis-001-castle-workers-v1', + workersPerCastle: 4, + expectedCastleCount: 1, + expectedWorkerCount: 4, + rosterDigest: 'migration-worker-roster-digest', + mode: 'staged', + legacyDrainRequired: true, + createdAt: ctx.timestamp, + activatedAt: undefined, + }); + for (let ordinal = 1; ordinal <= 4; ordinal += 1) { + ctx.db.castleWorkerV1.insert({ + workerId: `genesis-001-castle-991101-worker-0${ordinal}`, + originCastleId: castleId, + ordinal, + status: ordinal === 1 ? 'gathering' : 'idle', + resourceKind: ordinal === 1 ? 'stone' : undefined, + siteId: ordinal === 1 ? siteId : undefined, + startedAtMicros: ordinal === 1 ? startedAtMicros : undefined, + arrivesAtMicros: ordinal === 1 ? arrivesAtMicros : undefined, + gatheringEndsAtMicros: ordinal === 1 ? gatheringEndsAtMicros : undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: ordinal === 1 ? returnsAtMicros : undefined, + routeSteps: ordinal === 1 ? 1 : undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: 0, + revision: 0n, + }); + } + ctx.db.workerAssignmentV1.insert({ + assignmentId, + workerId, + fid, + originCastleId: castleId, + resourceKind: 'stone', + siteId, + phase: 'gathering', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnStartedAtMicros: undefined, + returnsAtMicros, + routeSteps: 1, + returnStartProgressBasisPoints: 0, + settledThroughMicros: arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + timelineRevision: 0, + policyVersion: 'genesis-001-castle-workers-v1', + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.workerNodeOccupationV1.insert({ + nodeKey: 'stone:migration-worker-site', + resourceKind: 'stone', + siteId, + workerId, + workerOrdinal: 1, + originCastleId: castleId, + phase: 'gathering', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + timelineRevision: 0, + }); + ctx.db.workerCommandIdempotencyV1.insert({ + requestKey: '991102:migration-worker-request-0001', + fid, + workerId, + commandKind: 'dispatch', + resourceKind: 'stone', + siteId, + assignmentId, + resultRevision: 0n, + createdAt: ctx.timestamp, + }); + ctx.db.workerAssignmentScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(gatheringEndsAtMicros), + assignmentId, + workerId, + timelineRevision: 0, + stage: 'gathering-expiry', + }); + }, +); + +export default db; diff --git a/spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json b/spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json new file mode 100644 index 00000000..ff6a5945 --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "target": "ESNext", + "lib": ["ES2021", "dom"], + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/spacetimedb/pnpm-lock.yaml b/spacetimedb/pnpm-lock.yaml index e3e7575d..f39761b7 100644 --- a/spacetimedb/pnpm-lock.yaml +++ b/spacetimedb/pnpm-lock.yaml @@ -39,6 +39,16 @@ importers: specifier: 5.6.3 version: 5.6.3 + migration-fixtures/additive-v12-schema: + dependencies: + spacetimedb: + specifier: 2.6.1 + version: 2.6.1 + devDependencies: + typescript: + specifier: 5.6.3 + version: 5.6.3 + migration-fixtures/additive-v2-schema: dependencies: spacetimedb: diff --git a/spacetimedb/src/castleWorkerAuthority.ts b/spacetimedb/src/castleWorkerAuthority.ts new file mode 100644 index 00000000..62c2ca8a --- /dev/null +++ b/spacetimedb/src/castleWorkerAuthority.ts @@ -0,0 +1,1172 @@ +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; +import { ScheduleAt } from 'spacetimedb'; + +import { assertGenesisResourceForFid } from './resourceAuthority'; +import { RESOURCE_BALANCE_CAP } from './resourceAuthorityPolicy'; +import { + activeExpeditionResourceReservations, + planResourceSettlementForActiveExpeditionReservations, +} from './resourceExpeditionReservationAuthority'; +import type warpkeep from './schema'; +import { + CASTLE_WORKER_POLICY_VERSION, + CASTLE_WORKER_TRAVEL_MICROS_PER_STEP, + CASTLE_WORKERS_PER_CASTLE, + CastleWorkerPolicyError, + type CastleWorkerPhase, + type CastleWorkerSiteShape, + planCastleWorkerAccrual, + planCastleWorkerTimeline, + rosterDigestForCastleIds, + workerAssignmentStateIsConsistent, + workerIdForCastle, + workerResourcePolicy, + assertCastleWorkerId, + assertWorkerCommandKey, + canonicalWorkerRouteSteps, +} from './castleWorkerPolicy'; +import { + recallAllReplayMatches, + recallAllWorkersReceipt, + recallReplayMatches, + recallWorkerReceipt, + takeBoundedRows, + workerCastleOwnershipMatches, + workerCommandReceiptShapeIsValid, + workerScheduleMatchesAssignment, +} from './castleWorkerCommandPolicy'; +import { + assertCastleWorkerRoster, + castleWorkerPublicStateIsConsistent, + workerSystemRowIsStagedOrActive, +} from './castleWorkerRoster'; +import { + CANONICAL_REALM, + canonicalMetaForKey, + canonicalTileForKey, + matchesCanonicalTerrain, + matchesCanonicalWorldMeta, +} from './world'; + +type WarpkeepReducerContext = ReducerCtx>; +type CastleRow = NonNullable>; +type WorkerRow = NonNullable>; +type AssignmentRow = NonNullable>; +type ScheduleRow = NonNullable>; +type WorkerReceiptRow = NonNullable>; +type ResourceAccountRow = NonNullable>; + +export const WORKER_SCHEDULE_STAGE_ARRIVAL = 'arrival'; +export const WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY = 'gathering-expiry'; +export const WORKER_SCHEDULE_STAGE_RETURN_COMPLETE = 'return-complete'; +const WORKER_SYSTEM_REALM_ID = CANONICAL_REALM.realmId; +const WORKER_TIMELINE_MAX = 0xffff_ffff; +export const WORKER_IDEMPOTENCY_RECEIPTS_PER_FID = 64; +const BOUNDED_WORKER_ERROR_CODE = /^[A-Z][A-Z0-9_]{0,63}$/; + +export class CastleWorkerAuthorityError extends Error { + constructor(readonly code: string) { + super(code); + this.name = 'CastleWorkerAuthorityError'; + } +} + +function fail(code: string): never { + throw new CastleWorkerAuthorityError(code); +} + +function safeNextU32(value: number, code: string): number { + if (!Number.isSafeInteger(value) || value < 0 || value >= WORKER_TIMELINE_MAX) fail(code); + return value + 1; +} + +function safeNextU64(value: bigint, code: string): bigint { + if (value < 0n || value >= (1n << 64n) - 1n) fail(code); + return value + 1n; +} + +function boundedRows(rows: Iterable, maximum: number, code: string): readonly Row[] { + const bounded = takeBoundedRows(rows, maximum); + if (bounded.overflow) fail(code); + return bounded.rows; +} + +function assignmentRequestKey(fid: bigint, idempotencyKey: string): string { + assertWorkerCommandKey(idempotencyKey); + return `${fid.toString()}:${idempotencyKey}`; +} + +function resourceField(kind: string): 'food' | 'wood' | 'stone' | 'gold' { + if (kind === 'food' || kind === 'wood' || kind === 'stone' || kind === 'gold') return kind; + fail('WORKER_RESOURCE_UNSUPPORTED'); +} + +function assignmentPhase(value: string): CastleWorkerPhase { + if (value === 'outbound' || value === 'gathering' || value === 'returning') return value; + fail('WORKER_PHASE_INVALID'); +} + +function systemRow(ctx: WarpkeepReducerContext) { + if (ctx.db.realmWorkerSystemV1.count() !== 1n) fail('WORKER_SYSTEM_NOT_READY'); + const row = ctx.db.realmWorkerSystemV1.realmId.find(WORKER_SYSTEM_REALM_ID); + if (row === null || !workerSystemRowIsStagedOrActive(row)) fail('WORKER_SYSTEM_NOT_READY'); + return row; +} + +function workerSettlementActive(ctx: WarpkeepReducerContext) { + const row = systemRow(ctx); + if (row.mode !== 'active') fail('WORKER_SYSTEM_STAGED'); + if (row.legacyDrainRequired) fail('WORKER_LEGACY_DRAIN_REQUIRED'); + const legacy = legacyActiveCounts(ctx); + if (legacy.expeditions !== 0n || legacy.occupations !== 0n || legacy.schedules !== 0n) { + fail('WORKER_LEGACY_DRAIN_REQUIRED'); + } + return row; +} + +function workerSystemActive(ctx: WarpkeepReducerContext) { + const row = workerSettlementActive(ctx); + const expectedCastleCount = BigInt(row.expectedCastleCount); + const expectedWorkerCount = BigInt(row.expectedWorkerCount); + if ( + expectedWorkerCount !== expectedCastleCount * BigInt(CASTLE_WORKERS_PER_CASTLE) + || ctx.db.castle.count() !== expectedCastleCount + || ctx.db.castleWorkerV1.count() !== expectedWorkerCount + || !/^[0-9a-f]{16}$/.test(row.rosterDigest) + ) fail('WORKER_ROSTER_NOT_READY'); + return row; +} + +function canonicalSiteFor( + ctx: WarpkeepReducerContext, + resourceKind: string, + siteId: string, +): CastleWorkerSiteShape { + const policy = workerResourcePolicy(resourceKind); + const canonical = policy.canonicalSiteForId(siteId); + if (canonical === undefined || !canonical.active || !policy.matchesCanonicalSite(canonical)) { + fail('WORKER_SITE_UNAVAILABLE'); + } + const stored = resourceKind === 'gold' + ? ctx.db.goldSiteV1.siteId.find(siteId) + : resourceKind === 'food' + ? ctx.db.foodSiteV1.siteId.find(siteId) + : resourceKind === 'wood' + ? ctx.db.woodSiteV1.siteId.find(siteId) + : ctx.db.stoneSiteV1.siteId.find(siteId); + if (stored === null || !policy.matchesCanonicalSite(stored)) fail('WORKER_SITE_INTEGRITY'); + const tileKey = `${canonical.q},${canonical.r}`; + const tile = ctx.db.worldTile.key.find(tileKey); + const meta = ctx.db.worldTileMetaV1.tileKey.find(tileKey); + const expectedTile = canonicalTileForKey(tileKey); + const expectedMeta = canonicalMetaForKey(tileKey); + if ( + tile === null + || meta === null + || expectedTile === undefined + || expectedMeta === undefined + || !matchesCanonicalTerrain(tile) + || !matchesCanonicalWorldMeta(meta) + || !matchesCanonicalTerrain(expectedTile) + || !matchesCanonicalWorldMeta(expectedMeta) + || !meta.passable + || meta.staticContentKind !== 'resource-capable' + || tile.q !== canonical.q + || tile.r !== canonical.r + ) fail('WORKER_SITE_WORLD_INTEGRITY'); + return canonical; +} + +function legacyOccupationAt(ctx: WarpkeepReducerContext, resourceKind: string, siteId: string): boolean { + return resourceKind === 'gold' + ? ctx.db.goldNodeOccupationV1.siteId.find(siteId) !== null + : resourceKind === 'food' + ? ctx.db.foodNodeOccupationV1.siteId.find(siteId) !== null + : resourceKind === 'wood' + ? ctx.db.woodNodeOccupationV1.siteId.find(siteId) !== null + : ctx.db.stoneNodeOccupationV1.siteId.find(siteId) !== null; +} + +function publicWorkerMatchesAssignment(worker: WorkerRow, assignment: AssignmentRow): boolean { + const expectedReturnProgress = assignment.phase === 'returning' + ? assignment.returnStartProgressBasisPoints + : undefined; + return worker.workerId === assignment.workerId + && worker.ordinal >= 1 + && worker.ordinal <= CASTLE_WORKERS_PER_CASTLE + && worker.workerId === workerIdForCastle(assignment.originCastleId, worker.ordinal) + && worker.originCastleId === assignment.originCastleId + && worker.status === assignment.phase + && worker.resourceKind === assignment.resourceKind + && worker.siteId === assignment.siteId + && worker.startedAtMicros === assignment.startedAtMicros + && worker.arrivesAtMicros === assignment.arrivesAtMicros + && worker.gatheringEndsAtMicros === assignment.gatheringEndsAtMicros + && worker.returnStartedAtMicros === assignment.returnStartedAtMicros + && worker.returnsAtMicros === assignment.returnsAtMicros + && worker.routeSteps === assignment.routeSteps + && worker.returnStartProgressBasisPoints === expectedReturnProgress + && worker.timelineRevision === assignment.timelineRevision; +} + +function occupationMatchesAssignment( + occupation: NonNullable>, + assignment: AssignmentRow, +): boolean { + // The occupation is only the outbound/gathering site lease and is deleted + // before return starts. Return chronology therefore belongs to the worker + // projection; every field that the occupation does expose is matched here. + return occupation.nodeKey === `${assignment.resourceKind}:${assignment.siteId}` + && occupation.resourceKind === assignment.resourceKind + && occupation.siteId === assignment.siteId + && occupation.workerId === assignment.workerId + && occupation.workerOrdinal >= 1 + && occupation.workerOrdinal <= CASTLE_WORKERS_PER_CASTLE + && assignment.workerId === workerIdForCastle(assignment.originCastleId, occupation.workerOrdinal) + && occupation.originCastleId === assignment.originCastleId + && assignment.phase !== 'returning' + && occupation.phase === assignment.phase + && occupation.startedAtMicros === assignment.startedAtMicros + && occupation.arrivesAtMicros === assignment.arrivesAtMicros + && occupation.gatheringEndsAtMicros === assignment.gatheringEndsAtMicros + && occupation.timelineRevision === assignment.timelineRevision; +} + +function canonicalCastleOwnershipMatches( + ctx: WarpkeepReducerContext, + fid: bigint, + castleId: bigint, +): boolean { + const castle = ctx.db.castle.castleId.find(castleId); + const account = ctx.db.resourceAccountV1.fid.find(fid); + return workerCastleOwnershipMatches({ + fid, + castleId, + castleOwnerFid: castle?.ownerFid, + accountFid: account?.fid, + accountCastleId: account?.castleId, + }); +} + +function assignmentOwnerIsCanonical(ctx: WarpkeepReducerContext, assignment: AssignmentRow): boolean { + return canonicalCastleOwnershipMatches(ctx, assignment.fid, assignment.originCastleId); +} + +function receiptOwnerIsCanonical( + ctx: WarpkeepReducerContext, + receipt: WorkerReceiptRow, + expectedCastleId?: bigint, +): boolean { + if (receipt.workerId !== undefined) { + const worker = ctx.db.castleWorkerV1.workerId.find(receipt.workerId); + return worker !== null + && worker.ordinal >= 1 + && worker.ordinal <= CASTLE_WORKERS_PER_CASTLE + && worker.workerId === workerIdForCastle(worker.originCastleId, worker.ordinal) + && (expectedCastleId === undefined || worker.originCastleId === expectedCastleId) + && canonicalCastleOwnershipMatches(ctx, receipt.fid, worker.originCastleId); + } + const castle = ctx.db.castle.ownerFid.find(receipt.fid); + return castle !== null + && (expectedCastleId === undefined || castle.castleId === expectedCastleId) + && canonicalCastleOwnershipMatches(ctx, receipt.fid, castle.castleId); +} + +function workerReceiptShapeIsValid(receipt: WorkerReceiptRow): boolean { + return workerCommandReceiptShapeIsValid(receipt); +} + +function assertAssignmentState(assignment: AssignmentRow): void { + assignmentPhase(assignment.phase); + assertCastleWorkerId(assignment.workerId); + if ( + assignment.assignmentId.length === 0 + || assignment.fid <= 0n + || assignment.originCastleId < 0n + || assignment.siteId.length === 0 + || assignment.policyVersion !== CASTLE_WORKER_POLICY_VERSION + || !workerAssignmentStateIsConsistent(assignment) + || assignment.returnStartProgressBasisPoints > 10_000 + || !Number.isSafeInteger(assignment.timelineRevision) + || assignment.timelineRevision < 0 + ) fail('WORKER_ASSIGNMENT_STATE_INVALID'); +} + +function insertSchedule( + ctx: WarpkeepReducerContext, + assignment: AssignmentRow, + stage: string, + atMicros: bigint, +): void { + ctx.db.workerAssignmentScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(atMicros), + assignmentId: assignment.assignmentId, + workerId: assignment.workerId, + timelineRevision: assignment.timelineRevision, + stage, + }); +} + +function deleteSchedulesForAssignment(ctx: WarpkeepReducerContext, assignmentId: string): void { + for (const schedule of [...ctx.db.workerAssignmentScheduleV1.byAssignment.filter(assignmentId)]) { + ctx.db.workerAssignmentScheduleV1.scheduleId.delete(schedule.scheduleId); + } +} + +function scheduleMatchesAssignment(schedule: ScheduleRow, assignment: AssignmentRow): boolean { + return workerScheduleMatchesAssignment(schedule, assignment); +} + +type CallerWorkerGraph = Readonly<{ + roster: readonly WorkerRow[]; + assignments: readonly AssignmentRow[]; +}>; + +/** + * Validate only the caller's fixed-size worker graph. Whole-realm inspection + * remains available through the admin procedures, but gameplay paths never + * iterate realm-wide tables. + */ +function assertCallerWorkerGraph( + ctx: WarpkeepReducerContext, + fid: bigint, + castleId: bigint, +): CallerWorkerGraph { + const roster = assertCastleWorkerRoster(ctx, castleId); + const rosterByWorker = new Map(roster.map(worker => [worker.workerId, worker])); + const assignments = boundedRows( + ctx.db.workerAssignmentV1.byFid.filter(fid), + CASTLE_WORKERS_PER_CASTLE, + 'WORKER_ASSIGNMENT_LIMIT', + ); + const assignmentByWorker = new Map(); + for (const assignment of assignments) { + assertAssignmentState(assignment); + const worker = rosterByWorker.get(assignment.workerId); + if ( + assignment.fid !== fid + || assignment.originCastleId !== castleId + || !assignmentOwnerIsCanonical(ctx, assignment) + || worker === undefined + || assignmentByWorker.has(assignment.workerId) + || !publicWorkerMatchesAssignment(worker, assignment) + ) fail('WORKER_ASSIGNMENT_INTEGRITY'); + assignmentByWorker.set(assignment.workerId, assignment); + } + for (const worker of roster) { + const directAssignment = ctx.db.workerAssignmentV1.workerId.find(worker.workerId); + const assignment = assignmentByWorker.get(worker.workerId); + if ( + (directAssignment === null) !== (assignment === undefined) + || (directAssignment !== null && directAssignment.assignmentId !== assignment?.assignmentId) + ) fail('WORKER_ASSIGNMENT_INTEGRITY'); + const occupations = boundedRows( + ctx.db.workerNodeOccupationV1.byWorker.filter(worker.workerId), + 1, + 'WORKER_OCCUPATION_LIMIT', + ); + const schedules = boundedRows( + ctx.db.workerAssignmentScheduleV1.byWorker.filter(worker.workerId), + 1, + 'WORKER_SCHEDULE_LIMIT', + ); + if (assignment === undefined) { + if (worker.status !== 'idle' || occupations.length !== 0 || schedules.length !== 0) { + fail('WORKER_ASSIGNMENT_INTEGRITY'); + } + continue; + } + const expectedOccupationCount = assignment.phase === 'returning' ? 0 : 1; + if ( + occupations.length !== expectedOccupationCount + || (occupations[0] !== undefined && !occupationMatchesAssignment(occupations[0], assignment)) + || schedules.length !== 1 + || !scheduleMatchesAssignment(schedules[0]!, assignment) + ) fail('WORKER_ASSIGNMENT_INTEGRITY'); + } + const receipts = boundedRows( + ctx.db.workerCommandIdempotencyV1.byFid.filter(fid), + WORKER_IDEMPOTENCY_RECEIPTS_PER_FID, + 'WORKER_IDEMPOTENCY_LIMIT', + ); + const highestRosterRevision = roster.reduce( + (highest, worker) => worker.revision > highest ? worker.revision : highest, + 0n, + ); + for (const receipt of receipts) { + const receiptWorker = receipt.workerId === undefined + ? undefined + : rosterByWorker.get(receipt.workerId); + if ( + !workerReceiptShapeIsValid(receipt) + || !receiptOwnerIsCanonical(ctx, receipt, castleId) + || (receiptWorker !== undefined && receipt.resultRevision > receiptWorker.revision) + || (receipt.workerId === undefined && receipt.resultRevision > highestRosterRevision) + ) fail('WORKER_IDEMPOTENCY_OWNER_INVALID'); + } + return Object.freeze({ roster, assignments }); +} + +function pruneWorkerIdempotencyReceipts(ctx: WarpkeepReducerContext, fid: bigint): void { + const receipts = [...boundedRows( + ctx.db.workerCommandIdempotencyV1.byFid.filter(fid), + WORKER_IDEMPOTENCY_RECEIPTS_PER_FID, + 'WORKER_IDEMPOTENCY_LIMIT', + )] + .sort((left, right) => { + const timeOrder = left.createdAt.microsSinceUnixEpoch < right.createdAt.microsSinceUnixEpoch + ? -1 + : left.createdAt.microsSinceUnixEpoch > right.createdAt.microsSinceUnixEpoch ? 1 : 0; + return timeOrder || left.requestKey.localeCompare(right.requestKey); + }); + const deleteCount = Math.max(0, receipts.length - WORKER_IDEMPOTENCY_RECEIPTS_PER_FID + 1); + for (const receipt of receipts.slice(0, deleteCount)) { + ctx.db.workerCommandIdempotencyV1.requestKey.delete(receipt.requestKey); + } +} + +function updateResourceAccount( + ctx: WarpkeepReducerContext, + resource: ResourceAccountRow, + balances: ResourceAccountRow, + passiveSettledThroughMicros: bigint, + revision: bigint, +): void { + ctx.db.resourceAccountV1.fid.update({ + ...resource, + food: balances.food, + wood: balances.wood, + stone: balances.stone, + gold: balances.gold, + settledThroughMicros: passiveSettledThroughMicros, + revision, + updatedAt: ctx.timestamp, + }); +} + +/** + * Materialize every complete worker quantum for one caller in one transaction. + * Reads use the sibling projection below and never call this writer. No + * per-minute writes occur: schedules and caller reads settle exact quanta. + */ +export function settleAllWorkerAssignmentsForFid( + ctx: WarpkeepReducerContext, + fid: bigint, + observedAtMicros = ctx.timestamp.microsSinceUnixEpoch, +): void { + const resource = assertGenesisResourceForFid(ctx, fid); + const assignments = boundedRows( + ctx.db.workerAssignmentV1.byFid.filter(fid), + CASTLE_WORKERS_PER_CASTLE, + 'WORKER_ASSIGNMENT_LIMIT', + ); + if (assignments.length > 0) workerSettlementActive(ctx); + const passive = planResourceSettlementForActiveExpeditionReservations( + ctx, + fid, + resource.account, + resource.terrainKind, + observedAtMicros, + ); + const balances = { + ...resource.account, + food: passive.balances.food, + wood: passive.balances.wood, + stone: passive.balances.stone, + gold: passive.balances.gold, + }; + let changed = passive.completedQuanta > 0n; + for (const assignment of assignments) { + assertAssignmentState(assignment); + if (assignment.fid !== fid || assignment.originCastleId !== resource.castle.castleId) fail('WORKER_OWNER_INTEGRITY'); + const plan = planCastleWorkerAccrual(assignment, observedAtMicros); + const credit = plan.accruedAmount - assignment.materializedAmount; + if (credit < 0n) fail('WORKER_MATERIALIZATION_INVALID'); + if (plan.completedQuanta === 0n && credit === 0n) continue; + const field = resourceField(assignment.resourceKind); + if (credit > RESOURCE_BALANCE_CAP - balances[field]) fail('WORKER_ACCOUNT_CAPACITY'); + balances[field] += credit; + ctx.db.workerAssignmentV1.assignmentId.update({ + ...assignment, + settledThroughMicros: plan.settledThroughMicros, + accruedAmount: plan.accruedAmount, + materializedAmount: plan.accruedAmount, + updatedAt: ctx.timestamp, + }); + changed = true; + } + if (changed) { + updateResourceAccount( + ctx, + resource.account, + balances, + passive.settledThroughMicros, + safeNextU64(resource.account.revision, 'WORKER_RESOURCE_REVISION'), + ); + } +} + +export type WorkerPrivateProjection = Readonly<{ + workerId: string; + ordinal: number; + status: string; + resourceKind: string | undefined; + siteId: string | undefined; + accruedAmount: bigint; + materializedAmount: bigint; + availableAmount: bigint; + observedAtMicros: bigint; + revision: bigint; +}>; + +export function projectMyWorkerState( + ctx: WarpkeepReducerContext, + fid: bigint, + observedAtMicros = ctx.timestamp.microsSinceUnixEpoch, +): Readonly<{ resource: ResourceAccountRow; balances: Readonly>; workers: readonly WorkerPrivateProjection[] }> { + workerSystemActive(ctx); + const resource = assertGenesisResourceForFid(ctx, fid); + const callerGraph = assertCallerWorkerGraph(ctx, fid, resource.castle.castleId); + const passive = planResourceSettlementForActiveExpeditionReservations( + ctx, + fid, + resource.account, + resource.terrainKind, + observedAtMicros, + ); + const balances = { + food: passive.balances.food, + wood: passive.balances.wood, + stone: passive.balances.stone, + gold: passive.balances.gold, + }; + const workers = [...callerGraph.roster] + .sort((left, right) => left.ordinal - right.ordinal) + .map(worker => { + const assignment = ctx.db.workerAssignmentV1.workerId.find(worker.workerId); + if (assignment === null) { + if (worker.status !== 'idle') fail('WORKER_ASSIGNMENT_MISSING'); + return Object.freeze({ + workerId: worker.workerId, + ordinal: worker.ordinal, + status: worker.status, + resourceKind: worker.resourceKind, + siteId: worker.siteId, + accruedAmount: 0n, + materializedAmount: 0n, + availableAmount: 0n, + observedAtMicros, + revision: worker.revision, + }); + } + assertAssignmentState(assignment); + if (!publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + const plan = planCastleWorkerAccrual(assignment, observedAtMicros); + const availableAmount = plan.accruedAmount - assignment.materializedAmount; + if (availableAmount < 0n) fail('WORKER_MATERIALIZATION_INVALID'); + const field = resourceField(assignment.resourceKind); + if (availableAmount > RESOURCE_BALANCE_CAP - balances[field]) fail('WORKER_ACCOUNT_CAPACITY'); + balances[field] += availableAmount; + return Object.freeze({ + workerId: worker.workerId, + ordinal: worker.ordinal, + status: worker.status, + resourceKind: worker.resourceKind, + siteId: worker.siteId, + accruedAmount: plan.accruedAmount, + materializedAmount: assignment.materializedAmount, + availableAmount, + observedAtMicros, + revision: worker.revision, + }); + }); + return Object.freeze({ resource: resource.account, balances: Object.freeze(balances), workers: Object.freeze(workers) }); +} + +function assertDispatchReservations( + ctx: WarpkeepReducerContext, + fid: bigint, + account: ResourceAccountRow, + resourceKind: string, +): void { + const policy = workerResourcePolicy(resourceKind); + const reservations = activeExpeditionResourceReservations(ctx, fid); + const field = resourceField(resourceKind); + const existingReservation = reservations[field]; + if (account[field] > RESOURCE_BALANCE_CAP || existingReservation > RESOURCE_BALANCE_CAP) fail('WORKER_ACCOUNT_STATE_INVALID'); + if (policy.gatheringTotal > RESOURCE_BALANCE_CAP - account[field] - existingReservation) { + fail('WORKER_ACCOUNT_CAPACITY'); + } +} + +export type WorkerDispatchResult = Readonly<{ assignment: AssignmentRow; idempotent: boolean }>; + +export function dispatchCastleWorker( + ctx: WarpkeepReducerContext, + input: Readonly<{ fid: bigint; castle: CastleRow; workerId: string; resourceKind: string; siteId: string; idempotencyKey: string }>, +): WorkerDispatchResult { + const requestKey = assignmentRequestKey(input.fid, input.idempotencyKey); + const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey); + if (prior !== null) { + if (prior.fid !== input.fid || prior.commandKind !== 'dispatch' || prior.workerId !== input.workerId || prior.resourceKind !== input.resourceKind || prior.siteId !== input.siteId || prior.assignmentId === undefined) fail('WORKER_IDEMPOTENCY_CONFLICT'); + if ( + !workerReceiptShapeIsValid(prior) + || !canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId) + || !receiptOwnerIsCanonical(ctx, prior, input.castle.castleId) + ) fail('WORKER_IDEMPOTENCY_OWNER_INVALID'); + const assignment = ctx.db.workerAssignmentV1.assignmentId.find(prior.assignmentId); + if ( + assignment === null + || assignment.fid !== input.fid + || assignment.workerId !== input.workerId + || assignment.resourceKind !== input.resourceKind + || assignment.siteId !== input.siteId + || assignment.originCastleId !== input.castle.castleId + || !assignmentOwnerIsCanonical(ctx, assignment) + ) fail('WORKER_IDEMPOTENCY_STALE'); + assertAssignmentState(assignment); + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) { + fail('WORKER_IDEMPOTENCY_STALE'); + } + return Object.freeze({ assignment, idempotent: true }); + } + workerSystemActive(ctx); + if (!canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId)) fail('WORKER_NOT_OWNED'); + const callerGraph = assertCallerWorkerGraph(ctx, input.fid, input.castle.castleId); + settleAllWorkerAssignmentsForFid(ctx, input.fid); + const roster = callerGraph.roster; + const worker = ctx.db.castleWorkerV1.workerId.find(input.workerId); + if (worker === null || worker.originCastleId !== input.castle.castleId || !roster.some(row => row.workerId === worker.workerId)) fail('WORKER_NOT_OWNED'); + assertCastleWorkerId(worker.workerId); + if (worker.status !== 'idle' || ctx.db.workerAssignmentV1.workerId.find(worker.workerId) !== null) fail('WORKER_NOT_IDLE'); + const site = canonicalSiteFor(ctx, input.resourceKind, input.siteId); + if (legacyOccupationAt(ctx, input.resourceKind, input.siteId)) fail('WORKER_LEGACY_SITE_OCCUPIED'); + const nodeKey = `${input.resourceKind}:${input.siteId}`; + if (ctx.db.workerNodeOccupationV1.nodeKey.find(nodeKey) !== null) fail('WORKER_SITE_OCCUPIED'); + const routeSteps = canonicalWorkerRouteSteps(input.castle, site); + if (routeSteps === undefined || routeSteps <= 0) fail('WORKER_ROUTE_INVALID'); + const resource = assertGenesisResourceForFid(ctx, input.fid); + assertDispatchReservations(ctx, input.fid, resource.account, input.resourceKind); + const timeline = planCastleWorkerTimeline(ctx.timestamp.microsSinceUnixEpoch, routeSteps); + const timelineRevision = safeNextU32(worker.timelineRevision, 'WORKER_TIMELINE_REVISION'); + const assignment = ctx.db.workerAssignmentV1.insert({ + assignmentId: ctx.newUuidV7().toString(), + workerId: worker.workerId, + fid: input.fid, + originCastleId: input.castle.castleId, + resourceKind: input.resourceKind, + siteId: input.siteId, + phase: 'outbound', + ...timeline, + returnStartedAtMicros: undefined, + routeSteps, + returnStartProgressBasisPoints: 0, + settledThroughMicros: timeline.arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + timelineRevision, + policyVersion: CASTLE_WORKER_POLICY_VERSION, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.castleWorkerV1.workerId.update({ + ...worker, + status: 'outbound', + resourceKind: input.resourceKind, + siteId: input.siteId, + startedAtMicros: assignment.startedAtMicros, + arrivesAtMicros: assignment.arrivesAtMicros, + gatheringEndsAtMicros: assignment.gatheringEndsAtMicros, + returnStartedAtMicros: undefined, + returnsAtMicros: assignment.returnsAtMicros, + routeSteps: assignment.routeSteps, + returnStartProgressBasisPoints: undefined, + timelineRevision, + revision: safeNextU64(worker.revision, 'WORKER_REVISION'), + }); + ctx.db.workerNodeOccupationV1.insert({ + nodeKey, + resourceKind: input.resourceKind, + siteId: input.siteId, + workerId: worker.workerId, + workerOrdinal: worker.ordinal, + originCastleId: input.castle.castleId, + phase: 'outbound', + startedAtMicros: assignment.startedAtMicros, + arrivesAtMicros: assignment.arrivesAtMicros, + gatheringEndsAtMicros: assignment.gatheringEndsAtMicros, + timelineRevision: assignment.timelineRevision, + }); + insertSchedule(ctx, assignment, WORKER_SCHEDULE_STAGE_ARRIVAL, assignment.arrivesAtMicros); + const updatedWorker = ctx.db.castleWorkerV1.workerId.find(worker.workerId); + if (updatedWorker === null || !publicWorkerMatchesAssignment(updatedWorker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + pruneWorkerIdempotencyReceipts(ctx, input.fid); + ctx.db.workerCommandIdempotencyV1.insert({ + requestKey, + fid: input.fid, + workerId: worker.workerId, + commandKind: 'dispatch', + resourceKind: input.resourceKind, + siteId: input.siteId, + assignmentId: assignment.assignmentId, + resultRevision: updatedWorker.revision, + createdAt: ctx.timestamp, + }); + return Object.freeze({ assignment, idempotent: false }); +} + +function progressBasisPoints(assignment: AssignmentRow, now: bigint): number { + if (now <= assignment.startedAtMicros) return 0; + if (now >= assignment.arrivesAtMicros) return 10_000; + const elapsed = now - assignment.startedAtMicros; + const duration = assignment.arrivesAtMicros - assignment.startedAtMicros; + return Number((elapsed * 10_000n) / duration); +} + +function remainingTravelMicros(assignment: AssignmentRow, progress: number): bigint { + const travel = BigInt(assignment.routeSteps) * CASTLE_WORKER_TRAVEL_MICROS_PER_STEP; + // The return path starts at the worker's current outbound position: zero + // progress is still at the castle, while 10,000 is at the node. + return (travel * BigInt(progress)) / 10_000n; +} + +function beginWorkerReturn( + ctx: WarpkeepReducerContext, + assignment: AssignmentRow, + progress: number, + now: bigint, +): AssignmentRow { + assertAssignmentState(assignment); + if (assignment.phase !== 'outbound' && assignment.phase !== 'gathering') return assignment; + const occupation = ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`); + if (occupation === null || !occupationMatchesAssignment(occupation, assignment)) fail('WORKER_OCCUPATION_INTEGRITY'); + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + const returningAtMicros = now + remainingTravelMicros(assignment, progress); + const timelineRevision = safeNextU32(assignment.timelineRevision, 'WORKER_TIMELINE_REVISION'); + const returning = { + ...assignment, + phase: 'returning', + returnStartedAtMicros: now, + returnsAtMicros: returningAtMicros, + returnStartProgressBasisPoints: progress, + timelineRevision, + updatedAt: ctx.timestamp, + }; + deleteSchedulesForAssignment(ctx, assignment.assignmentId); + ctx.db.workerNodeOccupationV1.nodeKey.delete(occupation.nodeKey); + ctx.db.workerAssignmentV1.assignmentId.update(returning); + ctx.db.castleWorkerV1.workerId.update({ + ...worker, + status: 'returning', + returnStartedAtMicros: now, + returnsAtMicros: returningAtMicros, + returnStartProgressBasisPoints: progress, + timelineRevision, + revision: safeNextU64(worker.revision, 'WORKER_REVISION'), + }); + insertSchedule(ctx, returning, WORKER_SCHEDULE_STAGE_RETURN_COMPLETE, returningAtMicros); + return returning; +} + +function completeWorkerReturn(ctx: WarpkeepReducerContext, assignment: AssignmentRow, now: bigint): void { + if (now < assignment.returnsAtMicros) return; + if (assignment.phase !== 'returning') fail('WORKER_RETURN_STATE'); + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + if (ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`) !== null) { + fail('WORKER_OCCUPATION_INTEGRITY'); + } + deleteSchedulesForAssignment(ctx, assignment.assignmentId); + ctx.db.workerAssignmentV1.assignmentId.delete(assignment.assignmentId); + ctx.db.castleWorkerV1.workerId.update({ + ...worker, + status: 'idle', + resourceKind: undefined, + siteId: undefined, + startedAtMicros: undefined, + arrivesAtMicros: undefined, + gatheringEndsAtMicros: undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: undefined, + routeSteps: undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: safeNextU32(worker.timelineRevision, 'WORKER_TIMELINE_REVISION'), + revision: safeNextU64(worker.revision, 'WORKER_REVISION'), + }); +} + +function transitionWorkerArrival(ctx: WarpkeepReducerContext, assignment: AssignmentRow, now: bigint): AssignmentRow { + if (now < assignment.arrivesAtMicros) return assignment; + if (assignment.phase !== 'outbound') return assignment; + const occupation = ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`); + if (occupation === null || !occupationMatchesAssignment(occupation, assignment)) fail('WORKER_OCCUPATION_MISSING'); + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + const timelineRevision = safeNextU32(assignment.timelineRevision, 'WORKER_TIMELINE_REVISION'); + const gathering = { ...assignment, phase: 'gathering', timelineRevision, updatedAt: ctx.timestamp }; + deleteSchedulesForAssignment(ctx, assignment.assignmentId); + ctx.db.workerAssignmentV1.assignmentId.update(gathering); + ctx.db.workerNodeOccupationV1.nodeKey.update({ ...occupation, phase: 'gathering', timelineRevision }); + ctx.db.castleWorkerV1.workerId.update({ + ...worker, + status: 'gathering', + timelineRevision, + revision: safeNextU64(worker.revision, 'WORKER_REVISION'), + }); + insertSchedule(ctx, gathering, WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY, gathering.gatheringEndsAtMicros); + return gathering; +} + +function settleAndBeginReturnAt( + ctx: WarpkeepReducerContext, + assignment: AssignmentRow, + now: bigint, + progress: number, +): AssignmentRow { + settleAllWorkerAssignmentsForFid(ctx, assignment.fid, now); + const fresh = ctx.db.workerAssignmentV1.assignmentId.find(assignment.assignmentId); + if (fresh === null) fail('WORKER_ASSIGNMENT_MISSING'); + return beginWorkerReturn(ctx, fresh, progress, now); +} + +export function runCastleWorkerSchedule(ctx: WarpkeepReducerContext, schedule: ScheduleRow): void { + const assignment = ctx.db.workerAssignmentV1.assignmentId.find(schedule.assignmentId); + if (assignment === null || !scheduleMatchesAssignment(schedule, assignment)) { + ctx.db.workerAssignmentScheduleV1.scheduleId.delete(schedule.scheduleId); + return; + } + assertAssignmentState(assignment); + const now = ctx.timestamp.microsSinceUnixEpoch; + if (schedule.stage === WORKER_SCHEDULE_STAGE_ARRIVAL) { + transitionWorkerArrival(ctx, assignment, now); + return; + } + if (schedule.stage === WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY) { + const gathering = transitionWorkerArrival(ctx, assignment, now); + if (now < gathering.gatheringEndsAtMicros || gathering.phase === 'returning') return; + settleAndBeginReturnAt(ctx, gathering, gathering.gatheringEndsAtMicros, 10_000); + return; + } + if (schedule.stage === WORKER_SCHEDULE_STAGE_RETURN_COMPLETE) { + completeWorkerReturn(ctx, assignment, now); + return; + } + ctx.db.workerAssignmentScheduleV1.scheduleId.delete(schedule.scheduleId); +} + +export function recallCastleWorker( + ctx: WarpkeepReducerContext, + input: Readonly<{ fid: bigint; castle: CastleRow; workerId: string; idempotencyKey: string }>, +): void { + const requestKey = assignmentRequestKey(input.fid, input.idempotencyKey); + const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey); + if (prior !== null) { + if (prior.fid !== input.fid || prior.commandKind !== 'recall' || prior.workerId !== input.workerId) fail('WORKER_IDEMPOTENCY_CONFLICT'); + if ( + !recallReplayMatches(prior, input.fid, input.workerId) + || !canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId) + || !receiptOwnerIsCanonical(ctx, prior, input.castle.castleId) + ) fail('WORKER_IDEMPOTENCY_OWNER_INVALID'); + return; + } + workerSystemActive(ctx); + if (!canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId)) fail('WORKER_NOT_OWNED'); + const callerGraph = assertCallerWorkerGraph(ctx, input.fid, input.castle.castleId); + const worker = callerGraph.roster.find(row => row.workerId === input.workerId); + if (worker === undefined) fail('WORKER_NOT_OWNED'); + let assignment = ctx.db.workerAssignmentV1.workerId.find(worker.workerId); + if (assignment === null) { + if (worker.status !== 'idle') fail('WORKER_ASSIGNMENT_MISSING'); + } else if (assignment.phase !== 'returning') { + settleAllWorkerAssignmentsForFid(ctx, input.fid); + assignment = ctx.db.workerAssignmentV1.workerId.find(worker.workerId); + if (assignment === null || assignment.fid !== input.fid) fail('WORKER_ASSIGNMENT_MISSING'); + const now = ctx.timestamp.microsSinceUnixEpoch; + const returnStartedAtMicros = now < assignment.gatheringEndsAtMicros + ? now + : assignment.gatheringEndsAtMicros; + const progress = returnStartedAtMicros < assignment.arrivesAtMicros + ? progressBasisPoints(assignment, returnStartedAtMicros) + : 10_000; + assignment = beginWorkerReturn(ctx, assignment, progress, returnStartedAtMicros); + } + const updatedWorker = ctx.db.castleWorkerV1.workerId.find(worker.workerId); + if ( + updatedWorker === null + || (assignment !== null && !publicWorkerMatchesAssignment(updatedWorker, assignment)) + ) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + pruneWorkerIdempotencyReceipts(ctx, input.fid); + const receipt = recallWorkerReceipt( + requestKey, + input.fid, + worker.workerId, + updatedWorker.revision, + assignment === null ? undefined : { + resourceKind: assignment.resourceKind, + siteId: assignment.siteId, + assignmentId: assignment.assignmentId, + }, + ); + ctx.db.workerCommandIdempotencyV1.insert({ + ...receipt, + createdAt: ctx.timestamp, + }); +} + +export function recallAllCastleWorkers( + ctx: WarpkeepReducerContext, + input: Readonly<{ fid: bigint; castle: CastleRow; idempotencyKey: string }>, +): void { + const requestKey = assignmentRequestKey(input.fid, input.idempotencyKey); + const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey); + if (prior !== null) { + if (prior.fid !== input.fid || prior.commandKind !== 'recall-all' || prior.workerId !== undefined) fail('WORKER_IDEMPOTENCY_CONFLICT'); + if ( + !recallAllReplayMatches(prior, input.fid) + || !canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId) + || !receiptOwnerIsCanonical(ctx, prior, input.castle.castleId) + ) fail('WORKER_IDEMPOTENCY_OWNER_INVALID'); + return; + } + workerSystemActive(ctx); + if (!canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId)) fail('WORKER_NOT_OWNED'); + const callerGraph = assertCallerWorkerGraph(ctx, input.fid, input.castle.castleId); + const roster = [...callerGraph.roster].sort((left, right) => left.ordinal - right.ordinal); + if (callerGraph.assignments.some(assignment => assignment.phase !== 'returning')) { + settleAllWorkerAssignmentsForFid(ctx, input.fid); + } + const now = ctx.timestamp.microsSinceUnixEpoch; + let lastAssignmentId: string | undefined; + let resultRevision = roster.reduce( + (highest, worker) => worker.revision > highest ? worker.revision : highest, + 0n, + ); + for (const worker of roster) { + const fresh = ctx.db.castleWorkerV1.workerId.find(worker.workerId); + if (fresh === null || fresh.originCastleId !== input.castle.castleId) fail('WORKER_ROSTER_INTEGRITY'); + const assignment = ctx.db.workerAssignmentV1.workerId.find(fresh.workerId); + if (assignment === null) { + if (fresh.status !== 'idle') fail('WORKER_ASSIGNMENT_INTEGRITY'); + continue; + } + if (assignment.fid !== input.fid || !publicWorkerMatchesAssignment(fresh, assignment)) fail('WORKER_ASSIGNMENT_INTEGRITY'); + if (assignment.phase === 'returning') continue; + const returnStartedAtMicros = now < assignment.gatheringEndsAtMicros + ? now + : assignment.gatheringEndsAtMicros; + const progress = returnStartedAtMicros < assignment.arrivesAtMicros + ? progressBasisPoints(assignment, returnStartedAtMicros) + : 10_000; + const returning = beginWorkerReturn(ctx, assignment, progress, returnStartedAtMicros); + const updatedWorker = ctx.db.castleWorkerV1.workerId.find(fresh.workerId); + if (updatedWorker === null || !publicWorkerMatchesAssignment(updatedWorker, returning)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + lastAssignmentId = returning.assignmentId; + if (updatedWorker.revision > resultRevision) resultRevision = updatedWorker.revision; + } + pruneWorkerIdempotencyReceipts(ctx, input.fid); + const receipt = recallAllWorkersReceipt( + requestKey, + input.fid, + resultRevision, + lastAssignmentId, + ); + ctx.db.workerCommandIdempotencyV1.insert({ + ...receipt, + createdAt: ctx.timestamp, + }); +} + +export type WorkerGraphAggregate = Readonly<{ + systemRows: bigint; + mode: string; + systemConfigValid: boolean; + legacyDrainRequired: boolean; + expectedCastleCount: bigint; + expectedWorkerCount: bigint; + actualWorkerCount: bigint; + expectedCountsMatch: boolean; + rosterDigestMatches: boolean; + castlesMissingWorkers: bigint; + castlesWithExtraWorkers: bigint; + duplicateOrdinals: bigint; + malformedWorkerIds: bigint; + invalidWorkerStates: bigint; + idleWorkers: bigint; + outboundWorkers: bigint; + gatheringWorkers: bigint; + returningWorkers: bigint; + assignments: bigint; + occupations: bigint; + schedules: bigint; + orphanWorkers: bigint; + orphanAssignments: bigint; + assignmentsMissingOccupation: bigint; + assignmentsWithoutSingleSchedule: bigint; + orphanOccupations: bigint; + orphanSchedules: bigint; + invalidSchedules: bigint; + assignmentPublicMismatches: bigint; + occupationSiteMismatches: bigint; + invalidAssignments: bigint; + idempotencyReceipts: bigint; + invalidIdempotencyReceipts: bigint; + idempotencyOverflowFids: bigint; + legacyExpeditions: bigint; + legacyOccupations: bigint; + legacySchedules: bigint; + rosterDigest: string; + rosterDigestExpected: string; +}>; + +function legacyActiveCounts(ctx: WarpkeepReducerContext): Readonly<{ expeditions: bigint; occupations: bigint; schedules: bigint }> { + return Object.freeze({ + expeditions: ctx.db.goldExpeditionV1.count() + ctx.db.foodExpeditionV1.count() + ctx.db.woodExpeditionV1.count() + ctx.db.stoneExpeditionV1.count(), + occupations: ctx.db.goldNodeOccupationV1.count() + ctx.db.foodNodeOccupationV1.count() + ctx.db.woodNodeOccupationV1.count() + ctx.db.stoneNodeOccupationV1.count(), + schedules: ctx.db.goldExpeditionScheduleV1.count() + ctx.db.foodExpeditionScheduleV1.count() + ctx.db.woodExpeditionScheduleV1.count() + ctx.db.stoneExpeditionScheduleV1.count(), + }); +} + +export function inspectCastleWorkerGraph(ctx: WarpkeepReducerContext): WorkerGraphAggregate { + const system = ctx.db.realmWorkerSystemV1.realmId.find(WORKER_SYSTEM_REALM_ID); + const castles = [...ctx.db.castle.iter()].sort((left, right) => left.castleId < right.castleId ? -1 : left.castleId > right.castleId ? 1 : 0); + let castlesMissingWorkers = 0n; + let castlesWithExtraWorkers = 0n; + let duplicateOrdinals = 0n; + let malformedWorkerIds = 0n; + let invalidWorkerStates = 0n; + let orphanWorkers = 0n; + let idleWorkers = 0n; + let outboundWorkers = 0n; + let gatheringWorkers = 0n; + let returningWorkers = 0n; + for (const castle of castles) { + const rows = [...ctx.db.castleWorkerV1.byOriginCastle.filter(castle.castleId)]; + if (rows.length < CASTLE_WORKERS_PER_CASTLE) castlesMissingWorkers += 1n; + if (rows.length > CASTLE_WORKERS_PER_CASTLE) castlesWithExtraWorkers += 1n; + const ordinals = new Set(); + for (const row of rows) { + try { assertCastleWorkerId(row.workerId); } catch { malformedWorkerIds += 1n; } + if (!castleWorkerPublicStateIsConsistent(row)) invalidWorkerStates += 1n; + if (ordinals.has(row.ordinal)) duplicateOrdinals += 1n; + ordinals.add(row.ordinal); + if (row.status === 'idle') idleWorkers += 1n; + if (row.status === 'outbound') outboundWorkers += 1n; + if (row.status === 'gathering') gatheringWorkers += 1n; + if (row.status === 'returning') returningWorkers += 1n; + } + } + for (const row of ctx.db.castleWorkerV1.iter()) { + if (ctx.db.castle.castleId.find(row.originCastleId) === null) orphanWorkers += 1n; + } + let orphanAssignments = 0n; + let assignmentPublicMismatches = 0n; + let invalidAssignments = 0n; + let assignmentsMissingOccupation = 0n; + let assignmentsWithoutSingleSchedule = 0n; + let occupationSiteMismatches = 0n; + for (const assignment of ctx.db.workerAssignmentV1.iter()) { + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null) orphanAssignments += 1n; + else if (!publicWorkerMatchesAssignment(worker, assignment)) assignmentPublicMismatches += 1n; + try { + assertAssignmentState(assignment); + if (!assignmentOwnerIsCanonical(ctx, assignment)) fail('WORKER_OWNER_INTEGRITY'); + } catch { + invalidAssignments += 1n; + } + const occupation = ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`); + if (assignment.phase === 'returning') { + if (occupation !== null) occupationSiteMismatches += 1n; + } else if (occupation === null || !occupationMatchesAssignment(occupation, assignment)) { + assignmentsMissingOccupation += 1n; + } + const schedules = [...ctx.db.workerAssignmentScheduleV1.byAssignment.filter(assignment.assignmentId)]; + if (schedules.length !== 1 || !schedules.every(schedule => scheduleMatchesAssignment(schedule, assignment))) { + assignmentsWithoutSingleSchedule += 1n; + } + } + let orphanOccupations = 0n; + for (const occupation of ctx.db.workerNodeOccupationV1.iter()) { + const assignment = ctx.db.workerAssignmentV1.workerId.find(occupation.workerId); + if (assignment === null) orphanOccupations += 1n; + if (occupation.nodeKey !== `${occupation.resourceKind}:${occupation.siteId}`) occupationSiteMismatches += 1n; + if (assignment !== null && !occupationMatchesAssignment(occupation, assignment)) occupationSiteMismatches += 1n; + } + let orphanSchedules = 0n; + let invalidSchedules = 0n; + for (const schedule of ctx.db.workerAssignmentScheduleV1.iter()) { + const assignment = ctx.db.workerAssignmentV1.assignmentId.find(schedule.assignmentId); + if (assignment === null) orphanSchedules += 1n; + else if (!scheduleMatchesAssignment(schedule, assignment)) invalidSchedules += 1n; + } + const receiptsPerFid = new Map(); + let invalidIdempotencyReceipts = 0n; + for (const receipt of ctx.db.workerCommandIdempotencyV1.iter()) { + receiptsPerFid.set(receipt.fid, (receiptsPerFid.get(receipt.fid) ?? 0) + 1); + if ( + !workerReceiptShapeIsValid(receipt) + || !receiptOwnerIsCanonical(ctx, receipt) + ) invalidIdempotencyReceipts += 1n; + } + const idempotencyOverflowFids = BigInt([...receiptsPerFid.values()] + .filter(count => count > WORKER_IDEMPOTENCY_RECEIPTS_PER_FID).length); + const legacy = legacyActiveCounts(ctx); + const castleIds = castles.map(castle => castle.castleId); + const expectedWorkerCount = BigInt(castleIds.length * CASTLE_WORKERS_PER_CASTLE); + const expectedRosterDigest = rosterDigestForCastleIds(castleIds); + return Object.freeze({ + systemRows: ctx.db.realmWorkerSystemV1.count(), + mode: system?.mode ?? 'absent', + systemConfigValid: system !== null && workerSystemRowIsStagedOrActive(system), + legacyDrainRequired: system?.legacyDrainRequired ?? true, + expectedCastleCount: BigInt(system?.expectedCastleCount ?? 0), + expectedWorkerCount: BigInt(system?.expectedWorkerCount ?? 0), + actualWorkerCount: ctx.db.castleWorkerV1.count(), + expectedCountsMatch: system !== null + && BigInt(system.expectedCastleCount) === BigInt(castleIds.length) + && BigInt(system.expectedWorkerCount) === expectedWorkerCount + && ctx.db.castleWorkerV1.count() === expectedWorkerCount, + rosterDigestMatches: system !== null && system.rosterDigest === expectedRosterDigest, + castlesMissingWorkers, + castlesWithExtraWorkers, + duplicateOrdinals, + malformedWorkerIds, + invalidWorkerStates, + idleWorkers, + outboundWorkers, + gatheringWorkers, + returningWorkers, + assignments: ctx.db.workerAssignmentV1.count(), + occupations: ctx.db.workerNodeOccupationV1.count(), + schedules: ctx.db.workerAssignmentScheduleV1.count(), + orphanWorkers, + orphanAssignments, + assignmentsMissingOccupation, + assignmentsWithoutSingleSchedule, + orphanOccupations, + orphanSchedules, + invalidSchedules, + assignmentPublicMismatches, + occupationSiteMismatches, + invalidAssignments, + idempotencyReceipts: ctx.db.workerCommandIdempotencyV1.count(), + invalidIdempotencyReceipts, + idempotencyOverflowFids, + legacyExpeditions: legacy.expeditions, + legacyOccupations: legacy.occupations, + legacySchedules: legacy.schedules, + rosterDigest: system?.rosterDigest ?? '', + rosterDigestExpected: expectedRosterDigest, + }); +} + +export function castleWorkerErrorCode(error: unknown): string | undefined { + if ( + (error instanceof CastleWorkerAuthorityError || error instanceof CastleWorkerPolicyError) + && BOUNDED_WORKER_ERROR_CODE.test(error.code) + ) return error.code; + return undefined; +} diff --git a/spacetimedb/src/castleWorkerCommandPolicy.ts b/spacetimedb/src/castleWorkerCommandPolicy.ts new file mode 100644 index 00000000..33949050 --- /dev/null +++ b/spacetimedb/src/castleWorkerCommandPolicy.ts @@ -0,0 +1,186 @@ +import { assertWorkerCommandKey } from './castleWorkerPolicy'; + +export type WorkerCommandReceiptView = Readonly<{ + requestKey: string; + fid: bigint; + workerId: string | undefined; + commandKind: string; + resourceKind: string | undefined; + siteId: string | undefined; + assignmentId: string | undefined; + resultRevision: bigint; +}>; + +export type WorkerRecallCorrelation = Readonly<{ + resourceKind: string; + siteId: string; + assignmentId: string; +}>; + +export function workerCastleOwnershipMatches(input: Readonly<{ + fid: bigint; + castleId: bigint; + castleOwnerFid: bigint | undefined; + accountFid: bigint | undefined; + accountCastleId: bigint | undefined; +}>): boolean { + return input.castleOwnerFid === input.fid + && input.accountFid === input.fid + && input.accountCastleId === input.castleId; +} + +/** Consume no more than maximum plus the one row needed to prove overflow. */ +export function takeBoundedRows( + rows: Iterable, + maximum: number, +): Readonly<{ rows: readonly Row[]; overflow: boolean }> { + if (!Number.isSafeInteger(maximum) || maximum < 0) { + throw new Error('WORKER_BOUND_INVALID'); + } + const bounded: Row[] = []; + for (const row of rows) { + if (bounded.length >= maximum) { + return Object.freeze({ rows: Object.freeze(bounded), overflow: true }); + } + bounded.push(row); + } + return Object.freeze({ rows: Object.freeze(bounded), overflow: false }); +} + +export function workerCommandReceiptShapeIsValid(receipt: WorkerCommandReceiptView): boolean { + const separator = receipt.requestKey.indexOf(':'); + if ( + separator <= 0 + || receipt.requestKey.slice(0, separator) !== receipt.fid.toString() + || receipt.resultRevision < 0n + ) return false; + try { + assertWorkerCommandKey(receipt.requestKey.slice(separator + 1)); + } catch { + return false; + } + const correlated = receipt.resourceKind !== undefined + && ['gold', 'food', 'wood', 'stone'].includes(receipt.resourceKind) + && receipt.siteId !== undefined + && receipt.siteId.length > 0 + && receipt.assignmentId !== undefined + && receipt.assignmentId.length > 0; + if (receipt.commandKind === 'dispatch') { + return receipt.workerId !== undefined && correlated; + } + if (receipt.commandKind === 'recall') { + const noOp = receipt.resourceKind === undefined + && receipt.siteId === undefined + && receipt.assignmentId === undefined; + return receipt.workerId !== undefined && (correlated || noOp); + } + return receipt.commandKind === 'recall-all' + && receipt.workerId === undefined + && receipt.resourceKind === undefined + && receipt.siteId === undefined + && (receipt.assignmentId === undefined || receipt.assignmentId.length > 0); +} + +export function recallWorkerReceipt( + requestKey: string, + fid: bigint, + workerId: string, + resultRevision: bigint, + correlation?: WorkerRecallCorrelation, +): WorkerCommandReceiptView { + return Object.freeze({ + requestKey, + fid, + workerId, + commandKind: 'recall', + resourceKind: correlation?.resourceKind, + siteId: correlation?.siteId, + assignmentId: correlation?.assignmentId, + resultRevision, + }); +} + +export function recallAllWorkersReceipt( + requestKey: string, + fid: bigint, + resultRevision: bigint, + assignmentId?: string, +): WorkerCommandReceiptView { + return Object.freeze({ + requestKey, + fid, + workerId: undefined, + commandKind: 'recall-all', + resourceKind: undefined, + siteId: undefined, + assignmentId, + resultRevision, + }); +} + +export function recallReplayMatches( + receipt: WorkerCommandReceiptView, + fid: bigint, + workerId: string, +): boolean { + return receipt.fid === fid + && receipt.commandKind === 'recall' + && receipt.workerId === workerId + && workerCommandReceiptShapeIsValid(receipt); +} + +export function recallAllReplayMatches( + receipt: WorkerCommandReceiptView, + fid: bigint, +): boolean { + return receipt.fid === fid + && receipt.commandKind === 'recall-all' + && receipt.workerId === undefined + && workerCommandReceiptShapeIsValid(receipt); +} + +export function workerScheduleMatchesAssignment( + schedule: Readonly<{ + stage: string; + workerId: string; + timelineRevision: number; + scheduledAt: Readonly<{ + tag: string; + value?: unknown; + }>; + }>, + assignment: Readonly<{ + phase: string; + workerId: string; + timelineRevision: number; + arrivesAtMicros: bigint; + gatheringEndsAtMicros: bigint; + returnsAtMicros: bigint; + }>, +): boolean { + const expectedStage = assignment.phase === 'outbound' + ? 'arrival' + : assignment.phase === 'gathering' + ? 'gathering-expiry' + : assignment.phase === 'returning' + ? 'return-complete' + : undefined; + const expectedAtMicros = expectedStage === 'arrival' + ? assignment.arrivesAtMicros + : expectedStage === 'gathering-expiry' + ? assignment.gatheringEndsAtMicros + : assignment.returnsAtMicros; + const scheduledValue = schedule.scheduledAt.value; + const scheduledAtMicros = typeof scheduledValue === 'object' + && scheduledValue !== null + && 'microsSinceUnixEpoch' in scheduledValue + && typeof scheduledValue.microsSinceUnixEpoch === 'bigint' + ? scheduledValue.microsSinceUnixEpoch + : undefined; + return expectedStage !== undefined + && schedule.stage === expectedStage + && schedule.workerId === assignment.workerId + && schedule.timelineRevision === assignment.timelineRevision + && schedule.scheduledAt.tag === 'Time' + && scheduledAtMicros === expectedAtMicros; +} diff --git a/spacetimedb/src/castleWorkerPolicy.ts b/spacetimedb/src/castleWorkerPolicy.ts new file mode 100644 index 00000000..d0155bc1 --- /dev/null +++ b/spacetimedb/src/castleWorkerPolicy.ts @@ -0,0 +1,400 @@ +import { + FOOD_EXPEDITION_POLICY_VERSION, + FOOD_GATHERING_DURATION_MICROS, + FOOD_GATHER_QUANTUM_MICROS, + FOOD_GATHER_RATE_PER_QUANTUM, +} from './foodExpeditionPolicy'; +import { + GENESIS_TIER_I_FOOD_SITE_COUNT, + GENESIS_TIER_I_FOOD_SITE_DIGEST, + FOOD_SITE_POLICY_VERSION, + canonicalFoodSiteV1ForId, + matchesCanonicalTierIFoodSiteV1, +} from './foodSitePolicy'; +import { + GOLD_EXPEDITION_POLICY_VERSION, + GOLD_GATHERING_DURATION_MICROS, + GOLD_GATHER_QUANTUM_MICROS, + GOLD_GATHER_RATE_PER_QUANTUM, +} from './goldExpeditionPolicy'; +import { + GENESIS_TIER_I_GOLD_SITE_COUNT, + GENESIS_TIER_I_GOLD_SITE_DIGEST, + GOLD_SITE_POLICY_VERSION, + canonicalGoldSiteV1ForId, + matchesCanonicalTierIGoldSiteV1, + canonicalPassableRouteSteps, +} from './goldSitePolicy'; +import { + STONE_EXPEDITION_POLICY_VERSION, + STONE_GATHERING_DURATION_MICROS, + STONE_GATHER_QUANTUM_MICROS, + STONE_GATHER_RATE_PER_QUANTUM, +} from './stoneExpeditionPolicy'; +import { + GENESIS_TIER_I_STONE_SITE_COUNT, + GENESIS_TIER_I_STONE_SITE_DIGEST, + STONE_SITE_POLICY_VERSION, + canonicalStoneSiteV1ForId, + matchesCanonicalTierIStoneSiteV1, +} from './stoneSitePolicy'; +import { + WOOD_EXPEDITION_POLICY_VERSION, + WOOD_GATHERING_DURATION_MICROS, + WOOD_GATHER_QUANTUM_MICROS, + WOOD_GATHER_RATE_PER_QUANTUM, +} from './woodExpeditionPolicy'; +import { + GENESIS_TIER_I_WOOD_SITE_COUNT, + GENESIS_TIER_I_WOOD_SITE_DIGEST, + WOOD_SITE_POLICY_VERSION, + canonicalWoodSiteV1ForId, + matchesCanonicalTierIWoodSiteV1, +} from './woodSitePolicy'; + +export const CASTLE_WORKERS_PER_CASTLE = 4; +export const CASTLE_WORKER_POLICY_VERSION = 'genesis-001-castle-workers-v1'; +export const CASTLE_WORKER_GATHER_QUANTUM_MICROS = 60_000_000n; +export const CASTLE_WORKER_TRAVEL_MICROS_PER_STEP = 30_000_000n; +export const CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS = 30n * 24n * 60n * 60n * 1_000_000n; +export const CASTLE_WORKER_U64_MAX = (1n << 64n) - 1n; +export const CASTLE_WORKER_PROTOCOL_CAPABILITY = 'generic-castle-workers-v1'; + +export type WorkerResourceKind = 'gold' | 'food' | 'wood' | 'stone'; +export type CastleWorkerPhase = 'outbound' | 'gathering' | 'returning'; +export type CastleWorkerStatus = 'idle' | CastleWorkerPhase; + +export type CastleWorkerSiteShape = Readonly<{ + siteId: string; + q: number; + r: number; + tier: number; + active: boolean; +}>; + +export type CastleWorkerResourcePolicy = Readonly<{ + kind: WorkerResourceKind; + siteTable: string; + sitePolicyVersion: string; + siteCatalogDigest: string; + canonicalSiteCount: number; + expeditionPolicyVersion: string; + quantumMicros: bigint; + ratePerQuantum: bigint; + gatheringDurationMicros: bigint; + gatheringTotal: bigint; + canonicalSiteForId: (siteId: string) => CastleWorkerSiteShape | undefined; + matchesCanonicalSite: (site: CastleWorkerSiteShape) => boolean; +}>; + +const RESOURCE_POLICIES: Readonly> = Object.freeze({ + gold: Object.freeze({ + kind: 'gold', + siteTable: 'goldSiteV1', + sitePolicyVersion: GOLD_SITE_POLICY_VERSION, + siteCatalogDigest: GENESIS_TIER_I_GOLD_SITE_DIGEST, + canonicalSiteCount: GENESIS_TIER_I_GOLD_SITE_COUNT, + expeditionPolicyVersion: GOLD_EXPEDITION_POLICY_VERSION, + quantumMicros: GOLD_GATHER_QUANTUM_MICROS, + ratePerQuantum: GOLD_GATHER_RATE_PER_QUANTUM, + gatheringDurationMicros: GOLD_GATHERING_DURATION_MICROS, + gatheringTotal: (GOLD_GATHERING_DURATION_MICROS / GOLD_GATHER_QUANTUM_MICROS) * GOLD_GATHER_RATE_PER_QUANTUM, + canonicalSiteForId: canonicalGoldSiteV1ForId, + matchesCanonicalSite: matchesCanonicalTierIGoldSiteV1, + }), + food: Object.freeze({ + kind: 'food', + siteTable: 'foodSiteV1', + sitePolicyVersion: FOOD_SITE_POLICY_VERSION, + siteCatalogDigest: GENESIS_TIER_I_FOOD_SITE_DIGEST, + canonicalSiteCount: GENESIS_TIER_I_FOOD_SITE_COUNT, + expeditionPolicyVersion: FOOD_EXPEDITION_POLICY_VERSION, + quantumMicros: FOOD_GATHER_QUANTUM_MICROS, + ratePerQuantum: FOOD_GATHER_RATE_PER_QUANTUM, + gatheringDurationMicros: FOOD_GATHERING_DURATION_MICROS, + gatheringTotal: (FOOD_GATHERING_DURATION_MICROS / FOOD_GATHER_QUANTUM_MICROS) * FOOD_GATHER_RATE_PER_QUANTUM, + canonicalSiteForId: canonicalFoodSiteV1ForId, + matchesCanonicalSite: matchesCanonicalTierIFoodSiteV1, + }), + wood: Object.freeze({ + kind: 'wood', + siteTable: 'woodSiteV1', + sitePolicyVersion: WOOD_SITE_POLICY_VERSION, + siteCatalogDigest: GENESIS_TIER_I_WOOD_SITE_DIGEST, + canonicalSiteCount: GENESIS_TIER_I_WOOD_SITE_COUNT, + expeditionPolicyVersion: WOOD_EXPEDITION_POLICY_VERSION, + quantumMicros: WOOD_GATHER_QUANTUM_MICROS, + ratePerQuantum: WOOD_GATHER_RATE_PER_QUANTUM, + gatheringDurationMicros: WOOD_GATHERING_DURATION_MICROS, + gatheringTotal: (WOOD_GATHERING_DURATION_MICROS / WOOD_GATHER_QUANTUM_MICROS) * WOOD_GATHER_RATE_PER_QUANTUM, + canonicalSiteForId: canonicalWoodSiteV1ForId, + matchesCanonicalSite: matchesCanonicalTierIWoodSiteV1, + }), + stone: Object.freeze({ + kind: 'stone', + siteTable: 'stoneSiteV1', + sitePolicyVersion: STONE_SITE_POLICY_VERSION, + siteCatalogDigest: GENESIS_TIER_I_STONE_SITE_DIGEST, + canonicalSiteCount: GENESIS_TIER_I_STONE_SITE_COUNT, + expeditionPolicyVersion: STONE_EXPEDITION_POLICY_VERSION, + quantumMicros: STONE_GATHER_QUANTUM_MICROS, + ratePerQuantum: STONE_GATHER_RATE_PER_QUANTUM, + gatheringDurationMicros: STONE_GATHERING_DURATION_MICROS, + gatheringTotal: (STONE_GATHERING_DURATION_MICROS / STONE_GATHER_QUANTUM_MICROS) * STONE_GATHER_RATE_PER_QUANTUM, + canonicalSiteForId: canonicalStoneSiteV1ForId, + matchesCanonicalSite: matchesCanonicalTierIStoneSiteV1, + }), +}); + +export class CastleWorkerPolicyError extends Error { + constructor(readonly code: string) { + super(code); + this.name = 'CastleWorkerPolicyError'; + } +} + +function fail(code: string): never { + throw new CastleWorkerPolicyError(code); +} + +function assertU64(value: unknown, code: string): asserts value is bigint { + if (typeof value !== 'bigint' || value < 0n || value > CASTLE_WORKER_U64_MAX) fail(code); +} + +function checkedSum(left: bigint, right: bigint, code: string): bigint { + assertU64(left, code); + assertU64(right, code); + if (right > CASTLE_WORKER_U64_MAX - left) fail(code); + return left + right; +} + +function checkedProduct(left: bigint, right: bigint, code: string): bigint { + assertU64(left, code); + assertU64(right, code); + if (left !== 0n && right > CASTLE_WORKER_U64_MAX / left) fail(code); + return left * right; +} + +export function workerResourcePolicy(kind: string): CastleWorkerResourcePolicy { + if (kind !== 'gold' && kind !== 'food' && kind !== 'wood' && kind !== 'stone') { + fail('WORKER_RESOURCE_UNSUPPORTED'); + } + return RESOURCE_POLICIES[kind]; +} + +export function workerResourceKinds(): readonly WorkerResourceKind[] { + return Object.freeze(['gold', 'food', 'wood', 'stone']); +} + +export function workerIdForCastle(castleId: bigint, ordinal: number): string { + if (castleId < 0n || !Number.isSafeInteger(ordinal) || ordinal < 1 || ordinal > CASTLE_WORKERS_PER_CASTLE) { + fail('WORKER_ROSTER_ORDINAL_INVALID'); + } + return `genesis-001-castle-${castleId.toString()}-worker-${String(ordinal).padStart(2, '0')}`; +} + +export function assertCastleWorkerId(workerId: string): void { + if (!/^genesis-001-castle-[0-9]+-worker-0[1-4]$/.test(workerId)) { + fail('WORKER_ID_INVALID'); + } +} + +export function assertWorkerCommandKey(value: string): void { + if (!/^[a-z0-9][a-z0-9-]{15,79}$/.test(value)) fail('WORKER_COMMAND_KEY_INVALID'); +} + +export type CastleWorkerTimeline = Readonly<{ + startedAtMicros: bigint; + arrivesAtMicros: bigint; + gatheringEndsAtMicros: bigint; + returnsAtMicros: bigint; +}>; + +export function planCastleWorkerTimeline(startedAtMicros: bigint, routeSteps: number): CastleWorkerTimeline { + assertU64(startedAtMicros, 'WORKER_START_TIME_INVALID'); + if (!Number.isSafeInteger(routeSteps) || routeSteps <= 0) fail('WORKER_ROUTE_INVALID'); + const travelMicros = checkedProduct(BigInt(routeSteps), CASTLE_WORKER_TRAVEL_MICROS_PER_STEP, 'WORKER_TIME_OVERFLOW'); + const arrivesAtMicros = checkedSum(startedAtMicros, travelMicros, 'WORKER_TIME_OVERFLOW'); + const gatheringEndsAtMicros = checkedSum(arrivesAtMicros, CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS, 'WORKER_TIME_OVERFLOW'); + const returnsAtMicros = checkedSum(gatheringEndsAtMicros, travelMicros, 'WORKER_TIME_OVERFLOW'); + return Object.freeze({ startedAtMicros, arrivesAtMicros, gatheringEndsAtMicros, returnsAtMicros }); +} + +export type CastleWorkerAccrualState = Readonly<{ + phase: string; + startedAtMicros: bigint; + arrivesAtMicros: bigint; + gatheringEndsAtMicros: bigint; + returnStartedAtMicros: bigint | undefined; + returnsAtMicros: bigint; + routeSteps: number; + returnStartProgressBasisPoints: number; + settledThroughMicros: bigint; + accruedAmount: bigint; + materializedAmount: bigint; + resourceKind: string; + policyVersion: string; +}>; + +export type CastleWorkerAccrualPlan = Readonly<{ + accruedAmount: bigint; + newlyAccruedAmount: bigint; + completedQuanta: bigint; + settledThroughMicros: bigint; +}>; + +export function workerAssignmentStateIsConsistent(state: CastleWorkerAccrualState): boolean { + try { + const policy = workerResourcePolicy(state.resourceKind); + assertU64(state.startedAtMicros, 'WORKER_TIME_INVALID'); + assertU64(state.arrivesAtMicros, 'WORKER_TIME_INVALID'); + assertU64(state.gatheringEndsAtMicros, 'WORKER_TIME_INVALID'); + assertU64(state.returnsAtMicros, 'WORKER_TIME_INVALID'); + if (state.returnStartedAtMicros !== undefined) { + assertU64(state.returnStartedAtMicros, 'WORKER_TIME_INVALID'); + } + assertU64(state.settledThroughMicros, 'WORKER_CURSOR_INVALID'); + assertU64(state.accruedAmount, 'WORKER_ACCRUAL_INVALID'); + assertU64(state.materializedAmount, 'WORKER_MATERIALIZED_INVALID'); + if ( + !Number.isSafeInteger(state.routeSteps) + || state.routeSteps <= 0 + || !Number.isSafeInteger(state.returnStartProgressBasisPoints) + || state.returnStartProgressBasisPoints < 0 + || state.returnStartProgressBasisPoints > 10_000 + ) return false; + const travelMicros = checkedProduct( + BigInt(state.routeSteps), + CASTLE_WORKER_TRAVEL_MICROS_PER_STEP, + 'WORKER_TIME_OVERFLOW', + ); + const canonicalArrivesAtMicros = checkedSum( + state.startedAtMicros, + travelMicros, + 'WORKER_TIME_OVERFLOW', + ); + const canonicalGatheringEndsAtMicros = checkedSum( + canonicalArrivesAtMicros, + CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS, + 'WORKER_TIME_OVERFLOW', + ); + if ( + state.policyVersion !== CASTLE_WORKER_POLICY_VERSION + || (state.phase !== 'outbound' && state.phase !== 'gathering' && state.phase !== 'returning') + || !(state.startedAtMicros < state.arrivesAtMicros + && state.arrivesAtMicros < state.gatheringEndsAtMicros) + || state.arrivesAtMicros > state.settledThroughMicros + || state.settledThroughMicros > state.gatheringEndsAtMicros + || state.materializedAmount > state.accruedAmount + || state.accruedAmount > policy.gatheringTotal + || state.arrivesAtMicros !== canonicalArrivesAtMicros + || state.gatheringEndsAtMicros !== canonicalGatheringEndsAtMicros + ) return false; + if (state.phase !== 'returning') { + return state.returnStartedAtMicros === undefined + && state.returnStartProgressBasisPoints === 0 + && state.returnsAtMicros === checkedSum( + state.gatheringEndsAtMicros, + travelMicros, + 'WORKER_TIME_OVERFLOW', + ); + } + if ( + state.returnStartedAtMicros === undefined + || state.returnStartedAtMicros < state.startedAtMicros + || state.returnStartedAtMicros > state.gatheringEndsAtMicros + || state.returnsAtMicros < state.returnStartedAtMicros + ) return false; + const expectedProgress = state.returnStartedAtMicros >= state.arrivesAtMicros + ? 10_000 + : Number( + ((state.returnStartedAtMicros - state.startedAtMicros) * 10_000n) + / travelMicros, + ); + if (state.returnStartProgressBasisPoints !== expectedProgress) return false; + const expectedReturnsAtMicros = checkedSum( + state.returnStartedAtMicros, + (travelMicros * BigInt(expectedProgress)) / 10_000n, + 'WORKER_TIME_OVERFLOW', + ); + if (state.returnsAtMicros !== expectedReturnsAtMicros) return false; + // An outbound recall starts before gathering can begin. Its settlement + // cursor remains pinned to arrival and it can never have earned value. + if (state.returnStartedAtMicros < state.arrivesAtMicros) { + return state.settledThroughMicros === state.arrivesAtMicros + && state.accruedAmount === 0n + && state.materializedAmount === 0n; + } + // Gathering recalls may retain only complete quanta observed no later + // than the immutable return-start boundary. + return state.settledThroughMicros <= state.returnStartedAtMicros; + } catch { + return false; + } +} + +export function planCastleWorkerAccrual( + state: CastleWorkerAccrualState, + observedAtMicros: bigint, +): CastleWorkerAccrualPlan { + if (!workerAssignmentStateIsConsistent(state)) fail('WORKER_ASSIGNMENT_STATE_INVALID'); + assertU64(observedAtMicros, 'WORKER_OBSERVED_TIME_INVALID'); + const policy = workerResourcePolicy(state.resourceKind); + const phaseCeiling = state.phase === 'returning' + ? state.returnStartedAtMicros + : observedAtMicros; + if (phaseCeiling === undefined) fail('WORKER_ASSIGNMENT_STATE_INVALID'); + const ceiling = phaseCeiling < state.gatheringEndsAtMicros + ? phaseCeiling + : state.gatheringEndsAtMicros; + if (ceiling <= state.settledThroughMicros) { + return Object.freeze({ accruedAmount: state.accruedAmount, newlyAccruedAmount: 0n, completedQuanta: 0n, settledThroughMicros: state.settledThroughMicros }); + } + const completedQuanta = (ceiling - state.settledThroughMicros) / policy.quantumMicros; + const elapsed = checkedProduct(completedQuanta, policy.quantumMicros, 'WORKER_ACCRUAL_OVERFLOW'); + const settledThroughMicros = checkedSum(state.settledThroughMicros, elapsed, 'WORKER_ACCRUAL_OVERFLOW'); + const newlyAccruedAmount = checkedProduct(completedQuanta, policy.ratePerQuantum, 'WORKER_ACCRUAL_OVERFLOW'); + const accruedAmount = checkedSum(state.accruedAmount, newlyAccruedAmount, 'WORKER_ACCRUAL_OVERFLOW'); + if (accruedAmount > policy.gatheringTotal) fail('WORKER_ACCRUAL_CAP'); + return Object.freeze({ accruedAmount, newlyAccruedAmount, completedQuanta, settledThroughMicros }); +} + +/** Route authority is shared across all four canonical site catalogs. */ +export function canonicalWorkerRouteSteps( + origin: Readonly<{ q: number; r: number }>, + destination: Readonly<{ q: number; r: number }>, +): number | undefined { + return canonicalPassableRouteSteps(origin, destination); +} + +/** Stable roster digest; order and worker identity are part of the boundary. */ +export function rosterDigestForCastleIds(castleIds: readonly bigint[]): string { + const ids = [...castleIds].sort((a, b) => a < b ? -1 : a > b ? 1 : 0); + let hash = 0xcbf29ce484222325n; + for (const castleId of ids) { + hash = appendCastleWorkerRosterHash(hash, castleId); + } + return hash.toString(16).padStart(16, '0'); +} + +function appendCastleWorkerRosterHash(hash: bigint, castleId: bigint): bigint { + let next = hash; + for (const workerId of Array.from( + { length: CASTLE_WORKERS_PER_CASTLE }, + (_, index) => workerIdForCastle(castleId, index + 1), + )) { + for (const byte of new TextEncoder().encode(workerId)) { + next ^= BigInt(byte); + next = (next * 0x100000001b3n) & CASTLE_WORKER_U64_MAX; + } + } + return next; +} + +/** Extend the attested digest when an auto-incremented castle is appended. */ +export function appendCastleWorkerRosterDigest(digest: string, castleId: bigint): string { + if (!/^[0-9a-f]{16}$/.test(digest)) fail('WORKER_ROSTER_DIGEST_INVALID'); + return appendCastleWorkerRosterHash(BigInt(`0x${digest}`), castleId) + .toString(16) + .padStart(16, '0'); +} diff --git a/spacetimedb/src/castleWorkerRoster.ts b/spacetimedb/src/castleWorkerRoster.ts new file mode 100644 index 00000000..56b87366 --- /dev/null +++ b/spacetimedb/src/castleWorkerRoster.ts @@ -0,0 +1,213 @@ +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; + +import { + CASTLE_WORKER_POLICY_VERSION, + CASTLE_WORKERS_PER_CASTLE, + appendCastleWorkerRosterDigest, + workerIdForCastle, + assertCastleWorkerId, +} from './castleWorkerPolicy'; +import type warpkeep from './schema'; + +type WarpkeepReducerContext = ReducerCtx>; +type CastleRow = NonNullable>; +type CastleWorkerRow = NonNullable>; +const MAX_U32 = 0xffff_ffff; + +function fail(code = 'WORKER_ROSTER_INTEGRITY'): never { + throw new Error(code); +} + +export function expectedWorkerRowsForCastle( + castle: Pick, +): readonly CastleWorkerRow[] { + return Object.freeze(Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, index) => { + const ordinal = index + 1; + return Object.freeze({ + workerId: workerIdForCastle(castle.castleId, ordinal), + originCastleId: castle.castleId, + ordinal, + status: 'idle', + resourceKind: undefined, + siteId: undefined, + startedAtMicros: undefined, + arrivesAtMicros: undefined, + gatheringEndsAtMicros: undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: undefined, + routeSteps: undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: 0, + revision: 0n, + }); + })); +} + +function boundedRows(rows: Iterable, maximum: number, code: string): readonly Row[] { + const bounded: Row[] = []; + for (const row of rows) { + if (bounded.length >= maximum) fail(code); + bounded.push(row); + } + return bounded; +} + +export function workerSystemRowIsStagedOrActive( + row: NonNullable>, +): boolean { + return row.realmId === 'GENESIS_001' + && row.policyVersion === CASTLE_WORKER_POLICY_VERSION + && row.workersPerCastle === CASTLE_WORKERS_PER_CASTLE + && (row.mode === 'staged' || row.mode === 'active') + && row.expectedCastleCount >= 0 + && row.expectedWorkerCount === row.expectedCastleCount * CASTLE_WORKERS_PER_CASTLE; +} + +export function assertCastleWorkerRoster( + ctx: WarpkeepReducerContext, + castleId: bigint, +): readonly CastleWorkerRow[] { + const castle = ctx.db.castle.castleId.find(castleId); + if (castle === null) fail('WORKER_CASTLE_MISSING'); + const rows = [...boundedRows( + ctx.db.castleWorkerV1.byOriginCastle.filter(castleId), + CASTLE_WORKERS_PER_CASTLE + 1, + 'WORKER_ROSTER_OVERSIZED', + )] + .sort((left, right) => left.ordinal - right.ordinal || left.workerId.localeCompare(right.workerId)); + if (rows.length !== CASTLE_WORKERS_PER_CASTLE) fail('WORKER_ROSTER_INCOMPLETE'); + const expectedIds = new Set(); + for (const row of rows) { + assertCastleWorkerId(row.workerId); + if ( + row.originCastleId !== castleId + || row.ordinal < 1 + || row.ordinal > CASTLE_WORKERS_PER_CASTLE + || expectedIds.has(row.workerId) + || row.workerId !== workerIdForCastle(castleId, row.ordinal) + || row.revision < 0n + || row.timelineRevision < 0 + || !castleWorkerPublicStateIsConsistent(row) + ) fail('WORKER_ROSTER_INTEGRITY'); + expectedIds.add(row.workerId); + } + for (let ordinal = 1; ordinal <= CASTLE_WORKERS_PER_CASTLE; ordinal += 1) { + if (!expectedIds.has(workerIdForCastle(castleId, ordinal))) fail('WORKER_ROSTER_INTEGRITY'); + } + return Object.freeze(rows); +} + +export function castleWorkerPublicStateIsConsistent(row: CastleWorkerRow): boolean { + const optionalTimeline = [ + row.resourceKind, + row.siteId, + row.startedAtMicros, + row.arrivesAtMicros, + row.gatheringEndsAtMicros, + row.returnStartedAtMicros, + row.returnsAtMicros, + row.routeSteps, + row.returnStartProgressBasisPoints, + ]; + if (row.status === 'idle') return optionalTimeline.every(value => value === undefined); + if (row.status !== 'outbound' && row.status !== 'gathering' && row.status !== 'returning') return false; + if ( + row.resourceKind === undefined + || !['gold', 'food', 'wood', 'stone'].includes(row.resourceKind) + || row.siteId === undefined + || row.startedAtMicros === undefined + || row.arrivesAtMicros === undefined + || row.gatheringEndsAtMicros === undefined + || row.returnsAtMicros === undefined + || row.routeSteps === undefined + || row.routeSteps <= 0 + || !(row.startedAtMicros < row.arrivesAtMicros && row.arrivesAtMicros < row.gatheringEndsAtMicros) + ) return false; + if (row.status !== 'returning') { + return row.returnStartedAtMicros === undefined + && row.returnStartProgressBasisPoints === undefined + && row.gatheringEndsAtMicros < row.returnsAtMicros; + } + return row.returnStartedAtMicros !== undefined + && row.returnStartProgressBasisPoints !== undefined + && row.returnStartProgressBasisPoints <= 10_000 + && row.returnStartedAtMicros >= row.startedAtMicros + && row.returnStartedAtMicros <= row.gatheringEndsAtMicros + && row.returnsAtMicros >= row.returnStartedAtMicros; +} + +/** + * Founding calls this only when generic mode is active. In staged mode the + * function is a no-op, so this PR cannot seed production workers accidentally. + */ +export function ensureCastleWorkerRoster( + ctx: WarpkeepReducerContext, + castle: CastleRow, +): void { + const system = ctx.db.realmWorkerSystemV1.realmId.find('GENESIS_001'); + if (system === null) return; + if (ctx.db.realmWorkerSystemV1.count() !== 1n || !workerSystemRowIsStagedOrActive(system)) { + fail('WORKER_SYSTEM_INTEGRITY'); + } + if (system.mode !== 'active') return; + if (system.legacyDrainRequired) fail('WORKER_LEGACY_DRAIN_REQUIRED'); + if ( + ctx.db.goldExpeditionV1.count() + ctx.db.foodExpeditionV1.count() + + ctx.db.woodExpeditionV1.count() + ctx.db.stoneExpeditionV1.count() !== 0n + || ctx.db.goldNodeOccupationV1.count() + ctx.db.foodNodeOccupationV1.count() + + ctx.db.woodNodeOccupationV1.count() + ctx.db.stoneNodeOccupationV1.count() !== 0n + || ctx.db.goldExpeditionScheduleV1.count() + ctx.db.foodExpeditionScheduleV1.count() + + ctx.db.woodExpeditionScheduleV1.count() + ctx.db.stoneExpeditionScheduleV1.count() !== 0n + ) fail('WORKER_LEGACY_DRAIN_REQUIRED'); + const existing = boundedRows( + ctx.db.castleWorkerV1.byOriginCastle.filter(castle.castleId), + CASTLE_WORKERS_PER_CASTLE + 1, + 'WORKER_ROSTER_OVERSIZED', + ); + const castleCount = ctx.db.castle.count(); + const workerCount = ctx.db.castleWorkerV1.count(); + if (existing.length > 0) { + assertCastleWorkerRoster(ctx, castle.castleId); + if ( + BigInt(system.expectedCastleCount) !== castleCount + || BigInt(system.expectedWorkerCount) !== workerCount + || system.expectedWorkerCount !== system.expectedCastleCount * CASTLE_WORKERS_PER_CASTLE + || !/^[0-9a-f]{16}$/.test(system.rosterDigest) + ) fail('WORKER_SYSTEM_INTEGRITY'); + return; + } else { + if ( + castleCount !== BigInt(system.expectedCastleCount) + 1n + || workerCount !== BigInt(system.expectedWorkerCount) + || system.expectedWorkerCount !== system.expectedCastleCount * CASTLE_WORKERS_PER_CASTLE + || !/^[0-9a-f]{16}$/.test(system.rosterDigest) + ) fail('WORKER_SYSTEM_INTEGRITY'); + for (const row of expectedWorkerRowsForCastle(castle)) { + ctx.db.castleWorkerV1.insert(row); + } + assertCastleWorkerRoster(ctx, castle.castleId); + } + const nextCastleCount = system.expectedCastleCount + 1; + if (nextCastleCount > MAX_U32 || nextCastleCount > Math.floor(MAX_U32 / CASTLE_WORKERS_PER_CASTLE)) { + fail('WORKER_ROSTER_CAPACITY'); + } + const nextWorkerCount = nextCastleCount * CASTLE_WORKERS_PER_CASTLE; + if (ctx.db.castleWorkerV1.count() !== BigInt(nextWorkerCount)) { + fail('WORKER_ROSTER_INTEGRITY'); + } + ctx.db.realmWorkerSystemV1.realmId.update({ + ...system, + expectedCastleCount: nextCastleCount, + expectedWorkerCount: nextWorkerCount, + rosterDigest: appendCastleWorkerRosterDigest(system.rosterDigest, castle.castleId), + }); +} + +export function workerRosterDigestInput(castleIds: readonly bigint[]): string { + return [...castleIds] + .sort((left, right) => left < right ? -1 : left > right ? 1 : 0) + .flatMap(castleId => Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, index) => ( + workerIdForCastle(castleId, index + 1) + ))) + .join('|'); +} diff --git a/spacetimedb/src/foodExpeditionAuthority.ts b/spacetimedb/src/foodExpeditionAuthority.ts index 8de8bc37..3771ef69 100644 --- a/spacetimedb/src/foodExpeditionAuthority.ts +++ b/spacetimedb/src/foodExpeditionAuthority.ts @@ -27,6 +27,7 @@ import { } from './resourceAuthorityPolicy'; import { assertGenesisResourceForFid } from './resourceAuthority'; import { + assertLegacyExpeditionDispatchAllowed, ResourceExpeditionReservationAuthorityError, activeExpeditionResourceReservations, planResourceSettlementForActiveExpeditionReservations, @@ -397,6 +398,7 @@ export function dispatchGenesisFoodExpedition( }>, ): FoodExpeditionDispatch { const resource = assertGenesisResourceForFid(ctx, input.fid); + assertLegacyExpeditionDispatchAllowed(ctx); const requestKey = requestKeyFor(input.fid, input.idempotencyKey); const prior = ctx.db.foodExpeditionIdempotencyV1.requestKey.find(requestKey); if (prior !== null) { diff --git a/spacetimedb/src/foundingAuthority.ts b/spacetimedb/src/foundingAuthority.ts index bf979100..ab59a3d4 100644 --- a/spacetimedb/src/foundingAuthority.ts +++ b/spacetimedb/src/foundingAuthority.ts @@ -14,6 +14,7 @@ import { GENESIS_RESOURCE_POLICY_VERSION, GENESIS_STARTING_RESOURCE_BALANCES, } from './resourceAuthorityPolicy'; +import { ensureCastleWorkerRoster } from './castleWorkerRoster'; import { admissionProfileIsComplete, trustedProfilesEqual, @@ -219,6 +220,7 @@ export function ensureGenesisFounder( || !trustedProfilesEqual(existingProfile, admissionProfile) ) fail(); assertGenesisFoundingGraph(ctx); + ensureCastleWorkerRoster(ctx, existingCastle); return 'preserved'; } if ( @@ -314,6 +316,11 @@ export function ensureGenesisFounder( updatedAt: ctx.timestamp, }); + // Generic workers are created only after a separately authorized active + // system row exists. The PR ships staged authority and therefore performs + // no production roster backfill or activation. + ensureCastleWorkerRoster(ctx, castle); + assertGenesisFoundingGraph(ctx); return 'created'; } diff --git a/spacetimedb/src/goldExpeditionAuthority.ts b/spacetimedb/src/goldExpeditionAuthority.ts index 2ab554ea..a8fd969d 100644 --- a/spacetimedb/src/goldExpeditionAuthority.ts +++ b/spacetimedb/src/goldExpeditionAuthority.ts @@ -25,6 +25,7 @@ import { } from './resourceAuthorityPolicy'; import { assertGenesisResourceForFid } from './resourceAuthority'; import { + assertLegacyExpeditionDispatchAllowed, ResourceExpeditionReservationAuthorityError, planResourceSettlementForActiveExpeditionReservations, } from './resourceExpeditionReservationAuthority'; @@ -377,6 +378,7 @@ export function dispatchGenesisGoldExpedition( idempotencyKey: string; }>, ): GoldExpeditionDispatch { + assertLegacyExpeditionDispatchAllowed(ctx); const requestKey = requestKeyFor(input.fid, input.idempotencyKey); const prior = ctx.db.goldExpeditionIdempotencyV1.requestKey.find(requestKey); if (prior !== null) { diff --git a/spacetimedb/src/index.ts b/spacetimedb/src/index.ts index 429d630a..b4b3c138 100644 --- a/spacetimedb/src/index.ts +++ b/spacetimedb/src/index.ts @@ -41,6 +41,15 @@ export { adminBackfillResourceAccountsV1, adminGetAlphaStatusV4, } from './reducers/resources'; +export { + getMyWorkerRosterV1, + getMyResourceStateV2, + dispatchWorkerV1, + recallWorkerV1, + recallAllWorkersV1, + adminGetWorkerSystemStatusV1, + adminPlanWorkerRosterV1, +} from './reducers/castleWorkers'; export { getMyGoldExpeditionStateV1, dispatchGoldExpeditionV1, @@ -83,4 +92,5 @@ export { runFoodExpeditionScheduleV1, runWoodExpeditionScheduleV1, runStoneExpeditionScheduleV1, + runCastleWorkerScheduleV1, } from './schema'; diff --git a/spacetimedb/src/reducers/castleWorkers.ts b/spacetimedb/src/reducers/castleWorkers.ts new file mode 100644 index 00000000..d8b8ebde --- /dev/null +++ b/spacetimedb/src/reducers/castleWorkers.ts @@ -0,0 +1,322 @@ +import { SenderError, t } from 'spacetimedb/server'; + +import { requireAdmin, requireGameplayPlayerV1 } from '../auth'; +import { + castleWorkerErrorCode, + dispatchCastleWorker, + inspectCastleWorkerGraph, + projectMyWorkerState, + recallAllCastleWorkers, + recallCastleWorker, +} from '../castleWorkerAuthority'; +import warpkeep from '../schema'; + +const workerPrivate = t.object('WorkerPrivateV1', { + workerId: t.string(), + ordinal: t.u32(), + status: t.string(), + resourceKind: t.option(t.string()), + siteId: t.option(t.string()), + accruedAmount: t.u64(), + materializedAmount: t.u64(), + availableAmount: t.u64(), + observedAtMicros: t.u64(), + revision: t.u64(), +}); + +const myWorkerRoster = t.object('MyWorkerRosterV1', { + fid: t.u64(), + castleId: t.u64(), + observedAtMicros: t.u64(), + workers: t.array(workerPrivate), +}); + +const myResourceStateV2 = t.object('MyResourceStateV2', { + fid: t.u64(), + food: t.u64(), + wood: t.u64(), + stone: t.u64(), + gold: t.u64(), + workerPendingFood: t.u64(), + workerPendingWood: t.u64(), + workerPendingStone: t.u64(), + workerPendingGold: t.u64(), + observedAtMicros: t.u64(), + settledThroughMicros: t.u64(), + revision: t.u64(), + resourcePolicyVersion: t.string(), + workerPolicyVersion: t.string(), + workerSystemMode: t.string(), +}); + +const adminWorkerSystemStatus = t.object('AdminWorkerSystemStatusV1', { + systemRows: t.u64(), + mode: t.string(), + systemConfigValid: t.bool(), + legacyDrainRequired: t.bool(), + expectedCastleCount: t.u64(), + expectedWorkerCount: t.u64(), + actualWorkerCount: t.u64(), + expectedCountsMatch: t.bool(), + rosterDigestMatches: t.bool(), + castlesMissingWorkers: t.u64(), + castlesWithExtraWorkers: t.u64(), + duplicateOrdinals: t.u64(), + malformedWorkerIds: t.u64(), + invalidWorkerStates: t.u64(), + idleWorkers: t.u64(), + outboundWorkers: t.u64(), + gatheringWorkers: t.u64(), + returningWorkers: t.u64(), + assignments: t.u64(), + occupations: t.u64(), + schedules: t.u64(), + orphanWorkers: t.u64(), + orphanAssignments: t.u64(), + assignmentsMissingOccupation: t.u64(), + assignmentsWithoutSingleSchedule: t.u64(), + orphanOccupations: t.u64(), + orphanSchedules: t.u64(), + invalidSchedules: t.u64(), + assignmentPublicMismatches: t.u64(), + occupationSiteMismatches: t.u64(), + invalidAssignments: t.u64(), + idempotencyReceipts: t.u64(), + invalidIdempotencyReceipts: t.u64(), + idempotencyOverflowFids: t.u64(), + legacyExpeditions: t.u64(), + legacyOccupations: t.u64(), + legacySchedules: t.u64(), + rosterDigest: t.string(), + rosterDigestExpected: t.string(), +}); + +const adminWorkerRosterPlan = t.object('AdminWorkerRosterPlanV1', { + ready: t.bool(), + activationBlockedByLegacyRows: t.bool(), + mode: t.string(), + systemConfigValid: t.bool(), + legacyDrainRequired: t.bool(), + expectedCastleCount: t.u64(), + expectedWorkerCount: t.u64(), + actualWorkerCount: t.u64(), + expectedCountsMatch: t.bool(), + rosterDigestMatches: t.bool(), + castlesMissingWorkers: t.u64(), + castlesWithExtraWorkers: t.u64(), + orphanWorkers: t.u64(), + orphanAssignments: t.u64(), + assignmentsMissingOccupation: t.u64(), + assignmentsWithoutSingleSchedule: t.u64(), + orphanOccupations: t.u64(), + orphanSchedules: t.u64(), + invalidSchedules: t.u64(), + assignmentPublicMismatches: t.u64(), + occupationSiteMismatches: t.u64(), + invalidWorkerStates: t.u64(), + invalidAssignments: t.u64(), + invalidIdempotencyReceipts: t.u64(), + idempotencyOverflowFids: t.u64(), + legacyExpeditions: t.u64(), + legacyOccupations: t.u64(), + legacySchedules: t.u64(), + rosterDigest: t.string(), + rosterDigestExpected: t.string(), +}); + +function senderPolicyError(error: unknown): never { + const code = castleWorkerErrorCode(error); + if (code !== undefined) throw new SenderError(code); + if (error instanceof SenderError) throw error; + throw new SenderError('WORKER_REQUEST_FAILED'); +} + +function workerSystemMode(ctx: Parameters[0]): string { + return ctx.db.realmWorkerSystemV1.realmId.find('GENESIS_001')?.mode ?? 'absent'; +} + +function aggregateResult(aggregate: ReturnType) { + return { ...aggregate }; +} + +export const getMyWorkerRosterV1 = warpkeep.procedure( + { name: 'get_my_worker_roster_v1' }, + myWorkerRoster, + ctx => ctx.withTx(tx => { + try { + const { claims, castle } = requireGameplayPlayerV1(tx); + const observedAtMicros = tx.timestamp.microsSinceUnixEpoch; + const projection = projectMyWorkerState(tx, claims.fid, observedAtMicros); + return { + fid: claims.fid, + castleId: castle.castleId, + observedAtMicros, + workers: projection.workers.map(worker => ({ + workerId: worker.workerId, + ordinal: worker.ordinal, + status: worker.status, + resourceKind: worker.resourceKind, + siteId: worker.siteId, + accruedAmount: worker.accruedAmount, + materializedAmount: worker.materializedAmount, + availableAmount: worker.availableAmount, + observedAtMicros: worker.observedAtMicros, + revision: worker.revision, + })), + }; + } catch (error) { + return senderPolicyError(error); + } + }), +); + +export const getMyResourceStateV2 = warpkeep.procedure( + { name: 'get_my_resource_state_v2' }, + myResourceStateV2, + ctx => ctx.withTx(tx => { + try { + const { claims } = requireGameplayPlayerV1(tx); + const observedAtMicros = tx.timestamp.microsSinceUnixEpoch; + const projection = projectMyWorkerState(tx, claims.fid, observedAtMicros); + const pending = { food: 0n, wood: 0n, stone: 0n, gold: 0n }; + for (const worker of projection.workers) { + if (worker.resourceKind === 'food') pending.food += worker.availableAmount; + if (worker.resourceKind === 'wood') pending.wood += worker.availableAmount; + if (worker.resourceKind === 'stone') pending.stone += worker.availableAmount; + if (worker.resourceKind === 'gold') pending.gold += worker.availableAmount; + } + return { + fid: claims.fid, + food: projection.balances.food, + wood: projection.balances.wood, + stone: projection.balances.stone, + gold: projection.balances.gold, + workerPendingFood: pending.food, + workerPendingWood: pending.wood, + workerPendingStone: pending.stone, + workerPendingGold: pending.gold, + observedAtMicros, + settledThroughMicros: projection.resource.settledThroughMicros, + revision: projection.resource.revision, + resourcePolicyVersion: projection.resource.policyVersion, + workerPolicyVersion: 'genesis-001-castle-workers-v1', + workerSystemMode: workerSystemMode(tx), + }; + } catch (error) { + return senderPolicyError(error); + } + }), +); + +export const dispatchWorkerV1 = warpkeep.reducer( + { name: 'dispatch_worker_v1' }, + { workerId: t.string(), resourceKind: t.string(), siteId: t.string(), idempotencyKey: t.string() }, + (ctx, { workerId, resourceKind, siteId, idempotencyKey }) => { + try { + const { claims, castle } = requireGameplayPlayerV1(ctx); + dispatchCastleWorker(ctx, { fid: claims.fid, castle, workerId, resourceKind, siteId, idempotencyKey }); + } catch (error) { + return senderPolicyError(error); + } + }, +); + +export const recallWorkerV1 = warpkeep.reducer( + { name: 'recall_worker_v1' }, + { workerId: t.string(), idempotencyKey: t.string() }, + (ctx, { workerId, idempotencyKey }) => { + try { + const { claims, castle } = requireGameplayPlayerV1(ctx); + recallCastleWorker(ctx, { fid: claims.fid, castle, workerId, idempotencyKey }); + } catch (error) { + return senderPolicyError(error); + } + }, +); + +export const recallAllWorkersV1 = warpkeep.reducer( + { name: 'recall_all_workers_v1' }, + { idempotencyKey: t.string() }, + (ctx, { idempotencyKey }) => { + try { + const { claims, castle } = requireGameplayPlayerV1(ctx); + recallAllCastleWorkers(ctx, { fid: claims.fid, castle, idempotencyKey }); + } catch (error) { + return senderPolicyError(error); + } + }, +); + +export const adminGetWorkerSystemStatusV1 = warpkeep.procedure( + { name: 'admin_get_worker_system_status_v1' }, + adminWorkerSystemStatus, + ctx => ctx.withTx(tx => { + requireAdmin(tx); + return aggregateResult(inspectCastleWorkerGraph(tx)); + }), +); + +export const adminPlanWorkerRosterV1 = warpkeep.procedure( + { name: 'admin_plan_worker_roster_v1' }, + adminWorkerRosterPlan, + ctx => ctx.withTx(tx => { + requireAdmin(tx); + const aggregate = inspectCastleWorkerGraph(tx); + const legacyRows = aggregate.legacyExpeditions + aggregate.legacyOccupations + aggregate.legacySchedules; + return { + ready: aggregate.systemRows === 1n + && aggregate.systemConfigValid + && (aggregate.mode === 'staged' || aggregate.mode === 'active') + && !aggregate.legacyDrainRequired + && aggregate.expectedCountsMatch + && aggregate.rosterDigestMatches + && aggregate.castlesMissingWorkers === 0n + && aggregate.castlesWithExtraWorkers === 0n + && aggregate.duplicateOrdinals === 0n + && aggregate.malformedWorkerIds === 0n + && aggregate.invalidWorkerStates === 0n + && aggregate.orphanWorkers === 0n + && aggregate.orphanAssignments === 0n + && aggregate.assignmentsMissingOccupation === 0n + && aggregate.assignmentsWithoutSingleSchedule === 0n + && aggregate.orphanOccupations === 0n + && aggregate.orphanSchedules === 0n + && aggregate.invalidSchedules === 0n + && aggregate.assignmentPublicMismatches === 0n + && aggregate.occupationSiteMismatches === 0n + && aggregate.invalidAssignments === 0n + && aggregate.invalidIdempotencyReceipts === 0n + && aggregate.idempotencyOverflowFids === 0n + && legacyRows === 0n, + activationBlockedByLegacyRows: aggregate.legacyDrainRequired || legacyRows !== 0n, + mode: aggregate.mode, + systemConfigValid: aggregate.systemConfigValid, + legacyDrainRequired: aggregate.legacyDrainRequired, + expectedCastleCount: aggregate.expectedCastleCount, + expectedWorkerCount: aggregate.expectedWorkerCount, + actualWorkerCount: aggregate.actualWorkerCount, + expectedCountsMatch: aggregate.expectedCountsMatch, + rosterDigestMatches: aggregate.rosterDigestMatches, + castlesMissingWorkers: aggregate.castlesMissingWorkers, + castlesWithExtraWorkers: aggregate.castlesWithExtraWorkers, + orphanWorkers: aggregate.orphanWorkers, + orphanAssignments: aggregate.orphanAssignments, + assignmentsMissingOccupation: aggregate.assignmentsMissingOccupation, + assignmentsWithoutSingleSchedule: aggregate.assignmentsWithoutSingleSchedule, + orphanOccupations: aggregate.orphanOccupations, + orphanSchedules: aggregate.orphanSchedules, + invalidSchedules: aggregate.invalidSchedules, + assignmentPublicMismatches: aggregate.assignmentPublicMismatches, + occupationSiteMismatches: aggregate.occupationSiteMismatches, + invalidWorkerStates: aggregate.invalidWorkerStates, + invalidAssignments: aggregate.invalidAssignments, + invalidIdempotencyReceipts: aggregate.invalidIdempotencyReceipts, + idempotencyOverflowFids: aggregate.idempotencyOverflowFids, + legacyExpeditions: aggregate.legacyExpeditions, + legacyOccupations: aggregate.legacyOccupations, + legacySchedules: aggregate.legacySchedules, + rosterDigest: aggregate.rosterDigest, + rosterDigestExpected: aggregate.rosterDigestExpected, + }; + }), +); diff --git a/spacetimedb/src/reducers/resources.ts b/spacetimedb/src/reducers/resources.ts index 5c6393cc..3c4077af 100644 --- a/spacetimedb/src/reducers/resources.ts +++ b/spacetimedb/src/reducers/resources.ts @@ -2,6 +2,7 @@ import { SenderError, t } from 'spacetimedb/server'; import { WARPKEEP_BACKEND_PROTOCOL_VERSION } from '../config'; import { requireAdmin, requireGameplayPlayerV1 } from '../auth'; +import { castleWorkerErrorCode, settleAllWorkerAssignmentsForFid } from '../castleWorkerAuthority'; import { markAccountIsConsistent } from '../marksAuthorityPolicy'; import { ResourceAuthorityError, @@ -73,6 +74,8 @@ const adminAlphaStatusV4 = t.object('AdminAlphaStatusV4', { }); function senderPolicyError(error: unknown): never { + const workerCode = castleWorkerErrorCode(error); + if (workerCode !== undefined) throw new SenderError(workerCode); const foodExpeditionCode = foodExpeditionErrorCode(error); if (foodExpeditionCode !== undefined) throw new SenderError(foodExpeditionCode); const woodExpeditionCode = woodExpeditionErrorCode(error); @@ -165,6 +168,10 @@ export const collectResourcesV1 = warpkeep.reducer( collectActiveFoodExpedition(ctx, claims.fid); collectActiveWoodExpedition(ctx, claims.fid); collectActiveStoneExpedition(ctx, claims.fid); + // Generic workers settle into the same private inventory at this exact + // server timestamp. This keeps the legacy collect reducer compatible + // while new clients use get_my_resource_state_v2 for no-write reads. + settleAllWorkerAssignmentsForFid(ctx, claims.fid); const resourceAfterExpeditions = assertGenesisResourceForFid(ctx, claims.fid); const settlement = planResourceSettlementForActiveExpeditionReservations( ctx, diff --git a/spacetimedb/src/resourceExpeditionReservationAuthority.ts b/spacetimedb/src/resourceExpeditionReservationAuthority.ts index d1e7ae2a..9dd266ad 100644 --- a/spacetimedb/src/resourceExpeditionReservationAuthority.ts +++ b/spacetimedb/src/resourceExpeditionReservationAuthority.ts @@ -1,9 +1,18 @@ import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; +import { + CASTLE_WORKERS_PER_CASTLE, + workerResourcePolicy, +} from './castleWorkerPolicy'; + import { FOOD_GATHERING_TOTAL_FOOD, foodExpeditionStateIsConsistent, } from './foodExpeditionPolicy'; +import { + GOLD_GATHERING_TOTAL_GOLD, + goldExpeditionStateIsConsistent, +} from './goldExpeditionPolicy'; import { WOOD_GATHERING_TOTAL_WOOD, woodExpeditionStateIsConsistent, @@ -41,13 +50,20 @@ export type ActiveExpeditionResourceReservations = Readonly<{ food: bigint; wood: bigint; stone: bigint; + gold: bigint; }>; +/** Once generic workers are active, legacy wagon creation is permanently closed. */ +export function assertLegacyExpeditionDispatchAllowed(ctx: WarpkeepReducerContext): void { + const workerSystem = ctx.db.realmWorkerSystemV1.realmId.find('GENESIS_001'); + if (workerSystem?.mode === 'active') fail('LEGACY_EXPEDITION_DISPATCH_RETIRED'); +} + /** - * Return exact uncredited thirty-day awards for the caller's active Food, Wood, - * and Stone wagons. A returning row has already credited its whole award and - * thus reserves zero. Independent tables permit one wagon of each resource - * type. + * Return exact uncredited thirty-day awards for every active legacy wagon and + * generic assignment. A returning row has already credited its whole award and + * thus reserves zero. Independent tables permit one legacy wagon of each + * resource type while generic workers add their own private reservations. */ export function activeExpeditionResourceReservations( ctx: WarpkeepReducerContext, @@ -65,11 +81,33 @@ export function activeExpeditionResourceReservations( if (stone !== null && !stoneExpeditionStateIsConsistent(stone)) { fail('STONE_EXPEDITION_RESERVATION_STATE_INVALID'); } - return Object.freeze({ - food: food === null ? 0n : FOOD_GATHERING_TOTAL_FOOD - food.creditedFood, - wood: wood === null ? 0n : WOOD_GATHERING_TOTAL_WOOD - wood.creditedWood, - stone: stone === null ? 0n : STONE_GATHERING_TOTAL_STONE - stone.creditedStone, - }); + const gold = ctx.db.goldExpeditionV1.fid.find(fid); + if (gold !== null && !goldExpeditionStateIsConsistent(gold)) { + fail('GOLD_EXPEDITION_RESERVATION_STATE_INVALID'); + } + let foodReservation = food === null ? 0n : FOOD_GATHERING_TOTAL_FOOD - food.creditedFood; + let woodReservation = wood === null ? 0n : WOOD_GATHERING_TOTAL_WOOD - wood.creditedWood; + let stoneReservation = stone === null ? 0n : STONE_GATHERING_TOTAL_STONE - stone.creditedStone; + let goldReservation = gold === null ? 0n : GOLD_GATHERING_TOTAL_GOLD - gold.creditedGold; + let workerAssignmentCount = 0; + for (const assignment of ctx.db.workerAssignmentV1.byFid.filter(fid)) { + workerAssignmentCount += 1; + if (workerAssignmentCount > CASTLE_WORKERS_PER_CASTLE) { + fail('WORKER_ASSIGNMENT_LIMIT'); + } + if (assignment.phase === 'returning') continue; + const total = workerResourcePolicy(assignment.resourceKind).gatheringTotal; + // Reserve the complete remaining award, not only the currently accrued + // amount. This leaves room for lazy server-time settlement to materialize + // the exact future output without truncation. + const fullRemaining = total - assignment.materializedAmount; + if (fullRemaining < 0n) throw new ResourceExpeditionReservationAuthorityError('WORKER_RESERVATION_INVALID'); + if (assignment.resourceKind === 'food') foodReservation += fullRemaining; + if (assignment.resourceKind === 'wood') woodReservation += fullRemaining; + if (assignment.resourceKind === 'stone') stoneReservation += fullRemaining; + if (assignment.resourceKind === 'gold') goldReservation += fullRemaining; + } + return Object.freeze({ food: foodReservation, wood: woodReservation, stone: stoneReservation, gold: goldReservation }); } /** diff --git a/spacetimedb/src/schema.ts b/spacetimedb/src/schema.ts index 92f17dde..0de5c39a 100644 --- a/spacetimedb/src/schema.ts +++ b/spacetimedb/src/schema.ts @@ -16,6 +16,10 @@ import { runStoneExpeditionSchedule, stoneExpeditionErrorCode, } from './stoneExpeditionAuthority'; +import { + castleWorkerErrorCode, + runCastleWorkerSchedule, +} from './castleWorkerAuthority'; /** * Private closed-alpha admission list. This table is intentionally omitted @@ -1001,6 +1005,170 @@ export const realmWaterRevisionV1 = table( activatedAt: t.option(t.timestamp()), }, ); + +/** Public singleton for the staged generic-worker readiness boundary. */ +export const realmWorkerSystemV1 = table( + { name: 'realm_worker_system_v1', public: true }, + { + realmId: t.string().primaryKey(), + policyVersion: t.string(), + workersPerCastle: t.u32(), + expectedCastleCount: t.u32(), + expectedWorkerCount: t.u32(), + rosterDigest: t.string(), + mode: t.string(), + legacyDrainRequired: t.bool(), + createdAt: t.timestamp(), + activatedAt: t.option(t.timestamp()), + }, +); + +/** Public identity-safe worker roster and lifecycle presentation. */ +export const castleWorkerV1 = table( + { + name: 'castle_worker_v1', + public: true, + indexes: [{ + accessor: 'byOriginCastle', + algorithm: 'btree', + columns: ['originCastleId'] as const, + }] as const, + }, + { + workerId: t.string().primaryKey(), + originCastleId: t.u64(), + ordinal: t.u32(), + status: t.string(), + resourceKind: t.option(t.string()), + siteId: t.option(t.string()), + startedAtMicros: t.option(t.u64()), + arrivesAtMicros: t.option(t.u64()), + gatheringEndsAtMicros: t.option(t.u64()), + returnStartedAtMicros: t.option(t.u64()), + returnsAtMicros: t.option(t.u64()), + routeSteps: t.option(t.u32()), + returnStartProgressBasisPoints: t.option(t.u32()), + timelineRevision: t.u32(), + revision: t.u64(), + }, +); + +/** Private generic assignment authority. Never expose through subscriptions. */ +export const workerAssignmentV1 = table( + { + name: 'worker_assignment_v1', + indexes: [{ + accessor: 'byFid', + algorithm: 'btree', + columns: ['fid'] as const, + }, { + accessor: 'byFidAndPhase', + algorithm: 'btree', + columns: ['fid', 'phase'] as const, + }] as const, + }, + { + assignmentId: t.string().primaryKey(), + workerId: t.string().unique(), + fid: t.u64(), + originCastleId: t.u64(), + resourceKind: t.string(), + siteId: t.string().index(), + phase: t.string(), + startedAtMicros: t.u64(), + arrivesAtMicros: t.u64(), + gatheringEndsAtMicros: t.u64(), + returnStartedAtMicros: t.option(t.u64()), + returnsAtMicros: t.u64(), + routeSteps: t.u32(), + returnStartProgressBasisPoints: t.u32(), + settledThroughMicros: t.u64(), + accruedAmount: t.u64(), + materializedAmount: t.u64(), + timelineRevision: t.u32(), + policyVersion: t.string(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + }, +); + +/** Public generic node lease. It is deleted when a worker starts returning. */ +export const workerNodeOccupationV1 = table( + { + name: 'worker_node_occupation_v1', + public: true, + indexes: [{ + accessor: 'byOriginCastle', + algorithm: 'btree', + columns: ['originCastleId'] as const, + }, { + accessor: 'byWorker', + algorithm: 'btree', + columns: ['workerId'] as const, + }] as const, + }, + { + nodeKey: t.string().primaryKey(), + resourceKind: t.string(), + siteId: t.string(), + workerId: t.string(), + workerOrdinal: t.u32(), + originCastleId: t.u64(), + phase: t.string(), + startedAtMicros: t.u64(), + arrivesAtMicros: t.u64(), + gatheringEndsAtMicros: t.u64(), + timelineRevision: t.u32(), + }, +); + +/** Private exactly-once command receipts for dispatch and recall commands. */ +export const workerCommandIdempotencyV1 = table( + { + name: 'worker_command_idempotency_v1', + indexes: [{ + accessor: 'byFid', + algorithm: 'btree', + columns: ['fid'] as const, + }] as const, + }, + { + requestKey: t.string().primaryKey(), + fid: t.u64(), + workerId: t.option(t.string()), + commandKind: t.string(), + resourceKind: t.option(t.string()), + siteId: t.option(t.string()), + assignmentId: t.option(t.string()), + resultRevision: t.u64(), + createdAt: t.timestamp(), + }, +); + +/** Private scheduler authority. Assignment correlation never reaches clients. */ +export const workerAssignmentScheduleV1 = table( + { + name: 'worker_assignment_schedule_v_1', + indexes: [{ + accessor: 'byAssignment', + algorithm: 'btree', + columns: ['assignmentId'] as const, + }, { + accessor: 'byWorker', + algorithm: 'btree', + columns: ['workerId'] as const, + }] as const, + scheduled: (): any => runCastleWorkerScheduleV1, + }, + { + scheduleId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + assignmentId: t.string(), + workerId: t.string(), + timelineRevision: t.u32(), + stage: t.string(), + }, +); const warpkeep = schema({ // Preserve the original production schema prefix exactly. New tables are // append-only so SpacetimeDB can apply this migration without rewriting it. @@ -1051,6 +1219,12 @@ const warpkeep = schema({ stoneExpeditionIdempotencyV1, stoneExpeditionScheduleV1, realmWaterRevisionV1, + realmWorkerSystemV1, + castleWorkerV1, + workerAssignmentV1, + workerNodeOccupationV1, + workerCommandIdempotencyV1, + workerAssignmentScheduleV1, }); /** @@ -1124,6 +1298,21 @@ export const runStoneExpeditionScheduleV1 = warpkeep.reducer( }, ); +/** Scheduler-only lifecycle reducer for staged generic castle workers. */ +export const runCastleWorkerScheduleV1 = warpkeep.reducer( + { name: 'run_worker_assignment_schedule_v_1' }, + { arg: workerAssignmentScheduleV1.rowType }, + (ctx, { arg }) => { + try { + runCastleWorkerSchedule(ctx, arg); + } catch (error) { + const code = castleWorkerErrorCode(error); + if (code !== undefined) throw new SenderError(code); + throw error; + } + }, +); + // SpacetimeDB 2.6's default case converter separates a trailing digit from // its prefix (`v2` -> `v_2`). Pin every versioned wire spelling explicitly. for (const name of [ @@ -1168,6 +1357,13 @@ for (const name of [ 'admin_seed_genesis_water_layout_v1', 'admin_activate_genesis_water_layout_v1', 'admin_inspect_genesis_water_layout_v1', + 'get_my_worker_roster_v1', + 'get_my_resource_state_v2', + 'dispatch_worker_v1', + 'recall_worker_v1', + 'recall_all_workers_v1', + 'admin_get_worker_system_status_v1', + 'admin_plan_worker_roster_v1', ]) { warpkeep.moduleDef.explicitNames.entries.push({ tag: 'Function', diff --git a/spacetimedb/src/stoneExpeditionAuthority.ts b/spacetimedb/src/stoneExpeditionAuthority.ts index 4b96bee9..49bc887d 100644 --- a/spacetimedb/src/stoneExpeditionAuthority.ts +++ b/spacetimedb/src/stoneExpeditionAuthority.ts @@ -27,6 +27,7 @@ import { } from './resourceAuthorityPolicy'; import { assertGenesisResourceForFid } from './resourceAuthority'; import { + assertLegacyExpeditionDispatchAllowed, ResourceExpeditionReservationAuthorityError, activeExpeditionResourceReservations, planResourceSettlementForActiveExpeditionReservations, @@ -397,6 +398,7 @@ export function dispatchGenesisStoneExpedition( }>, ): StoneExpeditionDispatch { const resource = assertGenesisResourceForFid(ctx, input.fid); + assertLegacyExpeditionDispatchAllowed(ctx); const requestKey = requestKeyFor(input.fid, input.idempotencyKey); const prior = ctx.db.stoneExpeditionIdempotencyV1.requestKey.find(requestKey); if (prior !== null) { From 62568904ef1b917adf346da35457a6c12491c5e0 Mon Sep 17 00:00:00 2001 From: Ael <183124839+ael-dev3@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:05:49 +0200 Subject: [PATCH 05/10] Alpha 0.3.14 signed source (5/10) GitHub-signed reconstruction of the exact reviewed Alpha 0.3.14 tree. --- spacetimedb/src/woodExpeditionAuthority.ts | 2 + .../tests/castleWorkerAuthority.test.ts | 151 +++++ .../castleWorkerAuthorityBehavior.test.ts | 125 ++++ .../castleWorkerMigrationTooling.test.ts | 85 +++ spacetimedb/tests/castleWorkerPolicy.test.ts | 168 +++++ .../tests/foodExpeditionReducers.test.ts | 2 +- .../tests/goldExpeditionReducers.test.ts | 4 +- .../tests/playerIdentityPrivacy.test.ts | 6 + spacetimedb/tests/resourceReducers.test.ts | 6 + .../tests/stoneExpeditionReducers.test.ts | 2 +- .../tests/waterRevisionAuthority.test.ts | 2 +- .../waterRevisionMigrationTooling.test.ts | 10 +- .../tests/woodExpeditionReducers.test.ts | 2 +- src/components/WarpkeepExperience.tsx | 12 + src/components/menu/SettingsPanel.tsx | 3 + src/components/menu/latestPatchNotes.ts | 20 +- .../realm/RealmAccessibilityControls.tsx | 103 ++- src/components/realm/RealmHud.tsx | 160 ++++- src/components/realm/RealmMapScreen.css | 56 ++ src/components/realm/RealmMapScreen.tsx | 623 +++++++++++++++++- src/components/realm/WaterInspectionPanel.css | 1 + src/components/realm/WaterInspectionPanel.tsx | 171 +++++ src/components/realm/WorkerCommandCenter.css | 2 + src/components/realm/WorkerCommandCenter.tsx | 169 +++++ .../realm/WorkerInspectionPanel.css | 3 + 25 files changed, 1817 insertions(+), 71 deletions(-) create mode 100644 spacetimedb/tests/castleWorkerAuthority.test.ts create mode 100644 spacetimedb/tests/castleWorkerAuthorityBehavior.test.ts create mode 100644 spacetimedb/tests/castleWorkerMigrationTooling.test.ts create mode 100644 spacetimedb/tests/castleWorkerPolicy.test.ts create mode 100644 src/components/realm/WaterInspectionPanel.css create mode 100644 src/components/realm/WaterInspectionPanel.tsx create mode 100644 src/components/realm/WorkerCommandCenter.css create mode 100644 src/components/realm/WorkerCommandCenter.tsx create mode 100644 src/components/realm/WorkerInspectionPanel.css diff --git a/spacetimedb/src/woodExpeditionAuthority.ts b/spacetimedb/src/woodExpeditionAuthority.ts index 17d7bf73..59767d61 100644 --- a/spacetimedb/src/woodExpeditionAuthority.ts +++ b/spacetimedb/src/woodExpeditionAuthority.ts @@ -27,6 +27,7 @@ import { } from './resourceAuthorityPolicy'; import { assertGenesisResourceForFid } from './resourceAuthority'; import { + assertLegacyExpeditionDispatchAllowed, ResourceExpeditionReservationAuthorityError, activeExpeditionResourceReservations, planResourceSettlementForActiveExpeditionReservations, @@ -397,6 +398,7 @@ export function dispatchGenesisWoodExpedition( }>, ): WoodExpeditionDispatch { const resource = assertGenesisResourceForFid(ctx, input.fid); + assertLegacyExpeditionDispatchAllowed(ctx); const requestKey = requestKeyFor(input.fid, input.idempotencyKey); const prior = ctx.db.woodExpeditionIdempotencyV1.requestKey.find(requestKey); if (prior !== null) { diff --git a/spacetimedb/tests/castleWorkerAuthority.test.ts b/spacetimedb/tests/castleWorkerAuthority.test.ts new file mode 100644 index 00000000..44613420 --- /dev/null +++ b/spacetimedb/tests/castleWorkerAuthority.test.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +function source(path: string): string { + return readFileSync(new URL(path, import.meta.url), 'utf8'); +} + +function section(text: string, startNeedle: string, endNeedle: string): string { + const start = text.indexOf(startNeedle); + const end = text.indexOf(endNeedle, start + startNeedle.length); + assert.ok(start >= 0 && end > start, `missing section ${startNeedle}`); + return text.slice(start, end); +} + +test('worker lifecycle advances one synchronized revision and one schedule at a time', () => { + const authority = source('../src/castleWorkerAuthority.ts'); + const dispatch = section(authority, 'export function dispatchCastleWorker', 'function progressBasisPoints'); + const arrival = section(authority, 'function transitionWorkerArrival', 'function settleAndBeginReturnAt'); + const returning = section(authority, 'function beginWorkerReturn', 'function completeWorkerReturn'); + const complete = section(authority, 'function completeWorkerReturn', 'function transitionWorkerArrival'); + + assert.match(dispatch, /timelineRevision = safeNextU32\(worker\.timelineRevision/); + assert.equal(dispatch.match(/insertSchedule\(/g)?.length, 1); + assert.match(dispatch, /WORKER_SCHEDULE_STAGE_ARRIVAL/); + assert.match(dispatch, /revision: safeNextU64\(worker\.revision/); + + for (const transition of [arrival, returning]) { + assert.match(transition, /timelineRevision = safeNextU32\(assignment\.timelineRevision/); + assert.match(transition, /deleteSchedulesForAssignment\(ctx, assignment\.assignmentId\)/); + assert.match(transition, /revision: safeNextU64\(worker\.revision/); + assert.equal(transition.match(/insertSchedule\(/g)?.length, 1); + } + assert.match(arrival, /WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY/); + assert.match(returning, /WORKER_SCHEDULE_STAGE_RETURN_COMPLETE/); + assert.match(complete, /deleteSchedulesForAssignment\(ctx, assignment\.assignmentId\)/); + assert.match(complete, /workerAssignmentV1\.assignmentId\.delete\(assignment\.assignmentId\)/); +}); + +test('recall caps server-time accrual and persists replay-safe no-op receipts', () => { + const authority = source('../src/castleWorkerAuthority.ts'); + const recall = section(authority, 'export function recallCastleWorker', 'export function recallAllCastleWorkers'); + const recallAll = section(authority, 'export function recallAllCastleWorkers', 'export type WorkerGraphAggregate'); + const accrual = source('../src/castleWorkerPolicy.ts'); + + assert.match(recall, /now < assignment\.gatheringEndsAtMicros[\s\S]*assignment\.gatheringEndsAtMicros/); + assert.match(recall, /recallWorkerReceipt\([\s\S]*assignment === null \? undefined/); + assert.match(recallAll, /recallAllWorkersReceipt\([\s\S]*lastAssignmentId/); + assert.doesNotMatch(recallAll, /if \(lastAssignmentId === undefined\) return;/); + assert.match(authority, /WORKER_IDEMPOTENCY_RECEIPTS_PER_FID = 64/); + assert.match(authority, /workerCommandIdempotencyV1\.byFid\.filter\(fid\)/); + assert.match(accrual, /state\.phase === 'returning'[\s\S]*state\.returnStartedAtMicros/); +}); + +test('founding updates active roster readiness atomically and legacy dispatch is retired', () => { + const roster = source('../src/castleWorkerRoster.ts'); + const founding = source('../src/foundingAuthority.ts'); + const reservations = source('../src/resourceExpeditionReservationAuthority.ts'); + + assert.match(roster, /realmWorkerSystemV1\.realmId\.update\(\{[\s\S]*expectedCastleCount: nextCastleCount,[\s\S]*expectedWorkerCount: nextWorkerCount,[\s\S]*rosterDigest: appendCastleWorkerRosterDigest\(system\.rosterDigest, castle\.castleId\)/); + assert.doesNotMatch(roster, /ctx\.db\.castle\.iter\(\)|ctx\.db\.castleWorkerV1\.iter\(\)/); + assert.match(founding, /ensureCastleWorkerRoster\(ctx, existingCastle\)/); + assert.match(founding, /ensureCastleWorkerRoster\(ctx, castle\)/); + assert.match(reservations, /if \(workerSystem\?\.mode === 'active'\) fail\('LEGACY_EXPEDITION_DISPATCH_RETIRED'\)/); + for (const kind of ['gold', 'food', 'wood', 'stone']) { + const legacy = source(`../src/${kind}ExpeditionAuthority.ts`); + const dispatch = section(legacy, `export function dispatchGenesis${kind[0].toUpperCase()}${kind.slice(1)}Expedition`, 'const requestKey'); + assert.match(dispatch, /assertLegacyExpeditionDispatchAllowed\(ctx\)/); + } +}); + +test('worker reads use bounded indexes and public tables omit assignment correlation', () => { + const authority = source('../src/castleWorkerAuthority.ts'); + const reservations = source('../src/resourceExpeditionReservationAuthority.ts'); + const schema = source('../src/schema.ts'); + const settlement = section(authority, 'export function settleAllWorkerAssignmentsForFid', 'export type WorkerPrivateProjection'); + const active = section(authority, 'function workerSystemActive', 'function canonicalSiteFor'); + const callerGraph = section(authority, 'function assertCallerWorkerGraph', 'function pruneWorkerIdempotencyReceipts'); + const publicMatch = section(authority, 'function publicWorkerMatchesAssignment', 'function occupationMatchesAssignment'); + const occupationMatch = section(authority, 'function occupationMatchesAssignment', 'function canonicalCastleOwnershipMatches'); + + assert.match(settlement, /workerAssignmentV1\.byFid\.filter\(fid\)/); + assert.doesNotMatch(settlement, /workerAssignmentV1\.iter\(\)/); + assert.doesNotMatch(active, /\.iter\(\)|inspectCastleWorkerGraph/); + assert.match(callerGraph, /workerAssignmentV1\.byFid\.filter\(fid\)/); + assert.match(callerGraph, /workerNodeOccupationV1\.byWorker\.filter\(worker\.workerId\)/); + assert.match(callerGraph, /workerAssignmentScheduleV1\.byWorker\.filter\(worker\.workerId\)/); + assert.match(callerGraph, /workerCommandIdempotencyV1\.byFid\.filter\(fid\)/); + assert.doesNotMatch(callerGraph, /\.iter\(\)/); + assert.match(reservations, /workerAssignmentV1\.byFid\.filter\(fid\)/); + assert.match(publicMatch, /worker\.workerId === workerIdForCastle\(assignment\.originCastleId, worker\.ordinal\)/); + assert.match(publicMatch, /worker\.returnStartedAtMicros === assignment\.returnStartedAtMicros/); + assert.match(publicMatch, /worker\.returnStartProgressBasisPoints === expectedReturnProgress/); + for (const field of [ + 'nodeKey', 'resourceKind', 'siteId', 'workerId', 'workerOrdinal', + 'originCastleId', 'phase', 'startedAtMicros', 'arrivesAtMicros', + 'gatheringEndsAtMicros', 'timelineRevision', + ]) assert.match(occupationMatch, new RegExp(`occupation\\.${field}`)); + assert.match(occupationMatch, /assignment\.phase !== 'returning'/); + assert.doesNotMatch(section(schema, 'export const castleWorkerV1', 'export const workerAssignmentV1'), /assignmentId/); + assert.doesNotMatch(section(schema, 'export const workerNodeOccupationV1', 'export const workerCommandIdempotencyV1'), /assignmentId/); + assert.doesNotMatch(section(schema, 'export const workerAssignmentScheduleV1', 'const warpkeep = schema'), /public: true/); +}); + +test('worker assignments and replay receipts remain bound to canonical castle ownership', () => { + const authority = source('../src/castleWorkerAuthority.ts'); + const graph = section(authority, 'export function inspectCastleWorkerGraph', 'export function castleWorkerErrorCode'); + const dispatch = section(authority, 'export function dispatchCastleWorker', 'function progressBasisPoints'); + const recall = section(authority, 'export function recallCastleWorker', 'export function recallAllCastleWorkers'); + const recallAll = section(authority, 'export function recallAllCastleWorkers', 'export type WorkerGraphAggregate'); + + assert.match(authority, /function canonicalCastleOwnershipMatches[\s\S]*castle\.castleId\.find\(castleId\)[\s\S]*resourceAccountV1\.fid\.find\(fid\)/); + assert.match(graph, /if \(!assignmentOwnerIsCanonical\(ctx, assignment\)\) fail\('WORKER_OWNER_INTEGRITY'\)/); + assert.match(graph, /!workerReceiptShapeIsValid\(receipt\)[\s\S]*!receiptOwnerIsCanonical\(ctx, receipt\)/); + assert.match(graph, /!receiptOwnerIsCanonical\(ctx, receipt\)/); + assert.match(dispatch, /workerReceiptShapeIsValid\(prior\)/); + assert.match(recall, /recallReplayMatches\(prior, input\.fid, input\.workerId\)/); + assert.match(recallAll, /recallAllReplayMatches\(prior, input\.fid\)/); + for (const replay of [dispatch, recall, recallAll]) { + assert.match(replay, /receiptOwnerIsCanonical\(ctx, prior, input\.castle\.castleId\)/); + assert.match(replay, /canonicalCastleOwnershipMatches\(ctx, input\.fid, input\.castle\.castleId\)/); + } + assert.match(dispatch, /!assignmentOwnerIsCanonical\(ctx, assignment\)/); +}); + +test('admin readiness and sender errors fail closed on every bounded graph signal', () => { + const reducers = source('../src/reducers/castleWorkers.ts'); + const authority = source('../src/castleWorkerAuthority.ts'); + + for (const signal of [ + 'systemConfigValid', 'legacyDrainRequired', 'expectedCountsMatch', + 'rosterDigestMatches', 'invalidWorkerStates', 'assignmentsMissingOccupation', + 'assignmentsWithoutSingleSchedule', 'orphanSchedules', 'invalidSchedules', + 'invalidAssignments', 'invalidIdempotencyReceipts', 'idempotencyOverflowFids', + ]) assert.match(reducers, new RegExp(signal)); + assert.match(section(reducers, 'export const adminPlanWorkerRosterV1', '\n);'), /requireAdmin\(tx\)/); + assert.match(reducers, /throw new SenderError\('WORKER_REQUEST_FAILED'\)/); + assert.match(authority, /BOUNDED_WORKER_ERROR_CODE = \/\^\[A-Z\]\[A-Z0-9_\]\{0,63\}\$\//); +}); + +test('CI runs both static and real populated v11 to v12 migration proofs', () => { + const workflow = source('../../.github/workflows/verify.yml'); + const verifier = source('../../scripts/verify-spacetime-additive-migration.mjs'); + + assert.match(workflow, /npm run stdb:verify-worker-migration[\s\S]*npm run stdb:verify-additive-migration/); + assert.match(verifier, /function assertAdditiveV12Schema\(before, after\)/); + assert.match(verifier, /fixture_seed_generic_worker_sentinel_v12/); + assert.match(verifier, /populatedWaterStoneV12Rows/); + assert.match(verifier, /additiveV11SchemaFixture,[\s\S]{0,120}populatedWaterStoneMigrationDatabase,[\s\S]{0,40}false/); + assert.match(verifier, /every v12 table was populated, retained through the real candidate/); +}); diff --git a/spacetimedb/tests/castleWorkerAuthorityBehavior.test.ts b/spacetimedb/tests/castleWorkerAuthorityBehavior.test.ts new file mode 100644 index 00000000..397c2974 --- /dev/null +++ b/spacetimedb/tests/castleWorkerAuthorityBehavior.test.ts @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + recallAllReplayMatches, + recallAllWorkersReceipt, + recallReplayMatches, + recallWorkerReceipt, + takeBoundedRows, + workerCastleOwnershipMatches, + workerCommandReceiptShapeIsValid, + workerScheduleMatchesAssignment, +} from '../src/castleWorkerCommandPolicy'; + +test('canonical worker ownership rejects cross-FID and cross-castle authority', () => { + assert.equal(workerCastleOwnershipMatches({ + fid: 101n, + castleId: 7n, + castleOwnerFid: 101n, + accountFid: 101n, + accountCastleId: 7n, + }), true); + assert.equal(workerCastleOwnershipMatches({ + fid: 202n, + castleId: 7n, + castleOwnerFid: 101n, + accountFid: 202n, + accountCastleId: 9n, + }), false); + assert.equal(workerCastleOwnershipMatches({ + fid: 101n, + castleId: 7n, + castleOwnerFid: 101n, + accountFid: 101n, + accountCastleId: 8n, + }), false); +}); + +test('an idle recall receipt remains an exact harmless replay after reassignment', () => { + const receipt = recallWorkerReceipt( + '101:recall-noop-0001', + 101n, + 'genesis-001-castle-7-worker-01', + 4n, + ); + assert.equal(workerCommandReceiptShapeIsValid(receipt), true); + assert.equal(recallReplayMatches( + receipt, + 101n, + 'genesis-001-castle-7-worker-01', + ), true); + + const laterAssignment = Object.freeze({ + assignmentId: 'later-assignment', + workerId: 'genesis-001-castle-7-worker-01', + phase: 'outbound', + }); + assert.equal(recallReplayMatches(receipt, 101n, laterAssignment.workerId), true); + assert.equal(receipt.assignmentId, undefined); + assert.equal(receipt.resourceKind, undefined); + assert.equal(receipt.siteId, undefined); +}); + +test('recall and recall-all receipts distinguish correlation from replay-safe no-op', () => { + const correlated = recallWorkerReceipt( + '101:recall-active-01', + 101n, + 'genesis-001-castle-7-worker-02', + 9n, + { resourceKind: 'wood', siteId: 'logging-camp-001', assignmentId: 'assignment-01' }, + ); + const noOpAll = recallAllWorkersReceipt('101:recall-all-noop1', 101n, 9n); + assert.equal(workerCommandReceiptShapeIsValid(correlated), true); + assert.equal(workerCommandReceiptShapeIsValid(noOpAll), true); + assert.equal(recallAllReplayMatches(noOpAll, 101n), true); + assert.equal(recallAllReplayMatches(noOpAll, 202n), false); + assert.equal(recallReplayMatches(correlated, 101n, correlated.workerId!), true); + + assert.equal(workerCommandReceiptShapeIsValid({ + ...correlated, + assignmentId: undefined, + resourceKind: 'wood', + }), false); +}); + +test('stale schedule generations never match the current worker lifecycle', () => { + const assignment = Object.freeze({ + phase: 'outbound', + workerId: 'genesis-001-castle-7-worker-03', + timelineRevision: 8, + arrivesAtMicros: 2_000_000n, + gatheringEndsAtMicros: 3_000_000n, + returnsAtMicros: 4_000_000n, + }); + const current = Object.freeze({ + stage: 'arrival', + workerId: assignment.workerId, + timelineRevision: 8, + scheduledAt: Object.freeze({ + tag: 'Time', + value: Object.freeze({ microsSinceUnixEpoch: assignment.arrivesAtMicros }), + }), + }); + assert.equal(workerScheduleMatchesAssignment(current, assignment), true); + assert.equal(workerScheduleMatchesAssignment({ ...current, timelineRevision: 7 }, assignment), false); + assert.equal(workerScheduleMatchesAssignment({ ...current, stage: 'return-complete' }, assignment), false); + assert.equal(workerScheduleMatchesAssignment({ + ...current, + scheduledAt: { tag: 'Time', value: { microsSinceUnixEpoch: 2_000_001n } }, + }, assignment), false); +}); + +test('bounded caller reads consume only the one overflow row needed to fail closed', () => { + let reads = 0; + const rows = (function* manyRows() { + for (let index = 0; index < 100; index += 1) { + reads += 1; + yield index; + } + }()); + const result = takeBoundedRows(rows, 4); + assert.deepEqual(result.rows, [0, 1, 2, 3]); + assert.equal(result.overflow, true); + assert.equal(reads, 5); +}); diff --git a/spacetimedb/tests/castleWorkerMigrationTooling.test.ts b/spacetimedb/tests/castleWorkerMigrationTooling.test.ts new file mode 100644 index 00000000..27b7458f --- /dev/null +++ b/spacetimedb/tests/castleWorkerMigrationTooling.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +function source(path: string): string { + return readFileSync(new URL(path, import.meta.url), 'utf8'); +} + +function schemaRegistrations(text: string, marker: string): string[] { + const start = text.indexOf(marker); + const end = text.indexOf('\n);', start); + assert.ok(start >= 0 && end > start); + return text.slice(start + marker.length, end) + .split(/[,\n]/) + .map(value => value.trim()) + .filter(value => /^[A-Za-z][A-Za-z0-9]*$/.test(value)); +} + +function tableDefinition(text: string, name: string): string { + const start = text.indexOf(`const ${name} = table(`); + const end = [text.indexOf('\n);', start), text.indexOf('\n});', start)] + .filter(candidate => candidate > start) + .sort((left, right) => left - right)[0] ?? -1; + assert.ok(start >= 0 && end > start); + return text.slice(start, end); +} + +test('v12 fixture appends six generic-worker tables after the exact v11 prefix', () => { + const v11 = source('../migration-fixtures/additive-v11-schema/src/index.ts'); + const v12 = source('../migration-fixtures/additive-v12-schema/src/index.ts'); + const v11Registrations = schemaRegistrations(v11, 'const db = schema({'); + const v12Registrations = schemaRegistrations(v12, 'const db = schema({'); + assert.equal(v11Registrations.length, 47); + assert.deepEqual(v12Registrations.slice(0, 47), v11Registrations); + assert.deepEqual(v12Registrations.slice(47), [ + 'realmWorkerSystemV1', + 'castleWorkerV1', + 'workerAssignmentV1', + 'workerNodeOccupationV1', + 'workerCommandIdempotencyV1', + 'workerAssignmentScheduleV1', + ]); + assert.match(v12, /fixture_seed_generic_worker_sentinel_v12/); + assert.match(source('../migration-fixtures/additive-v12-schema/package.json'), /additive-v12-schema/); +}); + +test('public generic-worker rows exclude private ownership and accrual fields', () => { + const schema = source('../src/schema.ts'); + for (const name of ['realmWorkerSystemV1', 'castleWorkerV1', 'workerNodeOccupationV1']) { + const definition = tableDefinition(schema, name); + assert.match(definition, /public: true/); + assert.doesNotMatch(definition, /\bfid\b|assignmentId|accruedAmount|materializedAmount|balance|requestKey|auth/i); + } + const assignment = tableDefinition(schema, 'workerAssignmentV1'); + const idempotency = tableDefinition(schema, 'workerCommandIdempotencyV1'); + const schedule = tableDefinition(schema, 'workerAssignmentScheduleV1'); + assert.doesNotMatch(assignment, /public: true/); + assert.doesNotMatch(idempotency, /public: true/); + assert.doesNotMatch(schedule, /public: true/); + assert.match(assignment, /fid: t\.u64\(\)/); + assert.match(assignment, /accruedAmount: t\.u64\(\)/); + assert.match(idempotency, /requestKey: t\.string\(\)\.primaryKey\(\)/); + assert.doesNotMatch(tableDefinition(schema, 'castleWorkerV1'), /createdAt|updatedAt/); + assert.doesNotMatch( + tableDefinition(source('../migration-fixtures/additive-v12-schema/src/index.ts'), 'castleWorkerV1'), + /createdAt|updatedAt/, + ); +}); + +test('worker reducers are caller-bound and activation remains explicitly gated', () => { + const reducers = source('../src/reducers/castleWorkers.ts'); + const authority = source('../src/castleWorkerAuthority.ts'); + assert.match(reducers, /name: 'dispatch_worker_v1'/); + assert.match(reducers, /requireGameplayPlayerV1\(ctx\)/); + assert.match(reducers, /dispatchCastleWorker\(ctx, \{ fid: claims\.fid, castle/); + assert.match(reducers, /name: 'recall_all_workers_v1'/); + assert.match(reducers, /name: 'admin_plan_worker_roster_v1'/); + assert.match(authority, /if \(row\.mode !== 'active'\) fail\('WORKER_SYSTEM_STAGED'\)/); + assert.match(authority, /legacy\.expeditions !== 0n \|\| legacy\.occupations !== 0n \|\| legacy\.schedules !== 0n/); + assert.match(authority, /workerNodeOccupationV1\.nodeKey\.delete\(occupation\.nodeKey\)/); + assert.match(authority, /deleteSchedulesForAssignment/); + assert.match(authority, /WORKER_IDEMPOTENCY_RECEIPTS_PER_FID = 64/); + assert.match(authority, /planCastleWorkerAccrual\(assignment, observedAtMicros\)/); + assert.match(authority, /No[\s\S]{0,20}per-minute writes/); +}); diff --git a/spacetimedb/tests/castleWorkerPolicy.test.ts b/spacetimedb/tests/castleWorkerPolicy.test.ts new file mode 100644 index 00000000..a7358540 --- /dev/null +++ b/spacetimedb/tests/castleWorkerPolicy.test.ts @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS, + CASTLE_WORKER_POLICY_VERSION, + CASTLE_WORKERS_PER_CASTLE, + CastleWorkerPolicyError, + planCastleWorkerAccrual, + planCastleWorkerTimeline, + rosterDigestForCastleIds, + workerAssignmentStateIsConsistent, + workerIdForCastle, + workerResourceKinds, + workerResourcePolicy, +} from '../src/castleWorkerPolicy'; + +test('generic worker roster IDs are stable and exactly four per castle', () => { + assert.equal(CASTLE_WORKERS_PER_CASTLE, 4); + assert.deepEqual( + Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, index) => workerIdForCastle(42n, index + 1)), + [ + 'genesis-001-castle-42-worker-01', + 'genesis-001-castle-42-worker-02', + 'genesis-001-castle-42-worker-03', + 'genesis-001-castle-42-worker-04', + ], + ); + assert.notEqual(rosterDigestForCastleIds([42n, 7n]), rosterDigestForCastleIds([42n])); + assert.equal(rosterDigestForCastleIds([42n, 7n]), rosterDigestForCastleIds([7n, 42n])); +}); + +test('all four resource policies use the shared 60-second quantum and 30-day cap', () => { + assert.deepEqual(workerResourceKinds(), ['gold', 'food', 'wood', 'stone']); + for (const kind of workerResourceKinds()) { + const policy = workerResourcePolicy(kind); + assert.equal(policy.quantumMicros, 60_000_000n); + assert.equal(policy.gatheringDurationMicros, CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS); + assert.equal(policy.gatheringTotal, 43_200n * policy.ratePerQuantum); + } +}); + +test('timeline and accrual are server-time-only and quantum aligned', () => { + const timeline = planCastleWorkerTimeline(1_000_000n, 3); + assert.equal(timeline.arrivesAtMicros, 91_000_000n); + assert.equal(timeline.gatheringEndsAtMicros, 2_592_091_000_000n); + assert.equal(timeline.returnsAtMicros, 2_592_181_000_000n); + const policy = workerResourcePolicy('stone'); + const state = { + phase: 'gathering', + ...timeline, + returnStartedAtMicros: undefined, + routeSteps: 3, + returnStartProgressBasisPoints: 0, + settledThroughMicros: timeline.arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + resourceKind: 'stone', + policyVersion: CASTLE_WORKER_POLICY_VERSION, + } as const; + const plan = planCastleWorkerAccrual(state, timeline.arrivesAtMicros + 2n * policy.quantumMicros + 1n); + assert.equal(plan.completedQuanta, 2n); + assert.equal(plan.newlyAccruedAmount, 2n * policy.ratePerQuantum); + assert.equal(plan.settledThroughMicros, timeline.arrivesAtMicros + 2n * policy.quantumMicros); +}); + +test('early recall is structurally valid and permanently caps accrual at return start', () => { + const timeline = planCastleWorkerTimeline(1_000_000n, 3); + const policy = workerResourcePolicy('wood'); + const outboundRecall = { + phase: 'returning', + ...timeline, + returnStartedAtMicros: timeline.startedAtMicros + 30_000_000n, + returnsAtMicros: timeline.startedAtMicros + 59_997_000n, + routeSteps: 3, + returnStartProgressBasisPoints: 3_333, + settledThroughMicros: timeline.arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + resourceKind: 'wood', + policyVersion: CASTLE_WORKER_POLICY_VERSION, + } as const; + assert.equal(workerAssignmentStateIsConsistent(outboundRecall), true); + assert.deepEqual(planCastleWorkerAccrual(outboundRecall, timeline.gatheringEndsAtMicros), { + accruedAmount: 0n, + newlyAccruedAmount: 0n, + completedQuanta: 0n, + settledThroughMicros: timeline.arrivesAtMicros, + }); + + const returnStartedAtMicros = timeline.arrivesAtMicros + 2n * policy.quantumMicros + 30_000_000n; + const gatheringRecall = { + ...outboundRecall, + returnStartedAtMicros, + returnsAtMicros: returnStartedAtMicros + 90_000_000n, + returnStartProgressBasisPoints: 10_000, + }; + const capped = planCastleWorkerAccrual(gatheringRecall, timeline.gatheringEndsAtMicros); + assert.equal(capped.completedQuanta, 2n); + assert.equal(capped.accruedAmount, 2n * policy.ratePerQuantum); + assert.equal(capped.settledThroughMicros, timeline.arrivesAtMicros + 2n * policy.quantumMicros); + assert.equal( + planCastleWorkerAccrual({ + ...gatheringRecall, + settledThroughMicros: capped.settledThroughMicros, + accruedAmount: capped.accruedAmount, + materializedAmount: capped.accruedAmount, + }, timeline.gatheringEndsAtMicros).newlyAccruedAmount, + 0n, + ); +}); + +test('returning assignments fail closed without a bounded return-start cursor', () => { + const timeline = planCastleWorkerTimeline(1_000_000n, 1); + assert.equal(workerAssignmentStateIsConsistent({ + phase: 'returning', + ...timeline, + returnStartedAtMicros: undefined, + routeSteps: 1, + returnStartProgressBasisPoints: 0, + settledThroughMicros: timeline.arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + resourceKind: 'food', + policyVersion: CASTLE_WORKER_POLICY_VERSION, + }), false); +}); + +test('assignment timing cannot forge a shorter route or return-progress award', () => { + const timeline = planCastleWorkerTimeline(1_000_000n, 2); + const base = { + phase: 'outbound', + ...timeline, + returnStartedAtMicros: undefined, + routeSteps: 2, + returnStartProgressBasisPoints: 0, + settledThroughMicros: timeline.arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + resourceKind: 'stone', + policyVersion: CASTLE_WORKER_POLICY_VERSION, + } as const; + assert.equal(workerAssignmentStateIsConsistent(base), true); + assert.equal(workerAssignmentStateIsConsistent({ + ...base, + arrivesAtMicros: base.arrivesAtMicros - 1n, + }), false); + const returnStartedAtMicros = base.startedAtMicros + 30_000_000n; + assert.equal(workerAssignmentStateIsConsistent({ + ...base, + phase: 'returning', + returnStartedAtMicros, + returnStartProgressBasisPoints: 5_001, + returnsAtMicros: returnStartedAtMicros + 30_000_000n, + }), false); +}); + +test('policy rejects invalid resource kinds, roster ordinals, and routes', () => { + assert.throws(() => workerResourcePolicy('mana'), (error: unknown) => ( + error instanceof CastleWorkerPolicyError && error.code === 'WORKER_RESOURCE_UNSUPPORTED' + )); + assert.throws(() => workerIdForCastle(1n, 5), (error: unknown) => ( + error instanceof CastleWorkerPolicyError && error.code === 'WORKER_ROSTER_ORDINAL_INVALID' + )); + assert.throws(() => planCastleWorkerTimeline(0n, 0), (error: unknown) => ( + error instanceof CastleWorkerPolicyError && error.code === 'WORKER_ROUTE_INVALID' + )); +}); diff --git a/spacetimedb/tests/foodExpeditionReducers.test.ts b/spacetimedb/tests/foodExpeditionReducers.test.ts index dc88f138..101d6024 100644 --- a/spacetimedb/tests/foodExpeditionReducers.test.ts +++ b/spacetimedb/tests/foodExpeditionReducers.test.ts @@ -40,7 +40,7 @@ test('v7 Food tables remain intact through later additive suffixes', () => { const registrations = schemaRegistrations(schema); const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({')); assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations); - assert.deepEqual(registrations.slice(-27), [ + assert.deepEqual(registrations.slice(-33, -6), [ 'goldSiteV1', 'goldNodeOccupationV1', 'goldExpeditionV1', diff --git a/spacetimedb/tests/goldExpeditionReducers.test.ts b/spacetimedb/tests/goldExpeditionReducers.test.ts index 86218d53..0c9e3de9 100644 --- a/spacetimedb/tests/goldExpeditionReducers.test.ts +++ b/spacetimedb/tests/goldExpeditionReducers.test.ts @@ -40,7 +40,7 @@ test('v5 Gold authority prefix remains intact through later additive suffixes', const registrations = schemaRegistrations(schema); const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({')); assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations); - assert.deepEqual(registrations.slice(-27), [ + assert.deepEqual(registrations.slice(-33, -6), [ 'goldSiteV1', 'goldNodeOccupationV1', 'goldExpeditionV1', @@ -69,7 +69,7 @@ test('v5 Gold authority prefix remains intact through later additive suffixes', 'stoneExpeditionScheduleV1', 'realmWaterRevisionV1', ]); - assert.deepEqual(registrations.slice(-27, -22), [ + assert.deepEqual(registrations.slice(-33, -28), [ 'goldSiteV1', 'goldNodeOccupationV1', 'goldExpeditionV1', diff --git a/spacetimedb/tests/playerIdentityPrivacy.test.ts b/spacetimedb/tests/playerIdentityPrivacy.test.ts index 464485c1..f6b5d7b1 100644 --- a/spacetimedb/tests/playerIdentityPrivacy.test.ts +++ b/spacetimedb/tests/playerIdentityPrivacy.test.ts @@ -128,6 +128,7 @@ test('generated bindings contain the public projections and omit every private e const publicTableFiles = [ 'castle_slot_v_1_table.ts', 'castle_table.ts', + 'castle_worker_v_1_table.ts', 'food_expedition_schedule_v_1_table.ts', 'food_node_occupation_v_1_table.ts', 'food_site_v_1_table.ts', @@ -145,12 +146,14 @@ test('generated bindings contain the public projections and omit every private e 'realm_water_cell_v_1_table.ts', 'realm_water_layout_v_1_table.ts', 'realm_water_revision_v_1_table.ts', + 'realm_worker_system_v_1_table.ts', 'stone_expedition_schedule_v_1_table.ts', 'stone_node_occupation_v_1_table.ts', 'stone_site_v_1_table.ts', 'wood_expedition_schedule_v_1_table.ts', 'wood_node_occupation_v_1_table.ts', 'wood_site_v_1_table.ts', + 'worker_node_occupation_v_1_table.ts', 'world_tile_meta_v_1_table.ts', 'world_tile_table.ts', ]; @@ -228,6 +231,9 @@ test('generated bindings contain the public projections and omit every private e 'wallet_attribution_snapshot_v_1', 'wood_expedition_idempotency_v_1', 'wood_expedition_v_1', + 'worker_assignment_schedule_v_1', + 'worker_assignment_v_1', + 'worker_command_idempotency_v_1', ]; for (const stem of privateTableStems) { assert.equal(existsSync(new URL(`${stem}_table.ts`, bindingsRoot)), false); diff --git a/spacetimedb/tests/resourceReducers.test.ts b/spacetimedb/tests/resourceReducers.test.ts index 67308336..b5ae9377 100644 --- a/spacetimedb/tests/resourceReducers.test.ts +++ b/spacetimedb/tests/resourceReducers.test.ts @@ -78,6 +78,12 @@ test('resource and Gold prefixes remain intact through later additive suffixes', 'stoneExpeditionIdempotencyV1', 'stoneExpeditionScheduleV1', 'realmWaterRevisionV1', + 'realmWorkerSystemV1', + 'castleWorkerV1', + 'workerAssignmentV1', + 'workerNodeOccupationV1', + 'workerCommandIdempotencyV1', + 'workerAssignmentScheduleV1', ]); const account = tableDefinition(schema, 'resourceAccountV1'); diff --git a/spacetimedb/tests/stoneExpeditionReducers.test.ts b/spacetimedb/tests/stoneExpeditionReducers.test.ts index 9810701a..ed68b295 100644 --- a/spacetimedb/tests/stoneExpeditionReducers.test.ts +++ b/spacetimedb/tests/stoneExpeditionReducers.test.ts @@ -40,7 +40,7 @@ test('v10 Stone tables remain before the additive Water revision suffix', () => const registrations = schemaRegistrations(schema); const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({')); assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations); - assert.deepEqual(registrations.slice(-10), [ + assert.deepEqual(registrations.slice(-16, -6), [ 'realmWaterLayoutV1', 'realmWaterBodyV1', 'realmWaterCellV1', diff --git a/spacetimedb/tests/waterRevisionAuthority.test.ts b/spacetimedb/tests/waterRevisionAuthority.test.ts index 0a800f33..83a52c0c 100644 --- a/spacetimedb/tests/waterRevisionAuthority.test.ts +++ b/spacetimedb/tests/waterRevisionAuthority.test.ts @@ -62,5 +62,5 @@ test('the append-only public revision table stores policy without topology', () assert.match(revision, /navigationFogBoundaryDepthCells: t\.u32\(\)/); assert.match(revision, /activatedAt: t\.option\(t\.timestamp\(\)\)/); assert.doesNotMatch(revision, /\n\s*q:|\n\s*r:|cellKey:|bodyId:/); - assert.match(schema, /stoneExpeditionScheduleV1,\n\s*realmWaterRevisionV1,\n\}\);/); + assert.match(schema, /stoneExpeditionScheduleV1,\n\s*realmWaterRevisionV1,\n\s*realmWorkerSystemV1,\n\s*castleWorkerV1,\n\s*workerAssignmentV1,\n\s*workerNodeOccupationV1,\n\s*workerCommandIdempotencyV1,\n\s*workerAssignmentScheduleV1,\n\}\);/); }); diff --git a/spacetimedb/tests/waterRevisionMigrationTooling.test.ts b/spacetimedb/tests/waterRevisionMigrationTooling.test.ts index 04d2a5d2..6034984d 100644 --- a/spacetimedb/tests/waterRevisionMigrationTooling.test.ts +++ b/spacetimedb/tests/waterRevisionMigrationTooling.test.ts @@ -57,11 +57,11 @@ test('the auth-neutral v11 fixture extends the exact v10 table prefix at ref 46' assert.match(v11, /name: 'fixture_seed_water_revision_sentinel_v11'/); }); -test('the migration verifier proves v10 to v11 with populated state and no downgrade', () => { +test('the migration verifier retains the populated v10 to v11 proof inside protocol v12', () => { const verifier = source('../../scripts/verify-spacetime-additive-migration.mjs'); const receipt = source('../../scripts/spacetime-additive-migration-proof.mjs'); - assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 11/); + assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 12/); assert.match(verifier, /spacetimedb\/migration-fixtures\/additive-v11-schema/); assert.match(verifier, /const additiveV11Tables = Object\.freeze\(\[\s*'realm_water_revision_v1'/); assert.match(verifier, /realm_water_revision_v1: 46/); @@ -72,9 +72,9 @@ test('the migration verifier proves v10 to v11 with populated state and no downg assert.match(verifier, /populatedWaterStoneV10Rows/); assert.match(verifier, /populatedWaterStoneV11Rows/); assert.match(verifier, /additiveV10SchemaFixture,[\s\S]{0,120}populatedWaterStoneMigrationDatabase,[\s\S]{0,40}false/); - assert.match(verifier, /The immediate v11 -> v10 rollback must be refused/); + assert.match(verifier, /v12 boundary must refuse every predecessor/); assert.match(verifier, /deployedV11Tables/); - assert.match(verifier, /populated v10 Water\/Stone fixtures remained preserved through v11/); + assert.match(verifier, /populated Water\/Stone\/Water-revision fixtures remained preserved through v12/); assert.match(verifier, /stage = 'revision-base-precondition'/); assert.match(verifier, /stage = 'revision-inert-base-rejection'/); assert.match(verifier, /stage = 'revision-admin-denial'/); @@ -90,6 +90,6 @@ test('the migration verifier proves v10 to v11 with populated state and no downg assert.ok(inspectionFixtures.length >= 4); assert.deepEqual( new Set(inspectionFixtures), - new Set(['additiveV11SchemaFixture']), + new Set(['additiveV12SchemaFixture']), ); }); diff --git a/spacetimedb/tests/woodExpeditionReducers.test.ts b/spacetimedb/tests/woodExpeditionReducers.test.ts index 71109bf0..42ed2b47 100644 --- a/spacetimedb/tests/woodExpeditionReducers.test.ts +++ b/spacetimedb/tests/woodExpeditionReducers.test.ts @@ -40,7 +40,7 @@ test('v8 Wood tables remain intact through later additive suffixes', () => { const registrations = schemaRegistrations(schema); const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({')); assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations); - assert.deepEqual(registrations.slice(-27), [ + assert.deepEqual(registrations.slice(-33, -6), [ 'goldSiteV1', 'goldNodeOccupationV1', 'goldExpeditionV1', diff --git a/src/components/WarpkeepExperience.tsx b/src/components/WarpkeepExperience.tsx index ffa45db6..7d61fb4a 100644 --- a/src/components/WarpkeepExperience.tsx +++ b/src/components/WarpkeepExperience.tsx @@ -1128,6 +1128,18 @@ export function WarpkeepExperience() { onClaimStoneExpedition={backend.state.stoneExpedition === undefined ? undefined : backend.claimStoneExpedition} + workerProjection={backend.state.workerProjection} + workerRoster={backend.state.workerRoster} + workerResourceState={backend.state.workerResourceState} + onDispatchWorker={backend.state.workerProjection?.mode === 'active' + ? backend.dispatchWorker + : undefined} + onRecallWorker={backend.state.workerProjection?.mode === 'active' + ? backend.recallWorker + : undefined} + onRecallAllWorkers={backend.state.workerProjection?.mode === 'active' + ? backend.recallAllWorkers + : undefined} graphicsPreference={graphicsPreference} resolvedGraphicsQuality={resolvedGraphicsQuality} audioMuted={audioMuted} diff --git a/src/components/menu/SettingsPanel.tsx b/src/components/menu/SettingsPanel.tsx index 707e8e0d..83af3f48 100644 --- a/src/components/menu/SettingsPanel.tsx +++ b/src/components/menu/SettingsPanel.tsx @@ -32,6 +32,7 @@ const GRAPHICS_COPY: Readonly; export function SettingsPanel({ + id, audioMuted = false, closeLabel = 'BACK TO THE MENU', preference, @@ -66,6 +68,7 @@ export function SettingsPanel({ aria-labelledby="warpkeep-settings-title" aria-modal="true" className="warpkeep-settings__panel" + id={id} ref={dialogRef} role="dialog" > diff --git a/src/components/menu/latestPatchNotes.ts b/src/components/menu/latestPatchNotes.ts index 4872a27f..3c12f4c8 100644 --- a/src/components/menu/latestPatchNotes.ts +++ b/src/components/menu/latestPatchNotes.ts @@ -6,24 +6,24 @@ export type LatestPatchNotes = Readonly<{ alphaNotice: string; }>; -const ALPHA_0_3_13_PATCH_NOTES: LatestPatchNotes = Object.freeze({ - releasedOn: '19 JUL 2026', - title: 'THE LIVING LOWLANDS', +const ALPHA_0_3_14_PATCH_NOTES: LatestPatchNotes = Object.freeze({ + releasedOn: '22 JUL 2026', + title: 'A STEADIER FRONTIER', summary: - 'Genesis 001 breathes more naturally as rivers, forests, grasslands, and the ocean settle into a clearer frontier.', + 'Genesis 001 is steadier and easier to read, with resilient Realm rendering, selectable moving water, and a greener Lowlands.', highlights: Object.freeze([ - 'The old scattered lakes have receded into lowland, leaving twelve persistent one-cell rivers and the ocean around Genesis 001.', - 'You can pan through the coast and open water until the full fog boundary, including from the strategic overview.', - 'Grass now follows broad biome patterns and forests gather into natural groves while rivers, roads, keeps, and resource sites stay clear.', - 'Moving supply wagons can be selected, and the Realm menu now keeps every active expedition within reach.' + 'The Realm now recovers from temporary graphics interruptions while preserving your selection and camera intent.', + 'Castle rendering can continue at compact detail when optional richer models cannot load.', + 'Water surfaces now move gently when motion is enabled, and visible river and ocean cells can be selected for read-only public records.', + 'The Lowlands now use a clearer green palette and denser grass coverage without changing authoritative terrain, ownership, or resource rules.' ]), alphaNotice: - 'Alpha 0.3.13 is an unfinished, evolving world. Community feedback helps shape what is built next.' + 'Alpha 0.3.14 is an unfinished, evolving world. Four-worker gathering is staged for later and is not live yet; community feedback helps shape what is built next.' }); export const WARPKEEP_PATCH_NOTES_BY_VERSION: Readonly> = Object.freeze({ - '0.3.13': ALPHA_0_3_13_PATCH_NOTES + '0.3.14': ALPHA_0_3_14_PATCH_NOTES }); export function getLatestPatchNotes(productVersion: string) { diff --git a/src/components/realm/RealmAccessibilityControls.tsx b/src/components/realm/RealmAccessibilityControls.tsx index 5520c0e5..d748c7d8 100644 --- a/src/components/realm/RealmAccessibilityControls.tsx +++ b/src/components/realm/RealmAccessibilityControls.tsx @@ -25,6 +25,25 @@ export type RealmNavigatorCastle = Readonly<{ r: number; }>; +export type RealmNavigatorWaterBody = Readonly<{ + bodyId: string; + label: string; + sourceCellKey: string; + mouthCellKey: string; + sourceCoord: HexCoord; + mouthCoord: HexCoord; +}>; + +export type RealmNavigatorWorker = Readonly<{ + workerId: string; + ordinal: number; + originCastleId: number; + originCastleName: string; + status: 'idle' | 'outbound' | 'gathering' | 'returning'; + coord: HexCoord; + ownedByViewer: boolean; +}>; + export type RealmNavigatorCloseReason = 'escape' | 'close-button' | 'camera-preset'; export type RealmNavigatorCoordinateJump = Readonly<{ @@ -43,11 +62,16 @@ export type RealmAccessibilityControlsProps = Readonly<{ id: string; open: boolean; castles: readonly RealmNavigatorCastle[]; + workers?: readonly RealmNavigatorWorker[]; + waterBodies?: readonly RealmNavigatorWaterBody[]; ownCastleId?: number; selectedCastleId?: number; + selectedWorkerId?: string; onRequestOpen: () => void; onRequestClose: (reason: RealmNavigatorCloseReason) => void; onActivateCastle: (castle: RealmNavigatorCastle) => void; + onActivateWorker?: (worker: RealmNavigatorWorker) => void; + onActivateWaterCell?: (cellKey: string) => void; coordinateJump?: RealmNavigatorCoordinateJump; cameraPresets?: readonly RealmNavigatorCameraPreset[]; /** Player chrome may provide its own PFP launcher while reusing this dialog. */ @@ -75,11 +99,16 @@ export function RealmAccessibilityControls({ id, open, castles, + workers = [], + waterBodies = [], ownCastleId, selectedCastleId, + selectedWorkerId, onRequestOpen, onRequestClose, onActivateCastle, + onActivateWorker, + onActivateWaterCell, coordinateJump, cameraPresets = [], triggerVisible = true, @@ -126,6 +155,26 @@ export function RealmAccessibilityControls({ ? castles.filter((castle) => searchCopy(castle).includes(query)) : castles; }, [castles, search]); + const visibleWaterBodies = useMemo(() => { + const query = search.trim().toLocaleLowerCase(); + return query + ? waterBodies.filter((body) => ( + `${body.label} ${body.sourceCoord.q},${body.sourceCoord.r} ${body.mouthCoord.q},${body.mouthCoord.r}` + .toLocaleLowerCase() + .includes(query) + )) + : waterBodies; + }, [search, waterBodies]); + const visibleWorkers = useMemo(() => { + const query = search.trim().toLocaleLowerCase(); + return query + ? workers.filter((worker) => ( + `worker ${worker.ordinal} ${worker.originCastleName} ${worker.status} ${worker.coord.q},${worker.coord.r}` + .toLocaleLowerCase() + .includes(query) + )) + : workers; + }, [search, workers]); const handleDialogKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Escape') return; @@ -164,7 +213,7 @@ export function RealmAccessibilityControls({ + + ); + })} + + + ) : null} + + {visibleWaterBodies.length > 0 && onActivateWaterCell ? ( +

+ PUBLIC WATER +
    + {visibleWaterBodies.map((body) => ( +
  • +
    + {body.label} + source {body.sourceCoord.q},{body.sourceCoord.r} · mouth {body.mouthCoord.q},{body.mouthCoord.r} +
    + + +
    +
    +
  • + ))} +
+
+ ) : null} + {coordinateJump ? (
diff --git a/src/components/realm/RealmHud.tsx b/src/components/realm/RealmHud.tsx index 5e9eab42..4c0858c7 100644 --- a/src/components/realm/RealmHud.tsx +++ b/src/components/realm/RealmHud.tsx @@ -35,6 +35,14 @@ import { type RealmEconomicResourceKey } from './realmResourcePresentation'; import './RealmPlayerChrome.css'; +import { WorkerCommandCenter } from './WorkerCommandCenter'; +import { WorkerInspectionPanel } from './WorkerInspectionPanel'; +import type { + RealmWorkerDestinationPresentation, + ReadyWorkerProjection, + ReadyWorkerResourceState, + WorkerRosterPresentation +} from './realmWorkerPresentation'; type RealmHudProps = Readonly<{ identity: RealmIdentity; @@ -57,6 +65,16 @@ type RealmHudProps = Readonly<{ onRequestExplore?: () => void; activeWagons?: readonly RealmActiveWagonMenuItem[]; onOpenActiveWagon?: (wagon: RealmActiveWagonMenuItem) => void; + workerProjection?: ReadyWorkerProjection; + workerRoster?: WorkerRosterPresentation; + workerResourceState?: ReadyWorkerResourceState; + workerDestinations?: readonly RealmWorkerDestinationPresentation[]; + onDispatchWorker?: ( + workerId: string, + destination: RealmWorkerDestinationPresentation + ) => Promise; + onRecallWorker?: (workerId: string) => Promise; + onRecallAllWorkers?: () => Promise; onRecenterKeep: () => void; onRequestReturn: () => void; }>; @@ -115,7 +133,12 @@ const RESOURCE_ICON_PATHS: Readonly< }) }); -type RealmMenuSurface = 'closed' | 'menu' | 'settings'; +type RealmMenuSurface = 'closed' | 'menu' | 'settings' | 'workers' | 'worker-inspection'; + +const REALM_MENU_ID = 'realm-player-menu'; +const REALM_SETTINGS_ID = 'realm-player-settings'; +const REALM_WORKERS_ID = 'realm-worker-command-center'; +const REALM_WORKER_INSPECTION_ID = 'realm-worker-inspection'; function publicAssetUrl(path: string) { const base = import.meta.env.BASE_URL || '/'; @@ -142,8 +165,9 @@ function assignRef(ref: Ref | undefined, value: T | null) { } function RealmResourceRail({ - resources -}: Readonly<{ resources: ReadyRealmResourcePresentation }>) { + resources, + workerResourceState +}: Readonly<{ resources: ReadyRealmResourcePresentation; workerResourceState?: ReadyWorkerResourceState }>) { const tooltipIdPrefix = `realm-resource-tooltip-${useId().replace(/:/g, '')}`; const railRef = useRef(null); const [activeTooltip, setActiveTooltip] = useState(null); @@ -185,7 +209,9 @@ function RealmResourceRail({ } return { label: RESOURCE_LABELS[resource], - status: `${formatExactRealmResourceQuantity(resources.balances[resource]) ?? '0'} stored · ${formatExactRealmResourceQuantity(resources.pendingBalances[resource]) ?? '0'} ready to collect` + status: workerResourceState + ? `${formatExactRealmResourceQuantity(workerResourceState.available[resource]) ?? '0'} available` + : `${formatExactRealmResourceQuantity(resources.balances[resource]) ?? '0'} stored · ${formatExactRealmResourceQuantity(resources.pendingBalances[resource]) ?? '0'} ready to collect` }; }; @@ -219,14 +245,17 @@ function RealmResourceRail({ >
    {REALM_ECONOMIC_RESOURCE_ORDER.map((resource) => { - const compact = formatCompactRealmResourceQuantity(resources.balances[resource])!; - const exact = formatExactRealmResourceQuantity(resources.balances[resource])!; + const railValue = workerResourceState?.available[resource] ?? resources.balances[resource]; + const compact = formatCompactRealmResourceQuantity(railValue)!; + const exact = formatExactRealmResourceQuantity(railValue)!; const pending = formatExactRealmResourceQuantity(resources.pendingBalances[resource])!; return (
  • + {onWorkers && workerAvailability !== undefined ? ( + + ) : null} {onOpenActiveWagon ? (
    ) : null} {canOpenSettings ? ( - @@ -436,14 +489,31 @@ export function RealmHud({ onRequestExplore, activeWagons = [], onOpenActiveWagon, + workerProjection, + workerRoster, + workerResourceState, + workerDestinations = [], + onDispatchWorker, + onRecallWorker, + onRecallAllWorkers, onRecenterKeep, onRequestReturn }: RealmHudProps) { const [surface, setSurface] = useState('closed'); + const [selectedWorkerId, setSelectedWorkerId] = useState(undefined); const [collecting, setCollecting] = useState(false); const triggerRef = useRef(null); const wasOpenRef = useRef(false); - const menuId = 'realm-player-menu'; + const menuId = REALM_MENU_ID; + const controlledSurfaceId = surface === 'menu' + ? menuId + : surface === 'settings' + ? REALM_SETTINGS_ID + : surface === 'workers' + ? REALM_WORKERS_ID + : surface === 'worker-inspection' + ? REALM_WORKER_INSPECTION_ID + : undefined; const authoritativeKeepCoord = keepCoord ?? { q: 0, r: 0 }; const playerProfile: RealmCastlePublicPresentation = ownProfile ?? { canonicalUsername: identity.username, @@ -477,6 +547,25 @@ export function RealmHud({ const pendingYield = resources !== undefined && REALM_ECONOMIC_RESOURCE_ORDER.some( (resource) => resources.pendingBalances[resource] > 0n ); + const ownedWorkersForUi = workerProjection?.ownedWorkers.filter((worker) => worker.ownedByViewer); + const privateWorkerIds = new Set(workerRoster?.workers.map((worker) => worker.workerId) ?? []); + const authenticatedWorkerFid = Number.isSafeInteger(identity.fid) && identity.fid > 0 + ? BigInt(identity.fid) + : undefined; + const genericWorkersActive = authenticatedWorkerFid !== undefined + && workerResourceState?.fid === authenticatedWorkerFid + && workerProjection?.mode === 'active' + && ownedWorkersForUi?.length === 4 + && workerProjection.ownedWorkers.length === 4 + && workerRoster?.workers.length === 4 + && ownedWorkersForUi.every((worker) => privateWorkerIds.has(worker.workerId)) + && workerResourceState?.workerSystemMode === 'active'; + const availableWorkers = genericWorkersActive + ? ownedWorkersForUi.filter((worker) => worker.status === 'idle').length + : 0; + const selectedWorker = genericWorkersActive && selectedWorkerId + ? ownedWorkersForUi.find((worker) => worker.workerId === selectedWorkerId) + : undefined; if (selectionAnnouncementRef.current.key !== selectionAnnouncementKey) { selectionAnnouncementRef.current = { @@ -492,6 +581,19 @@ export function RealmHud({ wasOpenRef.current = surface !== 'closed'; }, [surface]); + useEffect(() => { + if (genericWorkersActive) { + if (surface === 'worker-inspection' && selectedWorker === undefined) { + setSurface('workers'); + } + return; + } + if (surface === 'workers' || surface === 'worker-inspection') { + setSelectedWorkerId(undefined); + setSurface('menu'); + } + }, [genericWorkersActive, selectedWorker, surface]); + const closeThen = (action: () => void) => { setSurface('closed'); action(); @@ -513,8 +615,8 @@ export function RealmHud({ return (
    - {resources ? : null} + {resources ? : null}

    setSurface('closed')} onCollect={() => void collect()} onExplore={() => closeThen(() => onRequestExplore?.())} @@ -556,11 +661,38 @@ export function RealmHud({ onRecenter={() => closeThen(onRecenterKeep)} onRequestReturn={() => closeThen(onRequestReturn)} onSettings={() => setSurface('settings')} + onWorkers={genericWorkersActive ? () => setSurface('workers') : undefined} + /> + ) : null} + + {genericWorkersActive && workerRoster && surface === 'workers' ? ( + setSurface('menu')} + onRecallAllWorkers={onRecallAllWorkers} + onRecallWorker={onRecallWorker} + onSelectWorker={(worker) => { + setSelectedWorkerId(worker.workerId); + setSurface('worker-inspection'); + }} + roster={workerRoster} + workers={ownedWorkersForUi} + /> + ) : null} + {genericWorkersActive && selectedWorker && surface === 'worker-inspection' ? ( + setSurface('workers')} + worker={selectedWorker} /> ) : null} {surface === 'settings' && onGraphicsPreferenceChange ? ( span, +.realm-cell-navigator__workers > span { + color: var(--realm-muted); + font-size: 0.6875rem; + font-weight: 800; + letter-spacing: 0.12em; +} + +.realm-cell-navigator__water-row { + display: grid; + gap: 0.28rem; + padding: 0.58rem 0.65rem; + border: 1px solid rgb(145 217 216 / 22%); + border-radius: 0.35rem; + background: rgb(19 59 70 / 24%); +} + +.realm-cell-navigator__water-row > div { + display: flex; + gap: 0.35rem; +} + +.realm-cell-navigator__water-row > div button { + width: auto; + padding: 0.28rem 0.45rem; + font-size: 0.625rem; +} + .realm-cell-navigator__community { display: grid; gap: 0.22rem; diff --git a/src/components/realm/RealmMapScreen.tsx b/src/components/realm/RealmMapScreen.tsx index 4ad09e52..a878429c 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -32,13 +32,20 @@ import type { } from '../../settings/graphicsPreference'; import type { CanonicalWarpkeepRealmSnapshot } from '../../spacetime/warpkeepBackendTypes'; import { isCanonicalGenesisSnapshot } from '../../spacetime/canonicalGenesisSnapshot'; -import type { ReadyRealmResourcePresentation } from './realmResourcePresentation'; +import type { + ReadyRealmResourcePresentation, + RealmEconomicResourceKey +} from './realmResourcePresentation'; import { CastleInspectionPanel } from './CastleInspectionPanel'; import { FoodFarmInspectionPanel } from './FoodFarmInspectionPanel'; import { GoldMineInspectionPanel } from './GoldMineInspectionPanel'; import { LoggingCampInspectionPanel } from './LoggingCampInspectionPanel'; import { StoneQuarryInspectionPanel } from './StoneQuarryInspectionPanel'; -import { RealmAccessibilityControls } from './RealmAccessibilityControls'; +import { WaterInspectionPanel } from './WaterInspectionPanel'; +import { + RealmAccessibilityControls, + type RealmNavigatorWorker +} from './RealmAccessibilityControls'; import { RealmCastleLabels, type CastleLabelRecord @@ -54,6 +61,11 @@ import { } from './createRealmScene'; import { resolveCanonicalWaterProjection } from './realmWaterProjection'; import { projectRealmWaterRevisionTerrainMetadata } from './realmWaterTerrainProjection'; +import { + realmWaterNavigatorBodies, + resolveRealmWaterInspectionRecords, + type RealmWaterInspectionRecord +} from './realmWaterInspectionPresentation'; import { resolveRealmGoldNodePresentations, type RealmGoldNodePresentation @@ -110,6 +122,7 @@ import type { RealmCastleProjectionFrame } from './realmTypes'; import { CASTLE_LABEL_LAYOUT_MAX_CASTLES, castleProfileLabel, + fallbackCastleIsInViewBox, fallbackCastleProjection, publicProfileForCastle, resolvePersistentCastleLabels, @@ -143,8 +156,26 @@ import { resolveRealmEscape, type RealmCameraTarget } from './realmInteractionState'; +import { + classifyRealmRendererFailure, + initialRealmRendererLifecycle, + REALM_RENDERER_CONTEXT_RESTORE_TIMEOUT_MS, + shouldRetryRealmRenderer, + transitionRealmRendererLifecycle, + type RealmRendererFailure, + type RealmRendererLifecycle +} from './realmRendererRecovery'; import './RealmMapScreen.css'; import './RealmCastlePresentation.css'; +import { WorkerInspectionPanel } from './WorkerInspectionPanel'; +import type { RealmWorkerSceneRecord } from './realmWorkerLayer'; +import type { + ReadyWorkerProjection, + ReadyWorkerResourceState, + RealmWorkerDestinationPresentation, + RealmWorkerPublicPresentation, + WorkerRosterPresentation +} from './realmWorkerPresentation'; export { BLOCKED_SHARED_FOREST_PROJECTION_SIGNATURE, @@ -183,6 +214,16 @@ type RealmMapScreenProps = Readonly<{ onDispatchStoneExpedition?: (siteId: string) => Promise; /** Guarded owner-only Stone settlement reducer; never supplied to observers. */ onClaimStoneExpedition?: () => Promise; + workerProjection?: ReadyWorkerProjection; + workerRoster?: WorkerRosterPresentation; + workerResourceState?: ReadyWorkerResourceState; + onDispatchWorker?: ( + workerId: string, + resourceKind: RealmEconomicResourceKey, + siteId: string + ) => Promise; + onRecallWorker?: (workerId: string) => Promise; + onRecallAllWorkers?: () => Promise; graphicsPreference?: GraphicsPreference; resolvedGraphicsQuality?: GraphicsQualityTier; audioMuted?: boolean; @@ -273,6 +314,12 @@ function CanonicalRealmMapScreen({ stoneExpedition, onDispatchStoneExpedition, onClaimStoneExpedition, + workerProjection, + workerRoster, + workerResourceState, + onDispatchWorker, + onRecallWorker, + onRecallAllWorkers, graphicsPreference, resolvedGraphicsQuality, audioMuted, @@ -308,6 +355,7 @@ function CanonicalRealmMapScreen({ const fallbackMapRef = useRef(null); const sceneRef = useRef(null); const inspectorFocusRef = useRef(null); + const workerInspectorFocusRef = useRef(null); const navigatorTriggerRef = useRef(null); const inspectorId = useId(); const navigatorId = useId(); @@ -322,6 +370,20 @@ function CanonicalRealmMapScreen({ () => projectRealmWaterRevisionTerrainMetadata(sharedTileMetadata, waterCells), [sharedTileMetadata, waterCells] ); + const waterRecords = useMemo( + () => resolveRealmWaterInspectionRecords(waterCells, projectedTileMetadata), + [projectedTileMetadata, waterCells] + ); + const waterRecordsByKey = useMemo( + () => new Map(waterRecords.map((record) => [record.cellKey, record] as const)), + [waterRecords] + ); + const waterRecordsByKeyRef = useRef>(waterRecordsByKey); + waterRecordsByKeyRef.current = waterRecordsByKey; + const navigatorWaterBodies = useMemo( + () => realmWaterNavigatorBodies(waterRecords), + [waterRecords] + ); const otherCastles = snapshot.castles; const surface = useMemo( () => createAuthoritativeRealmTerrainSurface( @@ -370,6 +432,8 @@ function CanonicalRealmMapScreen({ }, [otherCastles, ownCastle, surface]); const allCastlesRef = useRef(allCastles); allCastlesRef.current = allCastles; + const workerProjectionRef = useRef(workerProjection); + workerProjectionRef.current = workerProjection; const expectedCastleCountRef = useRef(allCastles.length); expectedCastleCountRef.current = allCastles.length; const resolvedGoldNodes = useMemo(() => ( @@ -456,6 +520,85 @@ function CanonicalRealmMapScreen({ const stoneNodesBySiteId = useMemo(() => new Map( stoneNodes.map((node) => [node.siteId, node] as const) ), [stoneNodes]); + const workerDestinations = useMemo(() => { + if (workerProjection?.mode !== 'active') return Object.freeze([]); + const occupied = new Set(workerProjection.occupations.map((occupation) => occupation.nodeKey)); + const destinations: RealmWorkerDestinationPresentation[] = []; + const append = ( + resourceKind: RealmEconomicResourceKey, + resourceLabel: string, + nodes: readonly GatheringNodePresentation[] + ) => { + for (const node of nodes) { + if (node.availability !== 'available' || occupied.has(`${resourceKind}:${node.siteId}`)) { + continue; + } + destinations.push(Object.freeze({ + resourceKind, + siteId: node.siteId, + label: `${resourceLabel} · Tier ${node.tier} · cell ${node.coord.q}, ${node.coord.r}` + })); + } + }; + append('food', 'Wheat Farm', foodNodes); + append('wood', 'Logging Camp', woodNodes); + append('stone', 'Stone Quarry', stoneNodes); + append('gold', 'Gold Mine', goldNodes); + return Object.freeze(destinations); + }, [foodNodes, goldNodes, stoneNodes, woodNodes, workerProjection]); + const workerSceneRecords = useMemo(() => { + if (observerMode || workerProjection?.mode !== 'active') return Object.freeze([]); + const castlesById = new Map(allCastles.map((castle) => [castle.castleId, castle] as const)); + const sitesByResource = Object.freeze({ + food: foodNodesBySiteId, + wood: woodNodesBySiteId, + stone: stoneNodesBySiteId, + gold: goldNodesBySiteId + }); + const records: RealmWorkerSceneRecord[] = []; + for (const worker of workerProjection.workers) { + const origin = castlesById.get(worker.originCastleId); + if (!origin) return Object.freeze([]); + const destination = worker.resourceKind && worker.siteId + ? sitesByResource[worker.resourceKind].get(worker.siteId) + : undefined; + if (worker.status !== 'idle' && destination === undefined) return Object.freeze([]); + records.push(Object.freeze({ + ...worker, + originCoord: Object.freeze({ q: origin.q, r: origin.r }), + ...(destination === undefined + ? {} + : { destinationCoord: Object.freeze({ ...destination.coord }) }) + })); + } + return Object.freeze(records); + }, [ + allCastles, + foodNodesBySiteId, + goldNodesBySiteId, + observerMode, + stoneNodesBySiteId, + woodNodesBySiteId, + workerProjection + ]); + const workerSceneCatalogKey = observerMode + ? '' + : workerProjection?.system.rosterDigest ?? ''; + const navigatorWorkers = useMemo(() => ( + workerSceneRecords.map((worker) => Object.freeze({ + workerId: worker.workerId, + ordinal: worker.ordinal, + originCastleId: worker.originCastleId, + originCastleName: worker.originCastleName, + status: worker.status, + coord: Object.freeze({ + ...(worker.status === 'outbound' || worker.status === 'gathering' + ? worker.destinationCoord ?? worker.originCoord + : worker.originCoord) + }), + ownedByViewer: worker.ownedByViewer + })) + ), [workerSceneRecords]); const activeWagons = useMemo(() => { if (observerMode) return Object.freeze([]); const items: RealmActiveWagonMenuItem[] = []; @@ -532,14 +675,29 @@ function CanonicalRealmMapScreen({ occupation.returnsAtMicros ].reduce((latest, candidate) => candidate > latest ? candidate : latest, observedAtMicros); } + for (const worker of workerSceneRecords) { + for (const candidate of [ + worker.startedAtMicros, + worker.arrivesAtMicros, + worker.gatheringEndsAtMicros, + worker.returnStartedAtMicros, + worker.returnsAtMicros + ]) { + if (candidate !== undefined && candidate > observedAtMicros) observedAtMicros = candidate; + } + } + if (workerRoster && workerRoster.observedAtMicros > observedAtMicros) { + observedAtMicros = workerRoster.observedAtMicros; + } return Object.freeze({ goldNodes, foodNodes, woodNodes, stoneNodes, + workers: workerSceneRecords, observedAtMicros }); - }, [foodNodes, goldNodes, stoneNodes, woodNodes]); + }, [foodNodes, goldNodes, stoneNodes, woodNodes, workerRoster, workerSceneRecords]); const profileRecords = useMemo(() => { return new Map(allCastles.map((castle) => [ castle.castleId, @@ -593,9 +751,25 @@ function CanonicalRealmMapScreen({ }), [surface, terrainPlacements, tileMetadataByKey]); const quality = useMemo(() => initialQuality(qualityOverride), [qualityOverride]); const qualitySpec = REALM_QUALITY_SPECS[quality]; - const [rendererMode, setRendererMode] = useState('loading'); + const [rendererLifecycle, setRendererLifecycle] = useState( + initialRealmRendererLifecycle + ); + const rendererMode: RendererMode = rendererLifecycle.state === 'static-unsupported' + ? 'fallback' + : rendererLifecycle.state === 'ready' ? 'webgl' : 'loading'; const rendererModeRef = useRef('loading'); rendererModeRef.current = rendererMode; + const rendererLifecycleRef = useRef(rendererLifecycle); + rendererLifecycleRef.current = rendererLifecycle; + // Scene callbacks can arrive after React has already retired their effect. + // Keep the active generation in a synchronous ref so stale scenes cannot + // publish ready/failure state into a newer renderer. + const activeRendererGenerationRef = useRef(0); + const lastSuccessfulRendererGenerationRef = useRef(0); + const rendererRecoveryTimerRef = useRef(null); + const rendererRecoveryNonceRef = useRef(0); + const [rendererRecoveryNonce, setRendererRecoveryNonce] = useState(0); + const rendererAttestationRef = useRef | null>(null); const [cameraMode, setCameraMode] = useState('realm'); const [interaction, dispatchInteraction] = useReducer( realmInteractionReducer, @@ -623,10 +797,21 @@ function CanonicalRealmMapScreen({ const handledKeyboardIntentSequenceRef = useRef(-1); const reducedMotion = useReducedMotionPreference(); const fallbackSurface = useMemo( - () => fallbackSurfacePresentation(surface), - [surface] + () => fallbackSurfacePresentation(surface, { focusCoord: keepCoord, radius: 16 }), + [keepCoord, surface] ); const viewBox = fallbackSurface.viewBox; + const fallbackVisibleCastleIds = useMemo(() => new Set( + allCastles + .filter((castle) => fallbackCastleIsInViewBox(castle, viewBox)) + .map((castle) => castle.castleId) + ), [allCastles, viewBox]); + const visibleFallbackFoundations = useMemo(() => fallbackFoundations.filter((foundation) => { + const castleId = foundation.id === 'own-keep' + ? ownCastle.castleId + : Number(foundation.id.replace(/^peer-castle-/, '')); + return fallbackVisibleCastleIds.has(castleId); + }), [fallbackFoundations, fallbackVisibleCastleIds, ownCastle.castleId]); const selectedCell = selectedCellFor(surface, selectedCoord, keepCoord); const selectedTerrainKindCandidate = tileMetadataByKey.get(hexKey(selectedCoord))?.terrainKind; const selectedTerrainKind = isRealmTerrainKind(selectedTerrainKindCandidate) @@ -657,10 +842,23 @@ function CanonicalRealmMapScreen({ && 'stoneSiteId' in selectedInspectorTarget ? stoneNodesBySiteId.get(selectedInspectorTarget.stoneSiteId) : undefined; + const inspectorWater = selectedInspectorTarget !== null + && 'cellKey' in selectedInspectorTarget + ? waterRecordsByKey.get(selectedInspectorTarget.cellKey) + : undefined; + const inspectorWorker = selectedInspectorTarget !== null + && 'workerId' in selectedInspectorTarget + ? workerProjection?.workers.find((worker) => ( + worker.workerId === selectedInspectorTarget.workerId + && worker.ordinal === selectedInspectorTarget.workerOrdinal + && worker.originCastleId === selectedInspectorTarget.originCastleId + )) + : undefined; const goldNodeAtSelectedCell = goldNodes.find((node) => sameCoord(node.coord, selectedCoord)); const foodNodeAtSelectedCell = foodNodes.find((node) => sameCoord(node.coord, selectedCoord)); const woodNodeAtSelectedCell = woodNodes.find((node) => sameCoord(node.coord, selectedCoord)); const stoneNodeAtSelectedCell = stoneNodes.find((node) => sameCoord(node.coord, selectedCoord)); + const waterAtSelectedCell = waterRecordsByKey.get(hexKey(selectedCoord)); const ownProfile = profileRecords.get(ownCastle.castleId)?.profile; const focusedCastleId = interaction.cameraTarget.kind === 'castle' ? interaction.cameraTarget.castleId @@ -723,6 +921,8 @@ function CanonicalRealmMapScreen({ rootRef.current?.focus({ preventScroll: true }); } else if (target.kind === 'inspector') { inspectorFocusRef.current?.focus({ preventScroll: true }); + } else if (target.kind === 'worker-inspector') { + workerInspectorFocusRef.current?.focus({ preventScroll: true }); } else if (target.kind === 'gold-mine-inspector') { inspectorFocusRef.current?.focus({ preventScroll: true }); } else if (target.kind === 'food-farm-inspector') { @@ -731,6 +931,8 @@ function CanonicalRealmMapScreen({ inspectorFocusRef.current?.focus({ preventScroll: true }); } else if (target.kind === 'stone-quarry-inspector') { inspectorFocusRef.current?.focus({ preventScroll: true }); + } else if (target.kind === 'water-inspector') { + inspectorFocusRef.current?.focus({ preventScroll: true }); } else if (target.kind === 'castle-label') { const label = rootRef.current ?.querySelector(`.realm-castle-label[data-castle-id="${target.castleId}"]`) @@ -833,6 +1035,31 @@ function CanonicalRealmMapScreen({ if (focusSite) sceneRef.current?.focusCell(node.coord); }, []); + const selectWaterCell = useCallback((record: RealmWaterInspectionRecord, focusWater = true) => { + selectedCoordRef.current = { ...record.coord }; + dispatchInteraction({ + type: 'activate-water-cell', + cellKey: record.cellKey, + bodyId: record.bodyId, + regime: record.regime, + coord: record.coord, + cameraIntent: focusWater ? 'focus-water' : 'preserve' + }); + if (focusWater) sceneRef.current?.focusWaterCell?.(record.cellKey); + }, []); + + const selectWorker = useCallback((worker: RealmWorkerPublicPresentation, coord: HexCoord) => { + selectedCoordRef.current = { ...coord }; + dispatchInteraction({ + type: 'activate-worker', + workerId: worker.workerId, + workerOrdinal: worker.ordinal, + originCastleId: worker.originCastleId, + coord + }); + sceneRef.current?.focusCell(coord); + }, []); + const openActiveWagon = useCallback((wagon: RealmActiveWagonMenuItem) => { if (!activeWagons.some((candidate) => ( candidate.resource === wagon.resource && candidate.siteId === wagon.siteId @@ -866,9 +1093,78 @@ function CanonicalRealmMapScreen({ woodNodesBySiteId ]); - const markRendererUnavailable = useCallback(() => { - rendererModeRef.current = 'fallback'; - setRendererMode('fallback'); + const markRendererFailure = useCallback((failureInput?: RealmRendererFailure | unknown) => { + const current = rendererLifecycleRef.current; + const failure = failureInput && typeof failureInput === 'object' && 'code' in failureInput + ? failureInput as RealmRendererFailure + : classifyRealmRendererFailure(failureInput, current.state); + if (failure.code === 'webgl-unavailable') { + rendererModeRef.current = current.everReady ? 'loading' : 'fallback'; + const nextLifecycle = transitionRealmRendererLifecycle(current, { + type: 'webgl-unsupported', + failure + }); + rendererLifecycleRef.current = nextLifecycle; + setRendererLifecycle(nextLifecycle); + return; + } + // Stop accepting pointer/camera mutations synchronously, before React has + // committed the loading/recovering state to the DOM. The scene itself + // applies the same guard while a WebGL context is lost. + rendererModeRef.current = 'loading'; + if (shouldRetryRealmRenderer(current, failure)) { + const nextAttempt = current.attempt + 1; + const nextLifecycle = transitionRealmRendererLifecycle(current, { + type: 'recover', + failure, + attempt: nextAttempt + }); + rendererLifecycleRef.current = nextLifecycle; + setRendererLifecycle(nextLifecycle); + if (failure.code !== 'context-lost') { + rendererRecoveryNonceRef.current += 1; + setRendererRecoveryNonce(rendererRecoveryNonceRef.current); + } + if (failure.code === 'context-lost') { + if (rendererRecoveryTimerRef.current !== null) window.clearTimeout(rendererRecoveryTimerRef.current); + rendererRecoveryTimerRef.current = window.setTimeout(() => { + rendererRecoveryTimerRef.current = null; + const latest = rendererLifecycleRef.current; + const timeoutFailure: RealmRendererFailure = { + code: 'context-restore-timeout', + retryable: true, + phase: latest.state, + message: 'The browser did not restore the Realm graphics context in time.' + }; + const failedLifecycle = transitionRealmRendererLifecycle(latest, { + type: 'failed', + failure: timeoutFailure + }); + rendererLifecycleRef.current = failedLifecycle; + setRendererLifecycle(failedLifecycle); + }, REALM_RENDERER_CONTEXT_RESTORE_TIMEOUT_MS); + } + return; + } + const failedLifecycle = transitionRealmRendererLifecycle(current, { type: 'failed', failure }); + rendererLifecycleRef.current = failedLifecycle; + setRendererLifecycle(failedLifecycle); + }, []); + + const retryRenderer = useCallback(() => { + if (rendererRecoveryTimerRef.current !== null) { + window.clearTimeout(rendererRecoveryTimerRef.current); + rendererRecoveryTimerRef.current = null; + } + const loadingLifecycle = transitionRealmRendererLifecycle(rendererLifecycleRef.current, { + type: 'load-start', + attempt: 0 + }); + rendererLifecycleRef.current = loadingLifecycle; + setRendererLifecycle(loadingLifecycle); + rendererModeRef.current = 'loading'; + rendererRecoveryNonceRef.current += 1; + setRendererRecoveryNonce(rendererRecoveryNonceRef.current); }, []); const isSceneCoordPassable = useCallback((coord: HexCoord) => ( @@ -889,6 +1185,25 @@ function CanonicalRealmMapScreen({ // The scene reserves its restrained ground outline for unoccupied terrain; // castle identity and raycasting provide the occupied-cell cue without a // depth-tested line cutting through the wider authored landscape base. + if (target?.kind === 'water-cell') { + sceneRef.current?.setHoveredWorkerId?.(null); + updateHoveredCastleId(undefined); + hoveredCoordRef.current = null; + sceneRef.current?.setHoveredWaterCellKey?.(target.cellKey); + sceneRef.current?.setHovered(null); + return; + } + if (target?.kind === 'worker') { + sceneRef.current?.setHoveredWaterCellKey?.(null); + updateHoveredCastleId(undefined); + updateHoveredCoord(null); + // setHovered intentionally clears worker hover, so the worker lane must + // be applied last and remain the sole visual cue for this target. + sceneRef.current?.setHoveredWorkerId?.(target.workerId); + return; + } + sceneRef.current?.setHoveredWorkerId?.(null); + sceneRef.current?.setHoveredWaterCellKey?.(null); updateHoveredCoord(target?.coord ?? null); updateHoveredCastleId(target?.kind === 'castle' ? target.castleId : undefined); }, [updateHoveredCastleId, updateHoveredCoord]); @@ -900,6 +1215,15 @@ function CanonicalRealmMapScreen({ if (castle) selectCastle(castle); return; } + if (target.kind === 'worker') { + const worker = workerProjectionRef.current?.workers.find((candidate) => ( + candidate.workerId === target.workerId + && candidate.ordinal === target.workerOrdinal + && candidate.originCastleId === target.originCastleId + )); + if (worker) selectWorker(worker, target.coord); + return; + } if (target.kind === 'gold-site') { const node = goldNodesRef.current.find((candidate) => candidate.siteId === target.siteId); if (node) selectGoldNode(node, target.source !== 'wagon'); @@ -920,8 +1244,13 @@ function CanonicalRealmMapScreen({ if (node) selectStoneNode(node, target.source !== 'wagon'); return; } + if (target.kind === 'water-cell') { + const record = waterRecordsByKeyRef.current.get(target.cellKey); + if (record) selectWaterCell(record); + return; + } selectCoord(target.coord); - }, [selectCastle, selectCoord, selectFoodNode, selectGoldNode, selectStoneNode, selectWoodNode]); + }, [selectCastle, selectCoord, selectFoodNode, selectGoldNode, selectStoneNode, selectWaterCell, selectWoodNode, selectWorker]); const updateCastleProjection = useCallback((frame: RealmCastleProjectionFrame) => { latestProjectionRef.current = frame; @@ -1077,6 +1406,7 @@ function CanonicalRealmMapScreen({ root.dataset.semanticTerrainKindCount = String(telemetry.semanticKindCount); root.dataset.semanticTerrainFeatureCount = String(telemetry.semanticFeatureCount); root.dataset.semanticTerrainFeatureDrawCalls = String(telemetry.semanticFeatureDrawCalls); + root.dataset.semanticTerrainFeatureCounts = JSON.stringify(telemetry.semanticFeatureCounts); root.dataset.totalTerrainDetailInstanceCount = String(telemetry.totalDetailInstanceCount); root.dataset.totalTerrainDetailDrawCalls = String(telemetry.totalDetailDrawCalls); root.dataset.forestPlacementSource = telemetry.forestPlacementSource; @@ -1089,7 +1419,18 @@ function CanonicalRealmMapScreen({ root.dataset.grassCacheEntries = String(telemetry.grassCacheEntries); root.dataset.grassAnimated = String(telemetry.grassAnimated); root.dataset.grassTargetAnimationCadence = String(telemetry.grassTargetAnimationCadence); + root.dataset.grassCandidateCellsByTerrain = JSON.stringify( + telemetry.grassCandidateCellsByTerrain + ); + root.dataset.grassActiveCellsByTerrain = JSON.stringify(telemetry.grassActiveCellsByTerrain); root.dataset.grassCountsByTerrain = JSON.stringify(telemetry.grassCountsByTerrain); + root.dataset.grassAverageRetainedPatchesByTerrain = JSON.stringify( + telemetry.grassAverageRetainedPatchesByTerrain + ); + root.dataset.grassPaletteLuminanceMin = String(telemetry.grassPaletteLuminanceMin); + root.dataset.grassPaletteLuminanceMax = String(telemetry.grassPaletteLuminanceMax); + root.dataset.grassPaletteGreenMin = String(telemetry.grassPaletteGreenMin); + root.dataset.grassPaletteGreenMax = String(telemetry.grassPaletteGreenMax); root.dataset.grassCompletelyBareActiveCells = String(telemetry.grassCompletelyBareActiveCells); root.dataset.grassRejectedByStructureClearance = String( telemetry.grassRejectedByStructureClearance @@ -1130,7 +1471,7 @@ function CanonicalRealmMapScreen({ root.querySelectorAll( '.realm-hud, .realm-hud__actions, .realm-profile-trigger, .realm-resource-rail, ' + '.castle-inspection, .gold-mine-inspection, .food-farm-inspection, .logging-camp-inspection, ' - + '.stone-quarry-inspection, .realm-cell-navigator' + + '.stone-quarry-inspection, .realm-cell-navigator, .water-inspection' ).forEach((element) => observer?.observe(element)); } window.addEventListener('resize', updateSceneComposition, { passive: true }); @@ -1189,14 +1530,26 @@ function CanonicalRealmMapScreen({ useEffect(() => { const canvas = canvasRef.current; if (!canvas || !canUseWebGL()) { - markRendererUnavailable(); + markRendererFailure({ + code: 'webgl-unavailable', + retryable: true, + phase: 'probing', + message: 'WebGL is unavailable on this device.' + }); return undefined; } let scene: RealmSceneHandle | null = null; + const rendererGeneration = rendererLifecycleRef.current.generation + 1; + const loadingLifecycle = transitionRealmRendererLifecycle(rendererLifecycleRef.current, { + type: 'load-start', + attempt: rendererLifecycleRef.current.attempt, + generation: rendererGeneration + }); + activeRendererGenerationRef.current = rendererGeneration; + rendererLifecycleRef.current = loadingLifecycle; try { - rendererModeRef.current = 'loading'; - setRendererMode('loading'); + setRendererLifecycle(loadingLifecycle); latestProjectionRef.current = { width: 0, height: 0, castles: [] }; labelMembershipSignatureRef.current = ''; latestVisibleCastleLabelsRef.current = []; @@ -1209,6 +1562,7 @@ function CanonicalRealmMapScreen({ rootRef.current.dataset.semanticTerrainKindCount = '0'; rootRef.current.dataset.semanticTerrainFeatureCount = '0'; rootRef.current.dataset.semanticTerrainFeatureDrawCalls = '0'; + rootRef.current.dataset.semanticTerrainFeatureCounts = '{}'; rootRef.current.dataset.waterPresentation = waterCells ? 'pending' : 'unavailable'; rootRef.current.dataset.waterLayoutVersion = '0'; rootRef.current.dataset.waterTriangleCount = '0'; @@ -1225,7 +1579,14 @@ function CanonicalRealmMapScreen({ rootRef.current.dataset.grassCacheEntries = '0'; rootRef.current.dataset.grassAnimated = 'false'; rootRef.current.dataset.grassTargetAnimationCadence = '0'; + rootRef.current.dataset.grassCandidateCellsByTerrain = '{}'; + rootRef.current.dataset.grassActiveCellsByTerrain = '{}'; rootRef.current.dataset.grassCountsByTerrain = '{}'; + rootRef.current.dataset.grassAverageRetainedPatchesByTerrain = '{}'; + rootRef.current.dataset.grassPaletteLuminanceMin = '0'; + rootRef.current.dataset.grassPaletteLuminanceMax = '0'; + rootRef.current.dataset.grassPaletteGreenMin = '0'; + rootRef.current.dataset.grassPaletteGreenMax = '0'; rootRef.current.dataset.grassCompletelyBareActiveCells = '0'; rootRef.current.dataset.grassRejectedByStructureClearance = '0'; rootRef.current.dataset.grassRejectedBySlope = '0'; @@ -1257,7 +1618,6 @@ function CanonicalRealmMapScreen({ rootRef.current.dataset.stoneMarkerOnlySiteCount = String(stoneNodeCatalog.length); } setVisibleCastleLabels([]); - setCameraMode('realm'); scene = createRealmScene({ canvas, surface, @@ -1268,10 +1628,14 @@ function CanonicalRealmMapScreen({ foodNodes: foodNodeCatalog, woodNodes: woodNodeCatalog, stoneNodes: stoneNodeCatalog, + workers: workerSceneRecords, sharedForestLayout: sharedForestProjection.layout, sharedForestTrees: sharedForestProjection.trees, waterCells, + waterBodies: snapshot.waterBodies, + waterEnvironment: snapshot.realmEnvironment, realmId: snapshot.realm.realmId, + rendererGeneration, // The retired local planner is exposed only to the synthetic dev // observer. Player scenes wait for the paired shared public tables. allowLegacyForestFallback: observerMode, @@ -1285,14 +1649,46 @@ function CanonicalRealmMapScreen({ onTargetHover: handleSceneTargetHover, onKeepStatusChange: () => undefined, onCastlesReady: (castleCount) => { + if (activeRendererGenerationRef.current !== rendererGeneration) return; if (castleCount !== expectedCastleCountRef.current) { - markRendererUnavailable(); + markRendererFailure({ + code: 'castle-count-mismatch', + retryable: true, + phase: 'loading', + message: `Expected ${expectedCastleCountRef.current} castles, received ${castleCount}.` + }); return; } rendererModeRef.current = 'webgl'; - setRendererMode('webgl'); + const activeLod = canvas.dataset.realmCastleActiveLod; + lastSuccessfulRendererGenerationRef.current = rendererGeneration; + const readyLifecycle = transitionRealmRendererLifecycle(rendererLifecycleRef.current, { + type: 'ready', + generation: rendererGeneration, + degradedQuality: activeLod === 'compact' || activeLod === 'balanced' + ? activeLod + : undefined + }); + rendererLifecycleRef.current = readyLifecycle; + setRendererLifecycle(readyLifecycle); updateSceneComposition(); }, + onCastleLodChange: (activeLod) => { + if (activeRendererGenerationRef.current !== rendererGeneration) return; + const currentLifecycle = rendererLifecycleRef.current; + // An optional model can settle while a WebGL context is lost. Its + // presentation upgrade must not clear the recovering/failed state. + if (currentLifecycle.state !== 'ready') return; + const readyLifecycle = transitionRealmRendererLifecycle(currentLifecycle, { + type: 'ready', + generation: rendererGeneration, + degradedQuality: activeLod === 'compact' || activeLod === 'balanced' + ? activeLod + : undefined + }); + rendererLifecycleRef.current = readyLifecycle; + setRendererLifecycle(readyLifecycle); + }, onCastlePresentationTelemetry: updateCastlePresentationTelemetry, onGoldNodePresentationTelemetry: updateGoldNodePresentationTelemetry, onFoodNodePresentationTelemetry: updateFoodNodePresentationTelemetry, @@ -1300,7 +1696,21 @@ function CanonicalRealmMapScreen({ onStoneNodePresentationTelemetry: updateStoneNodePresentationTelemetry, onTerrainPresentationTelemetry: updateTerrainPresentationTelemetry, onCastleProjection: updateCastleProjection, - onRendererUnavailable: markRendererUnavailable, + onRendererFailure: (failure) => { + if (activeRendererGenerationRef.current === rendererGeneration) { + markRendererFailure(failure); + } + }, + onRendererContextRestored: () => { + if (activeRendererGenerationRef.current !== rendererGeneration) return; + if (rendererRecoveryTimerRef.current !== null) { + window.clearTimeout(rendererRecoveryTimerRef.current); + rendererRecoveryTimerRef.current = null; + } + rendererRecoveryNonceRef.current += 1; + setRendererRecoveryNonce(rendererRecoveryNonceRef.current); + }, + onRendererUnavailable: () => undefined, onSelect: () => undefined, onTargetSelect: handleSceneTargetSelect }); @@ -1336,22 +1746,58 @@ function CanonicalRealmMapScreen({ ? interactionRef.current.inspectorTarget.stoneSiteId : null ); + scene.setSelectedWorkerId?.( + interactionRef.current.inspectorOpen + && interactionRef.current.inspectorTarget !== null + && 'workerId' in interactionRef.current.inspectorTarget + ? interactionRef.current.inspectorTarget.workerId + : null + ); + scene.setSelectedWaterCellKey?.( + interactionRef.current.inspectorOpen + && interactionRef.current.inspectorTarget !== null + && 'cellKey' in interactionRef.current.inspectorTarget + ? interactionRef.current.inspectorTarget.cellKey + : null + ); scene.setHovered(hoveredCoordRef.current); const cameraTarget: RealmCameraTarget = interactionRef.current.cameraTarget; if (cameraTarget.kind === 'castle') scene.focusCastle(cameraTarget.castleId); else if (cameraTarget.kind === 'cell') scene.focusCell(cameraTarget.coord); + else if (cameraTarget.kind === 'worker') scene.focusCell(cameraTarget.coord); + else if (cameraTarget.kind === 'water') scene.focusWaterCell?.(cameraTarget.cellKey); else if (cameraTarget.kind === 'keep') scene.recenterKeep(); else if (cameraTarget.kind === 'founding-district') scene.frameFoundingDistrict(); else scene.showRealm(); - } catch { - markRendererUnavailable(); + const attestation = rendererAttestationRef.current; + if (attestation && attestation.canvasId === canvas.dataset.realmCanvasIdentity) { + scene.restoreCameraAttestation?.(attestation); + } + } catch (error) { + if (activeRendererGenerationRef.current === rendererGeneration) { + markRendererFailure(classifyRealmRendererFailure(error, 'loading')); + } } return () => { + if (scene) { + try { + rendererAttestationRef.current = scene.getCameraAttestation(); + } catch { + rendererAttestationRef.current = null; + } + } scene?.dispose(); if (sceneRef.current === scene) sceneRef.current = null; + if (activeRendererGenerationRef.current === rendererGeneration) { + activeRendererGenerationRef.current = 0; + } + if (rendererRecoveryTimerRef.current !== null) { + window.clearTimeout(rendererRecoveryTimerRef.current); + rendererRecoveryTimerRef.current = null; + } }; - }, [foodNodeCatalog, goldNodeCatalog, handleSceneTargetHover, handleSceneTargetSelect, hasNearbyFoundingKeeps, isSceneCoordPassable, keepCoord, markRendererUnavailable, observerMode, ownCastle.castleId, peerCastles, projectedTileMetadata, qualitySpec, reducedMotion, sharedForestProjection, snapshot.realm.realmId, stoneNodeCatalog, surface, updateCastlePresentationTelemetry, updateCastleProjection, updateFoodNodePresentationTelemetry, updateGoldNodePresentationTelemetry, updateSceneComposition, updateStoneNodePresentationTelemetry, updateTerrainPresentationTelemetry, updateWoodNodePresentationTelemetry, waterCells, woodNodeCatalog]); + }, [foodNodeCatalog, goldNodeCatalog, handleSceneTargetHover, handleSceneTargetSelect, hasNearbyFoundingKeeps, isSceneCoordPassable, keepCoord, markRendererFailure, observerMode, ownCastle.castleId, peerCastles, projectedTileMetadata, qualitySpec, reducedMotion, rendererRecoveryNonce, sharedForestProjection, snapshot.realm.realmId, snapshot.realmEnvironment, snapshot.waterBodies, stoneNodeCatalog, surface, updateCastlePresentationTelemetry, updateCastleProjection, updateFoodNodePresentationTelemetry, updateGoldNodePresentationTelemetry, updateSceneComposition, updateStoneNodePresentationTelemetry, updateTerrainPresentationTelemetry, updateWoodNodePresentationTelemetry, waterCells, woodNodeCatalog, workerSceneCatalogKey]); useEffect(() => { sceneRef.current?.reconcileLiveGatheringState?.(liveGatheringState); @@ -1381,6 +1827,14 @@ function CanonicalRealmMapScreen({ sceneRef.current?.setSelectedStoneSiteId?.(inspectorStoneNode?.siteId ?? null); }, [inspectorStoneNode?.siteId]); + useEffect(() => { + sceneRef.current?.setSelectedWorkerId?.(inspectorWorker?.workerId ?? null); + }, [inspectorWorker?.workerId]); + + useEffect(() => { + sceneRef.current?.setSelectedWaterCellKey?.(inspectorWater?.cellKey ?? null); + }, [inspectorWater?.cellKey]); + useEffect(() => { const handleEscape = (event: KeyboardEvent) => { // Treat one physical Escape press as one hierarchy step. Ignoring the @@ -1463,6 +1917,8 @@ function CanonicalRealmMapScreen({ selectWoodNode(woodNodeAtSelectedCell); } else if (stoneNodeAtSelectedCell) { selectStoneNode(stoneNodeAtSelectedCell); + } else if (waterAtSelectedCell) { + selectWaterCell(waterAtSelectedCell); } else { sceneRef.current?.focusCell(selectedCoord); dispatchInteraction({ @@ -1479,16 +1935,31 @@ function CanonicalRealmMapScreen({ if (isPlayableRealmCoord(surface, next)) selectCoord(next); }; + // The scene mirrors these counters onto the canvas so a context event can + // be diagnosed without exposing implementation details in user copy. + const canvasTelemetry = canvasRef.current?.dataset; return (

    ) : null} - {rendererMode === 'loading' ? ( + {rendererLifecycle.state !== 'ready' && rendererLifecycle.state !== 'static-unsupported' ? (
    - Surveying the bright lowlands… - Preparing every canonical castle before the realm is revealed. + {rendererLifecycle.state === 'failed' ? ( + THE REALM COULD NOT BE RESTORED + ) : rendererLifecycle.state === 'recovering' ? ( + RESTORING THE REALM… + ) : ( + Surveying the bright lowlands… + )} + + {rendererLifecycle.state === 'failed' + ? 'Warpkeep could not restart 3D rendering. Your server-owned progress was not changed.' + : rendererLifecycle.state === 'recovering' + ? 'The graphics device was interrupted. Your game state is safe while the realm is restored.' + : 'Preparing every canonical castle before the realm is revealed.'} + + {rendererLifecycle.state === 'failed' && rendererLifecycle.failure?.retryable !== false ? ( + + ) : null} @@ -1801,6 +2293,19 @@ function CanonicalRealmMapScreen({ onRequestExplore={() => dispatchInteraction({ type: 'open-navigator' })} activeWagons={activeWagons} onOpenActiveWagon={openActiveWagon} + workerProjection={observerMode ? undefined : workerProjection} + workerRoster={observerMode ? undefined : workerRoster} + workerResourceState={observerMode ? undefined : workerResourceState} + workerDestinations={observerMode ? undefined : workerDestinations} + onDispatchWorker={observerMode || !onDispatchWorker + ? undefined + : (workerId, destination) => onDispatchWorker( + workerId, + destination.resourceKind, + destination.siteId + )} + onRecallWorker={observerMode ? undefined : onRecallWorker} + onRecallAllWorkers={observerMode ? undefined : onRecallAllWorkers} keepCoord={keepCoord} selectedCell={selectedCell} selectedTerrainKind={selectedTerrainKind} @@ -1877,12 +2382,49 @@ function CanonicalRealmMapScreen({ /> ) : null} + {inspectorWorker ? ( + onDispatchWorker( + workerId, + destination.resourceKind, + destination.siteId + )} + onRecallWorker={observerMode ? undefined : onRecallWorker} + onRequestClose={() => dispatchInteraction({ type: 'close-inspector' })} + worker={inspectorWorker} + /> + ) : null} + + {inspectorWater ? ( + dispatchInteraction({ type: 'close-inspector' })} + onFocusCell={(cellKey) => { + const record = waterRecordsByKeyRef.current.get(cellKey); + if (record) selectWaterCell(record); + }} + onViewUnderlyingCell={inspectorWater.underlyingTileKey + ? () => selectCoord(inspectorWater.coord) + : undefined} + /> + ) : null} + candidate.castleId === entry.castleId); if (castle) selectCastle(castle); }} + onActivateWorker={(entry) => { + const worker = workerProjectionRef.current?.workers.find((candidate) => ( + candidate.workerId === entry.workerId + && candidate.ordinal === entry.ordinal + && candidate.originCastleId === entry.originCastleId + )); + if (worker) selectWorker(worker, entry.coord); + }} + onActivateWaterCell={(cellKey) => { + const record = waterRecordsByKeyRef.current.get(cellKey); + if (record) { + selectWaterCell(record); + dispatchInteraction({ type: 'close-navigator' }); + } + }} coordinateJump={{ validate: (coord) => ( isPlayableRealmCoord(surface, coord) diff --git a/src/components/realm/WaterInspectionPanel.css b/src/components/realm/WaterInspectionPanel.css new file mode 100644 index 00000000..0a48d8eb --- /dev/null +++ b/src/components/realm/WaterInspectionPanel.css @@ -0,0 +1 @@ +.water-inspection{position:fixed;z-index:14;inset:0 0 0 auto;width:min(25rem,100vw);overflow:hidden;color:#eef7f3;background:radial-gradient(circle at 50% 15%,rgb(68 170 191 / 24%),transparent 44%),linear-gradient(180deg,#0b1a22,#071015 74%);box-shadow:-1rem 0 3rem rgb(0 0 0 / 42%);font-family:Inter,ui-sans-serif,system-ui,sans-serif}.water-inspection__drawer{display:flex;height:100%;flex-direction:column}.water-inspection__hero{position:relative;min-height:15.8rem;padding:10.5rem 1rem .8rem;background:linear-gradient(180deg,rgb(9 28 38 / 10%),rgb(7 16 21 / 97%) 88%)}.water-inspection__hero-art-stage{position:absolute;z-index:0;top:-1.8rem;left:50%;width:min(29rem,126%);height:16rem;transform:translateX(-50%);pointer-events:none}.water-inspection__art{position:relative;width:100%;height:100%;overflow:hidden;background:radial-gradient(circle at 50% 50%,rgb(190 244 238 / 21%),transparent 48%),linear-gradient(180deg,#2a5662,#0b1a23 68%);filter:drop-shadow(0 .4rem .2rem rgb(0 0 0 / 36%)) drop-shadow(0 1rem 1.3rem rgb(0 0 0 / 52%));clip-path:polygon(2% 4%,98% 4%,100% 86%,80% 100%,20% 100%,0 86%)}.water-inspection__art--river{background:radial-gradient(ellipse at 53% 39%,rgb(246 226 157 / 33%),transparent 23%),linear-gradient(180deg,#6baab2,#1b4b62 55%,#0a1d29)}.water-inspection__art-sun{position:absolute;top:2.3rem;left:54%;width:4rem;height:4rem;border-radius:50%;background:#f6df96;box-shadow:0 0 2.8rem 1rem rgb(247 222 148 / 42%)}.water-inspection__art-horizon{position:absolute;right:0;bottom:4.6rem;left:0;height:3rem;background:linear-gradient(180deg,transparent,rgb(11 38 46 / 60%))}.water-inspection__art-wave{position:absolute;left:-10%;width:120%;height:3.2rem;border:2px solid rgb(173 237 231 / 54%);border-color:rgb(173 237 231 / 54%) transparent transparent;border-radius:50%;transform:rotate(-7deg)}.water-inspection__art-wave--one{bottom:3.4rem}.water-inspection__art-wave--two{bottom:1rem;transform:rotate(5deg);opacity:.72}.water-inspection__art-crest{position:absolute;right:16%;bottom:3.2rem;color:#d7f3e9;font:500 5.4rem/1 Georgia,serif;text-shadow:0 0 1rem rgb(177 255 242 / 68%);transform:rotate(-8deg)}.water-inspection__title-lockup{position:relative;z-index:1;padding:0 2.8rem;text-align:center}.water-inspection__title-lockup p{margin:0;color:#b8e7dd;font-size:.62rem;font-weight:800;letter-spacing:.15em}.water-inspection__title-lockup h2{margin:.24rem 0 0;color:#e6d79b;font:500 1.88rem/1.05 Georgia,serif;letter-spacing:.055em}.water-inspection__dismiss{position:absolute;z-index:2;top:.68rem;right:.7rem;display:grid;width:2.8rem;height:2.8rem;place-items:center;border:1px solid rgb(215 240 231 / 42%);border-radius:50%;background:rgb(5 16 21 / 82%);color:#f4f7e8;font:400 1.8rem/1 Georgia,serif;cursor:pointer}.water-inspection__dismiss:focus-visible,.water-inspection__actions button:focus-visible{outline:2px solid #d7f1c5;outline-offset:2px}.water-inspection__body{flex:1;min-height:0;padding:.72rem 1rem 1.15rem;overflow:auto}.water-inspection__description,.water-inspection__read-only{margin:0;padding:.72rem .78rem;border-left:2px solid rgb(113 212 206 / 66%);background:linear-gradient(90deg,rgb(35 119 133 / 26%),transparent);color:#cbe4df;font-size:.76rem;line-height:1.5}.water-inspection__fields{display:grid;margin:.9rem 0 0;border:1px solid rgb(215 240 231 / 23%);border-radius:.56rem;overflow:hidden}.water-inspection__field{display:grid;grid-template-columns:minmax(7.1rem,.82fr) minmax(0,1.18fr);gap:.75rem;padding:.66rem .74rem;border-bottom:1px solid rgb(215 240 231 / 12%)}.water-inspection__field:last-child{border-bottom:0}.water-inspection__field dt,.water-inspection__field dd{margin:0}.water-inspection__field dt{color:#aec3c3;font-size:.72rem}.water-inspection__field dd{color:#e8d99e;font-size:.73rem;font-weight:760;text-align:right;overflow-wrap:anywhere}.water-inspection__read-only{margin-top:.82rem;border-left-color:rgb(212 228 255 / 56%)}.water-inspection__actions{display:grid;gap:.52rem;margin-top:.9rem}.water-inspection__actions button{min-height:2.65rem;padding:.56rem .7rem;border:1px solid rgb(214 239 216 / 62%);border-radius:.44rem;background:linear-gradient(180deg,rgb(85 170 173 / 97%),rgb(27 75 89 / 97%));color:#f0f6e6;font-size:.66rem;font-weight:840;letter-spacing:.1em;cursor:pointer}@media (max-width:40rem){.water-inspection{width:100%}.water-inspection__hero{min-height:14rem;padding-top:9.4rem}} diff --git a/src/components/realm/WaterInspectionPanel.tsx b/src/components/realm/WaterInspectionPanel.tsx new file mode 100644 index 00000000..1b7fc40b --- /dev/null +++ b/src/components/realm/WaterInspectionPanel.tsx @@ -0,0 +1,171 @@ +import { + useCallback, + useEffect, + useRef, + type KeyboardEvent, + type Ref +} from 'react'; + +import type { RealmWaterInspectionRecord } from './realmWaterInspectionPresentation'; +import './WaterInspectionPanel.css'; + +export type WaterInspectionPanelProps = Readonly<{ + id: string; + record: RealmWaterInspectionRecord; + focusTargetRef?: Ref; + onRequestClose: () => void; + onFocusCell?: (cellKey: string) => void; + onViewUnderlyingCell?: () => void; +}>; + +function assignRef(ref: Ref | undefined, value: T | null) { + if (typeof ref === 'function') ref(value); + else if (ref) (ref as { current: T | null }).current = value; +} + +function WaterRecordArt({ record }: Readonly<{ record: RealmWaterInspectionRecord }>) { + return ( + + ); +} + +function PublicField({ label, value }: Readonly<{ label: string; value: string }>) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ); +} + +export function WaterInspectionPanel({ + id, + record, + focusTargetRef, + onRequestClose, + onFocusCell, + onViewUnderlyingCell +}: WaterInspectionPanelProps) { + const closeButtonRef = useRef(null); + const titleId = `${id}-title`; + const descriptionId = `${id}-description`; + const setCloseButtonRef = useCallback((element: HTMLButtonElement | null) => { + closeButtonRef.current = element; + assignRef(focusTargetRef, element); + }, [focusTargetRef]); + + useEffect(() => { + closeButtonRef.current?.focus({ preventScroll: true }); + }, [id, record.cellKey]); + + const eyebrow = record.regime === 'river' + ? 'RIVER' + : record.displayType === 'coast' ? 'COAST' : 'OUTER SEA'; + const position = record.riverPosition + ? `${record.riverPosition} · ${record.flowClass}` + : `${record.oceanDepthClass} · ${record.fogBand} view`; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.repeat) return; + event.preventDefault(); + event.stopPropagation(); + onRequestClose(); + }; + + return ( + + ); +} diff --git a/src/components/realm/WorkerCommandCenter.css b/src/components/realm/WorkerCommandCenter.css new file mode 100644 index 00000000..a54cd994 --- /dev/null +++ b/src/components/realm/WorkerCommandCenter.css @@ -0,0 +1,2 @@ +.worker-command-center__scrim{position:fixed;z-index:13;inset:0;display:grid;place-items:start end;padding:clamp(4.5rem,12vh,7rem) clamp(.75rem,3vw,2rem);background:rgb(2 3 8 / 28%);overscroll-behavior:contain;touch-action:pan-y}.worker-command-center{width:min(29rem,100%);max-height:min(75vh,42rem);overflow:auto;border:1px solid rgb(240 207 130 / 42%);border-radius:.75rem;background:linear-gradient(180deg,#15111d,#08090f);box-shadow:0 1.25rem 3rem rgb(0 0 0 / 48%);color:#f9f0df;padding:.85rem;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}.worker-command-center__header{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.worker-command-center__header p{margin:0;color:#caa6ec;font-size:.62rem;font-weight:800;letter-spacing:.18em}.worker-command-center__header h2{margin:.16rem 0;color:#f6d98e;font:500 1.65rem/1 Georgia,serif;letter-spacing:.08em}.worker-command-center__header span{color:#bbb1c3;font-size:.75rem}.worker-command-center__header button{display:grid;width:2.6rem;height:2.6rem;place-items:center;border:1px solid rgb(240 207 130 / 40%);border-radius:50%;background:#090a10;color:#fff;font-size:1.55rem;cursor:pointer}.worker-command-center__header button:focus-visible,.worker-command-center__worker:focus-visible,.worker-command-center__recall:focus-visible,.worker-command-center__footer button:focus-visible{outline:2px solid #f6d98e;outline-offset:2px}.worker-command-center__summary{margin:.75rem 0;padding:.65rem .75rem;border-left:2px solid #a477c1;color:#d4c9d8;font-size:.76rem;line-height:1.45}.worker-command-center__roster{display:grid;gap:.5rem;margin:.8rem 0;padding:0;list-style:none}.worker-command-center__roster li{display:flex;gap:.4rem;align-items:stretch}.worker-command-center__worker{display:flex;flex:1;align-items:center;gap:.65rem;min-width:0;padding:.55rem .6rem;border:1px solid rgb(242 213 143 / 22%);border-radius:.45rem;background:#11121a;color:#fff;cursor:pointer;text-align:left}.worker-command-center__ordinal{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:50%;background:#4a2f5d;color:#f7dda0;font-weight:800}.worker-command-center__identity{display:grid;min-width:0;gap:.18rem}.worker-command-center__identity strong{font-size:.8rem}.worker-command-center__identity small{overflow:hidden;color:#bdb4c5;font-size:.65rem;text-overflow:ellipsis;white-space:nowrap}.worker-command-center__amount{margin-left:auto;color:#f2d48a;font-size:.68rem}.worker-command-center__recall{min-width:4.2rem;padding:.3rem;border:1px solid rgb(240 207 130 / 38%);border-radius:.4rem;background:#2b1c2f;color:#f9df9c;font-size:.62rem;font-weight:800;cursor:pointer}.worker-command-center__footer{display:flex;justify-content:flex-end;border-top:1px solid rgb(240 207 130 / 16%);padding-top:.75rem}.worker-command-center__footer button{min-height:2.6rem;padding:.5rem .8rem;border:1px solid rgb(240 207 130 / 55%);border-radius:.4rem;background:linear-gradient(180deg,#c08c42,#704029);color:#171016;font-size:.67rem;font-weight:850;letter-spacing:.08em;cursor:pointer}.worker-command-center__footer button:disabled{opacity:.5;cursor:default}@media (max-width:40rem){.worker-command-center__scrim{place-items:stretch;padding:4.1rem .5rem .5rem}.worker-command-center{width:100%;max-height:calc(100dvh - 4.6rem)}} +.worker-command-center__error{margin:.7rem 0 0;padding:.55rem .65rem;border:1px solid rgb(223 133 120 / 48%);border-radius:.4rem;background:rgb(97 35 38 / 26%);color:#ffd8cf;font-size:.72rem;line-height:1.4}.worker-command-center button:disabled{cursor:default;opacity:.52} diff --git a/src/components/realm/WorkerCommandCenter.tsx b/src/components/realm/WorkerCommandCenter.tsx new file mode 100644 index 00000000..38f5fc4e --- /dev/null +++ b/src/components/realm/WorkerCommandCenter.tsx @@ -0,0 +1,169 @@ +import { useRef, useState } from 'react'; + +import { useModalFocusBoundary } from '../menu/useModalFocusBoundary'; +import { + realmWorkerCanRecall, + realmWorkerLabel, + realmWorkerStatusLabel, + workerAvailabilityCount, + type RealmWorkerPublicPresentation, + type WorkerRosterPresentation +} from './realmWorkerPresentation'; +import './WorkerCommandCenter.css'; + +export type WorkerCommandCenterProps = Readonly<{ + id: string; + workers: readonly RealmWorkerPublicPresentation[]; + roster: WorkerRosterPresentation; + onRecallWorker?: (workerId: string) => Promise; + onRecallAllWorkers?: () => Promise; + onSelectWorker: (worker: RealmWorkerPublicPresentation) => void; + onClose: () => void; +}>; + +type PendingCommand = 'all' | string | undefined; + +function privateAmountLabel(worker: WorkerRosterPresentation['workers'][number]) { + const unit = worker.resourceKind + ? `${worker.resourceKind.slice(0, 1).toUpperCase()}${worker.resourceKind.slice(1)}` + : 'resource units'; + return `${worker.availableAmount.toString()} ${unit}`; +} + +export function WorkerCommandCenter({ + id, + workers, + roster, + onRecallWorker, + onRecallAllWorkers, + onSelectWorker, + onClose +}: WorkerCommandCenterProps) { + const dialogRef = useRef(null); + const headingRef = useRef(null); + const [pendingCommand, setPendingCommand] = useState(undefined); + const [commandFailed, setCommandFailed] = useState(false); + const available = workerAvailabilityCount(workers); + const hasRecallableWorker = workers.some(realmWorkerCanRecall); + useModalFocusBoundary({ + dialogRef, + initialFocusRef: headingRef, + onEscape: () => { + if (pendingCommand === undefined) onClose(); + } + }); + + const recall = async (workerId: string) => { + if (!onRecallWorker || pendingCommand !== undefined) return; + setPendingCommand(workerId); + setCommandFailed(false); + try { + await onRecallWorker(workerId); + } catch { + setCommandFailed(true); + } finally { + setPendingCommand(undefined); + } + }; + + const recallAll = async () => { + if (!onRecallAllWorkers || pendingCommand !== undefined) return; + setPendingCommand('all'); + setCommandFailed(false); + try { + await onRecallAllWorkers(); + } catch { + setCommandFailed(true); + } finally { + setPendingCommand(undefined); + } + }; + + return ( +
    { + if (event.target === event.currentTarget && pendingCommand === undefined) onClose(); + }} + role="presentation" + > + +
    + ); +} diff --git a/src/components/realm/WorkerInspectionPanel.css b/src/components/realm/WorkerInspectionPanel.css new file mode 100644 index 00000000..1b508325 --- /dev/null +++ b/src/components/realm/WorkerInspectionPanel.css @@ -0,0 +1,3 @@ +.worker-inspection{position:fixed;z-index:14;inset:0 0 0 auto;width:min(25rem,100vw);overflow:hidden;color:#f7f0e2;background:radial-gradient(circle at 50% 18%,rgb(118 75 156 / 22%),transparent 42%),linear-gradient(180deg,#0d0d14,#08080d 72%);box-shadow:-1rem 0 3rem rgb(0 0 0 / 42%);font-family:Inter,ui-sans-serif,system-ui,sans-serif}.worker-inspection__art-stage{position:absolute;z-index:0;top:-1.65rem;left:50%;width:min(30rem,124%);height:clamp(16rem,43vw,20.5rem);transform:translateX(-50%);pointer-events:none;background:radial-gradient(circle at 50% 56%,rgb(149 90 196 / 24%),transparent 54%),radial-gradient(circle at 50% 18%,rgb(242 207 136 / 13%),transparent 56%)}.worker-inspection__hero-art{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 .14rem .18rem rgb(10 8 16 / 72%)) drop-shadow(0 1rem 1.3rem rgb(17 8 26 / 64%));user-select:none}.worker-inspection__drawer{position:relative;z-index:1;display:flex;height:100%;flex-direction:column;padding-top:clamp(7.25rem,21vw,9rem)}.worker-inspection__hero{position:relative;min-height:8.2rem;padding:1.25rem 1rem .8rem;background:linear-gradient(180deg,rgb(22 17 31 / 25%),rgb(8 8 13 / 96%) 84%),radial-gradient(circle at 50% 6%,rgb(175 127 218 / 21%),transparent 58%)}.worker-inspection__title-lockup{padding:0 2.8rem;text-align:center}.worker-inspection__title-lockup p{margin:0;color:#d4b8ef;font-size:.64rem;font-weight:800;letter-spacing:.17em}.worker-inspection__title-lockup h2{margin:.24rem 0 0;color:#f7dda0;font:500 2.1rem/1.05 Georgia,serif;letter-spacing:.065em}.worker-inspection__dismiss{position:absolute;z-index:2;top:.68rem;right:.7rem;display:grid;width:2.8rem;height:2.8rem;place-items:center;border:1px solid rgb(243 213 142 / 42%);border-radius:50%;background:rgb(8 8 13 / 82%);color:#fff8e8;font:400 1.8rem/1 Georgia,serif;cursor:pointer}.worker-inspection__dismiss:focus-visible,.worker-inspection__recall:focus-visible{outline:2px solid #fff0bc;outline-offset:2px}.worker-inspection__body{flex:1;min-height:0;padding:.72rem 1rem 1.15rem;overflow:auto}.worker-inspection__description,.worker-inspection__read-only{margin:0;padding:.72rem .78rem;border-left:2px solid rgb(188 146 228 / 66%);background:linear-gradient(90deg,rgb(91 51 119 / 25%),transparent);color:#d8cede;font-size:.76rem;line-height:1.5}.worker-inspection__fields{display:grid;margin:.9rem 0 0;border:1px solid rgb(231 199 121 / 23%);border-radius:.56rem;overflow:hidden}.worker-inspection__fields>div{display:grid;grid-template-columns:minmax(7.1rem,.82fr) minmax(0,1.18fr);gap:.75rem;padding:.66rem .74rem;border-bottom:1px solid rgb(231 199 121 / 12%)}.worker-inspection__fields>div:last-child{border-bottom:0}.worker-inspection__fields dt,.worker-inspection__fields dd{margin:0}.worker-inspection__fields dt{color:#c8bdca;font-size:.72rem}.worker-inspection__fields dd{color:#f7dda0;font-size:.74rem;font-weight:760;text-align:right;overflow-wrap:anywhere}.worker-inspection__read-only{margin-top:.82rem;border-left-color:rgb(218 177 246 / 56%)}.worker-inspection__recall{width:100%;min-height:2.8rem;margin-top:.9rem;padding:.56rem .7rem;border:1px solid rgb(246 215 145 / 72%);border-radius:.44rem;background:linear-gradient(180deg,rgb(229 186 93 / 97%),rgb(121 76 37 / 97%));color:#171016;font-size:.7rem;font-weight:840;letter-spacing:.12em;cursor:pointer}@media (max-width:40rem){.worker-inspection{width:100%;}.worker-inspection__drawer{padding-top:clamp(6.5rem,24vw,8rem)}} +.worker-inspection__dispatch{display:grid;gap:.5rem;margin-top:.9rem}.worker-inspection__dispatch label{color:#d4b8ef;font-size:.64rem;font-weight:800;letter-spacing:.12em}.worker-inspection__dispatch select,.worker-inspection__dispatch button{width:100%;min-height:2.8rem;border:1px solid rgb(246 215 145 / 52%);border-radius:.44rem}.worker-inspection__dispatch select{padding:.55rem .65rem;background:#11111a;color:#f7f0e2}.worker-inspection__dispatch button{padding:.56rem .7rem;background:linear-gradient(180deg,rgb(229 186 93 / 97%),rgb(121 76 37 / 97%));color:#171016;font-size:.7rem;font-weight:840;letter-spacing:.12em;cursor:pointer}.worker-inspection__error{margin:.8rem 0 0;color:#ffd8cf;font-size:.72rem;line-height:1.4}.worker-inspection button:disabled,.worker-inspection select:disabled{cursor:default;opacity:.52}.worker-inspection__dismiss:focus-visible,.worker-inspection__recall:focus-visible,.worker-inspection__dispatch select:focus-visible,.worker-inspection__dispatch button:focus-visible{outline:2px solid #fff0bc;outline-offset:2px} +.worker-inspection__dismiss{top:max(.68rem,env(safe-area-inset-top));right:max(.7rem,env(safe-area-inset-right))}.worker-inspection__body{padding-right:max(1rem,env(safe-area-inset-right));padding-bottom:max(1.15rem,env(safe-area-inset-bottom));padding-left:max(1rem,env(safe-area-inset-left))}@media (max-width:40rem){.worker-inspection__drawer{padding-top:calc(clamp(6.5rem,24vw,8rem) + env(safe-area-inset-top))}} From ec59c6d35f5f7097d1b94f735353a8843d5bc95e Mon Sep 17 00:00:00 2001 From: Ael <183124839+ael-dev3@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:05:51 +0200 Subject: [PATCH 06/10] Alpha 0.3.14 signed source (6/10) GitHub-signed reconstruction of the exact reviewed Alpha 0.3.14 tree. --- .../realm/WorkerInspectionPanel.tsx | 200 +++++ src/components/realm/createRealmGrassLayer.ts | 69 +- src/components/realm/createRealmScene.ts | 780 +++++++++++++++--- .../realm/createRealmTerrainFeatures.ts | 71 +- .../realm/hegemonyKeepPrefabRepository.ts | 138 ++-- src/components/realm/realmCameraController.ts | 63 ++ .../realm/realmCastlePresentation.ts | 14 +- src/components/realm/realmInteractionState.ts | 88 +- .../realm/realmMapPresentationHelpers.ts | 46 +- src/components/realm/realmPickArbitration.ts | 57 ++ src/components/realm/realmRendererRecovery.ts | 157 ++++ .../realm/realmWaterInspectionPresentation.ts | 204 +++++ src/components/realm/realmWaterLayer.ts | 568 ++++++++++++- src/components/realm/realmWaterPhase.ts | 165 ++++ src/components/realm/realmWorkerLayer.ts | 409 +++++++++ .../realm/realmWorkerPresentation.ts | 582 +++++++++++++ 16 files changed, 3364 insertions(+), 247 deletions(-) create mode 100644 src/components/realm/WorkerInspectionPanel.tsx create mode 100644 src/components/realm/realmRendererRecovery.ts create mode 100644 src/components/realm/realmWaterInspectionPresentation.ts create mode 100644 src/components/realm/realmWaterPhase.ts create mode 100644 src/components/realm/realmWorkerLayer.ts create mode 100644 src/components/realm/realmWorkerPresentation.ts diff --git a/src/components/realm/WorkerInspectionPanel.tsx b/src/components/realm/WorkerInspectionPanel.tsx new file mode 100644 index 00000000..89f88283 --- /dev/null +++ b/src/components/realm/WorkerInspectionPanel.tsx @@ -0,0 +1,200 @@ +import { useEffect, useRef, useState, type Ref } from 'react'; + +import { useModalFocusBoundary } from '../menu/useModalFocusBoundary'; +import { + realmWorkerCanRecall, + realmWorkerLabel, + realmWorkerStatusLabel, + type RealmWorkerDestinationPresentation, + type RealmWorkerPublicPresentation +} from './realmWorkerPresentation'; +import './WorkerInspectionPanel.css'; + +function publicAssetUrl(path: string) { + const base = import.meta.env.BASE_URL || '/'; + return `${base.endsWith('/') ? base : `${base}/`}${path.replace(/^\/+/, '')}`; +} + +export type WorkerInspectionPanelProps = Readonly<{ + id: string; + worker: RealmWorkerPublicPresentation; + destinations: readonly RealmWorkerDestinationPresentation[]; + onDispatchWorker?: ( + workerId: string, + destination: RealmWorkerDestinationPresentation + ) => Promise; + onRecallWorker?: (workerId: string) => Promise; + onRequestClose: () => void; + focusTargetRef?: Ref; +}>; + +export function WorkerInspectionPanel({ + id, + worker, + destinations, + onDispatchWorker, + onRecallWorker, + onRequestClose, + focusTargetRef +}: WorkerInspectionPanelProps) { + const dialogRef = useRef(null); + const headingRef = useRef(null); + const [state, setState] = useState<'idle' | 'dispatching' | 'recalling' | 'failed'>('idle'); + const [selectedDestinationKey, setSelectedDestinationKey] = useState(''); + const commandPending = state === 'dispatching' || state === 'recalling'; + const canRecall = realmWorkerCanRecall(worker) && onRecallWorker !== undefined; + const canDispatch = worker.ownedByViewer + && worker.status === 'idle' + && onDispatchWorker !== undefined + && destinations.length > 0; + const selectedDestination = destinations.find((destination) => ( + `${destination.resourceKind}|${destination.siteId}` === selectedDestinationKey + )); + useModalFocusBoundary({ + dialogRef, + initialFocusRef: headingRef, + onEscape: () => { + if (!commandPending) onRequestClose(); + } + }); + + useEffect(() => { + setState((current) => ( + current === 'dispatching' || current === 'recalling' ? current : 'idle' + )); + if (worker.status !== 'idle') setSelectedDestinationKey(''); + }, [worker.workerId, worker.status, worker.revision]); + + const assignHeadingRef = (element: HTMLHeadingElement | null) => { + headingRef.current = element; + if (typeof focusTargetRef === 'function') focusTargetRef(element); + else if (focusTargetRef) focusTargetRef.current = element; + }; + + const dispatch = async () => { + if (!canDispatch || !selectedDestination || state === 'dispatching' || !onDispatchWorker) return; + setState('dispatching'); + try { + await onDispatchWorker(worker.workerId, selectedDestination); + setState('idle'); + } catch { + setState('failed'); + } + }; + + const recall = async () => { + if (!canRecall || !onRecallWorker || state === 'recalling') return; + setState('recalling'); + try { + await onRecallWorker(worker.workerId); + setState('idle'); + } catch { + setState('failed'); + } + }; + + const title = realmWorkerLabel(worker.ordinal); + return ( + + ); +} diff --git a/src/components/realm/createRealmGrassLayer.ts b/src/components/realm/createRealmGrassLayer.ts index c7fa42ca..941ca24c 100644 --- a/src/components/realm/createRealmGrassLayer.ts +++ b/src/components/realm/createRealmGrassLayer.ts @@ -8,7 +8,12 @@ import { type RealmGrassPoint, type RealmGrassTerrainKind } from '../../game/map/realmGrass'; -import { hexDistance, type HexCoord, type HexWorldPosition } from '../../game/map/hexCoordinates'; +import { + hexDistance, + hexKey, + type HexCoord, + type HexWorldPosition +} from '../../game/map/hexCoordinates'; import type { RealmTerrainKind } from '../../game/map/realmTerrainSemantics'; import type { RealmTerrainSurface } from '../../game/map/realmTerrainSurface'; import type { TerrainStructurePlacement } from '../../game/map/terrainPlacements'; @@ -46,11 +51,16 @@ export type RealmGrassTelemetry = Readonly<{ averageBladeHeight: number; paletteLuminanceMin: number; paletteLuminanceMax: number; + paletteGreenMin: number; + paletteGreenMax: number; alphaHashActive: boolean; alphaToCoverageActive: boolean; shaderFallbackActive: boolean; edgeFadeCount: number; + candidateCellsByTerrain: Readonly>; + activeCellsByTerrain: Readonly>; countsByTerrain: Readonly>; + averageRetainedPatchesByTerrain: Readonly>; completelyBareActiveCells: number; rejectedByStructureClearance: number; rejectedBySlope: number; @@ -92,6 +102,17 @@ type PackedPoint = Readonly<{ distance: number; }>; +const REALM_GRASS_TERRAIN_KINDS: readonly RealmGrassTerrainKind[] = Object.freeze([ + 'meadow', + 'lowland', + 'forest', + 'heath', + 'ridge', + 'lake', + 'ancient-stone', + 'apron' +]); + function emptyCounts(): Record { return { meadow: 0, @@ -105,6 +126,17 @@ function emptyCounts(): Record { }; } +function averageCounts( + totals: Readonly>, + divisors: Readonly> +) { + const averages = emptyCounts(); + REALM_GRASS_TERRAIN_KINDS.forEach((kind) => { + averages[kind] = totals[kind] / Math.max(1, divisors[kind]); + }); + return averages; +} + function emptyTelemetry(plan: RealmGrassRenderPlan, alphaToCoverage = false): RealmGrassTelemetry { return Object.freeze({ candidateCellCount: 0, @@ -122,11 +154,16 @@ function emptyTelemetry(plan: RealmGrassRenderPlan, alphaToCoverage = false): Re averageBladeHeight: 0, paletteLuminanceMin: 0, paletteLuminanceMax: 0, + paletteGreenMin: 0, + paletteGreenMax: 0, alphaHashActive: true, alphaToCoverageActive: alphaToCoverage, shaderFallbackActive: false, edgeFadeCount: 0, + candidateCellsByTerrain: Object.freeze(emptyCounts()), + activeCellsByTerrain: Object.freeze(emptyCounts()), countsByTerrain: Object.freeze(emptyCounts()), + averageRetainedPatchesByTerrain: Object.freeze(emptyCounts()), completelyBareActiveCells: 0, rejectedByStructureClearance: 0, rejectedBySlope: 0, @@ -232,6 +269,15 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re return generated; }; + const telemetryTerrainKindFor = ( + cell: RealmGrassActiveWindow['cells'][number]['cell'] + ): RealmGrassTerrainKind => { + const key = hexKey(cell.coord); + if (!options.surface.playableKeys.has(key)) return 'apron'; + const kind = options.terrainKindsByKey.get(key) ?? 'lowland'; + return options.visualizeLegacyLakes === true && kind === 'lake' ? 'lowland' : kind; + }; + const updateBounds = () => { meshes.forEach((currentMesh) => { currentMesh.computeBoundingBox(); @@ -262,11 +308,17 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re let completelyBareActiveCells = 0; let rejectedByStructureClearance = 0; let rejectedBySlope = 0; + // Both records are fixed to the eight presentation terrain kinds. The + // outer ring is classified without generating or caching invisible grass. + const candidateCellsByTerrain = emptyCounts(); + const activeCellsByTerrain = emptyCounts(); window.cells.forEach((activeCell) => { + candidateCellsByTerrain[telemetryTerrainKindFor(activeCell.cell)] += 1; // At zero fade the alpha-hashed geometry is fully discarded. Do not // spend cache/instance capacity on that invisible boundary ring. if (activeCell.edgeFade <= 0) return; const data = cellDataFor(activeCell.cell); + activeCellsByTerrain[data.terrainKind] += 1; if (data.completelyBare) completelyBareActiveCells += 1; rejectedByStructureClearance += data.rejectedByStructure + data.rejectedByExclusion; rejectedBySlope += data.rejectedBySlope; @@ -286,11 +338,15 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re }); const packedByVariant = collectors.map((collector) => collector.values()); const packed = packedByVariant.flat(); + const activeCellCount = Object.values(activeCellsByTerrain) + .reduce((total, count) => total + count, 0); const counts = emptyCounts(); let footprintTotal = 0; let heightTotal = 0; let luminanceMin = Number.POSITIVE_INFINITY; let luminanceMax = 0; + let greenMin = Number.POSITIVE_INFINITY; + let greenMax = 0; let edgeFadeCount = 0; packedByVariant.forEach((variantPoints, variant) => { const currentMesh = meshes[variant]!; @@ -313,6 +369,8 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re const luminance = 0.2126 * point.tint.r + 0.7152 * point.tint.g + 0.0722 * point.tint.b; luminanceMin = Math.min(luminanceMin, luminance); luminanceMax = Math.max(luminanceMax, luminance); + greenMin = Math.min(greenMin, point.tint.g); + greenMax = Math.max(greenMax, point.tint.g); if (edgeFade < 0.999) edgeFadeCount += 1; }); currentMesh.count = variantPoints.length; @@ -342,7 +400,7 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re ).alphaToCoverage === true; telemetry = Object.freeze({ candidateCellCount: window.cells.length, - activeCellCount: window.cells.length, + activeCellCount, instanceCount: packed.length, bladeCount: packed.length * REALM_GRASS_BLADES_PER_PATCH[plan.geometryProfile], triangleCount: packed.length * trianglesPerPatch, @@ -351,16 +409,21 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re cacheEntries: cache.size, animated: packed.length > 0 && plan.animationFrameCap > 0 && !options.reducedMotion, targetAnimationCadence: plan.animationFrameCap, - averageRetainedPatchesPerActiveCell: packed.length / Math.max(1, window.cells.length), + averageRetainedPatchesPerActiveCell: packed.length / Math.max(1, activeCellCount), averagePatchFootprint: packed.length > 0 ? footprintTotal / packed.length : 0, averageBladeHeight: packed.length > 0 ? heightTotal / packed.length : 0, paletteLuminanceMin: Number.isFinite(luminanceMin) ? luminanceMin : 0, paletteLuminanceMax: luminanceMax, + paletteGreenMin: Number.isFinite(greenMin) ? greenMin : 0, + paletteGreenMax: greenMax, alphaHashActive: alphaHash, alphaToCoverageActive: alphaCoverage, shaderFallbackActive: false, edgeFadeCount, + candidateCellsByTerrain: Object.freeze(candidateCellsByTerrain), + activeCellsByTerrain: Object.freeze(activeCellsByTerrain), countsByTerrain: Object.freeze(counts), + averageRetainedPatchesByTerrain: Object.freeze(averageCounts(counts, activeCellsByTerrain)), completelyBareActiveCells, rejectedByStructureClearance, rejectedBySlope, diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index 386c036a..11614853 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -21,8 +21,11 @@ import { import { resolveRealmSharedForestLayout } from '../../game/map/realmSharedForestPlacements'; import { generateTerrainDecorations } from '../../game/map/terrainDecorations'; import type { RealmGrassExclusion, RealmGrassTerrainKind } from '../../game/map/realmGrass'; -import { generateRealmTerrainFeatures } from '../../game/map/realmTerrainFeatures'; -import type { RealmTerrainFeaturePoint } from '../../game/map/realmTerrainFeatures'; +import { + generateRealmTerrainFeatures, + type RealmTerrainFeatureKind, + type RealmTerrainFeaturePoint +} from '../../game/map/realmTerrainFeatures'; import { indexRealmTerrainSemantics, type RealmTerrainKind, @@ -68,6 +71,7 @@ import { import { createRealmCameraController, DEFAULT_REALM_CAMERA_SPEC, + type RealmCameraControllerState, type RealmCameraComposition, type RealmCameraMode, type RealmKeepFocus @@ -90,6 +94,8 @@ import { import { createRealmAmbientScheduler, type RealmAmbientScheduler } from './realmAmbientScheduler'; import { createRealmWaterLayer, + REALM_WATER_ANIMATION_FRAME_CAPS, + waterSurfaceLevelToWorldY, type RealmWaterLayer } from './realmWaterLayer'; import { @@ -126,6 +132,11 @@ import { type RealmStoneNodePresentationTelemetry, type RealmStoneNodeSceneRecord } from './realmStoneNodeLayer'; +import { + createRealmWorkerLayer, + type RealmWorkerLayer, + type RealmWorkerSceneRecord +} from './realmWorkerLayer'; import { createRealmExpeditionSceneBudget } from './realmExpeditionPresentationBudget'; import { createRealmPointerGestureCoordinator, @@ -135,7 +146,9 @@ import { import { arbitrateRealmPick, type RealmInteractionTarget, - type RealmResourcePickHit + type RealmResourcePickHit, + type RealmWorkerPickHit, + type RealmWaterPickHit } from './realmPickArbitration'; import { REALM_LIGHTING_SPECS, @@ -148,6 +161,10 @@ import type { RealmCastleProjectionFrame, RealmCastleScreenBounds } from './realmTypes'; +import { + classifyRealmRendererFailure, + type RealmRendererFailure +} from './realmRendererRecovery'; const HEX_SIZE = 1; const OVERLAY_LIFT = 0.026; @@ -392,6 +409,7 @@ export type RealmTerrainPresentationTelemetry = Readonly<{ semanticKindCount: number; semanticFeatureCount: number; semanticFeatureDrawCalls: number; + semanticFeatureCounts: Readonly>; totalDetailInstanceCount: number; totalDetailDrawCalls: number; /** Canonical shared rows render only when their full layout validates. */ @@ -405,7 +423,14 @@ export type RealmTerrainPresentationTelemetry = Readonly<{ grassCacheEntries: number; grassAnimated: boolean; grassTargetAnimationCadence: number; + grassCandidateCellsByTerrain: Readonly>; + grassActiveCellsByTerrain: Readonly>; grassCountsByTerrain: Readonly>; + grassAverageRetainedPatchesByTerrain: Readonly>; + grassPaletteLuminanceMin: number; + grassPaletteLuminanceMax: number; + grassPaletteGreenMin: number; + grassPaletteGreenMax: number; grassCompletelyBareActiveCells: number; grassRejectedByStructureClearance: number; grassRejectedBySlope: number; @@ -416,9 +441,11 @@ export type RealmSceneHandle = Readonly<{ dispose: () => void; reconcileLiveGatheringState: (state: RealmLiveGatheringState) => void; getCameraAttestation: () => RealmCameraAttestation; + restoreCameraAttestation?: (attestation: RealmCameraAttestation) => void; getSceneBuildSequence: () => number; focusCastle: (castleId: number) => void; focusCell: (coord: HexCoord) => void; + focusWaterCell: (cellKey: string) => void; frameFoundingDistrict: () => void; focusKeep: () => void; recenterKeep: () => void; @@ -430,6 +457,10 @@ export type RealmSceneHandle = Readonly<{ setSelectedFoodSiteId: (siteId: string | null) => void; setSelectedWoodSiteId: (siteId: string | null) => void; setSelectedStoneSiteId: (siteId: string | null) => void; + setSelectedWorkerId: (workerId: string | null) => void; + setSelectedWaterCellKey: (cellKey: string | null) => void; + setHoveredWaterCellKey: (cellKey: string | null) => void; + setHoveredWorkerId: (workerId: string | null) => void; setComposition: (composition: RealmCameraComposition) => void; showRealm: () => void; }>; @@ -439,6 +470,7 @@ export type RealmLiveGatheringState = Readonly<{ foodNodes: readonly RealmFoodNodeSceneRecord[]; woodNodes: readonly RealmWoodNodeSceneRecord[]; stoneNodes: readonly RealmStoneNodeSceneRecord[]; + workers?: readonly RealmWorkerSceneRecord[]; observedAtMicros: bigint; }>; @@ -451,6 +483,7 @@ export type RealmCameraAttestation = Readonly<{ target: Readonly<{ x: number; y: number; z: number }>; fov: number; zoom: number; + controllerState: RealmCameraControllerState; selectedTerrainCoord: HexCoord | null; selectedCastleId: number | null; selectedGoldSiteId: string | null; @@ -515,14 +548,22 @@ export type CreateRealmSceneOptions = Readonly<{ woodNodes?: readonly RealmWoodNodeSceneRecord[]; /** Validated public Stone sites/occupations; absent/malformed data renders no Stone nodes. */ stoneNodes?: readonly RealmStoneNodeSceneRecord[]; + /** Complete validated public worker roster with public route coordinates only. */ + workers?: readonly RealmWorkerSceneRecord[]; /** Additive public `realm_forest_instance_v1` rows. */ sharedForestTrees?: unknown; /** Additive public `realm_forest_layout_v1` metadata row. */ sharedForestLayout?: unknown; /** Canonical realm id used to bind the shared forest table to this scene. */ realmId?: string; + /** React-owned monotonic generation for stale callback suppression. */ + rendererGeneration?: number; /** Complete, digest-validated public water projection; absent means water is unavailable. */ waterCells?: readonly GenesisWaterCellV1[]; + /** Validated body rows provide the deterministic phase seed and wave preset. */ + waterBodies?: readonly unknown[]; + /** Canonical environment boundary used to align renderer-only Water phase. */ + waterEnvironment?: unknown; /** * Test/DEV-observer-only bridge for the retired deterministic preview. * Production player scenes must not synthesize a forest before the public @@ -542,6 +583,8 @@ export type CreateRealmSceneOptions = Readonly<{ onKeepStatusChange: (status: KeepLoadStatus) => void; /** Fired only after every authoritative castle has a real GLB instance. */ onCastlesReady?: (castleCount: number) => void; + /** Reports a progressive contiguous LOD upgrade after initial readiness. */ + onCastleLodChange?: (lod: CastleLod) => void; /** Live counts derived from populated instance meshes after presentation masking. */ onCastlePresentationTelemetry?: ( telemetry: RealmCastleInstancePresentationTelemetry @@ -552,6 +595,12 @@ export type CreateRealmSceneOptions = Readonly<{ onFoodNodePresentationTelemetry?: (telemetry: RealmFoodNodePresentationTelemetry) => void; onWoodNodePresentationTelemetry?: (telemetry: RealmWoodNodePresentationTelemetry) => void; onStoneNodePresentationTelemetry?: (telemetry: RealmStoneNodePresentationTelemetry) => void; + /** Structured renderer lifecycle signal. The scene remains alive during a + * recoverable context loss so camera/selection state can be attested. */ + onRendererFailure?: (failure: RealmRendererFailure) => void; + onRendererContextRestored?: () => void; + /** Legacy callback retained for integrations that only understand a boolean + * renderer-unavailable signal. It is never used for context loss. */ onRendererUnavailable: () => void; /** @deprecated Prefer onTargetSelect for castle identity-aware interaction. */ onSelect: (coord: HexCoord) => void; @@ -944,6 +993,7 @@ function initializeRealmScene( cleanup: RealmSceneCleanup ): RealmSceneHandle { const sceneBuildSequence = nextRealmSceneBuildSequence++; + const rendererGeneration = options.rendererGeneration ?? sceneBuildSequence; const noLakeRevisionActive = realmNoLakeRevisionActive(options.waterCells); const landRenderMap = realmLandPresentationMap( options.surface.renderMap, @@ -974,7 +1024,7 @@ function initializeRealmScene( }; // Pure quality policy is needed by the first resize/projection callback; // initialize it before any observer or render loop can run. - const castleLodPolicy = castleLodPolicyForQuality(runtimeQuality); + let castleLodPolicy = castleLodPolicyForQuality(runtimeQuality); const terrainSemantics = indexRealmTerrainSemantics( options.surface, options.terrainMetadata @@ -994,6 +1044,8 @@ function initializeRealmScene( options.canvas.dataset.realmCanvasIdentity = canvasId; options.canvas.dataset.realmSceneBuildSequence = String(sceneBuildSequence); options.canvas.dataset.realmSceneIdentity = scene.uuid; + options.canvas.dataset.realmRendererGeneration = String(rendererGeneration); + options.canvas.dataset.realmLastSuccessfulRenderedGeneration = '0'; scene.background = new THREE.Color(REALM_SKY_FALLBACK_COLOR); const fog = new THREE.Fog( REALM_SKY_FALLBACK_COLOR, @@ -1238,6 +1290,9 @@ function initializeRealmScene( scene.add(terrain); let waterLayer: RealmWaterLayer | null = null; + const waterCellByKey = new Map( + (options.waterCells ?? []).map((cell) => [cell.cellKey, cell] as const) + ); if (options.waterCells !== undefined) { try { waterLayer = createRealmWaterLayer({ @@ -1245,6 +1300,9 @@ function initializeRealmScene( quality: runtimeQuality, reducedMotion: options.reducedMotion, hexSize: HEX_SIZE, + environment: options.waterEnvironment, + waterBodies: options.waterBodies, + nowMicros: localPresentationNowMicros, heightAtWorld: (world) => terrainHeightAtWorld( options.surface.renderMap, world, @@ -1270,6 +1328,14 @@ function initializeRealmScene( } else { options.canvas.dataset.waterPresentation = 'unavailable'; } + // Never expose the ocean navigation envelope without the matching visible + // layer. Construction/budget failure must retain the ordinary land clamp. + const activeWaterNavigationEnvelope = waterLayer + ? waterNavigationEnvelope + : undefined; + options.canvas.dataset.waterNavigation = activeWaterNavigationEnvelope + ? 'water-visible' + : 'land-only'; const decorations = createTerrainDecorationLayers( decorationData, @@ -1422,14 +1488,28 @@ function initializeRealmScene( averageBladeHeight: 0, paletteLuminanceMin: 0, paletteLuminanceMax: 0, + paletteGreenMin: 0, + paletteGreenMax: 0, alphaHashActive: true, alphaToCoverageActive: grassAlphaToCoverage, shaderFallbackActive: false, edgeFadeCount: 0, + candidateCellsByTerrain: Object.freeze({ + meadow: 0, lowland: 0, forest: 0, heath: 0, ridge: 0, lake: 0, + 'ancient-stone': 0, apron: 0 + }), + activeCellsByTerrain: Object.freeze({ + meadow: 0, lowland: 0, forest: 0, heath: 0, ridge: 0, lake: 0, + 'ancient-stone': 0, apron: 0 + }), countsByTerrain: Object.freeze({ meadow: 0, lowland: 0, forest: 0, heath: 0, ridge: 0, lake: 0, 'ancient-stone': 0, apron: 0 }), + averageRetainedPatchesByTerrain: Object.freeze({ + meadow: 0, lowland: 0, forest: 0, heath: 0, ridge: 0, lake: 0, + 'ancient-stone': 0, apron: 0 + }), completelyBareActiveCells: 0, rejectedByStructureClearance: 0, rejectedBySlope: 0, @@ -1439,6 +1519,13 @@ function initializeRealmScene( const grass = grassLayer?.getTelemetry() ?? emptyGrassTelemetry; const currentForestTelemetry = forestLayer?.getPresentationTelemetry(); const currentForestInfillTelemetry = forestInfillLayer?.getPresentationTelemetry(); + const semanticFeatureCounts = Object.freeze({ + ...semanticFeatures.counts, + 'forest-tree': semanticFeatures.counts['forest-tree'] + + (currentForestTelemetry?.instanceCount ?? 0) + + (currentForestInfillTelemetry?.instanceCount ?? 0), + 'heath-bloom': 0 + }); return Object.freeze({ terrainTriangleCount: terrainData.triangleCount, terrainTriangleBudget: renderPlan.terrainTriangleBudget, @@ -1449,12 +1536,12 @@ function initializeRealmScene( semanticCellCount: terrainSemantics.terrainKindsByKey.size, semanticKindCount: Object.values(terrainSemantics.terrainKindCounts) .filter((count) => count > 0).length, - semanticFeatureCount: semanticFeatures.instanceCount - + (currentForestTelemetry?.instanceCount ?? 0) - + (currentForestInfillTelemetry?.instanceCount ?? 0), + semanticFeatureCount: Object.values(semanticFeatureCounts) + .reduce((total, count) => total + count, 0), semanticFeatureDrawCalls: semanticFeatures.drawCalls + (currentForestTelemetry?.drawCalls ?? 0) + (currentForestInfillTelemetry?.drawCalls ?? 0), + semanticFeatureCounts, totalDetailInstanceCount: decorationData.points.length + semanticFeatures.instanceCount + (currentForestTelemetry?.instanceCount ?? 0) @@ -1475,7 +1562,14 @@ function initializeRealmScene( grassCacheEntries: grass.cacheEntries, grassAnimated: grass.animated, grassTargetAnimationCadence: grass.targetAnimationCadence, + grassCandidateCellsByTerrain: grass.candidateCellsByTerrain, + grassActiveCellsByTerrain: grass.activeCellsByTerrain, grassCountsByTerrain: grass.countsByTerrain, + grassAverageRetainedPatchesByTerrain: grass.averageRetainedPatchesByTerrain, + grassPaletteLuminanceMin: grass.paletteLuminanceMin, + grassPaletteLuminanceMax: grass.paletteLuminanceMax, + grassPaletteGreenMin: grass.paletteGreenMin, + grassPaletteGreenMax: grass.paletteGreenMax, grassCompletelyBareActiveCells: grass.completelyBareActiveCells, grassRejectedByStructureClearance: grass.rejectedByStructureClearance, grassRejectedBySlope: grass.rejectedBySlope, @@ -1488,6 +1582,7 @@ function initializeRealmScene( const signature = [ telemetry.semanticFeatureCount, telemetry.semanticFeatureDrawCalls, + Object.values(telemetry.semanticFeatureCounts).join(','), telemetry.totalDetailInstanceCount, telemetry.totalDetailDrawCalls, telemetry.forestPlacementSource, @@ -1497,7 +1592,14 @@ function initializeRealmScene( telemetry.grassTriangleCount, telemetry.grassCacheEntries, telemetry.grassAnimated, + Object.values(telemetry.grassCandidateCellsByTerrain).join(','), + Object.values(telemetry.grassActiveCellsByTerrain).join(','), Object.values(telemetry.grassCountsByTerrain).join(','), + Object.values(telemetry.grassAverageRetainedPatchesByTerrain).join(','), + telemetry.grassPaletteLuminanceMin, + telemetry.grassPaletteLuminanceMax, + telemetry.grassPaletteGreenMin, + telemetry.grassPaletteGreenMax, telemetry.grassCompletelyBareActiveCells, telemetry.grassRejectedByStructureClearance, telemetry.grassRejectedBySlope, @@ -1604,6 +1706,11 @@ function initializeRealmScene( const stoneNodeCoordinateKeys = new Set( (options.stoneNodes ?? []).map((node) => hexKey(node.coord)) ); + const waterCellCoordinateKeys = new Set( + (options.waterCells ?? []) + .filter((cell) => cell.regime === 'ocean' || cell.regime === 'river') + .map((cell) => `${cell.q},${cell.r}`) + ); const terrainOverlayCoord = (coord: HexCoord | null) => ( coord && !occupiedCastleCoordinateKeys.has(hexKey(coord)) @@ -1611,6 +1718,7 @@ function initializeRealmScene( && !foodNodeCoordinateKeys.has(hexKey(coord)) && !woodNodeCoordinateKeys.has(hexKey(coord)) && !stoneNodeCoordinateKeys.has(hexKey(coord)) + && !waterCellCoordinateKeys.has(hexKey(coord)) ? coord : null ); @@ -1629,6 +1737,7 @@ function initializeRealmScene( let foodNodeLayer: RealmFoodNodeLayer | null = null; let woodNodeLayer: RealmWoodNodeLayer | null = null; let stoneNodeLayer: RealmStoneNodeLayer | null = null; + let workerLayer: RealmWorkerLayer | null = null; let castleProjectionEnvelopeByLod: ReadonlyMap< CastleLod, RealmCastleProjectionEnvelope @@ -1644,6 +1753,8 @@ function initializeRealmScene( let selectedFoodSiteId: string | undefined; let selectedWoodSiteId: string | undefined; let selectedStoneSiteId: string | undefined; + let selectedWorkerId: string | undefined; + let hoveredWorkerId: string | undefined; let selectedTerrainCoord: HexCoord | null = null; let hoveredTerrainCoord: HexCoord | null = null; let castleFocusSize: Readonly<{ height: number; footprintDiameter: number }> = Object.freeze({ @@ -1680,9 +1791,15 @@ function initializeRealmScene( let presentedCastleKey = '*'; let renderPendingWhileHidden = false; let pendingCastlesReadyCount: number | null = null; + let contextLost = false; + let contextLossCount = 0; + let contextRestoreCount = 0; let ambientScheduler: RealmAmbientScheduler | null = null; const ambientIsNeeded = () => !options.reducedMotion - && renderPlan.grass.animationFrameCap > 0 + && Math.max( + renderPlan.grass.animationFrameCap, + REALM_WATER_ANIMATION_FRAME_CAPS[runtimeQuality.id] + ) > 0 && ( grassLayer?.isAnimationActive() === true || decorations.animated @@ -1690,6 +1807,7 @@ function initializeRealmScene( || foodNodeLayer?.hasMovingWagons() === true || woodNodeLayer?.hasMovingWagons() === true || stoneNodeLayer?.hasMovingWagons() === true + || workerLayer?.hasMovingWorkers() === true || waterLayer?.isAnimationActive() === true ); const disableGrassPresentation = () => { @@ -1800,6 +1918,7 @@ function initializeRealmScene( }; const render = () => { if (cleanup.isDisposed()) return; + if (contextLost) return; if (document.hidden) { renderPendingWhileHidden = true; return; @@ -1809,6 +1928,8 @@ function initializeRealmScene( if (grassLayer?.updateView(pose.focus, pose.mode)) { emitTerrainPresentationTelemetry(); } + const expeditionPresentationNowMicros = localPresentationNowMicros(); + workerLayer?.update(expeditionPresentationNowMicros); ambientScheduler?.setActive(ambientIsNeeded()); const viewportHeight = resolveRealmViewportSize({ canvasWidth: options.canvas.clientWidth, @@ -1838,7 +1959,6 @@ function initializeRealmScene( lastCastlePresentationTelemetryKey = presentationTelemetryKey; options.onCastlePresentationTelemetry?.(presentationTelemetry); } - const expeditionPresentationNowMicros = localPresentationNowMicros(); const expeditionPresentationElapsedSeconds = localPresentationElapsedSeconds(); goldNodeLayer?.update( cameraController.camera, @@ -1924,11 +2044,32 @@ function initializeRealmScene( try { renderer.render(scene, cameraController.camera); } catch (error) { - if (!isGrassShaderContractFailure(error) || !disableGrassPresentation()) throw error; + if (!isGrassShaderContractFailure(error) || !disableGrassPresentation()) { + reportRendererFailureAndDispose({ + code: 'scene-build-failed', + retryable: true, + phase: 'ready', + message: error instanceof Error ? error.message : String(error) + }, false); + return; + } // `onBeforeCompile` runs during rendering. Retry the same frame without // only the grass layer if its pinned shader chunk contract has changed. - renderer.render(scene, cameraController.camera); + try { + renderer.render(scene, cameraController.camera); + } catch (fallbackError) { + reportRendererFailureAndDispose({ + code: 'scene-build-failed', + retryable: true, + phase: 'ready', + message: fallbackError instanceof Error + ? fallbackError.message + : String(fallbackError) + }, false); + return; + } } + options.canvas.dataset.realmLastSuccessfulRenderedGeneration = String(rendererGeneration); projectCastleLabels(); if (pendingCastlesReadyCount !== null) { const castleCount = pendingCastlesReadyCount; @@ -1937,13 +2078,29 @@ function initializeRealmScene( && (presentedCastleIds === null || presentedCastleIds.size > 0) && (castleLayer?.getPacking().totalVisible ?? 0) === 0 ) { - throw new Error('Hegemony castle instances produced no visible rendered packing.'); + pendingCastlesReadyCount = null; + const failure: RealmRendererFailure = { + code: 'castle-pairing-failed', + retryable: false, + phase: 'loading', + message: 'Hegemony castle instances produced no visible rendered packing.' + }; + reportRendererFailureAndDispose(failure, true); + return; } if ( castleCount > 0 && !castleLayer?.hasExactCastleLandscapeBasePairing() ) { - throw new Error('Hegemony castle landscape-base presentation is incomplete.'); + pendingCastlesReadyCount = null; + const failure: RealmRendererFailure = { + code: 'castle-pairing-failed', + retryable: false, + phase: 'loading', + message: 'Hegemony castle landscape-base presentation is incomplete.' + }; + reportRendererFailureAndDispose(failure, true); + return; } pendingCastlesReadyCount = null; options.onCastlesReady?.(castleCount); @@ -1964,12 +2121,12 @@ function initializeRealmScene( )) } as const; const cameraController = createRealmCameraController({ - bounds: waterNavigationEnvelope?.bounds ?? terrainData.bounds, - navigationBoundary: waterNavigationEnvelope + bounds: activeWaterNavigationEnvelope?.bounds ?? terrainData.bounds, + navigationBoundary: activeWaterNavigationEnvelope ? { - maximumCenterHexRadius: waterNavigationEnvelope.maximumCenterHexRadius, - hexSize: waterNavigationEnvelope.hexSize, - blockedCenterCellKeys: waterNavigationEnvelope.blockedCenterCellKeys + maximumCenterHexRadius: activeWaterNavigationEnvelope.maximumCenterHexRadius, + hexSize: activeWaterNavigationEnvelope.hexSize, + blockedCenterCellKeys: activeWaterNavigationEnvelope.blockedCenterCellKeys } : undefined, // Keep the full generated terrain available to pan/clamp, but make the @@ -2092,13 +2249,56 @@ function initializeRealmScene( } catch { stoneNodeLayer = null; } + const createWorkerLayer = (workers: readonly RealmWorkerSceneRecord[]) => ( + createRealmWorkerLayer({ + workers, + hexSize: HEX_SIZE, + heightAtWorld: (world) => terrainHeightAtWorld( + options.surface.renderMap, + world, + HEX_SIZE, + terrainPlacements + ) + }) + ); + const installWorkerLayer = (next: RealmWorkerLayer, workerCount: number) => { + const previous = workerLayer; + workerLayer = next; + scene.add(next.group); + options.canvas.dataset.realmWorkerMarkerCount = String(workerCount); + next.setSelectedWorkerId(selectedWorkerId ?? null); + next.setHoveredWorkerId(hoveredWorkerId ?? null); + if (previous) { + scene.remove(previous.group); + previous.dispose(); + } + }; + try { + const initialWorkers = options.workers ?? []; + installWorkerLayer(createWorkerLayer(initialWorkers), initialWorkers.length); + } catch { + // Generic workers are an additive public presentation. A malformed + // catalog disables only their markers and command entry points. + workerLayer = null; + options.canvas.dataset.realmWorkerMarkerCount = '0'; + } + cleanup.add(() => { + const layer = workerLayer; + if (!layer) return; + scene.remove(layer.group); + layer.dispose(); + if (workerLayer === layer) workerLayer = null; + }); const handleRenderVisibility = () => { if (!document.hidden && renderPendingWhileHidden && !cleanup.isDisposed()) render(); }; document.addEventListener('visibilitychange', handleRenderVisibility); cleanup.add(() => document.removeEventListener('visibilitychange', handleRenderVisibility)); ambientScheduler = createRealmAmbientScheduler({ - frameCap: renderPlan.grass.animationFrameCap, + frameCap: Math.max( + renderPlan.grass.animationFrameCap, + REALM_WATER_ANIMATION_FRAME_CAPS[runtimeQuality.id] + ), active: ambientIsNeeded(), onStep: (elapsedSeconds) => { if (cleanup.isDisposed()) return; @@ -2108,6 +2308,7 @@ function initializeRealmScene( const foodWagonsMoving = foodNodeLayer?.hasMovingWagons() === true; const woodWagonsMoving = woodNodeLayer?.hasMovingWagons() === true; const stoneWagonsMoving = stoneNodeLayer?.hasMovingWagons() === true; + const workersMoving = workerLayer?.hasMovingWorkers() === true; const waterChanged = waterLayer?.updateEnvironment(elapsedSeconds) === true; if ( grassChanged @@ -2116,6 +2317,7 @@ function initializeRealmScene( || foodWagonsMoving || woodWagonsMoving || stoneWagonsMoving + || workersMoving || waterChanged ) render(); } @@ -2197,8 +2399,11 @@ function initializeRealmScene( if (woodNodeHit) resourceHits.push({ kind: 'wood-site', ...woodNodeHit }); const stoneNodeHit = stoneNodeLayer?.raycast(raycaster); if (stoneNodeHit) resourceHits.push({ kind: 'stone-site', ...stoneNodeHit }); + const workerHit = workerLayer?.raycast(raycaster); + const workerHits: RealmWorkerPickHit[] = workerHit ? [workerHit] : []; const castleHit = castleLayer?.raycast(raycaster); - const foregroundHit = arbitrateRealmPick({ resourceHits, castleHit }); + const waterHit: RealmWaterPickHit | null = waterLayer?.raycast(raycaster) ?? null; + const foregroundHit = arbitrateRealmPick({ resourceHits, workerHits, castleHit, waterHit }); if (foregroundHit) return foregroundHit; const intersections = raycaster.intersectObject(terrain, false); for (const intersection of intersections) { @@ -2355,8 +2560,16 @@ function initializeRealmScene( labelClickSuppressionTimer = window.setTimeout(clearLabelClickSuppression, 0); }; + const contextLostBlocksTarget = (target: EventTarget | null) => ( + laneForTarget(target) !== null + ); + const handlePointerDown = (event: PointerEvent) => { const lane = laneForTarget(event.target); + if (contextLost && lane !== null) { + event.preventDefault(); + return; + } if (!lane || (event.pointerType !== 'touch' && event.button !== 0)) return; if (pointerGestures.snapshot().pointerCount === 0) clearLabelClickSuppression(); const result = pointerGestures.start({ @@ -2379,6 +2592,14 @@ function initializeRealmScene( }; const handlePointerMove = (event: PointerEvent) => { + if (contextLost && ( + contextLostBlocksTarget(event.target) + || pointerGestures.snapshot().pointerCount > 0 + )) { + event.preventDefault(); + return; + } + if (contextLost) return; if (pointerGestures.snapshot().pointerCount === 0) { if (event.target === options.canvas) scheduleHover(event.clientX, event.clientY); return; @@ -2424,6 +2645,14 @@ function initializeRealmScene( }; const handlePointerUp = (event: PointerEvent) => { + if (contextLost && ( + contextLostBlocksTarget(event.target) + || pointerGestures.snapshot().pointerCount > 0 + )) { + event.preventDefault(); + return; + } + if (contextLost) return; const labelTarget = labelPointerTargets.get(event.pointerId); labelPointerTargets.delete(event.pointerId); const result = pointerGestures.end({ @@ -2445,6 +2674,14 @@ function initializeRealmScene( }; const handlePointerCancel = (event: PointerEvent) => { + if (contextLost && ( + contextLostBlocksTarget(event.target) + || pointerGestures.snapshot().pointerCount > 0 + )) { + event.preventDefault(); + return; + } + if (contextLost) return; labelPointerTargets.delete(event.pointerId); const result = pointerGestures.cancel(event.pointerId); if (!result.accepted) return; @@ -2455,6 +2692,7 @@ function initializeRealmScene( }; const handleLostPointerCapture = (event: PointerEvent) => { + if (contextLost) return; labelPointerTargets.delete(event.pointerId); const result = pointerGestures.lostCapture(event.pointerId); if (!result.accepted) return; @@ -2480,6 +2718,11 @@ function initializeRealmScene( }; const handleLabelClickCapture = (event: MouseEvent) => { + if (contextLost && contextLostBlocksTarget(event.target)) { + event.preventDefault(); + event.stopImmediatePropagation(); + return; + } // Keyboard and assistive-technology activation carries no mouse click // count and must never be consumed by a prior pointer gesture. if (event.detail === 0 || !(event.target instanceof Element)) return; @@ -2494,6 +2737,7 @@ function initializeRealmScene( }; const handlePointerLeave = () => { + if (contextLost) return; if (pointerGestures.snapshot().pointerCount === 0) { cancelPendingHover(); dispatchHover(null); @@ -2501,6 +2745,11 @@ function initializeRealmScene( }; const handleWheel = (event: WheelEvent) => { const lane = laneForTarget(event.target); + if (contextLost && lane !== null) { + event.preventDefault(); + return; + } + if (contextLost) return; if (!lane) return; event.preventDefault(); // Camera motion invalidates the last canvas hit. Clear it immediately so @@ -2540,8 +2789,29 @@ function initializeRealmScene( const handleContextLost = (event: Event) => { event.preventDefault(); if (cleanup.isDisposed()) return; - disposeScene(); - options.onRendererUnavailable(); + contextLost = true; + contextLossCount += 1; + options.canvas.dataset.realmRendererContextLost = 'true'; + options.canvas.dataset.realmRendererContextLossCount = String(contextLossCount); + options.canvas.dataset.realmRendererContextRestoreCount = String(contextRestoreCount); + cancelAllPointers(pointerGestures.blur()); + ambientScheduler?.setActive(false); + options.onRendererFailure?.({ + code: 'context-lost', + retryable: true, + phase: pendingCastlesReadyCount === null ? 'ready' : 'loading', + message: 'The WebGL context was lost; waiting for the browser to restore it.' + }); + }; + const handleContextRestored = () => { + if (cleanup.isDisposed() || !contextLost) return; + contextLost = false; + contextRestoreCount += 1; + options.canvas.dataset.realmRendererContextLost = 'false'; + options.canvas.dataset.realmRendererContextLossCount = String(contextLossCount); + options.canvas.dataset.realmRendererContextRestoreCount = String(contextRestoreCount); + ambientScheduler?.setActive(ambientIsNeeded()); + options.onRendererContextRestored?.(); }; interactionRoot.addEventListener('pointerdown', handlePointerDown, { @@ -2581,6 +2851,8 @@ function initializeRealmScene( cleanup.add(() => interactionRoot.removeEventListener('wheel', handleWheel, true)); options.canvas.addEventListener('webglcontextlost', handleContextLost); cleanup.add(() => options.canvas.removeEventListener('webglcontextlost', handleContextLost)); + options.canvas.addEventListener('webglcontextrestored', handleContextRestored); + cleanup.add(() => options.canvas.removeEventListener('webglcontextrestored', handleContextRestored)); const resize = () => { if (cleanup.isDisposed()) return; @@ -2626,6 +2898,7 @@ function initializeRealmScene( resize(); const usedCastleLods = castleLodsForQuality(runtimeQuality); + const requestedCastleLodPolicy = castleLodPolicy; const prefabRepository = createHegemonyKeepPrefabRepository({ baseUrl: options.baseUrl, maxAnisotropy: renderer.capabilities.getMaxAnisotropy() @@ -2643,45 +2916,69 @@ function initializeRealmScene( }); }; - const initializeCastleInstances = async () => { - if (authoritativeCastles.length === 0) { - if (!cleanup.isDisposed()) { - pendingCastlesReadyCount = 0; - render(); - } - return; - } - - const leases: HegemonyKeepPrefabLease[] = []; - let acquisitionStopped = false; - try { - await Promise.all(usedCastleLods.map(async (lod) => { + const acquiredCastleLeases = new Map(); + let activeCastleLod: CastleLod | null = null; + const releaseAcquiredCastleLease = (lod: CastleLod) => { + const lease = acquiredCastleLeases.get(lod); + if (!lease) return; + acquiredCastleLeases.delete(lod); + releaseLeases([lease]); + }; + cleanup.add(() => { + const layer = castleLayer; + castleLayer = null; + if (layer) { + try { + scene.remove(layer.group); + } finally { try { - const lease = await prefabRepository.acquire( - lod, - castleLoadAbortController.signal - ); - if (acquisitionStopped || cleanup.isDisposed()) { - releaseLeases([lease]); - return; - } - leases.push(lease); - } catch (error) { - acquisitionStopped = true; - throw error; + layer.dispose(); + } catch { + // Continue through repository lease release after layer cleanup. } - })); - } catch (error) { - acquisitionStopped = true; - releaseLeases(leases); - throw error; + } } + releaseLeases([...acquiredCastleLeases.values()]); + acquiredCastleLeases.clear(); + }); + + const installCastleLayer = (activeLod: CastleLod, signalReady: boolean) => { + const activeIndex = usedCastleLods.indexOf(activeLod); + const activeLods = usedCastleLods.slice(0, activeIndex + 1); + const prefabs = new Map(activeLods.map((lod) => { + const lease = acquiredCastleLeases.get(lod); + if (!lease) throw new Error(`Missing contiguous Hegemony keep ${lod} lease.`); + return [lod, lease.prefab] as const; + })); + const nextPolicy: CastleLodPolicy = Object.freeze({ + ...requestedCastleLodPolicy, + maximumLod: activeLod, + selectedMinimumLod: activeLod, + highInstanceBudget: activeLod === 'high' + ? DEFAULT_CASTLE_LOD_POLICY.highInstanceBudget + : 0, + balancedInstanceBudget: activeLod === 'compact' + ? 0 + : DEFAULT_CASTLE_LOD_POLICY.balancedInstanceBudget + }); + const nextLayer = createRealmCastleInstanceLayer({ + castles: authoritativeCastles, + prefabs, + policy: nextPolicy, + dynamicShadows: renderPlan.dynamicShadows + }); if (cleanup.isDisposed()) { - releaseLeases(leases); - return; + nextLayer.dispose(); + return false; + } + if (presentedCastleIds !== null) { + nextLayer.setPresentedCastleIds([...presentedCastleIds]); } - const prefabs = new Map(leases.map((lease) => [lease.prefab.lod, lease.prefab])); + const previousLayer = castleLayer; + scene.add(nextLayer.group); + castleLayer = nextLayer; + castleLodPolicy = nextPolicy; const nextProjectionEnvelopeByLod = new Map([...prefabs].map(([lod, prefab]) => [ lod, prefab.projectionEnvelope @@ -2690,60 +2987,27 @@ function initializeRealmScene( lod, prefab.renderProjectionEnvelope ] as const)); - let nextLayer: RealmCastleInstanceLayer; - try { - nextLayer = createRealmCastleInstanceLayer({ - castles: authoritativeCastles, - prefabs, - policy: castleLodPolicy, - dynamicShadows: renderPlan.dynamicShadows - }); - } catch (error) { - releaseLeases(leases); - throw error; - } - if (cleanup.isDisposed()) { - try { - nextLayer.dispose(); - } finally { - releaseLeases(leases); - } - return; - } - - if (presentedCastleIds !== null) { - nextLayer.setPresentedCastleIds([...presentedCastleIds]); - } - castleLayer = nextLayer; castleProjectionEnvelopeByLod = nextProjectionEnvelopeByLod; castleRenderEnvelopeByLod = nextRenderEnvelopeByLod; - fallbackCastleProjectionEnvelope = nextProjectionEnvelopeByLod - .get(castleLodPolicy.maximumLod) - ?? nextProjectionEnvelopeByLod.get('compact') + fallbackCastleProjectionEnvelope = nextProjectionEnvelopeByLod.get(activeLod) ?? DEFAULT_CASTLE_PROJECTION_ENVELOPE; - fallbackCastleRenderEnvelope = nextRenderEnvelopeByLod - .get(castleLodPolicy.maximumLod) - ?? nextRenderEnvelopeByLod.get('compact') + fallbackCastleRenderEnvelope = nextRenderEnvelopeByLod.get(activeLod) ?? DEFAULT_CASTLE_PROJECTION_ENVELOPE; - scene.add(nextLayer.group); - let released = false; - cleanup.add(() => { - if (released) return; - released = true; + options.canvas.dataset.realmCastleActiveLod = activeLod; + lastCastleProjectionKey = ''; + lastCastlePresentationTelemetryKey = ''; + activeCastleLod = activeLod; + + if (previousLayer) { + scene.remove(previousLayer.group); try { - scene.remove(nextLayer.group); - } finally { - try { - nextLayer.dispose(); - } finally { - if (castleLayer === nextLayer) castleLayer = null; - releaseLeases(leases); - } + previousLayer.dispose(); + } catch { + // The replacement is already authoritative; keep its live resources. } - }); + } - const focusPrefab = prefabs.get(castleLodPolicy.maximumLod) - ?? prefabs.get('compact'); + const focusPrefab = prefabs.get(activeLod) ?? prefabs.get('compact'); if (focusPrefab) { castleFocusSize = Object.freeze({ height: focusPrefab.visualHeight, @@ -2757,23 +3021,176 @@ function initializeRealmScene( footprintDiameter: focusPrefab.footprintDiameter }); } - options.onKeepStatusChange('ready'); - if (cleanup.isDisposed()) return; - pendingCastlesReadyCount = authoritativeCastles.length; + if (signalReady) { + options.onKeepStatusChange('ready'); + if (cleanup.isDisposed()) return false; + pendingCastlesReadyCount = authoritativeCastles.length; + } else { + options.onCastleLodChange?.(activeLod); + } render(); + return !cleanup.isDisposed(); + }; + + const initializeCastleInstances = async () => { + if (authoritativeCastles.length === 0) { + if (!cleanup.isDisposed()) { + pendingCastlesReadyCount = 0; + render(); + } + return; + } + + const compact = usedCastleLods.includes('compact') ? 'compact' : usedCastleLods[0]; + if (!compact) throw new Error('No compact castle LOD is configured.'); + const acquireWithRetry = async (lod: CastleLod) => { + let lastError: unknown; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + return await (attempt === 0 + ? prefabRepository.acquire(lod, castleLoadAbortController.signal) + : prefabRepository.retryFailed(lod, castleLoadAbortController.signal)); + } catch (error) { + lastError = error; + const failure = classifyRealmRendererFailure( + error, + 'loading', + 'castle-compact-load-failed' + ); + if (lod !== 'compact' || attempt !== 0 || !failure.retryable) break; + // Keep the retry bounded and deterministic. The short yield lets a + // transient browser fetch/decode stall settle without overlapping + // two requests for the same content-addressed lease. + await new Promise((resolve) => window.setTimeout(resolve, 40)); + } + } + throw lastError ?? new Error(`Unable to load castle ${lod} LOD.`); + }; + const optionalLods = usedCastleLods.filter((lod) => lod !== compact); + const optionalStates = new Map( + optionalLods.map((lod) => [lod, 'pending']) + ); + let compactInstalled = false; + const activeRank = () => activeCastleLod === null + ? -1 + : usedCastleLods.indexOf(activeCastleLod); + const highestContiguousReadyLod = () => { + let highest = compact; + for (const lod of optionalLods) { + if (optionalStates.get(lod) !== 'ready') break; + highest = lod; + } + return highest; + }; + const releaseLodsAboveGap = () => { + let gapFound = false; + optionalLods.forEach((lod) => { + if (gapFound && optionalStates.get(lod) === 'ready') { + releaseAcquiredCastleLease(lod); + optionalStates.set(lod, 'failed'); + options.canvas.dataset[`realmCastle${lod}Lod`] = 'unavailable'; + return; + } + if (optionalStates.get(lod) === 'failed') gapFound = true; + }); + }; + const upgradeToHighestContiguousLod = () => { + if (!compactInstalled || cleanup.isDisposed() || contextLost) return; + releaseLodsAboveGap(); + const nextLod = highestContiguousReadyLod(); + if (usedCastleLods.indexOf(nextLod) <= activeRank()) return; + try { + installCastleLayer(nextLod, false); + } catch { + optionalStates.set(nextLod, 'failed'); + options.canvas.dataset[`realmCastle${nextLod}Lod`] = 'unavailable'; + releaseAcquiredCastleLease(nextLod); + releaseLodsAboveGap(); + const fallbackLod = highestContiguousReadyLod(); + if (usedCastleLods.indexOf(fallbackLod) > activeRank()) { + try { + installCastleLayer(fallbackLod, false); + } catch { + optionalStates.set(fallbackLod, 'failed'); + options.canvas.dataset[`realmCastle${fallbackLod}Lod`] = 'unavailable'; + releaseAcquiredCastleLease(fallbackLod); + } + } + } + }; + + const compactLease = await acquireWithRetry(compact); + if (cleanup.isDisposed()) { + releaseLeases([compactLease]); + return; + } + acquiredCastleLeases.set(compact, compactLease); + try { + compactInstalled = installCastleLayer(compact, true); + } catch (error) { + releaseAcquiredCastleLease(compact); + throw error; + } + // Compact owns the critical path. Only after it has produced the first + // playable frame may optional LOD downloads compete for network/decoder + // resources; they still upgrade progressively as one contiguous chain. + optionalLods.forEach((lod) => { + void prefabRepository.acquire(lod, castleLoadAbortController.signal).then((lease) => { + if (cleanup.isDisposed()) { + releaseLeases([lease]); + return; + } + const prerequisiteFailed = lod === 'high' + && optionalStates.get('balanced') === 'failed'; + if (prerequisiteFailed) { + optionalStates.set(lod, 'failed'); + options.canvas.dataset[`realmCastle${lod}Lod`] = 'unavailable'; + releaseLeases([lease]); + return; + } + acquiredCastleLeases.set(lod, lease); + optionalStates.set(lod, 'ready'); + upgradeToHighestContiguousLod(); + }, () => { + if (cleanup.isDisposed()) return; + optionalStates.set(lod, 'failed'); + options.canvas.dataset[`realmCastle${lod}Lod`] = 'unavailable'; + releaseLodsAboveGap(); + upgradeToHighestContiguousLod(); + }); + }); + upgradeToHighestContiguousLod(); }; - void initializeCastleInstances().catch(() => { + void initializeCastleInstances().catch((error) => { if (cleanup.isDisposed()) return; try { options.onKeepStatusChange('fallback'); } catch { // Renderer fallback still has to engage when a status observer fails. } - disposeScene(); - options.onRendererUnavailable(); + reportRendererFailureAndDispose(classifyRealmRendererFailure( + error, + 'loading', + 'castle-compact-load-failed' + ), true); }); + function reportRendererFailureAndDispose( + failure: RealmRendererFailure, + notifyUnavailable: boolean + ) { + try { + options.onRendererFailure?.(failure); + } finally { + try { + if (notifyUnavailable) options.onRendererUnavailable(); + } finally { + disposeScene(); + } + } + } + function disposeScene() { cleanup.dispose(); } @@ -2818,6 +3235,7 @@ function initializeRealmScene( || !Array.isArray(state.foodNodes) || !Array.isArray(state.woodNodes) || !Array.isArray(state.stoneNodes) + || (state.workers !== undefined && !Array.isArray(state.workers)) || !matchesStaticCatalog(state.goldNodes, options.goldNodes ?? []) || !matchesStaticCatalog(state.foodNodes, options.foodNodes ?? []) || !matchesStaticCatalog(state.woodNodes, options.woodNodes ?? []) @@ -2826,6 +3244,18 @@ function initializeRealmScene( recordLiveReconciliationTelemetry(false); return; } + const nextWorkers = state.workers ?? []; + const workerCanReconcile = workerLayer?.canReconcile(nextWorkers) === true; + let preparedWorkerLayer: RealmWorkerLayer | undefined; + let workerCatalogAccepted = workerCanReconcile; + if (!workerCanReconcile) { + try { + preparedWorkerLayer = createWorkerLayer(nextWorkers); + workerCatalogAccepted = true; + } catch { + workerCatalogAccepted = false; + } + } const accepted = ( goldNodeLayer?.canReconcile(state.goldNodes) ?? true ) && ( @@ -2834,12 +3264,16 @@ function initializeRealmScene( woodNodeLayer?.canReconcile(state.woodNodes) ?? true ) && ( stoneNodeLayer?.canReconcile(state.stoneNodes) ?? true - ); + ) && workerCatalogAccepted; if (accepted) { goldNodeLayer?.reconcile(state.goldNodes); foodNodeLayer?.reconcile(state.foodNodes); woodNodeLayer?.reconcile(state.woodNodes); stoneNodeLayer?.reconcile(state.stoneNodes); + if (preparedWorkerLayer) installWorkerLayer(preparedWorkerLayer, nextWorkers.length); + else workerLayer?.reconcile(nextWorkers); + } else { + preparedWorkerLayer?.dispose(); } recordLiveReconciliationTelemetry(accepted); if (accepted) render(); @@ -2855,6 +3289,7 @@ function initializeRealmScene( target: Object.freeze({ ...pose.target }), fov: pose.fov, zoom: cameraController.getZoom(), + controllerState: cameraController.captureState(), selectedTerrainCoord: selectedTerrainCoord ? Object.freeze({ ...selectedTerrainCoord }) : null, @@ -2870,6 +3305,10 @@ function initializeRealmScene( dispose: disposeScene, reconcileLiveGatheringState, getCameraAttestation, + restoreCameraAttestation: (attestation) => { + if (cleanup.isDisposed()) return; + cameraController.restoreState(attestation.controllerState); + }, getSceneBuildSequence: () => sceneBuildSequence, focusCastle: (castleId) => { if (cleanup.isDisposed()) return; @@ -2905,6 +3344,28 @@ function initializeRealmScene( footprintDiameter: 1.24 }); }, + focusWaterCell: (cellKey) => { + if (cleanup.isDisposed() || !waterLayer) return; + const cell = waterCellByKey.get(cellKey); + if (!cell || (cell.regime === 'ocean' && cell.fogBand === 'full')) return; + const world = axialToWorld({ q: cell.q, r: cell.r }, HEX_SIZE); + const terrainY = terrainHeightAtWorld( + options.surface.renderMap, + world, + HEX_SIZE, + terrainPlacements + ); + const surfaceY = cell.regime === 'river' + ? Math.max(waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + 0.035, terrainY + 0.04) + : waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + 0.035; + cameraController.focusAt({ + x: world.x, + y: surfaceY, + z: world.z, + height: 0.18, + footprintDiameter: 1.24 + }); + }, frameFoundingDistrict: () => { const width = Math.max(1, options.canvas.clientWidth || window.innerWidth || 1); const height = Math.max(1, options.canvas.clientHeight || window.innerHeight || 1); @@ -2918,6 +3379,9 @@ function initializeRealmScene( recenterKeep: cameraController.recenterKeep, setHovered: (coord) => { if (cleanup.isDisposed()) return; + waterLayer?.setHoveredCellKey(null); + hoveredWorkerId = undefined; + workerLayer?.setHoveredWorkerId(null); hoveredTerrainCoord = coord; grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); // A terrain hex runs through the wider authored landscape-base mesh. @@ -2945,6 +3409,9 @@ function initializeRealmScene( }, setSelected: (coord) => { if (cleanup.isDisposed()) return; + waterLayer?.setSelectedCellKey(null); + selectedWorkerId = undefined; + workerLayer?.setSelectedWorkerId(null); selectedTerrainCoord = coord; grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); selectedCastleId = coord @@ -2964,6 +3431,9 @@ function initializeRealmScene( if (cleanup.isDisposed()) return; selectedCastleId = castleId === null ? undefined : castleId; if (castleId !== null) { + waterLayer?.setSelectedCellKey(null); + selectedWorkerId = undefined; + workerLayer?.setSelectedWorkerId(null); selectedTerrainCoord = null; grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); setOverlay(selectedOverlay, options.surface, null, terrainPlacements); @@ -2974,28 +3444,112 @@ function initializeRealmScene( if (cleanup.isDisposed()) return; selectedGoldSiteId = siteId === null ? undefined : siteId; goldNodeLayer?.setSelectedSiteId(siteId); - if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + if (siteId !== null) { + waterLayer?.setSelectedCellKey(null); + selectedWorkerId = undefined; + workerLayer?.setSelectedWorkerId(null); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } render(); }, setSelectedFoodSiteId: (siteId) => { if (cleanup.isDisposed()) return; selectedFoodSiteId = siteId === null ? undefined : siteId; foodNodeLayer?.setSelectedSiteId(siteId); - if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + if (siteId !== null) { + waterLayer?.setSelectedCellKey(null); + selectedWorkerId = undefined; + workerLayer?.setSelectedWorkerId(null); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } render(); }, setSelectedWoodSiteId: (siteId) => { if (cleanup.isDisposed()) return; selectedWoodSiteId = siteId === null ? undefined : siteId; woodNodeLayer?.setSelectedSiteId(siteId); - if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + if (siteId !== null) { + waterLayer?.setSelectedCellKey(null); + selectedWorkerId = undefined; + workerLayer?.setSelectedWorkerId(null); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } render(); }, setSelectedStoneSiteId: (siteId) => { if (cleanup.isDisposed()) return; selectedStoneSiteId = siteId === null ? undefined : siteId; stoneNodeLayer?.setSelectedSiteId(siteId); - if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + if (siteId !== null) { + waterLayer?.setSelectedCellKey(null); + selectedWorkerId = undefined; + workerLayer?.setSelectedWorkerId(null); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } + render(); + }, + setSelectedWorkerId: (workerId) => { + if (cleanup.isDisposed()) return; + selectedWorkerId = workerId === null ? undefined : workerId; + workerLayer?.setSelectedWorkerId(workerId); + if (workerId !== null) { + waterLayer?.setSelectedCellKey(null); + selectedTerrainCoord = null; + selectedCastleId = undefined; + selectedGoldSiteId = undefined; + selectedFoodSiteId = undefined; + selectedWoodSiteId = undefined; + selectedStoneSiteId = undefined; + goldNodeLayer?.setSelectedSiteId(null); + foodNodeLayer?.setSelectedSiteId(null); + woodNodeLayer?.setSelectedSiteId(null); + stoneNodeLayer?.setSelectedSiteId(null); + grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } + render(); + }, + setSelectedWaterCellKey: (cellKey) => { + if (cleanup.isDisposed()) return; + waterLayer?.setSelectedCellKey(cellKey); + if (cellKey !== null) { + selectedTerrainCoord = null; + selectedCastleId = undefined; + selectedGoldSiteId = undefined; + selectedFoodSiteId = undefined; + selectedWoodSiteId = undefined; + selectedStoneSiteId = undefined; + selectedWorkerId = undefined; + workerLayer?.setSelectedWorkerId(null); + goldNodeLayer?.setSelectedSiteId(null); + foodNodeLayer?.setSelectedSiteId(null); + woodNodeLayer?.setSelectedSiteId(null); + stoneNodeLayer?.setSelectedSiteId(null); + grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } + render(); + }, + setHoveredWorkerId: (workerId) => { + if (cleanup.isDisposed()) return; + hoveredWorkerId = workerId === null ? undefined : workerId; + workerLayer?.setHoveredWorkerId(workerId); + if (workerId !== null) { + waterLayer?.setHoveredCellKey(null); + hoveredTerrainCoord = null; + setOverlay(hoverOverlay, options.surface, null, terrainPlacements); + } + render(); + }, + setHoveredWaterCellKey: (cellKey) => { + if (cleanup.isDisposed()) return; + waterLayer?.setHoveredCellKey(cellKey); + if (cellKey !== null) { + hoveredWorkerId = undefined; + workerLayer?.setHoveredWorkerId(null); + hoveredTerrainCoord = null; + setOverlay(hoverOverlay, options.surface, null, terrainPlacements); + } render(); }, setComposition: (composition) => cameraController.setComposition(composition), diff --git a/src/components/realm/createRealmTerrainFeatures.ts b/src/components/realm/createRealmTerrainFeatures.ts index 09013398..15bc3392 100644 --- a/src/components/realm/createRealmTerrainFeatures.ts +++ b/src/components/realm/createRealmTerrainFeatures.ts @@ -20,18 +20,30 @@ export type RealmTerrainFeatureLayers = Readonly<{ dispose: () => void; }>; -function geometryForKind(kind: RealmTerrainFeatureKind) { +type PresentedTerrainFeatureKind = Exclude; + +const PRESENTED_TERRAIN_FEATURE_KINDS: readonly PresentedTerrainFeatureKind[] = Object.freeze([ + 'forest-tree', + 'ridge-outcrop', + 'lake-sheen', + 'ancient-monolith' +]); + +function isPresentedTerrainFeatureKind( + kind: RealmTerrainFeatureKind +): kind is PresentedTerrainFeatureKind { + return kind === 'forest-tree' + || kind === 'ridge-outcrop' + || kind === 'lake-sheen' + || kind === 'ancient-monolith'; +} + +function geometryForKind(kind: PresentedTerrainFeatureKind) { if (kind === 'forest-tree') { const geometry = new THREE.ConeGeometry(0.095, 0.34, 6, 1); geometry.translate(0, 0.17, 0); return geometry; } - if (kind === 'heath-bloom') { - const geometry = new THREE.DodecahedronGeometry(0.052, 0); - geometry.scale(1.45, 0.78, 1.45); - geometry.translate(0, 0.043, 0); - return geometry; - } if (kind === 'ridge-outcrop') { const geometry = new THREE.DodecahedronGeometry(0.14, 0); geometry.scale(1.28, 0.7, 0.92); @@ -48,7 +60,7 @@ function geometryForKind(kind: RealmTerrainFeatureKind) { return geometry; } -function materialForKind(kind: RealmTerrainFeatureKind): THREE.Material { +function materialForKind(kind: PresentedTerrainFeatureKind): THREE.Material { if (kind === 'forest-tree') { return new THREE.MeshStandardMaterial({ color: '#274936', @@ -56,15 +68,6 @@ function materialForKind(kind: RealmTerrainFeatureKind): THREE.Material { metalness: 0 }); } - if (kind === 'heath-bloom') { - return new THREE.MeshStandardMaterial({ - color: '#72527d', - emissive: '#2b1538', - emissiveIntensity: 0.12, - roughness: 0.88, - metalness: 0 - }); - } if (kind === 'ridge-outcrop') { return new THREE.MeshStandardMaterial({ color: '#625f5b', @@ -83,11 +86,11 @@ function materialForKind(kind: RealmTerrainFeatureKind): THREE.Material { }); } return new THREE.MeshStandardMaterial({ - color: '#5a5364', - emissive: '#291634', - emissiveIntensity: 0.1, - roughness: 0.78, - metalness: 0.08 + color: '#6f746c', + emissive: '#202720', + emissiveIntensity: 0.025, + roughness: 0.9, + metalness: 0.02 }); } @@ -106,11 +109,27 @@ export function createRealmTerrainFeatureLayers( const scale = new THREE.Vector3(); const axis = new THREE.Vector3(0, 1, 0); const meshes: THREE.InstancedMesh[] = []; + const pointsByKind = new Map< + PresentedTerrainFeatureKind, + RealmTerrainFeatureData['points'][number][] + >(PRESENTED_TERRAIN_FEATURE_KINDS.map((kind) => [kind, []])); + data.points.forEach((point) => { + if (!isPresentedTerrainFeatureKind(point.kind)) return; + pointsByKind.get(point.kind)!.push(point); + }); + const counts = Object.freeze({ + 'forest-tree': pointsByKind.get('forest-tree')!.length, + 'heath-bloom': 0, + 'ridge-outcrop': pointsByKind.get('ridge-outcrop')!.length, + 'lake-sheen': pointsByKind.get('lake-sheen')!.length, + 'ancient-monolith': pointsByKind.get('ancient-monolith')!.length + }) satisfies RealmTerrainFeatureData['counts']; + const instanceCount = Object.values(counts).reduce((total, count) => total + count, 0); let disposed = false; try { - (Object.keys(data.counts) as RealmTerrainFeatureKind[]).forEach((kind) => { - const points = data.points.filter((point) => point.kind === kind); + PRESENTED_TERRAIN_FEATURE_KINDS.forEach((kind) => { + const points = pointsByKind.get(kind)!; if (points.length === 0) return; const geometry = geometryForKind(kind); const material = materialForKind(kind); @@ -173,9 +192,9 @@ export function createRealmTerrainFeatureLayers( return Object.freeze({ group, - counts: data.counts, + counts, drawCalls: meshes.length, - instanceCount: data.points.length, + instanceCount, dispose: () => { if (disposed) return; disposed = true; diff --git a/src/components/realm/hegemonyKeepPrefabRepository.ts b/src/components/realm/hegemonyKeepPrefabRepository.ts index 959f2f36..3e866672 100644 --- a/src/components/realm/hegemonyKeepPrefabRepository.ts +++ b/src/components/realm/hegemonyKeepPrefabRepository.ts @@ -61,10 +61,16 @@ export type HegemonyKeepPrefabLoader = ( export type HegemonyKeepPrefabRepository = Readonly<{ /** - * Coalesces concurrent acquisition. A retired LOD cannot be reacquired in - * the same realm session because its GPU resources have already been freed. + * Coalesces concurrent acquisition. A successfully loaded, released LOD + * cannot be reacquired in the same realm session because its GPU resources + * have already been freed. */ acquire: (lod: CastleLod, signal?: AbortSignal) => Promise; + /** + * Retires one settled failed request before reacquiring it. Concurrent + * retry callers still coalesce onto the single replacement entry. + */ + retryFailed: (lod: CastleLod, signal?: AbortSignal) => Promise; }>; export type CreateHegemonyKeepPrefabRepositoryOptions = Readonly<{ @@ -94,6 +100,7 @@ type InternalPrefab = Readonly<{ type CacheEntry = { activeLeases: number; abortController: AbortController; + failed: boolean; internal?: InternalPrefab; pendingAcquisitions: number; promise: Promise; @@ -547,6 +554,7 @@ export function createHegemonyKeepPrefabRepository( const entry: CacheEntry = { activeLeases: 0, abortController, + failed: false, pendingAcquisitions: 0, promise: Promise.resolve() .then(() => loader(lod, abortController.signal)) @@ -562,6 +570,10 @@ export function createHegemonyKeepPrefabRepository( // Preserve cancellation after best-effort custom-loader cleanup. } throw createRealmLoadAbortError(`Hegemony keep ${lod} prefab`); + }) + .catch((error: unknown) => { + entry.failed = true; + throw error; }), resourcesReleased: false, retired: false @@ -570,61 +582,77 @@ export function createHegemonyKeepPrefabRepository( return entry; }; - return Object.freeze({ - acquire: (lod, signal) => { - if (signal?.aborted) { - return Promise.reject(createRealmLoadAbortError(`Hegemony keep ${lod} prefab`)); - } - const entry = entryFor(lod); - if (entry.retired) { - return Promise.reject(new Error( - `Hegemony keep ${lod} prefab is retired for this realm session.` - )); - } - entry.pendingAcquisitions += 1; - - return new Promise((resolve, reject) => { - let finished = false; - const finishPending = () => { - if (finished) return false; - finished = true; - signal?.removeEventListener('abort', onAbort); - entry.pendingAcquisitions -= 1; - return true; - }; - const onAbort = () => { - if (!finishPending()) return; - retireEntryIfUnused(entry); - reject(createRealmLoadAbortError(`Hegemony keep ${lod} prefab`)); - }; - - signal?.addEventListener('abort', onAbort, { once: true }); - entry.promise.then((internal) => { - if (!finishPending()) return; - // A previous lease or the final pending cancellation can retire a - // resolved entry. Never revive already-freed resources. - if (entry.retired) { - reject(new Error( - `Hegemony keep ${lod} prefab is retired for this realm session.` - )); - return; + const acquire = (lod: CastleLod, signal?: AbortSignal) => { + if (signal?.aborted) { + return Promise.reject(createRealmLoadAbortError(`Hegemony keep ${lod} prefab`)); + } + const entry = entryFor(lod); + if (entry.retired) { + return Promise.reject(new Error( + `Hegemony keep ${lod} prefab is retired for this realm session.` + )); + } + entry.pendingAcquisitions += 1; + + return new Promise((resolve, reject) => { + let finished = false; + const finishPending = () => { + if (finished) return false; + finished = true; + signal?.removeEventListener('abort', onAbort); + entry.pendingAcquisitions -= 1; + return true; + }; + const onAbort = () => { + if (!finishPending()) return; + retireEntryIfUnused(entry); + reject(createRealmLoadAbortError(`Hegemony keep ${lod} prefab`)); + }; + + signal?.addEventListener('abort', onAbort, { once: true }); + entry.promise.then((internal) => { + if (!finishPending()) return; + // A previous lease or the final pending cancellation can retire a + // resolved entry. Never revive already-freed resources. + if (entry.retired) { + reject(new Error( + `Hegemony keep ${lod} prefab is retired for this realm session.` + )); + return; + } + entry.activeLeases += 1; + let released = false; + resolve(Object.freeze({ + prefab: internal.prefab, + release: () => { + if (released) return; + released = true; + entry.activeLeases -= 1; + retireEntryIfUnused(entry); } - entry.activeLeases += 1; - let released = false; - resolve(Object.freeze({ - prefab: internal.prefab, - release: () => { - if (released) return; - released = true; - entry.activeLeases -= 1; - retireEntryIfUnused(entry); - } - })); - }, (error: unknown) => { - if (!finishPending()) return; - reject(error); - }); + })); + }, (error: unknown) => { + if (!finishPending()) return; + reject(error); }); + }); + }; + + return Object.freeze({ + acquire, + retryFailed: (lod, signal) => { + const failed = entries.get(lod); + if ( + failed?.failed === true + && failed.pendingAcquisitions === 0 + && failed.activeLeases === 0 + && failed.internal === undefined + ) { + failed.retired = true; + failed.abortController.abort(); + entries.delete(lod); + } + return acquire(lod, signal); } }); } diff --git a/src/components/realm/realmCameraController.ts b/src/components/realm/realmCameraController.ts index da1ab4e7..2002ef5c 100644 --- a/src/components/realm/realmCameraController.ts +++ b/src/components/realm/realmCameraController.ts @@ -879,6 +879,26 @@ type RealmCompositionState = Readonly<{ focusPadding: number; }>; +/** + * Complete camera-controller state that can survive a renderer rebuild. + * + * A Three.js camera pose alone is not sufficient: the next demand frame is + * derived from these current/target values and would otherwise overwrite a + * directly-mutated camera with the new controller's default state. + */ +export type RealmCameraControllerState = Readonly<{ + currentZoom: number; + targetZoom: number; + currentPan: RealmCameraPan; + targetPan: RealmCameraPan; + currentFocus: RealmKeepFocus; + targetFocus: RealmKeepFocus; + currentComposition: RealmCameraComposition; + targetComposition: RealmCameraComposition; + targetFocusIsKeep: boolean; + manualPlanarControl: boolean; +}>; + function compositionState(composition: RealmCameraComposition = {}): RealmCompositionState { const insets = normalizeInsets(composition.insets); const safeAreaInsets = normalizeInsets(composition.safeAreaInsets); @@ -963,6 +983,7 @@ export type RealmCameraController = Readonly<{ frameAt: (focus: RealmKeepFocus, zoom: number) => void; focusAt: (focus: RealmKeepFocus) => void; focusKeep: () => void; + captureState: () => RealmCameraControllerState; getMode: () => RealmCameraMode; getPose: () => RealmCameraPose; getSafeViewport: () => RealmSafeViewport; @@ -983,6 +1004,7 @@ export type RealmCameraController = Readonly<{ panByPixels: (deltaX: number, deltaY: number) => void; projectPoint: (point: RealmCameraPoint) => RealmScreenProjection; recenterKeep: () => void; + restoreState: (state: RealmCameraControllerState) => void; setComposition: (composition: RealmCameraComposition) => void; setKeepFocus: (focus: RealmKeepFocus) => void; setViewport: (width: number, height: number) => void; @@ -1503,6 +1525,18 @@ export function createRealmCameraController( return { beginDirectManipulation, camera, + captureState: () => Object.freeze({ + currentZoom, + targetZoom, + currentPan: Object.freeze({ ...currentPan }), + targetPan: Object.freeze({ ...targetPan }), + currentFocus: Object.freeze({ ...currentFocus }), + targetFocus: Object.freeze({ ...targetFocus }), + currentComposition: Object.freeze(stateComposition(currentComposition)), + targetComposition: Object.freeze(stateComposition(targetComposition)), + targetFocusIsKeep, + manualPlanarControl + }), dispose: () => { disposed = true; if (frame) window.cancelAnimationFrame(frame); @@ -1582,6 +1616,35 @@ export function createRealmCameraController( zoomAnchor = null; invalidate(); }, + restoreState: (state) => { + if (disposed) return; + if (frame) window.cancelAnimationFrame(frame); + frame = 0; + previousTime = 0; + currentZoom = clamp(finite(state.currentZoom, currentZoom), 0, 1); + targetZoom = clamp(finite(state.targetZoom, targetZoom), 0, 1); + currentPan = { + x: finite(state.currentPan.x, currentPan.x), + z: finite(state.currentPan.z, currentPan.z) + }; + targetPan = { + x: finite(state.targetPan.x, targetPan.x), + z: finite(state.targetPan.z, targetPan.z) + }; + currentFocus = normalizeKeepFocus(state.currentFocus, currentFocus); + targetFocus = normalizeKeepFocus(state.targetFocus, targetFocus); + currentComposition = compositionState(state.currentComposition); + targetComposition = compositionState(state.targetComposition); + targetFocusIsKeep = state.targetFocusIsKeep === true; + manualPlanarControl = state.manualPlanarControl === true; + // Pointer capture cannot survive a disposed canvas. Preserve the + // camera trajectory, but resume it as an ordinary demand transition. + directManipulation = false; + zoomAnchor = null; + applyPose(); + options.render(); + if (isUnsettled()) invalidate(); + }, setComposition: (next) => { // Insets change the projection beneath a screen-space zoom anchor. Drop // the old anchor so the camera can converge on the newly composed target diff --git a/src/components/realm/realmCastlePresentation.ts b/src/components/realm/realmCastlePresentation.ts index a35f29d5..9fbaa171 100644 --- a/src/components/realm/realmCastlePresentation.ts +++ b/src/components/realm/realmCastlePresentation.ts @@ -287,6 +287,7 @@ export function fallbackCastleProjection( const centerX = contentLeft + (world.x - viewBox.x) * scale; const centerY = contentTop + (-world.z - viewBox.y) * scale; const markerHalfSize = Math.max(10, Math.abs(scale) * 0.64); + const visible = fallbackCastleIsInViewBox(castle, viewBox); const castleBounds = { left: centerX - markerHalfSize, top: centerY - markerHalfSize, @@ -298,8 +299,19 @@ export function fallbackCastleProjection( x: centerX, y: centerY + markerHalfSize + CASTLE_LABEL_GAP_PIXELS, distance: hexDistance({ q: 0, r: 0 }, castle), - visible: true, + visible, castleBounds, conservativeCastleBounds: castleBounds }; } + +export function fallbackCastleIsInViewBox( + castle: Readonly<{ q: number; r: number }>, + viewBox: Readonly<{ x: number; y: number; width: number; height: number }> +) { + const world = axialToWorld(castle, 1); + return world.x >= viewBox.x + && world.x <= viewBox.x + viewBox.width + && -world.z >= viewBox.y + && -world.z <= viewBox.y + viewBox.height; +} diff --git a/src/components/realm/realmInteractionState.ts b/src/components/realm/realmInteractionState.ts index d6ca3527..db0d18ad 100644 --- a/src/components/realm/realmInteractionState.ts +++ b/src/components/realm/realmInteractionState.ts @@ -5,6 +5,13 @@ export type RealmCastleTarget = Readonly<{ coord: HexCoord; }>; +export type RealmWorkerTarget = Readonly<{ + workerId: string; + workerOrdinal: number; + originCastleId: number; + coord: HexCoord; +}>; + /** A public world-site target; it contains no economy or authorization data. */ export type RealmGoldSiteTarget = Readonly<{ siteId: string; @@ -29,27 +36,42 @@ export type RealmStoneSiteTarget = Readonly<{ coord: HexCoord; }>; +export type RealmWaterCellTarget = Readonly<{ + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: HexCoord; +}>; + export type RealmInspectorTarget = + | RealmWorkerTarget | RealmCastleTarget | RealmGoldSiteTarget | RealmFoodSiteTarget | RealmWoodSiteTarget - | RealmStoneSiteTarget; + | RealmStoneSiteTarget + | RealmWaterCellTarget; export type RealmCameraTarget = | Readonly<{ kind: 'realm' }> | Readonly<{ kind: 'founding-district' }> | Readonly<{ kind: 'keep' }> | Readonly<{ kind: 'cell'; coord: HexCoord }> - | Readonly<{ kind: 'castle'; castleId: number; coord: HexCoord }>; + | Readonly<{ kind: 'castle'; castleId: number; coord: HexCoord }> + | Readonly<{ kind: 'worker'; workerId: string; coord: HexCoord }> + // Water focus is bounded by the validated visible projection, not land + // passability; full-fog cells never become camera targets. + | Readonly<{ kind: 'water'; cellKey: string; coord: HexCoord }>; export type RealmKeyboardTarget = | Readonly<{ kind: 'map' }> | Readonly<{ kind: 'inspector'; castleId: number }> + | Readonly<{ kind: 'worker-inspector'; workerId: string }> | Readonly<{ kind: 'gold-mine-inspector'; siteId: string }> | Readonly<{ kind: 'food-farm-inspector'; siteId: string }> | Readonly<{ kind: 'logging-camp-inspector'; siteId: string }> | Readonly<{ kind: 'stone-quarry-inspector'; siteId: string }> + | Readonly<{ kind: 'water-inspector'; cellKey: string }> | Readonly<{ kind: 'castle-label'; castleId: number }> | Readonly<{ kind: 'navigator' }> | Readonly<{ kind: 'navigator-trigger' }>; @@ -76,6 +98,7 @@ export type RealmInteractionState = Readonly<{ export type RealmInteractionAction = | Readonly<{ type: 'select-cell'; coord: HexCoord }> | Readonly<{ type: 'activate-castle'; castleId: number; coord: HexCoord }> + | Readonly<{ type: 'activate-worker'; workerId: string; workerOrdinal: number; originCastleId: number; coord: HexCoord }> | Readonly<{ type: 'activate-gold-site'; siteId: string; @@ -100,6 +123,14 @@ export type RealmInteractionAction = coord: HexCoord; cameraIntent?: 'focus-site' | 'preserve'; }> + | Readonly<{ + type: 'activate-water-cell'; + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: HexCoord; + cameraIntent?: 'focus-water' | 'preserve'; + }> | Readonly<{ type: 'close-inspector' }> | Readonly<{ type: 'recenter-keep'; coord: HexCoord }> | Readonly<{ type: 'set-camera-target'; target: RealmCameraTarget }> @@ -123,6 +154,15 @@ function copyCastleTarget(target: RealmCastleTarget): RealmCastleTarget { return { castleId: target.castleId, coord: copyCoord(target.coord) }; } +function copyWorkerTarget(target: RealmWorkerTarget): RealmWorkerTarget { + return { + workerId: target.workerId, + workerOrdinal: target.workerOrdinal, + originCastleId: target.originCastleId, + coord: copyCoord(target.coord) + }; +} + function copyGoldSiteTarget(target: RealmGoldSiteTarget): RealmGoldSiteTarget { return { siteId: target.siteId, coord: copyCoord(target.coord) }; } @@ -139,6 +179,15 @@ function copyStoneSiteTarget(target: RealmStoneSiteTarget): RealmStoneSiteTarget return { stoneSiteId: target.stoneSiteId, coord: copyCoord(target.coord) }; } +function copyWaterCellTarget(target: RealmWaterCellTarget): RealmWaterCellTarget { + return { + cellKey: target.cellKey, + bodyId: target.bodyId, + regime: target.regime, + coord: copyCoord(target.coord) + }; +} + function isCastleTarget(target: RealmInspectorTarget | null): target is RealmCastleTarget { return target !== null && 'castleId' in target; } @@ -148,6 +197,8 @@ function copyCameraTarget(target: RealmCameraTarget): RealmCameraTarget { if (target.kind === 'founding-district') return { kind: 'founding-district' }; if (target.kind === 'keep') return { kind: 'keep' }; if (target.kind === 'cell') return { kind: 'cell', coord: copyCoord(target.coord) }; + if (target.kind === 'worker') return { kind: 'worker', workerId: target.workerId, coord: copyCoord(target.coord) }; + if (target.kind === 'water') return { kind: 'water', cellKey: target.cellKey, coord: copyCoord(target.coord) }; return { kind: 'castle', castleId: target.castleId, coord: copyCoord(target.coord) }; } @@ -212,6 +263,20 @@ export function realmInteractionReducer( }; } + case 'activate-worker': { + const target = copyWorkerTarget(action); + return { + ...state, + selectedCell: copyCoord(target.coord), + selectedCastle: null, + inspectorTarget: target, + inspectorOpen: true, + cameraTarget: { kind: 'worker', workerId: target.workerId, coord: copyCoord(target.coord) }, + navigatorOpen: false, + keyboardIntent: withKeyboardIntent(state, { kind: 'worker-inspector', workerId: target.workerId }) + }; + } + case 'activate-gold-site': { const target = copyGoldSiteTarget({ siteId: action.siteId, coord: action.coord }); return { @@ -280,6 +345,25 @@ export function realmInteractionReducer( }; } + case 'activate-water-cell': { + const target = copyWaterCellTarget(action); + return { + ...state, + selectedCell: copyCoord(target.coord), + selectedCastle: null, + inspectorTarget: target, + inspectorOpen: true, + cameraTarget: action.cameraIntent === 'preserve' + ? state.cameraTarget + : { kind: 'water', cellKey: target.cellKey, coord: copyCoord(target.coord) }, + navigatorOpen: false, + keyboardIntent: withKeyboardIntent(state, { + kind: 'water-inspector', + cellKey: target.cellKey + }) + }; + } + case 'close-inspector': { if (!state.inspectorOpen) return state; const castle = isCastleTarget(state.inspectorTarget) diff --git a/src/components/realm/realmMapPresentationHelpers.ts b/src/components/realm/realmMapPresentationHelpers.ts index c4686f99..8df5505f 100644 --- a/src/components/realm/realmMapPresentationHelpers.ts +++ b/src/components/realm/realmMapPresentationHelpers.ts @@ -1,7 +1,11 @@ import { useEffect, useState } from 'react'; -import type { HexCoord } from '../../game/map/hexCoordinates'; +import { axialToWorld, type HexCoord } from '../../game/map/hexCoordinates'; import { terrainCellByCoord } from '../../game/map/generateTerrainMap'; +import { + probeWebGL2Capability, + resetWebGL2CapabilityForTests +} from '../../settings/graphicsPreference'; import type { RealmTerrainSurface } from '../../game/map/realmTerrainSurface'; import type { TerrainCell } from '../../game/map/terrainTypes'; import { @@ -28,6 +32,12 @@ export type RealmFallbackSurfacePresentation = Readonly<{ playableHullPoints: string; }>; +export type RealmFallbackSurfaceOptions = Readonly<{ + /** Keep unsupported-device mode readable around the player's region. */ + focusCoord?: HexCoord; + radius?: number; +}>; + function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, value)); } @@ -71,14 +81,11 @@ export function directionForKey(key: string): HexCoord | null { } export function canUseWebGL() { - try { - const canvas = document.createElement('canvas'); - const context = canvas.getContext('webgl2'); - context?.getExtension('WEBGL_lose_context')?.loseContext(); - return Boolean(context); - } catch { - return false; - } + return probeWebGL2Capability().available; +} + +export function resetWebGLCapabilityForTests() { + resetWebGL2CapabilityForTests(); } export function pointsForSvg(coord: HexCoord) { @@ -92,7 +99,8 @@ function svgHullPoints(points: readonly Readonly<{ x: number; z: number }>[]) { } export function fallbackSurfacePresentation( - surface: RealmTerrainSurface + surface: RealmTerrainSurface, + options: RealmFallbackSurfaceOptions = {} ): RealmFallbackSurfacePresentation { const renderHull = createTerrainOverviewHull(surface.renderMap, REALM_HEX_SIZE); const playableHull = createTerrainOverviewHull(surface.playableMap, REALM_HEX_SIZE); @@ -115,6 +123,24 @@ export function fallbackSurfacePresentation( maxZ = Math.max(maxZ, point.z); }); const padding = 0.88; + const focus = options.focusCoord; + const radius = Number.isFinite(options.radius) && (options.radius ?? 0) > 0 + ? options.radius! + : undefined; + if (focus && radius) { + const center = axialToWorld(focus, REALM_HEX_SIZE); + const span = Math.max(4, radius * 2.15); + return { + viewBox: { + x: center.x - span * 0.5, + y: -center.z - span * 0.5, + width: span, + height: span + }, + renderHullPoints: svgHullPoints(renderHull), + playableHullPoints: svgHullPoints(playableHull) + }; + } return { viewBox: { x: minX - padding, diff --git a/src/components/realm/realmPickArbitration.ts b/src/components/realm/realmPickArbitration.ts index 7e353552..46e8c92f 100644 --- a/src/components/realm/realmPickArbitration.ts +++ b/src/components/realm/realmPickArbitration.ts @@ -14,7 +14,33 @@ export type RealmResourcePickHit = Readonly<{ distance: number; }>; +export type RealmWorkerPickHit = Readonly<{ + workerId: string; + workerOrdinal: number; + originCastleId: number; + coord: HexCoord; + distance: number; +}>; + +export type RealmWaterPickHit = Readonly<{ + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: HexCoord; + distance: number; +}>; + +/** A visible, public water identity that can be handed to the interaction lane. */ +export type RealmWaterInteractionTarget = Readonly<{ + kind: 'water-cell'; + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: HexCoord; +}>; + export type RealmInteractionTarget = + | Readonly<{ kind: 'worker'; workerId: string; workerOrdinal: number; originCastleId: number; coord: HexCoord }> | Readonly<{ kind: 'castle'; castleId: number; coord: HexCoord }> | Readonly<{ kind: RealmResourcePickKind; @@ -22,6 +48,7 @@ export type RealmInteractionTarget = coord: HexCoord; source: 'site' | 'wagon'; }> + | RealmWaterInteractionTarget | Readonly<{ kind: 'terrain'; coord: HexCoord }>; function nearestValidHit( @@ -40,6 +67,15 @@ function nearestValidHit( return nearest; } +function nearestValidWorkerHit(hits: readonly RealmWorkerPickHit[]) { + let nearest: RealmWorkerPickHit | undefined; + for (const hit of hits) { + if (!Number.isFinite(hit.distance) || hit.distance < 0) continue; + if (!nearest || hit.distance < nearest.distance) nearest = hit; + } + return nearest; +} + /** * Resolve overlapping scene targets by gameplay intent, not mesh distance. * Moving wagons remain operable over a keep; keeps remain operable beneath a @@ -47,9 +83,21 @@ function nearestValidHit( */ export function arbitrateRealmPick(input: Readonly<{ resourceHits: readonly RealmResourcePickHit[]; + workerHits?: readonly RealmWorkerPickHit[]; + waterHit?: RealmWaterPickHit | null; castleHit?: Readonly<{ castleId: number; coord: HexCoord }> | null; terrainHit?: Readonly<{ coord: HexCoord }> | null; }>): RealmInteractionTarget | null { + const worker = nearestValidWorkerHit(input.workerHits ?? []); + if (worker) { + return Object.freeze({ + kind: 'worker', + workerId: worker.workerId, + workerOrdinal: worker.workerOrdinal, + originCastleId: worker.originCastleId, + coord: worker.coord + }); + } const wagon = nearestValidHit(input.resourceHits, 'wagon'); if (wagon) { return Object.freeze({ @@ -75,6 +123,15 @@ export function arbitrateRealmPick(input: Readonly<{ source: site.source }); } + if (input.waterHit && Number.isFinite(input.waterHit.distance) && input.waterHit.distance >= 0) { + return Object.freeze({ + kind: 'water-cell', + cellKey: input.waterHit.cellKey, + bodyId: input.waterHit.bodyId, + regime: input.waterHit.regime, + coord: input.waterHit.coord + }); + } return input.terrainHit ? Object.freeze({ kind: 'terrain', coord: input.terrainHit.coord }) : null; diff --git a/src/components/realm/realmRendererRecovery.ts b/src/components/realm/realmRendererRecovery.ts new file mode 100644 index 00000000..4fd4aa13 --- /dev/null +++ b/src/components/realm/realmRendererRecovery.ts @@ -0,0 +1,157 @@ +export type RealmRendererLifecycleState = + | 'probing' + | 'loading' + | 'ready' + | 'recovering' + | 'static-unsupported' + | 'failed'; + +export type RealmRendererFailureCode = + | 'webgl-unavailable' + | 'renderer-construction-failed' + | 'context-lost' + | 'context-restore-timeout' + | 'castle-count-mismatch' + | 'castle-prefab-assembly-failed' + | 'castle-pairing-failed' + | 'castle-compact-load-failed' + | 'castle-integrity-failed' + | 'scene-build-failed' + | 'sync-failed'; + +export type RealmRendererFailure = Readonly<{ + code: RealmRendererFailureCode; + message?: string; + retryable: boolean; + phase: RealmRendererLifecycleState; + attempt?: number; +}>; + +export type RealmRendererLifecycle = Readonly<{ + state: RealmRendererLifecycleState; + attempt: number; + /** Monotonic scene-generation identifier used to correlate DOM telemetry. */ + generation: number; + failure?: RealmRendererFailure; + everReady: boolean; + degradedQuality?: 'compact' | 'balanced'; +}>; + +export const REALM_RENDERER_MAX_RECOVERY_ATTEMPTS = 2; +export const REALM_RENDERER_CONTEXT_RESTORE_TIMEOUT_MS = 8_000; + +export function initialRealmRendererLifecycle(): RealmRendererLifecycle { + return Object.freeze({ state: 'probing', attempt: 0, generation: 0, everReady: false }); +} + +export function transitionRealmRendererLifecycle( + current: RealmRendererLifecycle, + event: + | { type: 'probe-start' } + | { type: 'webgl-unsupported'; failure?: RealmRendererFailure } + | { type: 'load-start'; attempt?: number; generation?: number } + | { type: 'ready'; degradedQuality?: 'compact' | 'balanced'; generation?: number } + | { type: 'recover'; failure: RealmRendererFailure; attempt?: number; generation?: number } + | { type: 'failed'; failure: RealmRendererFailure; generation?: number } +): RealmRendererLifecycle { + switch (event.type) { + case 'probe-start': + return Object.freeze({ ...current, state: 'probing', failure: undefined }); + case 'webgl-unsupported': + // A renderer that has already presented a frame must never be + // reclassified as a static unsupported device. That would silently + // replace a live world with an SVG after a transient runtime failure. + if (current.everReady) { + return Object.freeze({ + ...current, + state: 'failed', + failure: { + code: 'renderer-construction-failed' as const, + retryable: true, + phase: current.state, + message: 'WebGL became unavailable after the Realm renderer was ready.' + } + }); + } + return Object.freeze({ + ...current, + state: 'static-unsupported', + failure: event.failure, + everReady: false + }); + case 'load-start': + return Object.freeze({ + ...current, + state: 'loading', + attempt: event.attempt ?? current.attempt, + generation: event.generation ?? current.generation + 1, + failure: undefined + }); + case 'ready': + // Late callbacks from a disposed scene must never publish readiness for + // a newer scene generation. Omitting generation keeps the reducer + // convenient for pure callers and legacy integrations. + if (event.generation !== undefined && event.generation !== current.generation) { + return current; + } + return Object.freeze({ + ...current, + state: 'ready', + failure: undefined, + everReady: true, + degradedQuality: event.degradedQuality + }); + case 'recover': + if (event.generation !== undefined && event.generation !== current.generation) { + return current; + } + return Object.freeze({ + ...current, + state: 'recovering', + attempt: event.attempt ?? current.attempt + 1, + failure: event.failure + }); + case 'failed': + if (event.generation !== undefined && event.generation !== current.generation) { + return current; + } + return Object.freeze({ ...current, state: 'failed', failure: event.failure }); + default: + return current; + } +} + +export function classifyRealmRendererFailure( + error: unknown, + phase: RealmRendererLifecycleState, + fallbackCode: RealmRendererFailureCode = 'scene-build-failed' +): RealmRendererFailure { + const message = error instanceof Error ? error.message : String(error ?? 'Unknown renderer failure'); + const normalized = message.toLowerCase(); + let code = fallbackCode; + if (/integrity|sha-?256|content-addressed|digest/.test(normalized)) code = 'castle-integrity-failed'; + else if (/pair|landscape base|landscape-base/.test(normalized)) code = 'castle-pairing-failed'; + else if (/prefab|assembly|no renderable meshes|normalized bounds/.test(normalized)) { + code = 'castle-prefab-assembly-failed'; + } else if (/timeout|timed out|network|fetch|response body/.test(normalized)) { + code = 'castle-compact-load-failed'; + } else if (/webgl|renderer/.test(normalized)) code = 'renderer-construction-failed'; + return Object.freeze({ + code, + message, + retryable: ![ + 'castle-integrity-failed', + 'castle-pairing-failed', + 'castle-prefab-assembly-failed' + ].includes(code), + phase + }); +} + +export function shouldRetryRealmRenderer( + lifecycle: RealmRendererLifecycle, + failure: RealmRendererFailure +) { + return failure.retryable + && lifecycle.attempt < REALM_RENDERER_MAX_RECOVERY_ATTEMPTS; +} diff --git a/src/components/realm/realmWaterInspectionPresentation.ts b/src/components/realm/realmWaterInspectionPresentation.ts new file mode 100644 index 00000000..ff4bf5c5 --- /dev/null +++ b/src/components/realm/realmWaterInspectionPresentation.ts @@ -0,0 +1,204 @@ +import type { HexCoord } from '../../game/map/hexCoordinates'; +import { + GENESIS_WATER_BODIES_V1, + type GenesisWaterBodyV1, + type GenesisWaterCellV1 +} from '../../../spacetimedb/src/waterWorld'; +import { + isRealmTerrainKind, + realmTerrainLabel, + type RealmTerrainKind, + type RealmTerrainSemanticRow +} from '../../game/map/realmTerrainSemantics'; + +export type RealmWaterInspectionRecord = Readonly<{ + cellKey: string; + coord: HexCoord; + bodyId: string; + regime: 'ocean' | 'river'; + displayType: 'river' | 'coast' | 'outer-sea'; + displayName: string; + description: string; + riverOrdinal?: number; + riverPosition?: 'source' | 'upper reach' | 'middle reach' | 'lower reach' | 'mouth'; + riverOrder?: number; + riverCellCount?: number; + sourceCellKey?: string; + mouthCellKey?: string; + sourceCoord?: HexCoord; + mouthCoord?: HexCoord; + downstreamWaterCellKey?: string; + flowClass?: 'headwater' | 'branching reach' | 'main reach' | 'lower reach'; + oceanDepthClass?: 'coast' | 'open water'; + depthCells: number; + fogBand: 'clear' | 'haze'; + underlyingTileKey?: string; + underlyingTerrainKind?: RealmTerrainKind; + underlyingTerrainLabel?: string; + underlyingPassable?: boolean; + gameplayBoundary: string; +}>; + +export type RealmWaterNavigatorBody = Readonly<{ + bodyId: string; + label: string; + sourceCellKey: string; + mouthCellKey: string; + sourceCoord: HexCoord; + mouthCoord: HexCoord; +}>; + +function safeBodyMap(bodies: readonly GenesisWaterBodyV1[]) { + return new Map(bodies.map((body) => [body.bodyId, body] as const)); +} + +function riverPosition(cell: GenesisWaterCellV1, body: GenesisWaterBodyV1) { + const order = cell.riverOrder ?? 0; + const progress = body.cellCount <= 1 ? 0 : order / Math.max(1, body.cellCount - 1); + if (order === 0) return 'source' as const; + if (order >= body.cellCount - 1) return 'mouth' as const; + if (progress < 0.34) return 'upper reach' as const; + if (progress < 0.67) return 'middle reach' as const; + return 'lower reach' as const; +} + +function riverFlowClass(cell: GenesisWaterCellV1) { + if (cell.flowAccumulation <= 1) return 'headwater' as const; + if (cell.flowAccumulation <= 3) return 'branching reach' as const; + if (cell.flowAccumulation <= 8) return 'main reach' as const; + return 'lower reach' as const; +} + +function coordForCell( + cellsByKey: ReadonlyMap, + cellKey: string +) { + const cell = cellsByKey.get(cellKey); + return cell ? { q: cell.q, r: cell.r } : undefined; +} + +/** + * Build bounded, read-only records from the already validated Water subset. + * Full-fog cells and any unexpected lake rows fail closed rather than creating + * browser-local identity or a misleading inspection surface. + */ +export function resolveRealmWaterInspectionRecords( + cells: readonly GenesisWaterCellV1[] | undefined, + terrainMetadata: readonly RealmTerrainSemanticRow[] = [], + bodies: readonly GenesisWaterBodyV1[] = GENESIS_WATER_BODIES_V1 +): readonly RealmWaterInspectionRecord[] { + if (!cells || cells.some((cell) => cell.regime === 'lake')) return Object.freeze([]); + const bodyMap = safeBodyMap(bodies); + const cellsByKey = new Map(cells.map((cell) => [cell.cellKey, cell] as const)); + const terrainByKey = new Map(); + for (const metadata of terrainMetadata) { + if ( + typeof metadata.tileKey !== 'string' + || metadata.tileKey.length === 0 + || terrainByKey.has(metadata.tileKey) + ) return Object.freeze([]); + terrainByKey.set(metadata.tileKey, metadata); + } + const records: RealmWaterInspectionRecord[] = []; + + for (const cell of cells) { + if ( + cell.regime !== 'ocean' && cell.regime !== 'river' + || cell.fogBand === 'full' + || !Number.isSafeInteger(cell.q) + || !Number.isSafeInteger(cell.r) + ) continue; + const body = bodyMap.get(cell.bodyId); + if (!body || body.regime !== cell.regime) return Object.freeze([]); + const coord = { q: cell.q, r: cell.r }; + if (cell.regime === 'river') { + const ordinal = body.ordinal; + const riverName = `Genesis River ${String(ordinal).padStart(2, '0')}`; + const sourceCoord = coordForCell(cellsByKey, body.sourceCellKey); + const mouthCoord = coordForCell(cellsByKey, body.mouthCellKey); + const sourceCell = cellsByKey.get(body.sourceCellKey); + const mouthCell = cellsByKey.get(body.mouthCellKey); + if ( + !sourceCoord + || !mouthCoord + || sourceCell?.bodyId !== body.bodyId + || sourceCell.regime !== 'river' + || mouthCell?.bodyId !== body.bodyId + || mouthCell.regime !== 'river' + ) return Object.freeze([]); + const underlying = cell.underlyingTileKey + ? terrainByKey.get(cell.underlyingTileKey) + : undefined; + const underlyingTerrainKind = isRealmTerrainKind(underlying?.terrainKind) + ? underlying.terrainKind + : undefined; + records.push(Object.freeze({ + cellKey: cell.cellKey, + coord, + bodyId: cell.bodyId, + regime: 'river', + displayType: 'river', + displayName: riverName, + description: 'A persistent Genesis watercourse crossing the Lowlands from source to mouth.', + riverOrdinal: ordinal, + riverPosition: riverPosition(cell, body), + riverOrder: cell.riverOrder, + riverCellCount: body.cellCount, + sourceCellKey: body.sourceCellKey, + mouthCellKey: body.mouthCellKey, + sourceCoord, + mouthCoord, + downstreamWaterCellKey: cell.downstreamWaterCellKey, + flowClass: riverFlowClass(cell), + depthCells: cell.depthCells, + fogBand: cell.fogBand, + underlyingTileKey: cell.underlyingTileKey, + underlyingTerrainKind, + underlyingTerrainLabel: underlyingTerrainKind + ? realmTerrainLabel(underlyingTerrainKind) + : undefined, + underlyingPassable: typeof underlying?.passable === 'boolean' + ? underlying.passable + : undefined, + gameplayBoundary: 'Water is visual and does not add boats, swimming, current force, or resource rewards.' + })); + continue; + } + const isCoast = cell.oceanDepth <= 2; + records.push(Object.freeze({ + cellKey: cell.cellKey, + coord, + bodyId: cell.bodyId, + regime: 'ocean', + displayType: isCoast ? 'coast' : 'outer-sea', + displayName: isCoast ? 'Lowlands Coast' : 'Outer Sea', + description: isCoast + ? 'A clear coastal water cell at the edge of the Lowlands.' + : 'Open water beyond the coast, presented within the public fog boundary.', + oceanDepthClass: isCoast ? 'coast' : 'open water', + depthCells: cell.depthCells, + fogBand: cell.fogBand, + gameplayBoundary: 'The sea is a shared visual boundary; it is not claimable territory.' + })); + } + return Object.freeze(records); +} + +export function realmWaterNavigatorBodies( + records: readonly RealmWaterInspectionRecord[] +): readonly RealmWaterNavigatorBody[] { + const byBody = new Map(); + records.forEach((record) => { + if (record.regime === 'river' && !byBody.has(record.bodyId)) byBody.set(record.bodyId, record); + }); + return Object.freeze([...byBody.values()] + .sort((left, right) => (left.riverOrdinal ?? 0) - (right.riverOrdinal ?? 0)) + .map((record) => Object.freeze({ + bodyId: record.bodyId, + label: record.displayName, + sourceCellKey: record.sourceCellKey!, + mouthCellKey: record.mouthCellKey!, + sourceCoord: record.sourceCoord!, + mouthCoord: record.mouthCoord! + }))); +} diff --git a/src/components/realm/realmWaterLayer.ts b/src/components/realm/realmWaterLayer.ts index cadc3d88..8ef5996c 100644 --- a/src/components/realm/realmWaterLayer.ts +++ b/src/components/realm/realmWaterLayer.ts @@ -2,13 +2,17 @@ import * as THREE from 'three'; import { axialToWorld, + hexDisc, hexDistance, + hexKey, + worldToNearestAxial, type HexWorldPosition } from '../../game/map/hexCoordinates'; import { GENESIS_OCEAN_DEPTH_BY_KEY, GENESIS_WATER_LAYOUT_VERSION, genesisWaterWorldHeightFromMilli, + type GenesisWaterBodyV1, type GenesisWaterCellV1 } from '../../../spacetimedb/src/waterWorld'; import type { RealmQualitySpec } from './realmQuality'; @@ -17,6 +21,10 @@ import { GENESIS_WATER_REVISION_ENABLED_CELLS_V1, GENESIS_WATER_REVISION_VERSION } from '../../../spacetimedb/src/waterRevision'; +import { + resolveRealmWaterPhase, + type RealmWaterPhase +} from './realmWaterPhase'; const WATER_Y_LIFT = 0.035; const RIVER_BANK_BLEND = 0.28; @@ -27,6 +35,12 @@ const RIVER_BANK_BLEND = 0.28; const RIVER_TERRAIN_CLEARANCE = 0.014; const RIVER_SURFACE_PROBE_SUBDIVISIONS = 6; const MAXIMUM_RIVER_SURFACE_CORRECTION = 0.16; +const OUTER_CURTAIN_BOTTOM = -20; +const OUTER_CURTAIN_TOP = 38; +const ANALYTIC_PICK_NEIGHBORHOOD_RADIUS = 2; +const ANALYTIC_PICK_DIRECTION_EPSILON = 0.000_001; +const RIVER_TRIANGLES_PER_CELL = 6; +const RIVER_INDICES_PER_CELL = RIVER_TRIANGLES_PER_CELL * 3; /** Convert the persisted +1000 fixed-point datum into the terrain's world-Y space. */ export function waterSurfaceLevelToWorldY(surfaceLevelMilli: number): number { @@ -46,6 +60,13 @@ export const REALM_WATER_RENDER_BUDGETS = Object.freeze({ reduced: Object.freeze({ triangles: 35_000, draws: 4, waveComponents: 0 }) }); +/** Water shares one demand-driven scheduler with grass and moving wagons. */ +export const REALM_WATER_ANIMATION_FRAME_CAPS = Object.freeze({ + high: 30, + balanced: 22, + reduced: 0 +}); + export type RealmWaterLayerTelemetry = Readonly<{ layoutVersion: number; oceanCellCount: number; @@ -57,8 +78,20 @@ export type RealmWaterLayerTelemetry = Readonly<{ fullFogOceanCellCount: number; }>; +export type RealmWaterCellHit = Readonly<{ + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: Readonly<{ q: number; r: number }>; + distance: number; +}>; + export type RealmWaterLayer = Readonly<{ group: THREE.Group; + raycast: (raycaster: THREE.Raycaster) => RealmWaterCellHit | null; + getCellPresentation: (cellKey: string) => GenesisWaterCellV1 | undefined; + setSelectedCellKey: (cellKey: string | null) => void; + setHoveredCellKey: (cellKey: string | null) => void; updateEnvironment: (elapsedSeconds: number) => boolean; isAnimationActive: () => boolean; getTelemetry: () => RealmWaterLayerTelemetry; @@ -71,8 +104,50 @@ type WaterLayerOptions = Readonly<{ reducedMotion: boolean; hexSize: number; heightAtWorld: (world: HexWorldPosition) => number; + environment?: unknown; + waterBodies?: readonly unknown[]; + /** Test seam; production defaults to a bounded local wall-clock sample. */ + nowMicros?: () => bigint; }>; +function shoreFoamForCell(cell: GenesisWaterCellV1) { + if (cell.regime === 'river') return 0.82; + if (cell.regime !== 'ocean') return 0.16; + const depth = GENESIS_OCEAN_DEPTH_BY_KEY.get(cell.cellKey) ?? cell.oceanDepth; + if (depth <= 1) return 1; + if (depth === 2) return 0.56; + return 0.06; +} + +function waterRegimeForCell(cell: GenesisWaterCellV1) { + return cell.regime === 'river' ? 1 : 0; +} + +function flowForCell( + cell: GenesisWaterCellV1, + cellsByKey: ReadonlyMap +) { + const current = axialToWorld(cell, 1); + const downstream = cell.downstreamWaterCellKey + ? cellsByKey.get(cell.downstreamWaterCellKey) + : undefined; + const upstream = downstream + ? undefined + : [...cellsByKey.values()].find((candidate) => ( + candidate.downstreamWaterCellKey === cell.cellKey + )); + const neighbor = downstream ?? upstream; + if (!neighbor) return { x: 0, z: 1 }; + const neighborWorld = axialToWorld(neighbor, 1); + const direction = downstream + ? { x: neighborWorld.x - current.x, z: neighborWorld.z - current.z } + : { x: current.x - neighborWorld.x, z: current.z - neighborWorld.z }; + const magnitude = Math.hypot(direction.x, direction.z); + return magnitude > 0.000_001 + ? { x: direction.x / magnitude, z: direction.z / magnitude } + : { x: 0, z: 1 }; +} + function regimeColor(cell: GenesisWaterCellV1): THREE.Color { if (cell.regime === 'river') return new THREE.Color('#4aa9c7'); if (cell.regime === 'lake') return new THREE.Color('#548eac'); @@ -92,6 +167,10 @@ function surfaceGeometry( const waterDepth: number[] = []; const waterBankBlend: number[] = []; const waterFogMix: number[] = []; + const waterRegime: number[] = []; + const waterShoreFoam: number[] = []; + const waterFlowX: number[] = []; + const waterFlowZ: number[] = []; const indices: number[] = []; cells.forEach((cell) => { const center = axialToWorld({ q: cell.q, r: cell.r }, hexSize); @@ -112,6 +191,10 @@ function surfaceGeometry( waterDepth.push(Math.min(1, cell.depthCells / 5)); waterBankBlend.push(cell.regime === 'river' ? RIVER_BANK_BLEND : 0); waterFogMix.push(fogMixForCell(cell)); + waterRegime.push(waterRegimeForCell(cell)); + waterShoreFoam.push(shoreFoamForCell(cell)); + waterFlowX.push(0); + waterFlowZ.push(0); normals.push(0, 1, 0); pointyHexCorners({ q: cell.q, r: cell.r }, hexSize).forEach((corner) => { positions.push(corner.x, ground, corner.z); @@ -119,6 +202,10 @@ function surfaceGeometry( waterDepth.push(Math.min(1, cell.depthCells / 5)); waterBankBlend.push(cell.regime === 'river' ? RIVER_BANK_BLEND : 0); waterFogMix.push(fogMixForCell(cell)); + waterRegime.push(waterRegimeForCell(cell)); + waterShoreFoam.push(shoreFoamForCell(cell)); + waterFlowX.push(0); + waterFlowZ.push(0); normals.push(0, 1, 0); }); for (let corner = 0; corner < 6; corner += 1) { @@ -134,6 +221,11 @@ function surfaceGeometry( geometry.setAttribute('waterDepth', new THREE.Float32BufferAttribute(waterDepth, 1)); geometry.setAttribute('waterBankBlend', new THREE.Float32BufferAttribute(waterBankBlend, 1)); geometry.setAttribute('waterFogMix', new THREE.Float32BufferAttribute(waterFogMix, 1)); + geometry.setAttribute('waterRegime', new THREE.Float32BufferAttribute(waterRegime, 1)); + geometry.setAttribute('waterShoreFoam', new THREE.Float32BufferAttribute(waterShoreFoam, 1)); + geometry.setAttribute('waterFlowX', new THREE.Float32BufferAttribute(waterFlowX, 1)); + geometry.setAttribute('waterFlowZ', new THREE.Float32BufferAttribute(waterFlowZ, 1)); + geometry.userData.realmWaterCellKeys = cells.map((cell) => cell.cellKey); try { geometry.setIndex(indices); geometry.computeBoundingSphere(); @@ -174,6 +266,7 @@ function riverSurfaceGeometry( hexSize: number, heightAtWorld: (world: HexWorldPosition) => number ) { + const cellsByKey = new Map(cells.map((cell) => [cell.cellKey, cell] as const)); const sharedCorners = new Map(); const plans = cells.map((cell): RiverSurfacePlan => { const centerWorld = axialToWorld({ q: cell.q, r: cell.r }, hexSize); @@ -245,10 +338,15 @@ function riverSurfaceGeometry( const waterDepth: number[] = []; const waterBankBlend: number[] = []; const waterFogMix: number[] = []; + const waterRegime: number[] = []; + const waterShoreFoam: number[] = []; + const waterFlowX: number[] = []; + const waterFlowZ: number[] = []; const indices: number[] = []; plans.forEach((plan) => { const color = regimeColor(plan.cell); const depth = Math.min(1, plan.cell.depthCells / 5); + const flow = flowForCell(plan.cell, cellsByKey); const base = positions.length / 3; [plan.center, ...plan.corners].forEach((node) => { positions.push(node.world.x, node.height, node.world.z); @@ -256,6 +354,10 @@ function riverSurfaceGeometry( waterDepth.push(depth); waterBankBlend.push(RIVER_BANK_BLEND); waterFogMix.push(0); + waterRegime.push(1); + waterShoreFoam.push(shoreFoamForCell(plan.cell)); + waterFlowX.push(flow.x); + waterFlowZ.push(flow.z); normals.push(0, 1, 0); }); for (let corner = 0; corner < 6; corner += 1) { @@ -269,6 +371,11 @@ function riverSurfaceGeometry( geometry.setAttribute('waterDepth', new THREE.Float32BufferAttribute(waterDepth, 1)); geometry.setAttribute('waterBankBlend', new THREE.Float32BufferAttribute(waterBankBlend, 1)); geometry.setAttribute('waterFogMix', new THREE.Float32BufferAttribute(waterFogMix, 1)); + geometry.setAttribute('waterRegime', new THREE.Float32BufferAttribute(waterRegime, 1)); + geometry.setAttribute('waterShoreFoam', new THREE.Float32BufferAttribute(waterShoreFoam, 1)); + geometry.setAttribute('waterFlowX', new THREE.Float32BufferAttribute(waterFlowX, 1)); + geometry.setAttribute('waterFlowZ', new THREE.Float32BufferAttribute(waterFlowZ, 1)); + geometry.userData.realmWaterCellKeys = cells.map((cell) => cell.cellKey); try { geometry.setIndex(indices); geometry.computeBoundingSphere(); @@ -283,7 +390,6 @@ function outerSkirtGeometry(cells: readonly GenesisWaterCellV1[], hexSize: numbe const keys = new Set(cells.map((cell) => cell.cellKey)); const positions: number[] = []; const indices: number[] = []; - const bottom = -1.25; for (const cell of cells) { if (cell.regime !== 'ocean' || hexDistance(cell, { q: 0, r: 0 }) !== 65) continue; const corners = pointyHexCorners({ q: cell.q, r: cell.r }, hexSize); @@ -294,8 +400,15 @@ function outerSkirtGeometry(cells: readonly GenesisWaterCellV1[], hexSize: numbe const a = corners[side]!; const b = corners[(side + 1) % 6]!; const base = positions.length / 3; - const surfaceY = waterSurfaceLevelToWorldY(cell.surfaceLevelMilli); - positions.push(a.x, surfaceY, a.z, b.x, surfaceY, b.z, b.x, bottom, b.z, a.x, bottom, a.z); + // A full-height horizon curtain closes the frustum even when the camera + // pans over the visible ocean apron. It is presentation-only and follows + // the exact outer edge of the canonical Water disc. + positions.push( + a.x, OUTER_CURTAIN_TOP, a.z, + b.x, OUTER_CURTAIN_TOP, b.z, + b.x, OUTER_CURTAIN_BOTTOM, b.z, + a.x, OUTER_CURTAIN_BOTTOM, a.z + ); indices.push(base, base + 1, base + 2, base, base + 2, base + 3); } } @@ -311,13 +424,17 @@ function outerSkirtGeometry(cells: readonly GenesisWaterCellV1[], hexSize: numbe } } -function createWaterMaterial(quality: RealmQualitySpec, reducedMotion: boolean) { +function createWaterMaterial( + quality: RealmQualitySpec, + reducedMotion: boolean, + river: boolean +) { const material = new THREE.MeshStandardMaterial({ vertexColors: true, // Keep the material base neutral so the authoritative per-regime vertex // palette is not multiplied back toward the pale Lowlands ground tint. color: '#ffffff', - roughness: 0.28, + roughness: river ? 0.34 : 0.27, metalness: 0.04, transparent: false, depthWrite: true, @@ -325,43 +442,141 @@ function createWaterMaterial(quality: RealmQualitySpec, reducedMotion: boolean) }); const activeWaveComponents = reducedMotion ? 0 - : REALM_WATER_RENDER_BUDGETS[quality.id].waveComponents; + : river + ? Math.min(2, REALM_WATER_RENDER_BUDGETS[quality.id].waveComponents) + : REALM_WATER_RENDER_BUDGETS[quality.id].waveComponents; const uniforms = { uWaterTime: { value: 0 }, uWaterHorizonColor: { value: new THREE.Color('#b9cad8') } }; const waveTerms = Array.from({ length: activeWaveComponents }, (_, index) => { const ordinal = index + 1; - const xFrequency = (2.25 + ordinal * 0.37).toFixed(2); - const zFrequency = (1.65 + ordinal * 0.29).toFixed(2); - const timeFrequency = (0.19 + ordinal * 0.035).toFixed(3); - return `sin(vViewPosition.x * ${xFrequency} + vViewPosition.z * ${zFrequency} + uWaterTime * ${timeFrequency})`; + const directionX = (0.54 + ((ordinal * 17) % 31) / 100).toFixed(3); + const directionZ = (0.84 - ((ordinal * 11) % 23) / 100).toFixed(3); + const frequency = (0.28 + ordinal * 0.075).toFixed(3); + const speed = (0.16 + ordinal * 0.031).toFixed(3); + const amplitude = (river ? 0.005 : 0.024 / Math.sqrt(ordinal)).toFixed(5); + return `sin(dot(waterWorldXZ, vec2(${directionX}, ${directionZ})) * ${frequency} + uWaterTime * ${speed}) * ${amplitude}`; }); - const waveSource = waveTerms.length === 0 - ? 'float waterGlimmer = 0.0;' - : `float waterGlimmer = (${waveTerms.join(' + ')}) * ${(0.018 / waveTerms.length).toFixed(8)};`; + const timeUniform = activeWaveComponents > 0 ? 'uniform float uWaterTime;\n' : ''; + const heightFunction = activeWaveComponents === 0 + ? 'float warpkeepWaterHeight(vec2 waterWorldXZ, float waterRegime, vec2 waterFlow) { return 0.0; }' + : `float warpkeepWaterHeight(vec2 waterWorldXZ, float waterRegime, vec2 waterFlow) { + float oceanWave = ${waveTerms.join(' + ')}; + float riverWave = sin(dot(waterWorldXZ, normalize(waterFlow + vec2(0.0001))) * 2.3 + uWaterTime * 0.72) * 0.006; + return waterRegime > 0.5 ? riverWave : oceanWave; +}`; + const shaderContract = `warpkeep-water-world-space-r185-${river ? 'river' : 'ocean'}-v2`; material.onBeforeCompile = (shader) => { + if ( + !shader.vertexShader.includes('#include ') + || !shader.vertexShader.includes('#include ') + || !shader.vertexShader.includes('#include ') + || !shader.fragmentShader.includes('#include ') + ) throw new Error('REALM_WATER_SHADER_CONTRACT_CHANGED'); if (activeWaveComponents > 0) shader.uniforms.uWaterTime = uniforms.uWaterTime; shader.uniforms.uWaterHorizonColor = uniforms.uWaterHorizonColor; - shader.vertexShader = `attribute float waterDepth;\nattribute float waterBankBlend;\nattribute float waterFogMix;\nvarying float vWarpkeepWaterDepth;\nvarying float vWarpkeepWaterBankBlend;\nvarying float vWarpkeepWaterFogMix;\n${shader.vertexShader}` - .replace('#include ', '#include \n vWarpkeepWaterDepth = waterDepth;\n vWarpkeepWaterBankBlend = waterBankBlend;\n vWarpkeepWaterFogMix = waterFogMix;'); - const timeUniform = activeWaveComponents > 0 ? 'uniform float uWaterTime;\n' : ''; - shader.fragmentShader = `${timeUniform}uniform vec3 uWaterHorizonColor;\nvarying float vWarpkeepWaterDepth;\nvarying float vWarpkeepWaterBankBlend;\nvarying float vWarpkeepWaterFogMix;\n${shader.fragmentShader}` + shader.vertexShader = `${timeUniform} +attribute float waterDepth; +attribute float waterBankBlend; +attribute float waterFogMix; +attribute float waterRegime; +attribute float waterShoreFoam; +attribute float waterFlowX; +attribute float waterFlowZ; +varying float vWarpkeepWaterDepth; +varying float vWarpkeepWaterBankBlend; +varying float vWarpkeepWaterFogMix; +varying float vWarpkeepWaterRegime; +varying float vWarpkeepWaterShoreFoam; +varying float vWarpkeepWaterWave; +varying vec2 vWarpkeepWaterWorldXZ; +${heightFunction} +${shader.vertexShader}` + .replace('#include ', `#include + vWarpkeepWaterDepth = waterDepth; + vWarpkeepWaterBankBlend = waterBankBlend; + vWarpkeepWaterFogMix = waterFogMix; + vWarpkeepWaterRegime = waterRegime; + vWarpkeepWaterShoreFoam = waterShoreFoam;`) + .replace('#include ', `#include + vWarpkeepWaterWorldXZ = (modelMatrix * vec4(position, 1.0)).xz; + vWarpkeepWaterWave = warpkeepWaterHeight(vWarpkeepWaterWorldXZ, waterRegime, vec2(waterFlowX, waterFlowZ)); + transformed.y += vWarpkeepWaterWave;`) + .replace('#include ', `#include + float warpkeepWaterEpsilon = 0.045; + vec2 warpkeepWaterNormalWorldXZ = (modelMatrix * vec4(position, 1.0)).xz; + float warpkeepWaterNormalHeight = warpkeepWaterHeight(warpkeepWaterNormalWorldXZ, waterRegime, vec2(waterFlowX, waterFlowZ)); + float warpkeepWaterDx = (warpkeepWaterHeight(warpkeepWaterNormalWorldXZ + vec2(warpkeepWaterEpsilon, 0.0), waterRegime, vec2(waterFlowX, waterFlowZ)) - warpkeepWaterNormalHeight) / warpkeepWaterEpsilon; + float warpkeepWaterDz = (warpkeepWaterHeight(warpkeepWaterNormalWorldXZ + vec2(0.0, warpkeepWaterEpsilon), waterRegime, vec2(waterFlowX, waterFlowZ)) - warpkeepWaterNormalHeight) / warpkeepWaterEpsilon; + objectNormal = normalize(vec3(-warpkeepWaterDx, 1.0, -warpkeepWaterDz));`); + shader.fragmentShader = `uniform vec3 uWaterHorizonColor; +varying float vWarpkeepWaterDepth; +varying float vWarpkeepWaterBankBlend; +varying float vWarpkeepWaterFogMix; +varying float vWarpkeepWaterRegime; +varying float vWarpkeepWaterShoreFoam; +varying float vWarpkeepWaterWave; +varying vec2 vWarpkeepWaterWorldXZ; +${shader.fragmentShader}` .replace('#include ', ` - ${waveSource} - float waterFresnel = pow(1.0 - max(dot(normalize(vNormal), normalize(-vViewPosition)), 0.0), 3.0) * 0.08; - float waterDepthTint = mix(1.0, 0.72, clamp(vWarpkeepWaterDepth, 0.0, 1.0)); - float bankSoftness = 1.0 - clamp(vWarpkeepWaterBankBlend, 0.0, 1.0) * 0.18; - outgoingLight += vec3(waterGlimmer + waterFresnel) * waterDepthTint * bankSoftness; - outgoingLight = mix(outgoingLight, uWaterHorizonColor, clamp(vWarpkeepWaterFogMix, 0.0, 1.0) * 0.62); + float waterViewFacing = max(dot(normalize(vNormal), normalize(-vViewPosition)), 0.0); + float waterFresnel = pow(1.0 - waterViewFacing, 3.0) * (vWarpkeepWaterRegime > 0.5 ? 0.045 : 0.095); + vec3 waterDeepColor = vec3(0.055, 0.22, 0.34); + vec3 waterShallowColor = vec3(0.16, 0.48, 0.58); + vec3 waterBodyColor = mix(waterShallowColor, waterDeepColor, clamp(vWarpkeepWaterDepth, 0.0, 1.0) * 0.78); + float waterGlimmer = abs(vWarpkeepWaterWave) * (vWarpkeepWaterRegime > 0.5 ? 1.8 : 3.2); + float waterCrest = smoothstep(0.012, 0.032, abs(vWarpkeepWaterWave)); + float waterFoam = clamp(vWarpkeepWaterShoreFoam, 0.0, 1.0) * (0.08 + waterCrest * 0.34); + float bankSoftness = 1.0 - clamp(vWarpkeepWaterBankBlend, 0.0, 1.0) * 0.16; + outgoingLight = mix(outgoingLight, outgoingLight * waterBodyColor * 1.65, 0.42); + outgoingLight += (waterBodyColor * waterFresnel + vec3(waterGlimmer)) * bankSoftness; + outgoingLight = mix(outgoingLight, vec3(0.93, 0.91, 0.82), waterFoam); + outgoingLight = mix(outgoingLight, uWaterHorizonColor, clamp(vWarpkeepWaterFogMix, 0.0, 1.0)); #include `); - material.userData.waterShaderContract = 'three-r185-reviewed'; + material.userData.waterShaderContract = shaderContract; }; + material.customProgramCacheKey = () => shaderContract; material.userData.waterUniforms = uniforms; material.userData.waterWaveComponents = activeWaveComponents; + material.userData.waterShaderContract = shaderContract; return material; } +function waterLayerRecord(value: unknown): Readonly> | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Readonly> + : undefined; +} + +function pointInsidePointyHex( + point: HexWorldPosition, + center: HexWorldPosition, + hexSize: number +) { + const localX = Math.abs(point.x - center.x); + const localZ = Math.abs(point.z - center.z); + const epsilon = 0.000_01; + return localX <= Math.sqrt(3) * hexSize * 0.5 + epsilon + && localX / Math.sqrt(3) + localZ <= hexSize + epsilon; +} + +function rayPointAtSurfaceY( + ray: THREE.Ray, + surfaceY: number, + target: THREE.Vector3 +) { + if ( + !Number.isFinite(surfaceY) + || !Number.isFinite(ray.origin.y) + || !Number.isFinite(ray.direction.y) + || Math.abs(ray.direction.y) <= ANALYTIC_PICK_DIRECTION_EPSILON + ) return undefined; + const rayParameter = (surfaceY - ray.origin.y) / ray.direction.y; + if (!Number.isFinite(rayParameter) || rayParameter < 0) return undefined; + return ray.at(rayParameter, target); +} + export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLayer { const ocean = options.cells.filter((cell) => cell.regime === 'ocean'); const lakes = options.cells.filter((cell) => cell.regime === 'lake'); @@ -396,9 +611,9 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay // persisted one-cell-wide topology legible without inventing new paths. riverGeometryData = riverSurfaceGeometry(rivers, options.hexSize, options.heightAtWorld); skirtGeometry = outerSkirtGeometry(ocean, options.hexSize); - waterMaterial = createWaterMaterial(options.quality, options.reducedMotion); - lakeMaterial = createWaterMaterial(options.quality, options.reducedMotion); - riverMaterial = createWaterMaterial(options.quality, options.reducedMotion); + waterMaterial = createWaterMaterial(options.quality, options.reducedMotion, false); + lakeMaterial = createWaterMaterial(options.quality, options.reducedMotion, false); + riverMaterial = createWaterMaterial(options.quality, options.reducedMotion, true); // Rivers occupy only one authoritative hex at a time and sit over the // pale Lowlands palette. A restrained cool emissive lift keeps the // connected channel readable in daylight without changing its geometry. @@ -406,12 +621,13 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay riverMaterial.emissiveIntensity = 0.2; riverMaterial.roughness = 0.22; skirtMaterial = new THREE.MeshBasicMaterial({ - color: '#26485e', - transparent: true, - opacity: 0.82, - depthWrite: false, - fog: true, - side: THREE.DoubleSide + color: '#b9cad8', + transparent: false, + depthWrite: true, + depthTest: true, + fog: false, + side: THREE.DoubleSide, + toneMapped: false }); } catch (error) { disposeResources(); @@ -432,6 +648,211 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay skirtMesh.name = 'canonical-ocean-downward-skirt'; riverMesh.renderOrder = 2; skirtMesh.renderOrder = 1; + const cellsByKey = new Map(options.cells.map((cell) => [cell.cellKey, cell] as const)); + const visibleOverlayCells = new Set(options.cells + .filter((cell) => cell.regime !== 'ocean' || cell.fogBand !== 'full') + .map((cell) => cell.cellKey)); + const visiblePickCellsByKey = new Map(options.cells + .filter((cell) => ( + (cell.regime === 'ocean' || cell.regime === 'river') + && cell.fogBand !== 'full' + )) + .map((cell) => [cell.cellKey, cell] as const)); + const riverCellIndexByKey = new Map(rivers.map( + (cell, index) => [cell.cellKey, index] as const + )); + const pickHeightByCellKey = new Map(); + ocean.forEach((cell) => { + pickHeightByCellKey.set( + cell.cellKey, + waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + ); + }); + const riverPositionAttribute = riverGeometryData.getAttribute('position'); + const riverIndexAttribute = riverGeometryData.index; + const visiblePickHeights: number[] = []; + ocean.forEach((cell) => { + if (!visiblePickCellsByKey.has(cell.cellKey)) return; + const height = pickHeightByCellKey.get(cell.cellKey); + if (height !== undefined && Number.isFinite(height)) visiblePickHeights.push(height); + }); + rivers.forEach((cell, cellIndex) => { + if (!visiblePickCellsByKey.has(cell.cellKey)) return; + for (let vertexOffset = 0; vertexOffset < 7; vertexOffset += 1) { + const height = riverPositionAttribute.getY(cellIndex * 7 + vertexOffset); + if (Number.isFinite(height)) visiblePickHeights.push(height); + } + }); + const minimumPickHeight = visiblePickHeights.length > 0 + ? Math.min(...visiblePickHeights) + : 0; + const maximumPickHeight = visiblePickHeights.length > 0 + ? Math.max(...visiblePickHeights) + : 0; + const createWaterOverlayGeometry = () => { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(18), 3)); + return geometry; + }; + const selectedWaterOverlay = new THREE.LineLoop( + createWaterOverlayGeometry(), + new THREE.LineBasicMaterial({ + color: '#e8fbce', + transparent: true, + opacity: 0.94, + depthTest: false, + depthWrite: false, + toneMapped: false + }) + ); + const hoveredWaterOverlay = new THREE.LineLoop( + createWaterOverlayGeometry(), + new THREE.LineBasicMaterial({ + color: '#d3f4ec', + transparent: true, + opacity: 0.6, + depthTest: false, + depthWrite: false, + toneMapped: false + }) + ); + selectedWaterOverlay.name = 'selected-water-cell-outline'; + hoveredWaterOverlay.name = 'hovered-water-cell-outline'; + selectedWaterOverlay.renderOrder = 6; + hoveredWaterOverlay.renderOrder = 5; + selectedWaterOverlay.visible = false; + hoveredWaterOverlay.visible = false; + group.add(selectedWaterOverlay, hoveredWaterOverlay); + const updateWaterOverlay = ( + overlay: THREE.LineLoop, + cellKey: string | null, + opacity: number + ) => { + const cell = cellKey ? cellsByKey.get(cellKey) : undefined; + if (!cell || !visibleOverlayCells.has(cell.cellKey)) { + overlay.visible = false; + return; + } + const center = axialToWorld({ q: cell.q, r: cell.r }, options.hexSize); + const corners = pointyHexCorners({ q: cell.q, r: cell.r }, options.hexSize); + const ground = cell.regime === 'river' + ? Math.max( + waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + WATER_Y_LIFT, + options.heightAtWorld(center) + 0.035 + ) + : waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + WATER_Y_LIFT; + const positions = overlay.geometry.getAttribute('position') as THREE.BufferAttribute; + corners.forEach((corner, index) => { + positions.setXYZ( + index, + corner.x, + ground + (opacity > 0.8 ? 0.018 : 0.012), + corner.z + ); + }); + positions.needsUpdate = true; + overlay.geometry.computeBoundingSphere(); + (overlay.material as THREE.LineBasicMaterial).opacity = opacity; + overlay.visible = true; + }; + const analyticCandidateKeys = new Set(); + const analyticSamplePoint = new THREE.Vector3(); + const analyticOceanHitPoint = new THREE.Vector3(); + const analyticRiverTriangleA = new THREE.Vector3(); + const analyticRiverTriangleB = new THREE.Vector3(); + const analyticRiverTriangleC = new THREE.Vector3(); + const analyticRiverHitPoint = new THREE.Vector3(); + const analyticRaycast = (raycaster: THREE.Raycaster) => { + if (visiblePickCellsByKey.size === 0) return null; + analyticCandidateKeys.clear(); + const heightSpan = maximumPickHeight - minimumPickHeight; + // Three bounded height samples account for the shallow river elevation + // range without raycasting thousands of rendered triangles on every hover. + for (const fraction of [0, 0.5, 1]) { + const sampleHeight = minimumPickHeight + heightSpan * fraction; + const point = rayPointAtSurfaceY(raycaster.ray, sampleHeight, analyticSamplePoint); + if (!point) continue; + const nearestCoord = worldToNearestAxial({ x: point.x, z: point.z }, options.hexSize); + hexDisc(nearestCoord, ANALYTIC_PICK_NEIGHBORHOOD_RADIUS).forEach((coord) => { + analyticCandidateKeys.add(hexKey(coord)); + }); + } + let nearest: RealmWaterCellHit | null = null; + for (const cellKey of analyticCandidateKeys) { + const cell = visiblePickCellsByKey.get(cellKey); + if (!cell) continue; + let distance: number | undefined; + if (cell.regime === 'river') { + const cellIndex = riverCellIndexByKey.get(cell.cellKey); + if (cellIndex === undefined || !riverIndexAttribute) continue; + const firstCellIndex = cellIndex * RIVER_INDICES_PER_CELL; + for ( + let triangleOffset = 0; + triangleOffset < RIVER_INDICES_PER_CELL; + triangleOffset += 3 + ) { + const first = riverIndexAttribute.getX(firstCellIndex + triangleOffset); + const second = riverIndexAttribute.getX(firstCellIndex + triangleOffset + 1); + const third = riverIndexAttribute.getX(firstCellIndex + triangleOffset + 2); + analyticRiverTriangleA.fromBufferAttribute(riverPositionAttribute, first); + analyticRiverTriangleB.fromBufferAttribute(riverPositionAttribute, second); + analyticRiverTriangleC.fromBufferAttribute(riverPositionAttribute, third); + const point = raycaster.ray.intersectTriangle( + analyticRiverTriangleA, + analyticRiverTriangleB, + analyticRiverTriangleC, + true, + analyticRiverHitPoint + ); + if (!point) continue; + const triangleDistance = raycaster.ray.origin.distanceTo(point); + if ( + !Number.isFinite(triangleDistance) + || triangleDistance < Math.max(0, raycaster.near) + || triangleDistance > raycaster.far + ) continue; + if (distance === undefined || triangleDistance < distance) { + distance = triangleDistance; + } + } + } else if (cell.regime === 'ocean') { + // Ocean cells remain planar, so their existing cheap analytic path is + // exact and avoids broad mesh raycasting across the surrounding disc. + const surfaceY = pickHeightByCellKey.get(cell.cellKey); + if (surfaceY === undefined) continue; + const point = rayPointAtSurfaceY(raycaster.ray, surfaceY, analyticOceanHitPoint); + if (!point) continue; + const center = axialToWorld(cell, options.hexSize); + if (!pointInsidePointyHex({ x: point.x, z: point.z }, center, options.hexSize)) continue; + distance = raycaster.ray.origin.distanceTo(point); + } + if ( + distance === undefined + || !Number.isFinite(distance) + || distance < Math.max(0, raycaster.near) + || distance > raycaster.far + ) continue; + if ( + nearest !== null + && (distance > nearest.distance + || (distance === nearest.distance && cell.cellKey >= nearest.cellKey)) + ) continue; + const regime = cell.regime === 'river' + ? 'river' + : cell.regime === 'ocean' + ? 'ocean' + : undefined; + if (!regime) continue; + nearest = Object.freeze({ + cellKey: cell.cellKey, + bodyId: cell.bodyId, + regime, + coord: Object.freeze({ q: cell.q, r: cell.r }), + distance + }); + } + return nearest; + }; group.add(oceanMesh, lakeMesh, riverMesh, skirtMesh); const triangleCount = (oceanGeometry.index?.count ?? 0) / 3 + (lakeGeometry.index?.count ?? 0) / 3 @@ -440,6 +861,10 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay const drawCalls = [oceanMesh, lakeMesh, riverMesh, skirtMesh] .filter((mesh) => (mesh.geometry.index?.count ?? 0) > 0).length; if (triangleCount > budget.triangles || drawCalls > budget.draws) { + selectedWaterOverlay.geometry.dispose(); + (selectedWaterOverlay.material as THREE.Material).dispose(); + hoveredWaterOverlay.geometry.dispose(); + (hoveredWaterOverlay.material as THREE.Material).dispose(); disposeResources(); throw new Error('REALM_WATER_RENDER_BUDGET_EXCEEDED'); } @@ -447,7 +872,33 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay .filter((material) => (material.userData.waterWaveComponents as number) > 0) .map((material) => material.userData.waterUniforms as { uWaterTime: { value: number } }); const animated = uniforms.length > 0; - let lastTime = -1; + const environment = waterLayerRecord(options.environment); + const environmentEpoch = typeof environment?.environmentEpoch === 'bigint' + && environment.environmentEpoch >= 0n + ? environment.environmentEpoch + : 1n; + const environmentUpdatedAtMicros = typeof environment?.updatedAtMicros === 'bigint' + && environment.updatedAtMicros >= 0n + ? environment.updatedAtMicros + : undefined; + const waterBodies = new Map(); + for (const value of options.waterBodies ?? []) { + const candidate = waterLayerRecord(value); + if ( + !candidate + || typeof candidate.bodyId !== 'string' + || typeof candidate.seed !== 'number' + || !Number.isFinite(candidate.seed) + || typeof candidate.wavePreset !== 'string' + ) continue; + waterBodies.set(candidate.bodyId, candidate as GenesisWaterBodyV1); + } + const phaseCell = ocean[0] ?? rivers[0] ?? lakes[0]; + const phaseBody = phaseCell ? waterBodies.get(phaseCell.bodyId) : undefined; + const phaseSeed = phaseBody?.seed ?? phaseCell?.bankSeed ?? 0; + const phaseWavePreset = phaseBody?.wavePreset ?? phaseCell?.bodyId ?? 'genesis-water'; + let lastElapsedSeconds = -1; + let lastPhase: RealmWaterPhase | undefined; let disposed = false; const telemetry = Object.freeze({ layoutVersion: options.cells === GENESIS_WATER_REVISION_ENABLED_CELLS_V1 @@ -464,9 +915,35 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay return { group, updateEnvironment: (elapsedSeconds) => { - if (disposed || !animated || !Number.isFinite(elapsedSeconds) || elapsedSeconds === lastTime) return false; - lastTime = elapsedSeconds; - uniforms.forEach((uniform) => { uniform.uWaterTime.value = elapsedSeconds; }); + if ( + disposed + || !animated + || !Number.isFinite(elapsedSeconds) + || elapsedSeconds === lastElapsedSeconds + ) return false; + lastElapsedSeconds = elapsedSeconds; + let synchronizedServerTimeMicros: bigint | undefined; + if (options.nowMicros) { + try { + const sample = options.nowMicros(); + if (typeof sample === 'bigint' && sample >= 0n) synchronizedServerTimeMicros = sample; + } catch { + synchronizedServerTimeMicros = undefined; + } + } + const phase = resolveRealmWaterPhase({ + environmentEpoch, + environmentUpdatedAtMicros, + synchronizedServerTimeMicros, + localMonotonicSeconds: elapsedSeconds, + previousLocalMonotonicSeconds: lastPhase?.localMonotonicSeconds, + previousUnwrappedPhaseSeconds: lastPhase?.unwrappedPhaseSeconds, + reducedMotion: options.reducedMotion, + bodySeed: phaseSeed, + wavePreset: phaseWavePreset + }); + lastPhase = phase; + uniforms.forEach((uniform) => { uniform.uWaterTime.value = phase.phaseSeconds; }); return true; }, isAnimationActive: () => animated, @@ -474,7 +951,24 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay dispose: () => { if (disposed) return; disposed = true; + selectedWaterOverlay.geometry.dispose(); + (selectedWaterOverlay.material as THREE.Material).dispose(); + hoveredWaterOverlay.geometry.dispose(); + (hoveredWaterOverlay.material as THREE.Material).dispose(); disposeResources(); + }, + raycast: (raycaster) => { + if (disposed) return null; + return analyticRaycast(raycaster); + }, + getCellPresentation: (cellKey) => cellsByKey.get(cellKey), + setSelectedCellKey: (cellKey) => { + if (disposed) return; + updateWaterOverlay(selectedWaterOverlay, cellKey, 0.94); + }, + setHoveredCellKey: (cellKey) => { + if (disposed) return; + updateWaterOverlay(hoveredWaterOverlay, cellKey, 0.6); } }; } diff --git a/src/components/realm/realmWaterPhase.ts b/src/components/realm/realmWaterPhase.ts new file mode 100644 index 00000000..0570eb4b --- /dev/null +++ b/src/components/realm/realmWaterPhase.ts @@ -0,0 +1,165 @@ +import { mixUint32 } from '../../../spacetimedb/src/world'; + +export type RealmWaterPhaseInput = Readonly<{ + environmentEpoch: bigint; + /** Canonical scalar emitted by `validateCanonicalGenesisSnapshot`. */ + environmentUpdatedAtMicros?: bigint; + /** A host clock sample; correction from it is presentation-only and bounded. */ + synchronizedServerTimeMicros?: bigint; + localMonotonicSeconds: number; + previousLocalMonotonicSeconds?: number; + previousUnwrappedPhaseSeconds?: number; + reducedMotion?: boolean; + bodySeed?: number; + wavePreset?: string; +}>; + +export type RealmWaterPhase = Readonly<{ + /** Shader-facing phase, always wrapped into one short finite period. */ + phaseSeconds: number; + /** Internal continuity lane; never moves backward between valid samples. */ + unwrappedPhaseSeconds: number; + localMonotonicSeconds: number; + source: 'server-estimate' | 'server-boundary' | 'local-monotonic' | 'deterministic-freeze'; + driftSeconds: number; + correctionSeconds: number; +}>; + +const PHASE_PERIOD_SECONDS = 97; +const PHASE_PERIOD_MICROS = 97_000_000n; +const MAX_DRIFT_CORRECTION_RATE = 0.18; +const MAX_LOCAL_STEP_SECONDS = 0.25; + +function finiteNonNegative(value: number | undefined, fallback = 0) { + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, value) + : fallback; +} + +function wrapPhase(value: number) { + if (!Number.isFinite(value)) return 0; + return ((value % PHASE_PERIOD_SECONDS) + PHASE_PERIOD_SECONDS) % PHASE_PERIOD_SECONDS; +} + +function shortestWrappedDelta(target: number, current: number) { + const half = PHASE_PERIOD_SECONDS * 0.5; + return ((target - current + half) % PHASE_PERIOD_SECONDS + PHASE_PERIOD_SECONDS) + % PHASE_PERIOD_SECONDS - half; +} + +function phaseSeed(epoch: bigint, bodySeed: number, wavePreset: string) { + const epochLow = Number(epoch & 0xffff_ffffn) >>> 0; + let presetHash = 2_166_136_261; + for (let index = 0; index < wavePreset.length; index += 1) { + presetHash ^= wavePreset.charCodeAt(index); + presetHash = Math.imul(presetHash, 16_777_619); + } + return (mixUint32(epochLow ^ (bodySeed >>> 0) ^ presetHash) / 0x1_0000_0000) + * PHASE_PERIOD_SECONDS; +} + +function sharedTargetPhase( + seedSeconds: number, + boundaryMicros: bigint | undefined, + nowMicros: bigint | undefined +) { + if ( + boundaryMicros === undefined + || nowMicros === undefined + || boundaryMicros < 0n + || nowMicros < 0n + ) return undefined; + const elapsed = nowMicros > boundaryMicros ? nowMicros - boundaryMicros : 0n; + const elapsedWithinPeriod = elapsed % PHASE_PERIOD_MICROS; + return wrapPhase(seedSeconds + Number(elapsedWithinPeriod) / 1_000_000); +} + +/** + * Resolve one shared Water clock sample. Wall/server time can align clients, + * but it never drives a backward or unbounded jump: local monotonic time owns + * continuity and clock drift is eased at a fixed fraction of each local step. + */ +export function resolveRealmWaterPhase(input: RealmWaterPhaseInput): RealmWaterPhase { + const epoch = input.environmentEpoch >= 0n ? input.environmentEpoch : 0n; + const seedSeconds = phaseSeed( + epoch, + typeof input.bodySeed === 'number' && Number.isFinite(input.bodySeed) + ? Math.trunc(input.bodySeed) + : 0, + typeof input.wavePreset === 'string' ? input.wavePreset : 'genesis-water' + ); + const localMonotonicSeconds = finiteNonNegative(input.localMonotonicSeconds); + if (input.reducedMotion) { + return Object.freeze({ + phaseSeconds: seedSeconds, + unwrappedPhaseSeconds: seedSeconds, + localMonotonicSeconds, + source: 'deterministic-freeze', + driftSeconds: 0, + correctionSeconds: 0 + }); + } + + const target = sharedTargetPhase( + seedSeconds, + input.environmentUpdatedAtMicros, + input.synchronizedServerTimeMicros + ); + const previousPhase = typeof input.previousUnwrappedPhaseSeconds === 'number' + && Number.isFinite(input.previousUnwrappedPhaseSeconds) + && input.previousUnwrappedPhaseSeconds >= 0 + ? input.previousUnwrappedPhaseSeconds + : undefined; + if (previousPhase === undefined) { + const first = target ?? seedSeconds; + return Object.freeze({ + phaseSeconds: wrapPhase(first), + unwrappedPhaseSeconds: first, + localMonotonicSeconds, + source: target === undefined + ? input.environmentUpdatedAtMicros === undefined + ? 'local-monotonic' + : 'server-boundary' + : 'server-estimate', + driftSeconds: 0, + correctionSeconds: 0 + }); + } + + const previousLocal = finiteNonNegative( + input.previousLocalMonotonicSeconds, + localMonotonicSeconds + ); + const localStep = Math.min( + MAX_LOCAL_STEP_SECONDS, + Math.max(0, localMonotonicSeconds - previousLocal) + ); + const predicted = previousPhase + localStep; + const driftSeconds = target === undefined + ? 0 + : shortestWrappedDelta(target, wrapPhase(predicted)); + const maximumCorrection = localStep * MAX_DRIFT_CORRECTION_RATE; + const correctionSeconds = Math.max( + -maximumCorrection, + Math.min(maximumCorrection, driftSeconds) + ); + // Since the correction rate is strictly below one, a valid sample cannot + // reverse the clock even when the external estimate moves backward. + const unwrappedPhaseSeconds = Math.max(previousPhase, predicted + correctionSeconds); + return Object.freeze({ + phaseSeconds: wrapPhase(unwrappedPhaseSeconds), + unwrappedPhaseSeconds, + localMonotonicSeconds, + source: target === undefined + ? input.environmentUpdatedAtMicros === undefined + ? 'local-monotonic' + : 'server-boundary' + : 'server-estimate', + driftSeconds, + correctionSeconds + }); +} + +export const REALM_WATER_PHASE_PERIOD_SECONDS = PHASE_PERIOD_SECONDS; +export const REALM_WATER_MAX_DRIFT_CORRECTION_RATE = MAX_DRIFT_CORRECTION_RATE; +export const REALM_WATER_MAX_LOCAL_STEP_SECONDS = MAX_LOCAL_STEP_SECONDS; diff --git a/src/components/realm/realmWorkerLayer.ts b/src/components/realm/realmWorkerLayer.ts new file mode 100644 index 00000000..5189f461 --- /dev/null +++ b/src/components/realm/realmWorkerLayer.ts @@ -0,0 +1,409 @@ +import * as THREE from 'three'; + +import { + axialToWorld, + worldToNearestAxial, + type HexCoord, + type HexWorldPosition +} from '../../game/map/hexCoordinates'; +import type { RealmWorkerPublicPresentation } from './realmWorkerPresentation'; + +const MAX_RENDERED_REALM_WORKERS = 512; +const IDLE_RING_RADIUS = 0.82; +const SITE_RING_RADIUS = 0.34; +const WORKER_GROUND_LIFT = 0.19; + +export type RealmWorkerSceneRecord = RealmWorkerPublicPresentation & Readonly<{ + originCoord: HexCoord; + destinationCoord?: HexCoord; +}>; + +export type RealmWorkerLayerHit = Readonly<{ + workerId: string; + workerOrdinal: number; + originCastleId: number; + coord: HexCoord; + distance: number; +}>; + +export type RealmWorkerLayer = Readonly<{ + group: THREE.Group; + canReconcile: (workers: readonly RealmWorkerSceneRecord[]) => boolean; + reconcile: (workers: readonly RealmWorkerSceneRecord[]) => void; + update: (nowMicros: bigint) => boolean; + hasMovingWorkers: () => boolean; + raycast: (raycaster: THREE.Raycaster) => RealmWorkerLayerHit | null; + setHoveredWorkerId: (workerId: string | null) => void; + setSelectedWorkerId: (workerId: string | null) => void; + dispose: () => void; +}>; + +type RealmWorkerLayerOptions = Readonly<{ + workers: readonly RealmWorkerSceneRecord[]; + hexSize: number; + heightAtWorld: (world: HexWorldPosition) => number; +}>; + +function finiteCoord(coord: HexCoord | undefined): coord is HexCoord { + return coord !== undefined + && Number.isSafeInteger(coord.q) + && Number.isSafeInteger(coord.r); +} + +function boundedProgress(now: bigint, start: bigint | undefined, end: bigint | undefined) { + if (start === undefined || end === undefined || end <= start) return end !== undefined && now >= end ? 1 : 0; + if (now <= start) return 0; + if (now >= end) return 1; + return Number(now - start) / Number(end - start); +} + +function ordinalOffset(ordinal: number, radius: number) { + const angle = -Math.PI * 0.5 + (ordinal - 1) * Math.PI * 0.5; + return Object.freeze({ + x: Math.cos(angle) * radius, + z: Math.sin(angle) * radius + }); +} + +function offsetWorld(coord: HexCoord, ordinal: number, radius: number, hexSize: number) { + const center = axialToWorld(coord, hexSize); + const offset = ordinalOffset(ordinal, radius * hexSize); + return Object.freeze({ x: center.x + offset.x, z: center.z + offset.z }); +} + +/** Resolve only public world position; no owner-only assignment identity enters the scene. */ +export function resolveRealmWorkerWorldPosition( + worker: RealmWorkerSceneRecord, + nowMicros: bigint, + hexSize: number +): HexWorldPosition { + const origin = offsetWorld(worker.originCoord, worker.ordinal, IDLE_RING_RADIUS, hexSize); + if (!finiteCoord(worker.destinationCoord) || worker.status === 'idle') return origin; + const destination = offsetWorld( + worker.destinationCoord, + worker.ordinal, + SITE_RING_RADIUS, + hexSize + ); + if (worker.status === 'gathering') return destination; + if (worker.status === 'outbound') { + const progress = boundedProgress(nowMicros, worker.startedAtMicros, worker.arrivesAtMicros); + return Object.freeze({ + x: THREE.MathUtils.lerp(origin.x, destination.x, progress), + z: THREE.MathUtils.lerp(origin.z, destination.z, progress) + }); + } + const returnStartProgress = Math.min( + 1, + Math.max(0, (worker.returnStartProgressBasisPoints ?? 10_000) / 10_000) + ); + const returnOrigin = Object.freeze({ + x: THREE.MathUtils.lerp(origin.x, destination.x, returnStartProgress), + z: THREE.MathUtils.lerp(origin.z, destination.z, returnStartProgress) + }); + const progress = boundedProgress( + nowMicros, + worker.returnStartedAtMicros, + worker.returnsAtMicros + ); + return Object.freeze({ + x: THREE.MathUtils.lerp(returnOrigin.x, origin.x, progress), + z: THREE.MathUtils.lerp(returnOrigin.z, origin.z, progress) + }); +} + +function validWorkerCatalog(workers: readonly RealmWorkerSceneRecord[]) { + if (workers.length > MAX_RENDERED_REALM_WORKERS) return false; + const ids = new Set(); + for (const worker of workers) { + if ( + typeof worker.workerId !== 'string' + || worker.workerId.length === 0 + || ids.has(worker.workerId) + || !Number.isSafeInteger(worker.ordinal) + || worker.ordinal < 1 + || worker.ordinal > 4 + || !Number.isSafeInteger(worker.originCastleId) + || worker.originCastleId <= 0 + || !finiteCoord(worker.originCoord) + || (worker.status !== 'idle' && !finiteCoord(worker.destinationCoord)) + ) return false; + ids.add(worker.workerId); + } + return true; +} + +function sameStaticWorkerCatalog( + current: readonly RealmWorkerSceneRecord[], + next: readonly RealmWorkerSceneRecord[] +) { + if (!validWorkerCatalog(next) || current.length !== next.length) return false; + const nextById = new Map(next.map((worker) => [worker.workerId, worker] as const)); + return current.every((worker) => { + const candidate = nextById.get(worker.workerId); + return candidate !== undefined + && candidate.ordinal === worker.ordinal + && candidate.originCastleId === worker.originCastleId + && candidate.originCoord.q === worker.originCoord.q + && candidate.originCoord.r === worker.originCoord.r; + }); +} + +export function createRealmWorkerLayer(options: RealmWorkerLayerOptions): RealmWorkerLayer { + if (!validWorkerCatalog(options.workers)) throw new Error('REALM_WORKER_CATALOG_INVALID'); + const workers = [...options.workers].sort((left, right) => ( + left.originCastleId - right.originCastleId + || left.ordinal - right.ordinal + || left.workerId.localeCompare(right.workerId) + )); + const group = new THREE.Group(); + group.name = 'realm-workers'; + const markerGeometry = new THREE.CylinderGeometry(0.075, 0.12, 0.3, 6, 1); + const markerMaterial = new THREE.MeshStandardMaterial({ + color: '#ffffff', + roughness: 0.54, + metalness: 0.05, + vertexColors: true + }); + const markerMesh = new THREE.InstancedMesh(markerGeometry, markerMaterial, workers.length); + markerMesh.name = 'realm-worker-markers'; + markerMesh.castShadow = false; + markerMesh.receiveShadow = false; + markerMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + const pickGeometry = new THREE.SphereGeometry(0.24, 8, 5); + const pickMaterial = new THREE.MeshBasicMaterial({ + color: '#ffffff', + transparent: true, + opacity: 0, + depthWrite: false, + colorWrite: false + }); + const pickMesh = new THREE.InstancedMesh(pickGeometry, pickMaterial, workers.length); + pickMesh.name = 'realm-worker-pick-volumes'; + pickMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + pickMesh.renderOrder = -1; + group.add(markerMesh, pickMesh); + + const recordsById = new Map(workers.map((worker) => [worker.workerId, worker] as const)); + const positions = new Map>(); + const styles = new Map(); + const movingWorkerIds = new Set(); + const dirtyPoseWorkerIds = new Set(workers.map((worker) => worker.workerId)); + const matrix = new THREE.Matrix4(); + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + const color = new THREE.Color(); + let selectedWorkerId: string | null = null; + let hoveredWorkerId: string | null = null; + let lastNowMicros = 0n; + let disposed = false; + const disposeLayer = () => { + if (disposed) return; + disposed = true; + const cleanupSteps = [ + () => group.remove(markerMesh, pickMesh), + () => markerMesh.dispose(), + () => pickMesh.dispose(), + () => markerGeometry.dispose(), + () => markerMaterial.dispose(), + () => pickGeometry.dispose(), + () => pickMaterial.dispose() + ]; + for (const cleanup of cleanupSteps) { + try { + cleanup(); + } catch { + // A single GPU/browser disposal failure must not strand the rest. + } + } + recordsById.clear(); + positions.clear(); + styles.clear(); + movingWorkerIds.clear(); + dirtyPoseWorkerIds.clear(); + }; + + const isMovingAt = (worker: RealmWorkerSceneRecord, nowMicros: bigint) => { + if (worker.status === 'outbound') { + return worker.arrivesAtMicros !== undefined && nowMicros < worker.arrivesAtMicros; + } + if (worker.status === 'returning') { + return worker.returnsAtMicros !== undefined && nowMicros < worker.returnsAtMicros; + } + return false; + }; + + const sameDynamicWorker = ( + current: RealmWorkerSceneRecord, + next: RealmWorkerSceneRecord + ) => current.status === next.status + && current.resourceKind === next.resourceKind + && current.siteId === next.siteId + && current.startedAtMicros === next.startedAtMicros + && current.arrivesAtMicros === next.arrivesAtMicros + && current.gatheringEndsAtMicros === next.gatheringEndsAtMicros + && current.returnStartedAtMicros === next.returnStartedAtMicros + && current.returnsAtMicros === next.returnsAtMicros + && current.routeSteps === next.routeSteps + && current.returnStartProgressBasisPoints === next.returnStartProgressBasisPoints + && current.timelineRevision === next.timelineRevision + && current.revision === next.revision + && current.ownedByViewer === next.ownedByViewer + && current.destinationCoord?.q === next.destinationCoord?.q + && current.destinationCoord?.r === next.destinationCoord?.r; + + const apply = (nowMicros: bigint) => { + let changed = false; + let markerMatricesChanged = false; + let pickMatricesChanged = false; + let markerColorsChanged = false; + workers.forEach((identity, index) => { + const worker = recordsById.get(identity.workerId)!; + const wasMoving = movingWorkerIds.has(worker.workerId); + const moving = isMovingAt(worker, nowMicros); + const dirty = dirtyPoseWorkerIds.has(worker.workerId); + let workerPosition = positions.get(worker.workerId); + let positionChanged = false; + if (workerPosition === undefined || dirty || wasMoving || moving) { + const world = resolveRealmWorkerWorldPosition(worker, nowMicros, options.hexSize); + const groundY = options.heightAtWorld(world); + if (!Number.isFinite(groundY)) throw new Error('REALM_WORKER_GROUND_INVALID'); + const next = Object.freeze({ x: world.x, y: groundY + WORKER_GROUND_LIFT, z: world.z }); + positionChanged = workerPosition === undefined + || Math.abs(workerPosition.x - next.x) > 0.000_01 + || Math.abs(workerPosition.y - next.y) > 0.000_01 + || Math.abs(workerPosition.z - next.z) > 0.000_01; + if (positionChanged) { + workerPosition = next; + positions.set(worker.workerId, next); + changed = true; + } + } + if (moving) movingWorkerIds.add(worker.workerId); + else movingWorkerIds.delete(worker.workerId); + if (!workerPosition) return; + + const selected = selectedWorkerId === worker.workerId; + const hovered = hoveredWorkerId === worker.workerId; + const nextStyle = selected + ? 'selected' + : hovered + ? 'hovered' + : worker.ownedByViewer + ? 'owned' + : 'peer'; + const styleChanged = styles.get(worker.workerId) !== nextStyle; + if (styleChanged) styles.set(worker.workerId, nextStyle); + position.set(workerPosition.x, workerPosition.y, workerPosition.z); + const markerScale = selected ? 1.38 : hovered ? 1.2 : 1; + if (positionChanged || styleChanged) { + scale.setScalar(markerScale * options.hexSize); + matrix.compose(position, quaternion, scale); + markerMesh.setMatrixAt(index, matrix); + markerMatricesChanged = true; + changed = true; + } + if (positionChanged) { + scale.setScalar(options.hexSize); + matrix.compose(position, quaternion, scale); + pickMesh.setMatrixAt(index, matrix); + pickMatricesChanged = true; + } + if (styleChanged) { + color.set(selected + ? '#fff3b5' + : hovered + ? '#c9f3dc' + : worker.ownedByViewer + ? '#e1b95f' + : '#8e7bbd'); + markerMesh.setColorAt(index, color); + markerColorsChanged = true; + } + }); + dirtyPoseWorkerIds.clear(); + if (markerMatricesChanged) { + markerMesh.instanceMatrix.needsUpdate = true; + markerMesh.computeBoundingSphere(); + } + if (pickMatricesChanged) { + pickMesh.instanceMatrix.needsUpdate = true; + pickMesh.computeBoundingSphere(); + } + if (markerColorsChanged && markerMesh.instanceColor) { + markerMesh.instanceColor.needsUpdate = true; + } + return changed; + }; + try { + apply(lastNowMicros); + } catch (error) { + disposeLayer(); + throw error; + } + + const updateSelection = () => { + if (disposed) return; + apply(lastNowMicros); + }; + + return Object.freeze({ + group, + canReconcile: (next) => !disposed && sameStaticWorkerCatalog(workers, next), + reconcile: (next) => { + if (disposed || !sameStaticWorkerCatalog(workers, next)) return; + for (const worker of next) { + const current = recordsById.get(worker.workerId); + if (!current || !sameDynamicWorker(current, worker)) { + dirtyPoseWorkerIds.add(worker.workerId); + } + recordsById.set(worker.workerId, worker); + } + apply(lastNowMicros); + }, + update: (nowMicros) => { + if (disposed || typeof nowMicros !== 'bigint' || nowMicros < 0n) return false; + lastNowMicros = nowMicros; + return apply(nowMicros); + }, + hasMovingWorkers: () => { + if (disposed) return false; + for (const worker of recordsById.values()) { + if (isMovingAt(worker, lastNowMicros)) return true; + } + return false; + }, + raycast: (raycaster) => { + if (disposed || workers.length === 0) return null; + const intersection = raycaster.intersectObject(pickMesh, false)[0]; + const instanceId = intersection?.instanceId; + if (intersection === undefined || instanceId === undefined || !Number.isSafeInteger(instanceId)) { + return null; + } + const identity = workers[instanceId]; + if (!identity || !Number.isFinite(intersection.distance) || intersection.distance < 0) return null; + const worker = recordsById.get(identity.workerId); + const workerPosition = positions.get(identity.workerId); + if (!worker || !workerPosition) return null; + return Object.freeze({ + workerId: worker.workerId, + workerOrdinal: worker.ordinal, + originCastleId: worker.originCastleId, + coord: Object.freeze(worldToNearestAxial(workerPosition, options.hexSize)), + distance: intersection.distance + }); + }, + setHoveredWorkerId: (workerId) => { + if (disposed || hoveredWorkerId === workerId) return; + hoveredWorkerId = workerId !== null && recordsById.has(workerId) ? workerId : null; + updateSelection(); + }, + setSelectedWorkerId: (workerId) => { + if (disposed || selectedWorkerId === workerId) return; + selectedWorkerId = workerId !== null && recordsById.has(workerId) ? workerId : null; + updateSelection(); + }, + dispose: disposeLayer + }); +} diff --git a/src/components/realm/realmWorkerPresentation.ts b/src/components/realm/realmWorkerPresentation.ts new file mode 100644 index 00000000..d01880f1 --- /dev/null +++ b/src/components/realm/realmWorkerPresentation.ts @@ -0,0 +1,582 @@ +import { + REALM_RESOURCE_POLICY_VERSION, + type RealmEconomicResourceKey +} from './realmResourcePresentation'; + +export const CASTLE_WORKER_ORDINALS = Object.freeze([1, 2, 3, 4] as const); +export const CASTLE_WORKER_POLICY_VERSION = 'genesis-001-castle-workers-v1'; +export const CASTLE_WORKER_REALM_ID = 'GENESIS_001'; +export type RealmWorkerOrdinal = typeof CASTLE_WORKER_ORDINALS[number]; +export type RealmWorkerStatus = 'idle' | 'outbound' | 'gathering' | 'returning'; +export type RealmWorkerSystemMode = 'staged' | 'active'; + +export type RealmWorkerSystemPresentation = Readonly<{ + realmId: string; + policyVersion: string; + workersPerCastle: 4; + expectedCastleCount: number; + expectedWorkerCount: number; + rosterDigest: string; + mode: RealmWorkerSystemMode; + legacyDrainRequired: boolean; +}>; + +export type RealmWorkerPublicPresentation = Readonly<{ + workerId: string; + ordinal: RealmWorkerOrdinal; + originCastleId: number; + originCastleName: string; + status: RealmWorkerStatus; + resourceKind?: RealmEconomicResourceKey; + siteId?: string; + startedAtMicros?: bigint; + arrivesAtMicros?: bigint; + gatheringEndsAtMicros?: bigint; + returnStartedAtMicros?: bigint; + returnsAtMicros?: bigint; + routeSteps?: number; + returnStartProgressBasisPoints?: number; + timelineRevision: number; + revision: bigint; + ownedByViewer: boolean; +}>; + +/** Browser-safe public lease containing only public worker/resource coordinates. */ +export type RealmWorkerNodeOccupation = Readonly<{ + nodeKey: string; + resourceKind: RealmEconomicResourceKey; + siteId: string; + workerId: string; + workerOrdinal: RealmWorkerOrdinal; + originCastleId: number; + phase: 'outbound' | 'gathering'; + startedAtMicros: bigint; + arrivesAtMicros: bigint; + gatheringEndsAtMicros: bigint; + timelineRevision: number; +}>; + +export type WorkerRosterPresentation = Readonly<{ + castleId: number; + observedAtMicros: bigint; + workers: readonly Readonly<{ + workerId: string; + ordinal: RealmWorkerOrdinal; + status: RealmWorkerStatus; + resourceKind?: RealmEconomicResourceKey; + siteId?: string; + accruedAmount: bigint; + materializedAmount: bigint; + availableAmount: bigint; + observedAtMicros: bigint; + revision: bigint; + }>[]; +}>; + +export type ReadyWorkerResourceState = Readonly<{ + status: 'ready'; + fid: bigint; + available: Readonly>; + pending: Readonly>; + observedAtMicros: bigint; + settledThroughMicros: bigint; + revision: bigint; + resourcePolicyVersion: string; + workerPolicyVersion: string; + workerSystemMode: RealmWorkerSystemMode; +}>; + +export type ReadyWorkerProjection = Readonly<{ + mode: 'active'; + system: RealmWorkerSystemPresentation; + /** Realm-wide public motion state; never use this array as an owner roster. */ + workers: readonly RealmWorkerPublicPresentation[]; + /** The only four workers permitted in owner command surfaces. */ + ownedWorkers: readonly RealmWorkerPublicPresentation[]; + occupations: readonly RealmWorkerNodeOccupation[]; +}>; + +export type RealmWorkerDestinationPresentation = Readonly<{ + resourceKind: RealmEconomicResourceKey; + siteId: string; + label: string; +}>; + +const resourceKinds = new Set(['food', 'wood', 'stone', 'gold']); +const workerStatuses = new Set(['idle', 'outbound', 'gathering', 'returning']); +const occupationPhases = new Set(['outbound', 'gathering']); +const RESOURCE_ORDER = Object.freeze(['food', 'wood', 'stone', 'gold'] as const); +const U64_MAX = (1n << 64n) - 1n; + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function safeNumber(value: unknown, positive = false) { + const number = typeof value === 'bigint' + && value >= 0n + && value <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(value) + : value; + return typeof number === 'number' + && Number.isSafeInteger(number) + && number >= 0 + && (!positive || number > 0) + ? number + : undefined; +} + +function safeBigInt(value: unknown) { + return typeof value === 'bigint' && value >= 0n && value <= U64_MAX ? value : undefined; +} + +function optionalBigInt(value: unknown): bigint | undefined | null { + return value === undefined || value === null ? undefined : safeBigInt(value) ?? null; +} + +function optionalNumber(value: unknown): number | undefined | null { + return value === undefined || value === null ? undefined : safeNumber(value) ?? null; +} + +function optionalResource(value: unknown) { + return typeof value === 'string' && resourceKinds.has(value as RealmEconomicResourceKey) + ? value as RealmEconomicResourceKey + : value === undefined || value === null ? undefined : null; +} + +function optionalString(value: unknown) { + return value === undefined || value === null + ? undefined + : typeof value === 'string' && value.length > 0 ? value : null; +} + +function canonicalWorkerId(castleId: number, ordinal: RealmWorkerOrdinal) { + return `genesis-001-castle-${castleId}-worker-${String(ordinal).padStart(2, '0')}`; +} + +export function workerRosterDigestForCastleIds(castleIds: readonly number[]) { + let hash = 0xcbf29ce484222325n; + for (const castleId of [...castleIds].sort((left, right) => left - right)) { + for (const ordinal of CASTLE_WORKER_ORDINALS) { + for (const byte of new TextEncoder().encode(canonicalWorkerId(castleId, ordinal))) { + hash ^= BigInt(byte); + hash = (hash * 0x100000001b3n) & U64_MAX; + } + } + } + return hash.toString(16).padStart(16, '0'); +} + +function assignedWorkerStateIsConsistent(worker: RealmWorkerPublicPresentation) { + const assigned = worker.status !== 'idle'; + if (!assigned) { + return worker.resourceKind === undefined + && worker.siteId === undefined + && worker.startedAtMicros === undefined + && worker.arrivesAtMicros === undefined + && worker.gatheringEndsAtMicros === undefined + && worker.returnStartedAtMicros === undefined + && worker.returnsAtMicros === undefined + && worker.routeSteps === undefined + && worker.returnStartProgressBasisPoints === undefined; + } + if ( + worker.resourceKind === undefined + || worker.siteId === undefined + || worker.startedAtMicros === undefined + || worker.arrivesAtMicros === undefined + || worker.gatheringEndsAtMicros === undefined + || worker.returnsAtMicros === undefined + || worker.routeSteps === undefined + || worker.routeSteps <= 0 + || !(worker.startedAtMicros < worker.arrivesAtMicros + && worker.arrivesAtMicros < worker.gatheringEndsAtMicros) + ) return false; + if (worker.status !== 'returning') { + return worker.returnStartedAtMicros === undefined + && worker.returnStartProgressBasisPoints === undefined + && worker.gatheringEndsAtMicros < worker.returnsAtMicros; + } + if ( + worker.returnStartedAtMicros === undefined + || worker.returnStartedAtMicros < worker.startedAtMicros + || worker.returnStartedAtMicros > worker.gatheringEndsAtMicros + || worker.returnStartProgressBasisPoints === undefined + || worker.returnStartProgressBasisPoints > 10_000 + ) return false; + const outboundDuration = worker.arrivesAtMicros - worker.startedAtMicros; + const expectedProgress = worker.returnStartedAtMicros >= worker.arrivesAtMicros + ? 10_000 + : Number( + ((worker.returnStartedAtMicros - worker.startedAtMicros) * 10_000n) + / outboundDuration + ); + const expectedReturnsAtMicros = worker.returnStartedAtMicros + + (outboundDuration * BigInt(expectedProgress)) / 10_000n; + return worker.returnStartProgressBasisPoints === expectedProgress + && worker.returnsAtMicros === expectedReturnsAtMicros; +} + +export function decodeRealmWorkerSystem(value: unknown): RealmWorkerSystemPresentation | undefined { + if (!record(value)) return undefined; + const workersPerCastle = safeNumber(value.workersPerCastle); + const expectedCastleCount = safeNumber(value.expectedCastleCount); + const expectedWorkerCount = safeNumber(value.expectedWorkerCount); + const mode = value.mode === 'active' || value.mode === 'staged' ? value.mode : undefined; + if ( + typeof value.realmId !== 'string' || value.realmId.length === 0 + || typeof value.policyVersion !== 'string' || value.policyVersion.length === 0 + || workersPerCastle !== 4 || expectedCastleCount === undefined || expectedWorkerCount === undefined + || expectedWorkerCount !== expectedCastleCount * 4 + || typeof value.rosterDigest !== 'string' || !/^[0-9a-f]{16}$/.test(value.rosterDigest) + || mode === undefined || typeof value.legacyDrainRequired !== 'boolean' + ) return undefined; + return Object.freeze({ + realmId: value.realmId, + policyVersion: value.policyVersion, + workersPerCastle: 4 as const, + expectedCastleCount, + expectedWorkerCount, + rosterDigest: value.rosterDigest, + mode, + legacyDrainRequired: value.legacyDrainRequired + }); +} + +export function decodeRealmWorkerPublicRows( + rows: readonly unknown[], + castleNames: ReadonlyMap, + ownCastleId: number +): readonly RealmWorkerPublicPresentation[] | undefined { + const decoded: RealmWorkerPublicPresentation[] = []; + const ids = new Set(); + const castleOrdinals = new Set(); + for (const value of rows) { + if (!record(value)) return undefined; + const ordinal = safeNumber(value.ordinal); + const originCastleId = safeNumber(value.originCastleId, true); + const timelineRevision = safeNumber(value.timelineRevision); + const revision = safeBigInt(value.revision); + const status = workerStatuses.has(value.status as RealmWorkerStatus) + ? value.status as RealmWorkerStatus + : undefined; + const resourceKind = optionalResource(value.resourceKind); + const siteId = optionalString(value.siteId); + const startedAtMicros = optionalBigInt(value.startedAtMicros); + const arrivesAtMicros = optionalBigInt(value.arrivesAtMicros); + const gatheringEndsAtMicros = optionalBigInt(value.gatheringEndsAtMicros); + const returnStartedAtMicros = optionalBigInt(value.returnStartedAtMicros); + const returnsAtMicros = optionalBigInt(value.returnsAtMicros); + const routeSteps = optionalNumber(value.routeSteps); + const returnStartProgressBasisPoints = optionalNumber(value.returnStartProgressBasisPoints); + if ( + typeof value.workerId !== 'string' || ids.has(value.workerId) + || ordinal === undefined || !CASTLE_WORKER_ORDINALS.includes(ordinal as RealmWorkerOrdinal) + || originCastleId === undefined || status === undefined || resourceKind === null || siteId === null + || startedAtMicros === null || arrivesAtMicros === null || gatheringEndsAtMicros === null + || returnStartedAtMicros === null || returnsAtMicros === null || routeSteps === null + || returnStartProgressBasisPoints === null || timelineRevision === undefined || revision === undefined + || !castleNames.has(originCastleId) + || value.workerId !== canonicalWorkerId(originCastleId, ordinal as RealmWorkerOrdinal) + || castleOrdinals.has(`${originCastleId}:${ordinal}`) + ) return undefined; + const worker: RealmWorkerPublicPresentation = Object.freeze({ + workerId: value.workerId, + ordinal: ordinal as RealmWorkerOrdinal, + originCastleId, + originCastleName: castleNames.get(originCastleId)!, + status, + ...(resourceKind === undefined ? {} : { resourceKind }), + ...(siteId === undefined ? {} : { siteId }), + ...(startedAtMicros === undefined ? {} : { startedAtMicros }), + ...(arrivesAtMicros === undefined ? {} : { arrivesAtMicros }), + ...(gatheringEndsAtMicros === undefined ? {} : { gatheringEndsAtMicros }), + ...(returnStartedAtMicros === undefined ? {} : { returnStartedAtMicros }), + ...(returnsAtMicros === undefined ? {} : { returnsAtMicros }), + ...(routeSteps === undefined ? {} : { routeSteps }), + ...(returnStartProgressBasisPoints === undefined ? {} : { returnStartProgressBasisPoints }), + timelineRevision, + revision, + ownedByViewer: originCastleId === ownCastleId + }); + if (!assignedWorkerStateIsConsistent(worker)) return undefined; + ids.add(value.workerId); + castleOrdinals.add(`${originCastleId}:${ordinal}`); + decoded.push(worker); + } + const byCastle = new Map(); + for (const worker of decoded) { + byCastle.set(worker.originCastleId, (byCastle.get(worker.originCastleId) ?? 0) + 1); + } + if (decoded.length > 0 && [...byCastle.values()].some((count) => count !== 4)) return undefined; + return Object.freeze(decoded.sort((left, right) => ( + left.originCastleId - right.originCastleId || left.ordinal - right.ordinal + ))); +} + +export function decodeRealmWorkerOccupations(rows: readonly unknown[]): readonly RealmWorkerNodeOccupation[] | undefined { + const decoded: RealmWorkerNodeOccupation[] = []; + const keys = new Set(); + const workerIds = new Set(); + for (const value of rows) { + if (!record(value)) return undefined; + const workerOrdinal = safeNumber(value.workerOrdinal); + const originCastleId = safeNumber(value.originCastleId, true); + const timelineRevision = safeNumber(value.timelineRevision); + const resourceKind = optionalResource(value.resourceKind); + const phase = occupationPhases.has(value.phase as RealmWorkerNodeOccupation['phase']) + ? value.phase as RealmWorkerNodeOccupation['phase'] : undefined; + const startedAtMicros = safeBigInt(value.startedAtMicros); + const arrivesAtMicros = safeBigInt(value.arrivesAtMicros); + const gatheringEndsAtMicros = safeBigInt(value.gatheringEndsAtMicros); + if ( + typeof value.nodeKey !== 'string' || value.nodeKey.length === 0 || keys.has(value.nodeKey) + || typeof value.siteId !== 'string' || value.siteId.length === 0 + || typeof value.workerId !== 'string' || workerIds.has(value.workerId) + || resourceKind === undefined || resourceKind === null || phase === undefined + || workerOrdinal === undefined || !CASTLE_WORKER_ORDINALS.includes(workerOrdinal as RealmWorkerOrdinal) + || originCastleId === undefined || timelineRevision === undefined + || startedAtMicros === undefined || arrivesAtMicros === undefined || gatheringEndsAtMicros === undefined + || !(startedAtMicros < arrivesAtMicros && arrivesAtMicros < gatheringEndsAtMicros) + || value.nodeKey !== `${resourceKind}:${value.siteId}` + ) return undefined; + keys.add(value.nodeKey); + workerIds.add(value.workerId); + decoded.push(Object.freeze({ + nodeKey: value.nodeKey, + resourceKind, + siteId: value.siteId, + workerId: value.workerId, + workerOrdinal: workerOrdinal as RealmWorkerOrdinal, + originCastleId, + phase, + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + timelineRevision + })); + } + return Object.freeze(decoded.sort((left, right) => left.nodeKey.localeCompare(right.nodeKey))); +} + +export function decodeWorkerRoster(value: unknown, expectedFid: bigint): WorkerRosterPresentation | undefined { + const castleId = record(value) ? safeNumber(value.castleId, true) : undefined; + const observedAtMicros = record(value) ? safeBigInt(value.observedAtMicros) : undefined; + if (!record(value) || value.fid !== expectedFid || castleId === undefined + || observedAtMicros === undefined || !Array.isArray(value.workers)) return undefined; + const workers: WorkerRosterPresentation['workers'][number][] = []; + const ids = new Set(); + const ordinals = new Set(); + for (const workerValue of value.workers) { + if (!record(workerValue)) return undefined; + const ordinal = safeNumber(workerValue.ordinal); + const resourceKind = optionalResource(workerValue.resourceKind); + const siteId = optionalString(workerValue.siteId); + const accruedAmount = safeBigInt(workerValue.accruedAmount); + const materializedAmount = safeBigInt(workerValue.materializedAmount); + const availableAmount = safeBigInt(workerValue.availableAmount); + const workerObservedAtMicros = safeBigInt(workerValue.observedAtMicros); + const revision = safeBigInt(workerValue.revision); + const status = workerStatuses.has(workerValue.status as RealmWorkerStatus) + ? workerValue.status as RealmWorkerStatus : undefined; + if ( + typeof workerValue.workerId !== 'string' || ids.has(workerValue.workerId) + || ordinal === undefined || !CASTLE_WORKER_ORDINALS.includes(ordinal as RealmWorkerOrdinal) + || ordinals.has(ordinal) || status === undefined || resourceKind === null || siteId === null + || accruedAmount === undefined || materializedAmount === undefined || availableAmount === undefined + || workerObservedAtMicros !== observedAtMicros || revision === undefined + || workerValue.workerId !== canonicalWorkerId(castleId, ordinal as RealmWorkerOrdinal) + || materializedAmount > accruedAmount || availableAmount !== accruedAmount - materializedAmount + || (status === 'idle' ? resourceKind !== undefined || siteId !== undefined : resourceKind === undefined || siteId === undefined) + ) return undefined; + ids.add(workerValue.workerId); + ordinals.add(ordinal); + workers.push(Object.freeze({ + workerId: workerValue.workerId, + ordinal: ordinal as RealmWorkerOrdinal, + status, + ...(resourceKind === undefined ? {} : { resourceKind }), + ...(siteId === undefined ? {} : { siteId }), + accruedAmount, + materializedAmount, + availableAmount, + observedAtMicros: workerObservedAtMicros, + revision + })); + } + if (workers.length !== 4 || CASTLE_WORKER_ORDINALS.some((ordinal) => !ordinals.has(ordinal))) { + return undefined; + } + return Object.freeze({ + castleId, + observedAtMicros, + workers: Object.freeze(workers.sort((left, right) => left.ordinal - right.ordinal)) + }); +} + +export function decodeWorkerResourceState(value: unknown, expectedFid: bigint): ReadyWorkerResourceState | undefined { + if (!record(value) || value.fid !== expectedFid) return undefined; + const mode = value.workerSystemMode === 'active' || value.workerSystemMode === 'staged' + ? value.workerSystemMode : undefined; + const available = {} as Record; + const pending = {} as Record; + const observedAtMicros = safeBigInt(value.observedAtMicros); + const settledThroughMicros = safeBigInt(value.settledThroughMicros); + const revision = safeBigInt(value.revision); + for (const name of RESOURCE_ORDER) { + const availableValue = safeBigInt(value[name]); + const pendingValue = safeBigInt(value[`workerPending${name[0]!.toUpperCase()}${name.slice(1)}`]); + if (availableValue === undefined || pendingValue === undefined) return undefined; + available[name] = availableValue; + pending[name] = pendingValue; + } + if ( + mode === undefined + || typeof value.resourcePolicyVersion !== 'string' || value.resourcePolicyVersion.length === 0 + || typeof value.workerPolicyVersion !== 'string' || value.workerPolicyVersion.length === 0 + || observedAtMicros === undefined || settledThroughMicros === undefined || revision === undefined + || settledThroughMicros > observedAtMicros + ) return undefined; + return Object.freeze({ + status: 'ready' as const, + fid: expectedFid, + available: Object.freeze(available), + pending: Object.freeze(pending), + observedAtMicros, + settledThroughMicros, + revision, + resourcePolicyVersion: value.resourcePolicyVersion, + workerPolicyVersion: value.workerPolicyVersion, + workerSystemMode: mode + }); +} + +export function resolveReadyWorkerProjection(input: Readonly<{ + realmId: string; + castleIds: readonly number[]; + ownCastleId: number; + system?: RealmWorkerSystemPresentation; + workers?: readonly RealmWorkerPublicPresentation[]; + occupations?: readonly RealmWorkerNodeOccupation[]; + roster?: WorkerRosterPresentation; + resourceState?: ReadyWorkerResourceState; +}>): ReadyWorkerProjection | undefined { + const { system, workers, occupations, roster, resourceState } = input; + const castleIds = [...input.castleIds].sort((left, right) => left - right); + if ( + system?.mode !== 'active' + || system.legacyDrainRequired + || system.realmId !== input.realmId + || system.realmId !== CASTLE_WORKER_REALM_ID + || system.policyVersion !== CASTLE_WORKER_POLICY_VERSION + || new Set(castleIds).size !== castleIds.length + || system.expectedCastleCount !== castleIds.length + || system.expectedWorkerCount !== castleIds.length * 4 + || system.rosterDigest !== workerRosterDigestForCastleIds(castleIds) + || workers === undefined || workers.length !== system.expectedWorkerCount + || occupations === undefined + || roster?.castleId !== input.ownCastleId + || resourceState?.workerSystemMode !== 'active' + || resourceState.resourcePolicyVersion !== REALM_RESOURCE_POLICY_VERSION + || resourceState.workerPolicyVersion !== system.policyVersion + ) return undefined; + + const castleSet = new Set(castleIds); + const workersById = new Map(); + const ordinalsByCastle = new Map>(); + for (const worker of workers) { + if ( + !castleSet.has(worker.originCastleId) + || workersById.has(worker.workerId) + || worker.workerId !== canonicalWorkerId(worker.originCastleId, worker.ordinal) + || !assignedWorkerStateIsConsistent(worker) + || worker.ownedByViewer !== (worker.originCastleId === input.ownCastleId) + ) return undefined; + const ordinals = ordinalsByCastle.get(worker.originCastleId) ?? new Set(); + if (ordinals.has(worker.ordinal)) return undefined; + ordinals.add(worker.ordinal); + ordinalsByCastle.set(worker.originCastleId, ordinals); + workersById.set(worker.workerId, worker); + } + if (castleIds.some((castleId) => ( + ordinalsByCastle.get(castleId)?.size !== 4 + || CASTLE_WORKER_ORDINALS.some((ordinal) => !ordinalsByCastle.get(castleId)?.has(ordinal)) + ))) return undefined; + + const occupationByWorker = new Map(); + const occupationKeys = new Set(); + for (const occupation of occupations) { + const worker = workersById.get(occupation.workerId); + if ( + worker === undefined || occupationByWorker.has(occupation.workerId) + || occupationKeys.has(occupation.nodeKey) + || occupation.nodeKey !== `${occupation.resourceKind}:${occupation.siteId}` + || occupation.originCastleId !== worker.originCastleId + || occupation.workerOrdinal !== worker.ordinal + || occupation.phase !== worker.status + || occupation.resourceKind !== worker.resourceKind + || occupation.siteId !== worker.siteId + || occupation.startedAtMicros !== worker.startedAtMicros + || occupation.arrivesAtMicros !== worker.arrivesAtMicros + || occupation.gatheringEndsAtMicros !== worker.gatheringEndsAtMicros + || occupation.timelineRevision !== worker.timelineRevision + ) return undefined; + occupationByWorker.set(occupation.workerId, occupation); + occupationKeys.add(occupation.nodeKey); + } + if (workers.some((worker) => ( + (worker.status === 'outbound' || worker.status === 'gathering') + ? !occupationByWorker.has(worker.workerId) + : occupationByWorker.has(worker.workerId) + ))) return undefined; + + const ownedWorkers = workers.filter((worker) => worker.originCastleId === input.ownCastleId); + if (ownedWorkers.length !== 4 || roster.workers.length !== 4) return undefined; + const rosterById = new Map(roster.workers.map((worker) => [worker.workerId, worker] as const)); + for (const worker of ownedWorkers) { + const privateWorker = rosterById.get(worker.workerId); + if ( + privateWorker === undefined + || privateWorker.ordinal !== worker.ordinal + || privateWorker.status !== worker.status + || privateWorker.resourceKind !== worker.resourceKind + || privateWorker.siteId !== worker.siteId + || privateWorker.revision !== worker.revision + ) return undefined; + } + const expectedPending: Record = { + food: 0n, + wood: 0n, + stone: 0n, + gold: 0n + }; + for (const worker of roster.workers) { + if (worker.resourceKind !== undefined) { + expectedPending[worker.resourceKind] += worker.availableAmount; + } + } + if (RESOURCE_ORDER.some((resource) => resourceState.pending[resource] !== expectedPending[resource])) { + return undefined; + } + return Object.freeze({ + mode: 'active' as const, + system, + workers, + ownedWorkers: Object.freeze(ownedWorkers.slice().sort((left, right) => left.ordinal - right.ordinal)), + occupations + }); +} + +export function workerAvailabilityCount(workers: readonly RealmWorkerPublicPresentation[]) { + return workers.filter((worker) => worker.status === 'idle').length; +} + +export function realmWorkerLabel(ordinal: RealmWorkerOrdinal) { return `Worker ${ordinal}`; } +export function realmWorkerStatusLabel(worker: RealmWorkerPublicPresentation) { + if (worker.status === 'outbound' && worker.resourceKind) return `TRAVELLING TO ${worker.resourceKind.toUpperCase()} SITE`; + if (worker.status === 'gathering' && worker.resourceKind) return `GATHERING ${worker.resourceKind.toUpperCase()}`; + return ({ idle: 'READY AT KEEP', outbound: 'TRAVELLING TO RESOURCE', gathering: 'GATHERING RESOURCE', returning: 'RETURNING TO KEEP' } as const)[worker.status]; +} +export function realmWorkerCanRecall(worker: RealmWorkerPublicPresentation) { + return worker.ownedByViewer && (worker.status === 'outbound' || worker.status === 'gathering'); +} From 42eeaced87f371937662827cf6e7368c8c832408 Mon Sep 17 00:00:00 2001 From: Ael <183124839+ael-dev3@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:05:53 +0200 Subject: [PATCH 07/10] Alpha 0.3.14 signed source (7/10) GitHub-signed reconstruction of the exact reviewed Alpha 0.3.14 tree. --- .../title/WarpkeepTitleScreen3D.tsx | 10 +- src/game/map/realmGrass.ts | 18 +- src/game/map/realmTerrainFeatures.ts | 5 +- src/game/map/realmTerrainSemantics.ts | 2 + src/game/map/terrainColor.ts | 26 +- src/settings/graphicsPreference.ts | 58 ++- src/spacetime/WarpkeepSpacetimeProvider.tsx | 453 +++++++++++++++++- src/spacetime/canonicalGenesisSnapshot.ts | 51 +- ..._get_worker_system_status_v_1_procedure.ts | 19 + .../admin_plan_worker_roster_v_1_procedure.ts | 19 + .../castle_worker_v_1_table.ts | 29 ++ .../dispatch_worker_v_1_reducer.ts | 18 + .../get_my_resource_state_v_2_procedure.ts | 19 + .../get_my_worker_roster_v_1_procedure.ts | 19 + src/spacetime/module_bindings/index.ts | 59 +++ .../realm_worker_system_v_1_table.ts | 24 + .../recall_all_workers_v_1_reducer.ts | 15 + .../recall_worker_v_1_reducer.ts | 16 + src/spacetime/module_bindings/types.ts | 216 +++++++++ .../module_bindings/types/procedures.ts | 12 + .../module_bindings/types/reducers.ts | 6 + .../worker_node_occupation_v_1_table.ts | 25 + src/spacetime/playerModuleBindings.ts | 55 ++- src/spacetime/warpkeepBackendTypes.ts | 25 + 24 files changed, 1155 insertions(+), 44 deletions(-) create mode 100644 src/spacetime/module_bindings/admin_get_worker_system_status_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/admin_plan_worker_roster_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/castle_worker_v_1_table.ts create mode 100644 src/spacetime/module_bindings/dispatch_worker_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/get_my_resource_state_v_2_procedure.ts create mode 100644 src/spacetime/module_bindings/get_my_worker_roster_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/realm_worker_system_v_1_table.ts create mode 100644 src/spacetime/module_bindings/recall_all_workers_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/recall_worker_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts diff --git a/src/components/title/WarpkeepTitleScreen3D.tsx b/src/components/title/WarpkeepTitleScreen3D.tsx index 66aa0e71..9b14472f 100644 --- a/src/components/title/WarpkeepTitleScreen3D.tsx +++ b/src/components/title/WarpkeepTitleScreen3D.tsx @@ -51,6 +51,7 @@ import { type WarpkeepTitleScreenHandle, type WarpkeepTitleScreenProps } from './titleScreenTypes'; +import { probeWebGL2Capability } from '../../settings/graphicsPreference'; import './WarpkeepTitleScreen.css'; type PointLayer = { @@ -72,14 +73,7 @@ type GalaxyAssembly = { }; function canUseWebGL() { - try { - const canvas = document.createElement('canvas'); - const context = canvas.getContext('webgl2'); - context?.getExtension('WEBGL_lose_context')?.loseContext(); - return Boolean(context); - } catch { - return false; - } + return probeWebGL2Capability().available; } function createRandom(seed: number) { diff --git a/src/game/map/realmGrass.ts b/src/game/map/realmGrass.ts index d8200d66..3a28e172 100644 --- a/src/game/map/realmGrass.ts +++ b/src/game/map/realmGrass.ts @@ -46,39 +46,39 @@ export const REALM_GRASS_BIOME_PROFILES: Readonly< Record > = Object.freeze({ meadow: Object.freeze({ - kind: 'meadow', highCandidateCount: 30, completelyBareThreshold: 0.12, retention: 0.92, + kind: 'meadow', highCandidateCount: 34, completelyBareThreshold: 0.08, retention: 0.94, height: Object.freeze([0.11, 0.19]), width: Object.freeze([0.34, 0.52]), palette: palette(['#A8FF67', '#8EF04B', '#C0FF79', '#76DB39']), slopeSoftLimit: 0.42, slopeHardLimit: 0.78, minimumSeparation: 0.07 }), lowland: Object.freeze({ - kind: 'lowland', highCandidateCount: 24, completelyBareThreshold: 0.20, retention: 0.82, + kind: 'lowland', highCandidateCount: 30, completelyBareThreshold: 0.14, retention: 0.88, height: Object.freeze([0.10, 0.18]), width: Object.freeze([0.32, 0.50]), palette: palette(['#8AF052', '#70DC3D', '#A2F966', '#62C934']), slopeSoftLimit: 0.42, slopeHardLimit: 0.78, minimumSeparation: 0.075 }), forest: Object.freeze({ - kind: 'forest', highCandidateCount: 20, completelyBareThreshold: 0.32, retention: 0.76, + kind: 'forest', highCandidateCount: 24, completelyBareThreshold: 0.22, retention: 0.82, height: Object.freeze([0.10, 0.17]), width: Object.freeze([0.30, 0.46]), palette: palette(['#69D849', '#55C43D', '#7BE457', '#48B635']), slopeSoftLimit: 0.40, slopeHardLimit: 0.74, minimumSeparation: 0.08 }), heath: Object.freeze({ - kind: 'heath', highCandidateCount: 14, completelyBareThreshold: 0.47, retention: 0.68, + kind: 'heath', highCandidateCount: 22, completelyBareThreshold: 0.28, retention: 0.80, height: Object.freeze([0.09, 0.16]), width: Object.freeze([0.28, 0.43]), - palette: palette(['#75D84B', '#65C93F', '#7BBC57']), + palette: palette(['#7ED34A', '#67C33E', '#9BE05C']), slopeSoftLimit: 0.34, slopeHardLimit: 0.67, minimumSeparation: 0.085 }), ridge: Object.freeze({ - kind: 'ridge', highCandidateCount: 4, completelyBareThreshold: 0.84, retention: 0.56, + kind: 'ridge', highCandidateCount: 6, completelyBareThreshold: 0.72, retention: 0.62, height: Object.freeze([0.08, 0.13]), width: Object.freeze([0.24, 0.34]), palette: palette(['#8EC85A', '#79B84D']), slopeSoftLimit: 0.22, slopeHardLimit: 0.44, minimumSeparation: 0.10 }), 'ancient-stone': Object.freeze({ - kind: 'ancient-stone', highCandidateCount: 2, completelyBareThreshold: 0.96, retention: 0.46, + kind: 'ancient-stone', highCandidateCount: 4, completelyBareThreshold: 0.86, retention: 0.54, height: Object.freeze([0.07, 0.11]), width: Object.freeze([0.22, 0.30]), - palette: palette(['#8EC85A', '#79B84D']), + palette: palette(['#86B66A', '#6FA05C']), slopeSoftLimit: 0.18, slopeHardLimit: 0.34, minimumSeparation: 0.12 }), lake: Object.freeze({ @@ -87,7 +87,7 @@ export const REALM_GRASS_BIOME_PROFILES: Readonly< slopeSoftLimit: 0, slopeHardLimit: 0, minimumSeparation: 0 }), apron: Object.freeze({ - kind: 'apron', highCandidateCount: 5, completelyBareThreshold: 0.65, retention: 0.52, + kind: 'apron', highCandidateCount: 6, completelyBareThreshold: 0.52, retention: 0.56, height: Object.freeze([0.08, 0.13]), width: Object.freeze([0.24, 0.36]), palette: palette(['#75D84B', '#65C93F']), slopeSoftLimit: 0.30, slopeHardLimit: 0.58, minimumSeparation: 0.11 diff --git a/src/game/map/realmTerrainFeatures.ts b/src/game/map/realmTerrainFeatures.ts index 3488e4b1..353906b7 100644 --- a/src/game/map/realmTerrainFeatures.ts +++ b/src/game/map/realmTerrainFeatures.ts @@ -71,7 +71,9 @@ type RealmTerrainFeatureCandidate = Readonly<{ function featureKindForTerrain(kind: RealmTerrainKind): RealmTerrainFeatureKind | undefined { if (kind === 'forest') return 'forest-tree'; - if (kind === 'heath') return 'heath-bloom'; + // Keep the historical feature kind in the compatibility union, but ordinary + // heath is now expressed through its ground and grass palette only. + if (kind === 'heath') return undefined; if (kind === 'ridge') return 'ridge-outcrop'; if (kind === 'lake') return 'lake-sheen'; if (kind === 'ancient-stone') return 'ancient-monolith'; @@ -90,7 +92,6 @@ function featureCountForCell( ? 1 : 0; } - if (terrainKind === 'heath') return quality === 'reduced' ? 0 : 1; return featureKindForTerrain(terrainKind) === undefined ? 0 : 1; } diff --git a/src/game/map/realmTerrainSemantics.ts b/src/game/map/realmTerrainSemantics.ts index 01a9ea5d..c99e6802 100644 --- a/src/game/map/realmTerrainSemantics.ts +++ b/src/game/map/realmTerrainSemantics.ts @@ -28,6 +28,8 @@ export type RealmTerrainSemanticRow = Readonly<{ tileKey: string; terrainKind: string; staticContentKind: string; + /** Present on canonical public metadata; optional for renderer-only fixtures. */ + passable?: boolean; }>; const TERRAIN_KIND_SET = new Set(REALM_TERRAIN_KINDS); diff --git a/src/game/map/terrainColor.ts b/src/game/map/terrainColor.ts index 964aebab..b174275a 100644 --- a/src/game/map/terrainColor.ts +++ b/src/game/map/terrainColor.ts @@ -36,7 +36,8 @@ export type TerrainColorContext = Readonly<{ placements?: readonly TerrainStructurePlacement[]; }>; -const TERRAIN_KIND_PALETTE: Readonly>> = Object.freeze({ @@ -45,12 +46,29 @@ const TERRAIN_KIND_PALETTE: Readonly; + +/** Small stable metrics for visual-contract checks without exposing world data. */ +export function terrainColorVisualMetrics(kind: RealmTerrainKind): TerrainColorVisualMetrics { + const color = REALM_TERRAIN_KIND_PALETTE[kind].color; + const channels = [color.r, color.g, color.b]; + return Object.freeze({ + luminance: 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b, + greenLead: color.g - Math.max(color.r, color.b), + channelSpread: Math.max(...channels) - Math.min(...channels) + }); +} + function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, value)); } @@ -129,7 +147,7 @@ export function sampleLowlandsColor( ? 'lowland' : context.terrainKind; if (visualTerrainKind) { - const semantic = TERRAIN_KIND_PALETTE[visualTerrainKind]; + const semantic = REALM_TERRAIN_KIND_PALETTE[visualTerrainKind]; // Sparse canonical forest cells remain semantically forest, but a low // visual canopy keeps their ground from reading as isolated black-green // tiles between open meadows. The stable ecoregion field restores the full diff --git a/src/settings/graphicsPreference.ts b/src/settings/graphicsPreference.ts index 14877c25..785ce60c 100644 --- a/src/settings/graphicsPreference.ts +++ b/src/settings/graphicsPreference.ts @@ -22,6 +22,52 @@ export type GraphicsCapabilityInput = Readonly<{ maxTextureSize?: number; }>; +export type WebGL2Capability = Readonly<{ + available: boolean; + maxTextureSize?: number; +}>; + +let cachedWebGL2Capability: WebGL2Capability | undefined; + +/** + * Probe the renderer contract once without mutating the context. The Realm + * and title renderer must share this capability result so a feature probe can + * never consume or deliberately lose the context that a real scene needs. + */ +export function probeWebGL2Capability(): WebGL2Capability { + // A successful probe is stable enough to share across title and Realm. + // A negative probe can be transient (context pressure, background restore, + // browser lifecycle), so leave it uncached for an explicit/manual retry. + if (cachedWebGL2Capability?.available === true) return cachedWebGL2Capability; + if (typeof document === 'undefined') return Object.freeze({ available: false }); + try { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('webgl2'); + if (!context) { + return Object.freeze({ available: false }); + } + let maxTextureSize: number | undefined; + try { + const measured = context.getParameter(context.MAX_TEXTURE_SIZE); + if (typeof measured === 'number' && Number.isFinite(measured) && measured > 0) { + maxTextureSize = measured; + } + } catch { + // A context can be usable even when a non-essential capability query is + // blocked by the browser; quality resolution handles an unknown size. + } + cachedWebGL2Capability = Object.freeze({ available: true, maxTextureSize }); + } catch { + return Object.freeze({ available: false }); + } + return cachedWebGL2Capability ?? Object.freeze({ available: false }); +} + +/** Test-only reset; production callers should retain the shared probe cache. */ +export function resetWebGL2CapabilityForTests() { + cachedWebGL2Capability = undefined; +} + export type StorageLike = Pick; export function isGraphicsPreference(value: unknown): value is GraphicsPreference { @@ -110,17 +156,7 @@ export function browserGraphicsCapabilities(): GraphicsCapabilityInput { const navigatorWithMemory = typeof navigator === 'undefined' ? undefined : navigator as Navigator & { deviceMemory?: number }; - let maxTextureSize: number | undefined; - if (typeof document !== 'undefined') { - try { - const probe = document.createElement('canvas'); - const context = probe.getContext('webgl2'); - maxTextureSize = context?.getParameter(context.MAX_TEXTURE_SIZE) as number | undefined; - context?.getExtension('WEBGL_lose_context')?.loseContext(); - } catch { - maxTextureSize = undefined; - } - } + const maxTextureSize = probeWebGL2Capability().maxTextureSize; return { width: typeof window === 'undefined' ? 1280 : window.innerWidth, height: typeof window === 'undefined' ? 720 : window.innerHeight, diff --git a/src/spacetime/WarpkeepSpacetimeProvider.tsx b/src/spacetime/WarpkeepSpacetimeProvider.tsx index cd20c303..d756a24e 100644 --- a/src/spacetime/WarpkeepSpacetimeProvider.tsx +++ b/src/spacetime/WarpkeepSpacetimeProvider.tsx @@ -34,6 +34,11 @@ import { readWarpkeepWoodExpeditionState, readWarpkeepStoneExpeditionState, readWarpkeepResourceState, + readWarpkeepResourceStateV2, + readWarpkeepWorkerRoster, + dispatchWarpkeepWorker, + recallWarpkeepWorker, + recallAllWarpkeepWorkers, readWarpkeepRealmSnapshot, subscribeToWarpkeepRealm, type WarpkeepConnection @@ -53,12 +58,29 @@ import { type WarpkeepRuntimeConfig } from './warpkeepConfig'; import { readCompatibleWarpkeepBackendInfo } from './warpkeepProtocol'; -import type { ReadyRealmResourcePresentation } from '../components/realm/realmResourcePresentation'; +import type { + ReadyRealmResourcePresentation, + RealmEconomicResourceKey +} from '../components/realm/realmResourcePresentation'; import type { ReadyGoldExpeditionPresentation } from '../components/realm/realmGoldExpeditionPresentation'; import type { ReadyFoodExpeditionPresentation } from '../components/realm/realmFoodExpeditionPresentation'; import type { ReadyWoodExpeditionPresentation } from '../components/realm/realmWoodExpeditionPresentation'; import type { ReadyStoneExpeditionPresentation } from '../components/realm/realmStoneExpeditionPresentation'; +import type { + ReadyWorkerProjection, + ReadyWorkerResourceState, + WorkerRosterPresentation +} from '../components/realm/realmWorkerPresentation'; +import { resolveReadyWorkerProjection } from '../components/realm/realmWorkerPresentation'; import { createExpeditionIdempotencyKey } from './expeditionIdempotencyKey'; +import { + serializeWorkerCommandFingerprint, + workerCommandAttemptFor, + workerCommandAttemptMatchesLifecycle, + type WorkerCommandAttempt, + type WorkerCommandFingerprint, + type WorkerCommandLifecycleState +} from './workerCommandIdempotency'; /** * The generation-three Realm replicates 20,000 immutable world rows before @@ -66,8 +88,30 @@ import { createExpeditionIdempotencyKey } from './expeditionIdempotencyKey'; * smaller private resource procedure deadline below. */ export const CANONICAL_REALM_READINESS_TIMEOUT_MILLISECONDS = 60_000; +export const BACKEND_STAGE_OPERATION_TIMEOUT_MILLISECONDS = 30_000; export const RESOURCE_OPERATION_TIMEOUT_MILLISECONDS = 15_000; export const RESOURCE_REFRESH_INTERVAL_MILLISECONDS = 60_000; +const MAX_RETAINED_WORKER_COMMAND_ATTEMPTS = 64; + +class BackendStageOperationDeadlineError extends Error { + constructor() { + super('Warpkeep backend stage operation timed out.'); + this.name = 'BackendStageOperationDeadlineError'; + } +} + +function withBackendStageOperationDeadline(operation: Promise): Promise { + let deadline: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + deadline = setTimeout( + () => reject(new BackendStageOperationDeadlineError()), + BACKEND_STAGE_OPERATION_TIMEOUT_MILLISECONDS + ); + }); + return Promise.race([operation, timeout]).finally(() => { + if (deadline !== undefined) clearTimeout(deadline); + }); +} class ResourceOperationDeadlineError extends Error { constructor() { @@ -128,6 +172,16 @@ export type WarpkeepBackendControllerValue = Readonly<{ dispatchStoneExpedition: (siteId: string) => Promise; /** Settle only the caller's Stone expedition and refresh both private views. */ claimStoneExpedition: () => Promise; + workerProjection?: ReadyWorkerProjection; + workerRoster?: WorkerRosterPresentation; + workerResourceState?: ReadyWorkerResourceState; + dispatchWorker: ( + workerId: string, + resourceKind: RealmEconomicResourceKey, + siteId: string + ) => Promise; + recallWorker: (workerId: string) => Promise; + recallAllWorkers: () => Promise; }>; /** @@ -144,6 +198,11 @@ export type WarpkeepBackendRuntime = Readonly<{ acceptAlphaTerms: typeof acceptWarpkeepAlphaTerms; readResourceState: typeof readWarpkeepResourceState; collectResources: typeof collectWarpkeepResources; + readWorkerRoster?: typeof readWarpkeepWorkerRoster; + readResourceStateV2?: typeof readWarpkeepResourceStateV2; + dispatchWorker?: typeof dispatchWarpkeepWorker; + recallWorker?: typeof recallWarpkeepWorker; + recallAllWorkers?: typeof recallAllWarpkeepWorkers; /** Optional only for older deterministic test/QA runtimes without v5 Gold. */ readGoldExpeditionState?: typeof readWarpkeepGoldExpeditionState; /** Optional only for older deterministic test/QA runtimes without v5 Gold. */ @@ -180,6 +239,11 @@ const DEFAULT_WARPKEEP_BACKEND_RUNTIME: WarpkeepBackendRuntime = Object.freeze({ acceptAlphaTerms: acceptWarpkeepAlphaTerms, readResourceState: readWarpkeepResourceState, collectResources: collectWarpkeepResources, + readWorkerRoster: readWarpkeepWorkerRoster, + readResourceStateV2: readWarpkeepResourceStateV2, + dispatchWorker: dispatchWarpkeepWorker, + recallWorker: recallWarpkeepWorker, + recallAllWorkers: recallAllWarpkeepWorkers, readGoldExpeditionState: readWarpkeepGoldExpeditionState, dispatchGoldExpedition: dispatchWarpkeepGoldExpedition, collectGoldExpedition: collectWarpkeepGoldExpedition, @@ -284,6 +348,86 @@ function resourceProjectionIsAtLeastAsNew( ); } +function workerRosterIsAtLeastAsNew( + candidate: WorkerRosterPresentation | undefined, + current: WorkerRosterPresentation | undefined +) { + if (current === undefined) return true; + if ( + candidate === undefined + || candidate.castleId !== current.castleId + || candidate.observedAtMicros < current.observedAtMicros + || candidate.workers.length !== current.workers.length + ) return false; + const currentById = new Map(current.workers.map((worker) => [worker.workerId, worker] as const)); + return candidate.workers.every((worker) => { + const retained = currentById.get(worker.workerId); + return retained !== undefined + && worker.ordinal === retained.ordinal + && worker.revision >= retained.revision + && worker.observedAtMicros >= retained.observedAtMicros + && ( + worker.revision !== retained.revision + || ( + worker.status === retained.status + && worker.resourceKind === retained.resourceKind + && worker.siteId === retained.siteId + ) + ); + }); +} + +function workerResourceStateIsAtLeastAsNew( + candidate: ReadyWorkerResourceState | undefined, + current: ReadyWorkerResourceState | undefined +) { + if (current === undefined) return true; + return candidate !== undefined + && candidate.fid === current.fid + && candidate.resourcePolicyVersion === current.resourcePolicyVersion + && candidate.workerPolicyVersion === current.workerPolicyVersion + && candidate.workerSystemMode === current.workerSystemMode + && candidate.revision >= current.revision + && candidate.observedAtMicros >= current.observedAtMicros + && candidate.settledThroughMicros >= current.settledThroughMicros; +} + +function workerProjectionPairIsAtLeastAsNew( + candidateRoster: WorkerRosterPresentation | undefined, + candidateResourceState: ReadyWorkerResourceState | undefined, + currentRoster: WorkerRosterPresentation | undefined, + currentResourceState: ReadyWorkerResourceState | undefined +) { + return workerRosterIsAtLeastAsNew(candidateRoster, currentRoster) + && workerResourceStateIsAtLeastAsNew(candidateResourceState, currentResourceState); +} + +function activeWorkerProjection( + snapshot: WarpkeepRealmSnapshot, + roster: WorkerRosterPresentation | undefined, + resourceState: ReadyWorkerResourceState | undefined +): ReadyWorkerProjection | undefined { + return resolveReadyWorkerProjection({ + realmId: snapshot.realm.realmId, + castleIds: snapshot.castles.map((castle) => castle.castleId), + ownCastleId: snapshot.ownCastle.castleId, + system: snapshot.workerSystem, + workers: snapshot.workerWorkers, + occupations: snapshot.workerOccupations, + roster, + resourceState + }); +} + +function workerCommandLifecycleState( + roster: WorkerRosterPresentation +): WorkerCommandLifecycleState { + return Object.freeze({ + castleId: roster.castleId, + workers: roster.workers + }); +} + /** * A focused React provider around the generated client bindings. It bypasses * the SDK's URI/database-only connection cache so a sign-out or changed bridge @@ -347,6 +491,10 @@ export function WarpkeepSpacetimeProvider({ }> | undefined>(undefined); const stoneExpeditionOperationGenerationRef = useRef(undefined); const stoneDispatchAttemptRef = useRef(undefined); + const workerRosterStateRef = useRef | undefined>(undefined); + const workerResourceStateRef = useRef | undefined>(undefined); + const workerCommandGenerationRef = useRef(undefined); + const workerCommandAttemptsRef = useRef(new Map()); const processTermsAttemptRef = useRef<() => void>(() => undefined); stateRef.current = state; @@ -376,6 +524,10 @@ export function WarpkeepSpacetimeProvider({ stoneExpeditionStateRef.current = undefined; stoneExpeditionOperationGenerationRef.current = undefined; stoneDispatchAttemptRef.current = undefined; + workerRosterStateRef.current = undefined; + workerResourceStateRef.current = undefined; + workerCommandGenerationRef.current = undefined; + workerCommandAttemptsRef.current.clear(); canonicalRealmSourceRef.current = undefined; processTermsAttemptRef.current = () => undefined; runActiveTeardown(); @@ -455,6 +607,144 @@ export function WarpkeepSpacetimeProvider({ } }, [runActiveTeardown, runtime]); + const runWorkerCommand = useCallback(async ( + fingerprint: WorkerCommandFingerprint, + command: (connection: WarpkeepConnection, idempotencyKey: string) => Promise + ) => { + const generation = generationRef.current; + const currentState = stateRef.current; + const connection = connectionRef.current; + const fid = currentState.identity?.fid; + if ( + currentState.phase !== 'ready' || currentState.admission !== 'ready' + || currentState.workerProjection?.mode !== 'active' || connection === undefined || fid === undefined + || currentState.workerRoster === undefined || currentState.workerResourceState === undefined + || runtime.readWorkerRoster === undefined || runtime.readResourceStateV2 === undefined + || workerCommandGenerationRef.current === generation + ) throw new Error('Worker command is unavailable.'); + const serializedFingerprint = serializeWorkerCommandFingerprint(fingerprint); + if ( + !workerCommandAttemptsRef.current.has(serializedFingerprint) + && workerCommandAttemptsRef.current.size >= MAX_RETAINED_WORKER_COMMAND_ATTEMPTS + ) throw new Error('Worker command is unavailable.'); + const attempt = workerCommandAttemptFor( + workerCommandAttemptsRef.current.get(serializedFingerprint), + generation, + fingerprint, + workerCommandLifecycleState(currentState.workerRoster) + ); + if (attempt === undefined) throw new Error('Worker command is unavailable.'); + workerCommandGenerationRef.current = generation; + workerCommandAttemptsRef.current.set(serializedFingerprint, attempt); + try { + await withResourceOperationDeadline(command(connection, attempt.idempotencyKey)); + const [roster, resourceState] = await Promise.all([ + withResourceOperationDeadline(runtime.readWorkerRoster(connection, fid)), + withResourceOperationDeadline(runtime.readResourceStateV2(connection, fid)) + ]); + if (generationRef.current !== generation || roster === undefined || resourceState === undefined) { + throw new Error('Worker command is unavailable.'); + } + const retainedRoster = workerRosterStateRef.current?.generation === generation + ? workerRosterStateRef.current.value + : undefined; + const retainedResourceState = workerResourceStateRef.current?.generation === generation + ? workerResourceStateRef.current.value + : undefined; + if (!workerProjectionPairIsAtLeastAsNew( + roster, + resourceState, + retainedRoster, + retainedResourceState + )) throw new Error('Worker command is unavailable.'); + const refreshedLifecycle = workerCommandLifecycleState(roster); + for (const [retainedFingerprint, retainedAttempt] of workerCommandAttemptsRef.current) { + if (!workerCommandAttemptMatchesLifecycle(retainedAttempt, generation, refreshedLifecycle)) { + workerCommandAttemptsRef.current.delete(retainedFingerprint); + } + } + workerRosterStateRef.current = Object.freeze({ generation, value: roster }); + workerResourceStateRef.current = Object.freeze({ generation, value: resourceState }); + setState((latest) => { + if ( + generationRef.current !== generation + || latest.phase !== 'ready' + || latest.admission !== 'ready' + || latest.identity?.fid !== fid + || latest.realm === undefined + || !workerProjectionPairIsAtLeastAsNew( + roster, + resourceState, + latest.workerRoster, + latest.workerResourceState + ) + ) return latest; + const workerProjection = activeWorkerProjection(latest.realm, roster, resourceState); + return { + ...latest, + workerRoster: roster, + workerResourceState: resourceState, + ...(workerProjection === undefined + ? { workerProjection: undefined } + : { workerProjection }) + }; + }); + if (workerCommandAttemptsRef.current.get(serializedFingerprint) === attempt) { + workerCommandAttemptsRef.current.delete(serializedFingerprint); + } + } catch { + throw new Error('Worker command is unavailable.'); + } finally { + if (workerCommandGenerationRef.current === generation) workerCommandGenerationRef.current = undefined; + } + }, [runtime]); + + const dispatchWorker = useCallback(( + workerId: string, + resourceKind: RealmEconomicResourceKey, + siteId: string + ) => { + const projection = stateRef.current.workerProjection; + const worker = projection?.ownedWorkers.find((candidate) => candidate.workerId === workerId); + if ( + worker?.status !== 'idle' + || projection?.occupations.some((occupation) => ( + occupation.nodeKey === `${resourceKind}:${siteId}` + )) + ) return Promise.reject(new Error('Worker command is unavailable.')); + return runWorkerCommand({ kind: 'dispatch', workerId, resourceKind, siteId }, (connection, idempotencyKey) => { + if (runtime.dispatchWorker === undefined) return Promise.reject(new Error('Worker command is unavailable.')); + return runtime.dispatchWorker(connection, workerId, resourceKind, siteId, idempotencyKey); + }); + }, [runWorkerCommand, runtime]); + + const recallWorker = useCallback((workerId: string) => { + const worker = stateRef.current.workerProjection?.ownedWorkers.find( + (candidate) => candidate.workerId === workerId + ); + if (worker?.status !== 'outbound' && worker?.status !== 'gathering') { + return Promise.reject(new Error('Worker command is unavailable.')); + } + return runWorkerCommand({ kind: 'recall', workerId }, (connection, idempotencyKey) => { + if (runtime.recallWorker === undefined) return Promise.reject(new Error('Worker command is unavailable.')); + return runtime.recallWorker(connection, workerId, idempotencyKey); + }); + }, [runWorkerCommand, runtime]); + + const recallAllWorkers = useCallback(() => { + const castleId = stateRef.current.realm?.ownCastle.castleId; + const recallable = stateRef.current.workerProjection?.ownedWorkers.some((worker) => ( + worker.status === 'outbound' || worker.status === 'gathering' + )); + if (castleId === undefined || !recallable) { + return Promise.reject(new Error('Worker command is unavailable.')); + } + return runWorkerCommand({ kind: 'recall-all', castleId }, (connection, idempotencyKey) => { + if (runtime.recallAllWorkers === undefined) return Promise.reject(new Error('Worker command is unavailable.')); + return runtime.recallAllWorkers(connection, idempotencyKey); + }); + }, [runWorkerCommand, runtime]); + const dispatchGoldExpedition = useCallback(async (siteId: string) => { const generation = generationRef.current; const currentState = stateRef.current; @@ -1066,6 +1356,10 @@ export function WarpkeepSpacetimeProvider({ stoneExpeditionStateRef.current = undefined; stoneExpeditionOperationGenerationRef.current = undefined; stoneDispatchAttemptRef.current = undefined; + workerRosterStateRef.current = undefined; + workerResourceStateRef.current = undefined; + workerCommandGenerationRef.current = undefined; + workerCommandAttemptsRef.current.clear(); const previousState = stateRef.current; const canonicalRealmSource = [ config.spacetimeUri, @@ -1120,6 +1414,8 @@ export function WarpkeepSpacetimeProvider({ let activateRealm: (() => void) | undefined; let realmActivationPromise: Promise | undefined; let resourceRefreshInFlight = false; + let workerRefreshInFlight = false; + let queuedWorkerCapabilityRealm: WarpkeepRealmSnapshot | undefined; let realmActivated = false; let subscriptionApplied = false; let backendProtocolVersion: number | undefined; @@ -1189,6 +1485,20 @@ export function WarpkeepSpacetimeProvider({ if (stoneDispatchAttemptRef.current?.generation === generation) { stoneDispatchAttemptRef.current = undefined; } + if (workerRosterStateRef.current?.generation === generation) { + workerRosterStateRef.current = undefined; + } + if (workerResourceStateRef.current?.generation === generation) { + workerResourceStateRef.current = undefined; + } + if (workerCommandGenerationRef.current === generation) { + workerCommandGenerationRef.current = undefined; + } + for (const [fingerprint, attempt] of workerCommandAttemptsRef.current) { + if (attempt.generation === generation) { + workerCommandAttemptsRef.current.delete(fingerprint); + } + } const observer = cleanupObserver; cleanupObserver = undefined; try { @@ -1260,7 +1570,9 @@ export function WarpkeepSpacetimeProvider({ ...(currentState.realm ? { realm: currentState.realm } : {}) }); const pending = (async () => { - await runtime.acceptAlphaTerms(activeConnection); + await withBackendStageOperationDeadline( + runtime.acceptAlphaTerms(activeConnection) + ); if (!current()) return false; completedTermsAttemptRef.current = Math.max( completedTermsAttemptRef.current, @@ -1325,7 +1637,9 @@ export function WarpkeepSpacetimeProvider({ // injected/test runtime can never accidentally bypass compatibility. stage = 'backend_info'; const backendInfo = readCompatibleWarpkeepBackendInfo( - await runtime.readBackendInfo(activeConnection) + await withBackendStageOperationDeadline( + runtime.readBackendInfo(activeConnection) + ) ); backendProtocolVersion = backendInfo.protocolVersion; if (!current()) return; @@ -1333,7 +1647,9 @@ export function WarpkeepSpacetimeProvider({ setState({ phase: 'checking-admission', identity }); } stage = 'admission'; - let admission = await runtime.readAdmission(activeConnection); + let admission = await withBackendStageOperationDeadline( + runtime.readAdmission(activeConnection) + ); if (!current()) return; if (admission === 'not_admitted' || admission === 'disabled') { @@ -1347,10 +1663,14 @@ export function WarpkeepSpacetimeProvider({ setState({ phase: 'bootstrapping', identity, admission }); } stage = 'bootstrap'; - await runtime.bootstrapPlayer(activeConnection); + await withBackendStageOperationDeadline( + runtime.bootstrapPlayer(activeConnection) + ); if (!current()) return; stage = 'admission_after_bootstrap'; - admission = await runtime.readAdmission(activeConnection); + admission = await withBackendStageOperationDeadline( + runtime.readAdmission(activeConnection) + ); if (!current()) return; } @@ -1360,6 +1680,95 @@ export function WarpkeepSpacetimeProvider({ return; } + const refreshWorkerProjection = async (capabilityRealm: WarpkeepRealmSnapshot) => { + if ( + !current() + || capabilityRealm.workerSystem === undefined + || capabilityRealm.workerWorkers === undefined + || capabilityRealm.workerOccupations === undefined + || runtime.readWorkerRoster === undefined + || runtime.readResourceStateV2 === undefined + ) return; + if (workerRefreshInFlight) { + // Coalesce to the newest validated public projection. Dropping a + // later lifecycle while an older private pair is pending could + // otherwise hide Workers until the next periodic refresh. + queuedWorkerCapabilityRealm = capabilityRealm; + return; + } + workerRefreshInFlight = true; + try { + const [roster, resourceState] = await Promise.all([ + withResourceOperationDeadline( + runtime.readWorkerRoster(activeConnection, bridgeFid!) + ), + withResourceOperationDeadline( + runtime.readResourceStateV2(activeConnection, bridgeFid!) + ) + ]); + if (!current()) return; + if (roster === undefined || resourceState === undefined) return; + const retainedRoster = workerRosterStateRef.current?.generation === generation + ? workerRosterStateRef.current.value + : undefined; + const retainedResourceState = workerResourceStateRef.current?.generation === generation + ? workerResourceStateRef.current.value + : undefined; + if (!workerProjectionPairIsAtLeastAsNew( + roster, + resourceState, + retainedRoster, + retainedResourceState + )) return; + const projection = activeWorkerProjection(capabilityRealm, roster, resourceState); + if (projection === undefined) return; + workerRosterStateRef.current = Object.freeze({ generation, value: roster }); + workerResourceStateRef.current = Object.freeze({ generation, value: resourceState }); + const refreshedLifecycle = workerCommandLifecycleState(roster); + for (const [retainedFingerprint, retainedAttempt] of workerCommandAttemptsRef.current) { + if (!workerCommandAttemptMatchesLifecycle( + retainedAttempt, + generation, + refreshedLifecycle + )) { + workerCommandAttemptsRef.current.delete(retainedFingerprint); + } + } + setState((latest) => { + if ( + !current() + || latest.phase !== 'ready' + || latest.identity?.fid !== bridgeFid + || latest.realm === undefined + || !workerProjectionPairIsAtLeastAsNew( + roster, + resourceState, + latest.workerRoster, + latest.workerResourceState + ) + ) return latest; + const latestProjection = activeWorkerProjection(latest.realm, roster, resourceState); + if (latestProjection === undefined) return latest; + return { + ...latest, + workerRoster: roster, + workerResourceState: resourceState, + workerProjection: latestProjection + }; + }); + } catch { + // v12 is additive. An absent, rejected, or slow worker procedure + // must never delay or revoke the already-authoritative v11 Realm. + } finally { + workerRefreshInFlight = false; + const queuedRealm = queuedWorkerCapabilityRealm; + queuedWorkerCapabilityRealm = undefined; + if (queuedRealm !== undefined && current()) { + void refreshWorkerProjection(queuedRealm); + } + } + }; + const publishCanonicalRealm = (observedSnapshot?: WarpkeepRealmSnapshot) => { const resources = resourceStateRef.current?.generation === generation ? resourceStateRef.current.value @@ -1376,6 +1785,12 @@ export function WarpkeepSpacetimeProvider({ const stoneExpedition = stoneExpeditionStateRef.current?.generation === generation ? stoneExpeditionStateRef.current.value : undefined; + const workerRoster = workerRosterStateRef.current?.generation === generation + ? workerRosterStateRef.current.value + : undefined; + const workerResourceState = workerResourceStateRef.current?.generation === generation + ? workerResourceStateRef.current.value + : undefined; if ( !current() || !subscriptionApplied @@ -1392,6 +1807,11 @@ export function WarpkeepSpacetimeProvider({ readinessTimeout = undefined; } canonicalRealmSourceRef.current = canonicalRealmSource; + const workerProjection = activeWorkerProjection( + realm, + workerRoster, + workerResourceState + ); setState({ phase: 'ready', identity, @@ -1401,8 +1821,12 @@ export function WarpkeepSpacetimeProvider({ ...(goldExpedition === undefined ? {} : { goldExpedition }), ...(foodExpedition === undefined ? {} : { foodExpedition }), ...(woodExpedition === undefined ? {} : { woodExpedition }), - ...(stoneExpedition === undefined ? {} : { stoneExpedition }) + ...(stoneExpedition === undefined ? {} : { stoneExpedition }), + ...(workerRoster === undefined ? {} : { workerRoster }), + ...(workerResourceState === undefined ? {} : { workerResourceState }), + ...(workerProjection === undefined ? {} : { workerProjection }) }); + void refreshWorkerProjection(realm); } catch { failRealmActivation('canonical_snapshot_invalid'); } @@ -1490,6 +1914,9 @@ export function WarpkeepSpacetimeProvider({ return; } if (!current()) { + // observeRealm may synchronously report a terminal error before + // returning its cleanup handle. Close that late handle and never + // start a subscription for the invalidated generation. try { startedObserver(); } catch { @@ -1557,6 +1984,10 @@ export function WarpkeepSpacetimeProvider({ if (!current() || !realmActivated || resourceRefreshInFlight) return; resourceRefreshInFlight = true; try { + const readyRealm = stateRef.current.phase === 'ready' + ? stateRef.current.realm + : undefined; + if (readyRealm !== undefined) void refreshWorkerProjection(readyRealm); const goldRefresh = runtime.readGoldExpeditionState === undefined ? Promise.resolve(undefined) : withResourceOperationDeadline( @@ -1695,7 +2126,10 @@ export function WarpkeepSpacetimeProvider({ dispatchWoodExpedition, claimWoodExpedition, dispatchStoneExpedition, - claimStoneExpedition + claimStoneExpedition, + dispatchWorker, + recallWorker, + recallAllWorkers }), [ beginAlphaTermsAcceptance, cancelAlphaTermsAcceptance, @@ -1710,6 +2144,9 @@ export function WarpkeepSpacetimeProvider({ dispatchFoodExpedition, dispatchWoodExpedition, dispatchStoneExpedition, + dispatchWorker, + recallWorker, + recallAllWorkers, sharedAlphaAvailable, state ]); diff --git a/src/spacetime/canonicalGenesisSnapshot.ts b/src/spacetime/canonicalGenesisSnapshot.ts index 91fbb6c1..09ecceb1 100644 --- a/src/spacetime/canonicalGenesisSnapshot.ts +++ b/src/spacetime/canonicalGenesisSnapshot.ts @@ -186,6 +186,32 @@ function waterRowObject(value: unknown): Readonly> | und : undefined; } +/** Strip generated SDK objects down to one immutable browser-safe scalar. */ +function realmEnvironmentUpdatedAtMicros(value: unknown): bigint | undefined { + if (typeof value === 'bigint') return value >= 0n ? value : undefined; + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const timestamp = value as Readonly<{ + microsSinceUnixEpoch?: unknown; + toMillis?: () => unknown; + }>; + try { + if (typeof timestamp.microsSinceUnixEpoch === 'bigint') { + return timestamp.microsSinceUnixEpoch >= 0n + ? timestamp.microsSinceUnixEpoch + : undefined; + } + if (typeof timestamp.toMillis === 'function') { + const milliseconds = timestamp.toMillis(); + return typeof milliseconds === 'bigint' && milliseconds >= 0n + ? milliseconds * 1_000n + : undefined; + } + } catch { + return undefined; + } + return undefined; +} + /** * Water is additive, but unlike a local decorative hint its topology is an * authority artifact. Accept only the complete reviewed row set and retain a @@ -299,12 +325,25 @@ function validateWaterProjection(candidate: WarpkeepRealmSnapshotCandidate): { && row.fogBand === expected.fogBand && row.layoutVersion === expected.layoutVersion; }); + const updatedAtMicros = realmEnvironmentUpdatedAtMicros( + environment.updatedAtMicros ?? environment.updatedAt + ); + const canonicalEnvironment = Object.freeze({ + realmId: environment.realmId, + environmentEpoch: environment.environmentEpoch, + waterLayoutVersion: environment.waterLayoutVersion, + seaLevelMilli: environment.seaLevelMilli, + sunDirectionXMicro: environment.sunDirectionXMicro, + sunDirectionYMicro: environment.sunDirectionYMicro, + sunDirectionZMicro: environment.sunDirectionZMicro, + ...(updatedAtMicros === undefined ? {} : { updatedAtMicros }) + }); return { layout: validLayout ? Object.freeze({ ...layout }) : freezePresentationValue(rawLayout), bodies: validLayout && validBodies ? freezeRows(rawBodies) : freezeRows(rawBodies), cells: validLayout && validCells ? freezeRows(rawCells) : freezeRows(rawCells), realmEnvironment: validEnvironment - ? Object.freeze({ ...environment }) + ? canonicalEnvironment : freezePresentationValue(rawEnvironment), waterRevision: rawRevision === undefined ? undefined @@ -596,6 +635,13 @@ export function validateCanonicalGenesisSnapshot( const stoneNodeOccupations = stoneSites !== undefined ? freezeRows(candidate.stoneNodeOccupations!) : undefined; + // Generic workers are a staged additive projection. Preserve only the + // complete public trio; absent or malformed rows keep the legacy renderer + // path instead of revoking the canonical world snapshot. + const rawWorkerSystem = candidate.workerSystem; + const workerSystem = rawWorkerSystem === undefined ? undefined : freezePresentationValue(rawWorkerSystem); + const workerWorkers = candidate.workerWorkers === undefined ? undefined : freezePresentationValue(candidate.workerWorkers); + const workerOccupations = candidate.workerOccupations === undefined ? undefined : freezePresentationValue(candidate.workerOccupations); // The shared forest is another additive presentation pair. Keep each // field's presence intact for the browser-safe layout policy to verify. A // v6-but-unseeded `{ forestTrees: [] }`, a one-sided table, or malformed @@ -631,6 +677,9 @@ export function validateCanonicalGenesisSnapshot( ...(woodNodeOccupations === undefined ? {} : { woodNodeOccupations }), ...(stoneSites === undefined ? {} : { stoneSites }), ...(stoneNodeOccupations === undefined ? {} : { stoneNodeOccupations }), + ...(workerSystem === undefined ? {} : { workerSystem }), + ...(workerWorkers === undefined ? {} : { workerWorkers }), + ...(workerOccupations === undefined ? {} : { workerOccupations }), ...(forestLayout === undefined ? {} : { forestLayout }), ...(forestTrees === undefined ? {} : { forestTrees }), ...(waterProjection.layout === undefined ? {} : { waterLayout: waterProjection.layout }), diff --git a/src/spacetime/module_bindings/admin_get_worker_system_status_v_1_procedure.ts b/src/spacetime/module_bindings/admin_get_worker_system_status_v_1_procedure.ts new file mode 100644 index 00000000..3ca8b3fc --- /dev/null +++ b/src/spacetime/module_bindings/admin_get_worker_system_status_v_1_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AdminWorkerSystemStatusV1, +} from "./types"; + +export const params = { +}; +export const returnType = AdminWorkerSystemStatusV1 \ No newline at end of file diff --git a/src/spacetime/module_bindings/admin_plan_worker_roster_v_1_procedure.ts b/src/spacetime/module_bindings/admin_plan_worker_roster_v_1_procedure.ts new file mode 100644 index 00000000..5ebf2110 --- /dev/null +++ b/src/spacetime/module_bindings/admin_plan_worker_roster_v_1_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AdminWorkerRosterPlanV1, +} from "./types"; + +export const params = { +}; +export const returnType = AdminWorkerRosterPlanV1 \ No newline at end of file diff --git a/src/spacetime/module_bindings/castle_worker_v_1_table.ts b/src/spacetime/module_bindings/castle_worker_v_1_table.ts new file mode 100644 index 00000000..e4058594 --- /dev/null +++ b/src/spacetime/module_bindings/castle_worker_v_1_table.ts @@ -0,0 +1,29 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + workerId: __t.string().primaryKey().name("worker_id"), + originCastleId: __t.u64().name("origin_castle_id"), + ordinal: __t.u32(), + status: __t.string(), + resourceKind: __t.option(__t.string()).name("resource_kind"), + siteId: __t.option(__t.string()).name("site_id"), + startedAtMicros: __t.option(__t.u64()).name("started_at_micros"), + arrivesAtMicros: __t.option(__t.u64()).name("arrives_at_micros"), + gatheringEndsAtMicros: __t.option(__t.u64()).name("gathering_ends_at_micros"), + returnStartedAtMicros: __t.option(__t.u64()).name("return_started_at_micros"), + returnsAtMicros: __t.option(__t.u64()).name("returns_at_micros"), + routeSteps: __t.option(__t.u32()).name("route_steps"), + returnStartProgressBasisPoints: __t.option(__t.u32()).name("return_start_progress_basis_points"), + timelineRevision: __t.u32().name("timeline_revision"), + revision: __t.u64(), +}); diff --git a/src/spacetime/module_bindings/dispatch_worker_v_1_reducer.ts b/src/spacetime/module_bindings/dispatch_worker_v_1_reducer.ts new file mode 100644 index 00000000..6aa02ee2 --- /dev/null +++ b/src/spacetime/module_bindings/dispatch_worker_v_1_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workerId: __t.string(), + resourceKind: __t.string(), + siteId: __t.string(), + idempotencyKey: __t.string(), +}; diff --git a/src/spacetime/module_bindings/get_my_resource_state_v_2_procedure.ts b/src/spacetime/module_bindings/get_my_resource_state_v_2_procedure.ts new file mode 100644 index 00000000..d5ad5da5 --- /dev/null +++ b/src/spacetime/module_bindings/get_my_resource_state_v_2_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MyResourceStateV2, +} from "./types"; + +export const params = { +}; +export const returnType = MyResourceStateV2 \ No newline at end of file diff --git a/src/spacetime/module_bindings/get_my_worker_roster_v_1_procedure.ts b/src/spacetime/module_bindings/get_my_worker_roster_v_1_procedure.ts new file mode 100644 index 00000000..d64bde64 --- /dev/null +++ b/src/spacetime/module_bindings/get_my_worker_roster_v_1_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MyWorkerRosterV1, +} from "./types"; + +export const params = { +}; +export const returnType = MyWorkerRosterV1 \ No newline at end of file diff --git a/src/spacetime/module_bindings/index.ts b/src/spacetime/module_bindings/index.ts index 93cd2e51..0334651f 100644 --- a/src/spacetime/module_bindings/index.ts +++ b/src/spacetime/module_bindings/index.ts @@ -68,6 +68,9 @@ import DispatchFoodExpeditionV1Reducer from "./dispatch_food_expedition_v_1_redu import DispatchGoldExpeditionV1Reducer from "./dispatch_gold_expedition_v_1_reducer"; import DispatchStoneExpeditionV1Reducer from "./dispatch_stone_expedition_v_1_reducer"; import DispatchWoodExpeditionV1Reducer from "./dispatch_wood_expedition_v_1_reducer"; +import DispatchWorkerV1Reducer from "./dispatch_worker_v_1_reducer"; +import RecallAllWorkersV1Reducer from "./recall_all_workers_v_1_reducer"; +import RecallWorkerV1Reducer from "./recall_worker_v_1_reducer"; // Import all procedure arg schemas import * as AdminGetAlphaStatusProcedure from "./admin_get_alpha_status_procedure"; @@ -78,8 +81,10 @@ import * as AdminGetAlphaStatusV8Procedure from "./admin_get_alpha_status_v_8_pr import * as AdminGetAlphaStatusV10Procedure from "./admin_get_alpha_status_v_10_procedure"; import * as AdminGetFidAuthEpochProcedure from "./admin_get_fid_auth_epoch_procedure"; import * as AdminGetSnapScanBatchAggregateV1Procedure from "./admin_get_snap_scan_batch_aggregate_v_1_procedure"; +import * as AdminGetWorkerSystemStatusV1Procedure from "./admin_get_worker_system_status_v_1_procedure"; import * as AdminInspectGenesisWaterLayoutV1Procedure from "./admin_inspect_genesis_water_layout_v_1_procedure"; import * as AdminInspectGenesisWaterRevisionV1Procedure from "./admin_inspect_genesis_water_revision_v_1_procedure"; +import * as AdminPlanWorkerRosterV1Procedure from "./admin_plan_worker_roster_v_1_procedure"; import * as AuthResolverGetFidAdmissionV2Procedure from "./auth_resolver_get_fid_admission_v_2_procedure"; import * as GetAlphaBackendInfoProcedure from "./get_alpha_backend_info_procedure"; import * as GetMyAdmissionStatusProcedure from "./get_my_admission_status_procedure"; @@ -87,14 +92,17 @@ import * as GetMyAdmissionStatusV2Procedure from "./get_my_admission_status_v_2_ import * as GetMyFoodExpeditionStateV1Procedure from "./get_my_food_expedition_state_v_1_procedure"; import * as GetMyGoldExpeditionStateV1Procedure from "./get_my_gold_expedition_state_v_1_procedure"; import * as GetMyResourceStateV1Procedure from "./get_my_resource_state_v_1_procedure"; +import * as GetMyResourceStateV2Procedure from "./get_my_resource_state_v_2_procedure"; import * as GetMyStoneExpeditionStateV1Procedure from "./get_my_stone_expedition_state_v_1_procedure"; import * as GetMyWoodExpeditionStateV1Procedure from "./get_my_wood_expedition_state_v_1_procedure"; +import * as GetMyWorkerRosterV1Procedure from "./get_my_worker_roster_v_1_procedure"; import * as QaObserverGetRealmAttestationV2Procedure from "./qa_observer_get_realm_attestation_v_2_procedure"; import * as QaObserverGetRealmSnapshotV1Procedure from "./qa_observer_get_realm_snapshot_v_1_procedure"; // Import all table schema definitions import CastleRow from "./castle_table"; import CastleSlotV1Row from "./castle_slot_v_1_table"; +import CastleWorkerV1Row from "./castle_worker_v_1_table"; import FoodExpeditionScheduleV1Row from "./food_expedition_schedule_v_1_table"; import FoodNodeOccupationV1Row from "./food_node_occupation_v_1_table"; import FoodSiteV1Row from "./food_site_v_1_table"; @@ -112,12 +120,14 @@ import RealmWaterBodyV1Row from "./realm_water_body_v_1_table"; import RealmWaterCellV1Row from "./realm_water_cell_v_1_table"; import RealmWaterLayoutV1Row from "./realm_water_layout_v_1_table"; import RealmWaterRevisionV1Row from "./realm_water_revision_v_1_table"; +import RealmWorkerSystemV1Row from "./realm_worker_system_v_1_table"; import StoneExpeditionScheduleV1Row from "./stone_expedition_schedule_v_1_table"; import StoneNodeOccupationV1Row from "./stone_node_occupation_v_1_table"; import StoneSiteV1Row from "./stone_site_v_1_table"; import WoodExpeditionScheduleV1Row from "./wood_expedition_schedule_v_1_table"; import WoodNodeOccupationV1Row from "./wood_node_occupation_v_1_table"; import WoodSiteV1Row from "./wood_site_v_1_table"; +import WorkerNodeOccupationV1Row from "./worker_node_occupation_v_1_table"; import WorldTileRow from "./world_tile_table"; import WorldTileMetaV1Row from "./world_tile_meta_v_1_table"; @@ -162,6 +172,20 @@ const tablesSchema = __schema({ { name: 'castle_slot_v1_tile_key_key', constraint: 'unique', columns: ['tileKey'] }, ], }, CastleSlotV1Row), + castleWorkerV1: __table({ + name: 'castle_worker_v1', + indexes: [ + { accessor: 'byOriginCastle', name: 'castle_worker_v1_origin_castle_id_idx_btree', algorithm: 'btree', columns: [ + 'originCastleId', + ] }, + { accessor: 'workerId', name: 'castle_worker_v1_worker_id_idx_btree', algorithm: 'btree', columns: [ + 'workerId', + ] }, + ], + constraints: [ + { name: 'castle_worker_v1_worker_id_key', constraint: 'unique', columns: ['workerId'] }, + ], + }, CastleWorkerV1Row), foodExpeditionScheduleV1: __table({ name: 'food_expedition_schedule_v_1', indexes: [ @@ -391,6 +415,17 @@ const tablesSchema = __schema({ { name: 'realm_water_revision_v1_realm_id_key', constraint: 'unique', columns: ['realmId'] }, ], }, RealmWaterRevisionV1Row), + realmWorkerSystemV1: __table({ + name: 'realm_worker_system_v1', + indexes: [ + { accessor: 'realmId', name: 'realm_worker_system_v1_realm_id_idx_btree', algorithm: 'btree', columns: [ + 'realmId', + ] }, + ], + constraints: [ + { name: 'realm_worker_system_v1_realm_id_key', constraint: 'unique', columns: ['realmId'] }, + ], + }, RealmWorkerSystemV1Row), stoneExpeditionScheduleV1: __table({ name: 'stone_expedition_schedule_v_1', indexes: [ @@ -475,6 +510,23 @@ const tablesSchema = __schema({ { name: 'wood_site_v1_site_id_key', constraint: 'unique', columns: ['siteId'] }, ], }, WoodSiteV1Row), + workerNodeOccupationV1: __table({ + name: 'worker_node_occupation_v1', + indexes: [ + { accessor: 'nodeKey', name: 'worker_node_occupation_v1_node_key_idx_btree', algorithm: 'btree', columns: [ + 'nodeKey', + ] }, + { accessor: 'byOriginCastle', name: 'worker_node_occupation_v1_origin_castle_id_idx_btree', algorithm: 'btree', columns: [ + 'originCastleId', + ] }, + { accessor: 'byWorker', name: 'worker_node_occupation_v1_worker_id_idx_btree', algorithm: 'btree', columns: [ + 'workerId', + ] }, + ], + constraints: [ + { name: 'worker_node_occupation_v1_node_key_key', constraint: 'unique', columns: ['nodeKey'] }, + ], + }, WorkerNodeOccupationV1Row), worldTile: __table({ name: 'world_tile', indexes: [ @@ -542,6 +594,9 @@ const reducersSchema = __reducers( __reducerSchema("dispatch_gold_expedition_v1", DispatchGoldExpeditionV1Reducer), __reducerSchema("dispatch_stone_expedition_v1", DispatchStoneExpeditionV1Reducer), __reducerSchema("dispatch_wood_expedition_v1", DispatchWoodExpeditionV1Reducer), + __reducerSchema("dispatch_worker_v1", DispatchWorkerV1Reducer), + __reducerSchema("recall_all_workers_v1", RecallAllWorkersV1Reducer), + __reducerSchema("recall_worker_v1", RecallWorkerV1Reducer), ); /** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ @@ -554,8 +609,10 @@ const proceduresSchema = __procedures( __procedureSchema("admin_get_alpha_status_v_10", AdminGetAlphaStatusV10Procedure.params, AdminGetAlphaStatusV10Procedure.returnType), __procedureSchema("admin_get_fid_auth_epoch", AdminGetFidAuthEpochProcedure.params, AdminGetFidAuthEpochProcedure.returnType), __procedureSchema("admin_get_snap_scan_batch_aggregate_v1", AdminGetSnapScanBatchAggregateV1Procedure.params, AdminGetSnapScanBatchAggregateV1Procedure.returnType), + __procedureSchema("admin_get_worker_system_status_v1", AdminGetWorkerSystemStatusV1Procedure.params, AdminGetWorkerSystemStatusV1Procedure.returnType), __procedureSchema("admin_inspect_genesis_water_layout_v1", AdminInspectGenesisWaterLayoutV1Procedure.params, AdminInspectGenesisWaterLayoutV1Procedure.returnType), __procedureSchema("admin_inspect_genesis_water_revision_v_1", AdminInspectGenesisWaterRevisionV1Procedure.params, AdminInspectGenesisWaterRevisionV1Procedure.returnType), + __procedureSchema("admin_plan_worker_roster_v1", AdminPlanWorkerRosterV1Procedure.params, AdminPlanWorkerRosterV1Procedure.returnType), __procedureSchema("auth_resolver_get_fid_admission_v2", AuthResolverGetFidAdmissionV2Procedure.params, AuthResolverGetFidAdmissionV2Procedure.returnType), __procedureSchema("get_alpha_backend_info", GetAlphaBackendInfoProcedure.params, GetAlphaBackendInfoProcedure.returnType), __procedureSchema("get_my_admission_status", GetMyAdmissionStatusProcedure.params, GetMyAdmissionStatusProcedure.returnType), @@ -563,8 +620,10 @@ const proceduresSchema = __procedures( __procedureSchema("get_my_food_expedition_state_v1", GetMyFoodExpeditionStateV1Procedure.params, GetMyFoodExpeditionStateV1Procedure.returnType), __procedureSchema("get_my_gold_expedition_state_v1", GetMyGoldExpeditionStateV1Procedure.params, GetMyGoldExpeditionStateV1Procedure.returnType), __procedureSchema("get_my_resource_state_v1", GetMyResourceStateV1Procedure.params, GetMyResourceStateV1Procedure.returnType), + __procedureSchema("get_my_resource_state_v2", GetMyResourceStateV2Procedure.params, GetMyResourceStateV2Procedure.returnType), __procedureSchema("get_my_stone_expedition_state_v1", GetMyStoneExpeditionStateV1Procedure.params, GetMyStoneExpeditionStateV1Procedure.returnType), __procedureSchema("get_my_wood_expedition_state_v1", GetMyWoodExpeditionStateV1Procedure.params, GetMyWoodExpeditionStateV1Procedure.returnType), + __procedureSchema("get_my_worker_roster_v1", GetMyWorkerRosterV1Procedure.params, GetMyWorkerRosterV1Procedure.returnType), __procedureSchema("qa_observer_get_realm_attestation_v2", QaObserverGetRealmAttestationV2Procedure.params, QaObserverGetRealmAttestationV2Procedure.returnType), __procedureSchema("qa_observer_get_realm_snapshot_v1", QaObserverGetRealmSnapshotV1Procedure.params, QaObserverGetRealmSnapshotV1Procedure.returnType), ); diff --git a/src/spacetime/module_bindings/realm_worker_system_v_1_table.ts b/src/spacetime/module_bindings/realm_worker_system_v_1_table.ts new file mode 100644 index 00000000..cdd73634 --- /dev/null +++ b/src/spacetime/module_bindings/realm_worker_system_v_1_table.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + realmId: __t.string().primaryKey().name("realm_id"), + policyVersion: __t.string().name("policy_version"), + workersPerCastle: __t.u32().name("workers_per_castle"), + expectedCastleCount: __t.u32().name("expected_castle_count"), + expectedWorkerCount: __t.u32().name("expected_worker_count"), + rosterDigest: __t.string().name("roster_digest"), + mode: __t.string(), + legacyDrainRequired: __t.bool().name("legacy_drain_required"), + createdAt: __t.timestamp().name("created_at"), + activatedAt: __t.option(__t.timestamp()).name("activated_at"), +}); diff --git a/src/spacetime/module_bindings/recall_all_workers_v_1_reducer.ts b/src/spacetime/module_bindings/recall_all_workers_v_1_reducer.ts new file mode 100644 index 00000000..35750985 --- /dev/null +++ b/src/spacetime/module_bindings/recall_all_workers_v_1_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + idempotencyKey: __t.string(), +}; diff --git a/src/spacetime/module_bindings/recall_worker_v_1_reducer.ts b/src/spacetime/module_bindings/recall_worker_v_1_reducer.ts new file mode 100644 index 00000000..f0d65bbd --- /dev/null +++ b/src/spacetime/module_bindings/recall_worker_v_1_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workerId: __t.string(), + idempotencyKey: __t.string(), +}; diff --git a/src/spacetime/module_bindings/types.ts b/src/spacetime/module_bindings/types.ts index 90aa16df..18628883 100644 --- a/src/spacetime/module_bindings/types.ts +++ b/src/spacetime/module_bindings/types.ts @@ -231,6 +231,83 @@ export const AdminWaterRevisionStatusV1 = __t.object("AdminWaterRevisionStatusV1 }); export type AdminWaterRevisionStatusV1 = __Infer; +export const AdminWorkerRosterPlanV1 = __t.object("AdminWorkerRosterPlanV1", { + ready: __t.bool(), + activationBlockedByLegacyRows: __t.bool(), + mode: __t.string(), + systemConfigValid: __t.bool(), + legacyDrainRequired: __t.bool(), + expectedCastleCount: __t.u64(), + expectedWorkerCount: __t.u64(), + actualWorkerCount: __t.u64(), + expectedCountsMatch: __t.bool(), + rosterDigestMatches: __t.bool(), + castlesMissingWorkers: __t.u64(), + castlesWithExtraWorkers: __t.u64(), + orphanWorkers: __t.u64(), + orphanAssignments: __t.u64(), + assignmentsMissingOccupation: __t.u64(), + assignmentsWithoutSingleSchedule: __t.u64(), + orphanOccupations: __t.u64(), + orphanSchedules: __t.u64(), + invalidSchedules: __t.u64(), + assignmentPublicMismatches: __t.u64(), + occupationSiteMismatches: __t.u64(), + invalidWorkerStates: __t.u64(), + invalidAssignments: __t.u64(), + invalidIdempotencyReceipts: __t.u64(), + idempotencyOverflowFids: __t.u64(), + legacyExpeditions: __t.u64(), + legacyOccupations: __t.u64(), + legacySchedules: __t.u64(), + rosterDigest: __t.string(), + rosterDigestExpected: __t.string(), +}); +export type AdminWorkerRosterPlanV1 = __Infer; + +export const AdminWorkerSystemStatusV1 = __t.object("AdminWorkerSystemStatusV1", { + systemRows: __t.u64(), + mode: __t.string(), + systemConfigValid: __t.bool(), + legacyDrainRequired: __t.bool(), + expectedCastleCount: __t.u64(), + expectedWorkerCount: __t.u64(), + actualWorkerCount: __t.u64(), + expectedCountsMatch: __t.bool(), + rosterDigestMatches: __t.bool(), + castlesMissingWorkers: __t.u64(), + castlesWithExtraWorkers: __t.u64(), + duplicateOrdinals: __t.u64(), + malformedWorkerIds: __t.u64(), + invalidWorkerStates: __t.u64(), + idleWorkers: __t.u64(), + outboundWorkers: __t.u64(), + gatheringWorkers: __t.u64(), + returningWorkers: __t.u64(), + assignments: __t.u64(), + occupations: __t.u64(), + schedules: __t.u64(), + orphanWorkers: __t.u64(), + orphanAssignments: __t.u64(), + assignmentsMissingOccupation: __t.u64(), + assignmentsWithoutSingleSchedule: __t.u64(), + orphanOccupations: __t.u64(), + orphanSchedules: __t.u64(), + invalidSchedules: __t.u64(), + assignmentPublicMismatches: __t.u64(), + occupationSiteMismatches: __t.u64(), + invalidAssignments: __t.u64(), + idempotencyReceipts: __t.u64(), + invalidIdempotencyReceipts: __t.u64(), + idempotencyOverflowFids: __t.u64(), + legacyExpeditions: __t.u64(), + legacyOccupations: __t.u64(), + legacySchedules: __t.u64(), + rosterDigest: __t.string(), + rosterDigestExpected: __t.string(), +}); +export type AdminWorkerSystemStatusV1 = __Infer; + export const AllowedFid = __t.object("AllowedFid", { fid: __t.u64(), enabled: __t.bool(), @@ -293,6 +370,25 @@ export const CastleSlotV1 = __t.object("CastleSlotV1", { }); export type CastleSlotV1 = __Infer; +export const CastleWorkerV1 = __t.object("CastleWorkerV1", { + workerId: __t.string(), + originCastleId: __t.u64(), + ordinal: __t.u32(), + status: __t.string(), + resourceKind: __t.option(__t.string()), + siteId: __t.option(__t.string()), + startedAtMicros: __t.option(__t.u64()), + arrivesAtMicros: __t.option(__t.u64()), + gatheringEndsAtMicros: __t.option(__t.u64()), + returnStartedAtMicros: __t.option(__t.u64()), + returnsAtMicros: __t.option(__t.u64()), + routeSteps: __t.option(__t.u32()), + returnStartProgressBasisPoints: __t.option(__t.u32()), + timelineRevision: __t.u32(), + revision: __t.u64(), +}); +export type CastleWorkerV1 = __Infer; + export const FidWalletAttributionV1 = __t.object("FidWalletAttributionV1", { snapshotAttributionKey: __t.string(), attributionKey: __t.string(), @@ -491,6 +587,25 @@ export const MyResourceStateV1 = __t.object("MyResourceStateV1", { }); export type MyResourceStateV1 = __Infer; +export const MyResourceStateV2 = __t.object("MyResourceStateV2", { + fid: __t.u64(), + food: __t.u64(), + wood: __t.u64(), + stone: __t.u64(), + gold: __t.u64(), + workerPendingFood: __t.u64(), + workerPendingWood: __t.u64(), + workerPendingStone: __t.u64(), + workerPendingGold: __t.u64(), + observedAtMicros: __t.u64(), + settledThroughMicros: __t.u64(), + revision: __t.u64(), + resourcePolicyVersion: __t.string(), + workerPolicyVersion: __t.string(), + workerSystemMode: __t.string(), +}); +export type MyResourceStateV2 = __Infer; + export const MyStoneExpeditionStateV1 = __t.object("MyStoneExpeditionStateV1", { active: __t.bool(), expeditionId: __t.option(__t.string()), @@ -529,6 +644,16 @@ export const MyWoodExpeditionStateV1 = __t.object("MyWoodExpeditionStateV1", { }); export type MyWoodExpeditionStateV1 = __Infer; +export const MyWorkerRosterV1 = __t.object("MyWorkerRosterV1", { + fid: __t.u64(), + castleId: __t.u64(), + observedAtMicros: __t.u64(), + get workers() { + return __t.array(WorkerPrivateV1); + }, +}); +export type MyWorkerRosterV1 = __Infer; + export const Player = __t.object("Player", { fid: __t.u64(), identity: __t.identity(), @@ -798,6 +923,20 @@ export const RealmWaterRevisionV1 = __t.object("RealmWaterRevisionV1", { }); export type RealmWaterRevisionV1 = __Infer; +export const RealmWorkerSystemV1 = __t.object("RealmWorkerSystemV1", { + realmId: __t.string(), + policyVersion: __t.string(), + workersPerCastle: __t.u32(), + expectedCastleCount: __t.u32(), + expectedWorkerCount: __t.u32(), + rosterDigest: __t.string(), + mode: __t.string(), + legacyDrainRequired: __t.bool(), + createdAt: __t.timestamp(), + activatedAt: __t.option(__t.timestamp()), +}); +export type RealmWorkerSystemV1 = __Infer; + export const ResourceAccountV1 = __t.object("ResourceAccountV1", { fid: __t.u64(), castleId: __t.u64(), @@ -1009,6 +1148,83 @@ export const WoodSiteV1 = __t.object("WoodSiteV1", { }); export type WoodSiteV1 = __Infer; +export const WorkerAssignmentScheduleV1 = __t.object("WorkerAssignmentScheduleV1", { + scheduleId: __t.u64(), + scheduledAt: __t.scheduleAt(), + assignmentId: __t.string(), + workerId: __t.string(), + timelineRevision: __t.u32(), + stage: __t.string(), +}); +export type WorkerAssignmentScheduleV1 = __Infer; + +export const WorkerAssignmentV1 = __t.object("WorkerAssignmentV1", { + assignmentId: __t.string(), + workerId: __t.string(), + fid: __t.u64(), + originCastleId: __t.u64(), + resourceKind: __t.string(), + siteId: __t.string(), + phase: __t.string(), + startedAtMicros: __t.u64(), + arrivesAtMicros: __t.u64(), + gatheringEndsAtMicros: __t.u64(), + returnStartedAtMicros: __t.option(__t.u64()), + returnsAtMicros: __t.u64(), + routeSteps: __t.u32(), + returnStartProgressBasisPoints: __t.u32(), + settledThroughMicros: __t.u64(), + accruedAmount: __t.u64(), + materializedAmount: __t.u64(), + timelineRevision: __t.u32(), + policyVersion: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type WorkerAssignmentV1 = __Infer; + +export const WorkerCommandIdempotencyV1 = __t.object("WorkerCommandIdempotencyV1", { + requestKey: __t.string(), + fid: __t.u64(), + workerId: __t.option(__t.string()), + commandKind: __t.string(), + resourceKind: __t.option(__t.string()), + siteId: __t.option(__t.string()), + assignmentId: __t.option(__t.string()), + resultRevision: __t.u64(), + createdAt: __t.timestamp(), +}); +export type WorkerCommandIdempotencyV1 = __Infer; + +export const WorkerNodeOccupationV1 = __t.object("WorkerNodeOccupationV1", { + nodeKey: __t.string(), + resourceKind: __t.string(), + siteId: __t.string(), + workerId: __t.string(), + workerOrdinal: __t.u32(), + originCastleId: __t.u64(), + phase: __t.string(), + startedAtMicros: __t.u64(), + arrivesAtMicros: __t.u64(), + gatheringEndsAtMicros: __t.u64(), + timelineRevision: __t.u32(), +}); +export type WorkerNodeOccupationV1 = __Infer; + +export const WorkerPrivateV1 = __t.object("WorkerPrivateV1", { + workerId: __t.string(), + ordinal: __t.u32(), + status: __t.string(), + resourceKind: __t.option(__t.string()), + siteId: __t.option(__t.string()), + accruedAmount: __t.u64(), + materializedAmount: __t.u64(), + availableAmount: __t.u64(), + observedAtMicros: __t.u64(), + revision: __t.u64(), +}); +export type WorkerPrivateV1 = __Infer; + export const WorldTile = __t.object("WorldTile", { key: __t.string(), q: __t.i32(), diff --git a/src/spacetime/module_bindings/types/procedures.ts b/src/spacetime/module_bindings/types/procedures.ts index dcfd21fe..e08675cb 100644 --- a/src/spacetime/module_bindings/types/procedures.ts +++ b/src/spacetime/module_bindings/types/procedures.ts @@ -14,8 +14,10 @@ import * as AdminGetAlphaStatusV8Procedure from "../admin_get_alpha_status_v_8_p import * as AdminGetAlphaStatusV10Procedure from "../admin_get_alpha_status_v_10_procedure"; import * as AdminGetFidAuthEpochProcedure from "../admin_get_fid_auth_epoch_procedure"; import * as AdminGetSnapScanBatchAggregateV1Procedure from "../admin_get_snap_scan_batch_aggregate_v_1_procedure"; +import * as AdminGetWorkerSystemStatusV1Procedure from "../admin_get_worker_system_status_v_1_procedure"; import * as AdminInspectGenesisWaterLayoutV1Procedure from "../admin_inspect_genesis_water_layout_v_1_procedure"; import * as AdminInspectGenesisWaterRevisionV1Procedure from "../admin_inspect_genesis_water_revision_v_1_procedure"; +import * as AdminPlanWorkerRosterV1Procedure from "../admin_plan_worker_roster_v_1_procedure"; import * as AuthResolverGetFidAdmissionV2Procedure from "../auth_resolver_get_fid_admission_v_2_procedure"; import * as GetAlphaBackendInfoProcedure from "../get_alpha_backend_info_procedure"; import * as GetMyAdmissionStatusProcedure from "../get_my_admission_status_procedure"; @@ -23,8 +25,10 @@ import * as GetMyAdmissionStatusV2Procedure from "../get_my_admission_status_v_2 import * as GetMyFoodExpeditionStateV1Procedure from "../get_my_food_expedition_state_v_1_procedure"; import * as GetMyGoldExpeditionStateV1Procedure from "../get_my_gold_expedition_state_v_1_procedure"; import * as GetMyResourceStateV1Procedure from "../get_my_resource_state_v_1_procedure"; +import * as GetMyResourceStateV2Procedure from "../get_my_resource_state_v_2_procedure"; import * as GetMyStoneExpeditionStateV1Procedure from "../get_my_stone_expedition_state_v_1_procedure"; import * as GetMyWoodExpeditionStateV1Procedure from "../get_my_wood_expedition_state_v_1_procedure"; +import * as GetMyWorkerRosterV1Procedure from "../get_my_worker_roster_v_1_procedure"; import * as QaObserverGetRealmAttestationV2Procedure from "../qa_observer_get_realm_attestation_v_2_procedure"; import * as QaObserverGetRealmSnapshotV1Procedure from "../qa_observer_get_realm_snapshot_v_1_procedure"; @@ -44,10 +48,14 @@ export type AdminGetFidAuthEpochArgs = __Infer; export type AdminGetSnapScanBatchAggregateV1Args = __Infer; export type AdminGetSnapScanBatchAggregateV1Result = __Infer; +export type AdminGetWorkerSystemStatusV1Args = __Infer; +export type AdminGetWorkerSystemStatusV1Result = __Infer; export type AdminInspectGenesisWaterLayoutV1Args = __Infer; export type AdminInspectGenesisWaterLayoutV1Result = __Infer; export type AdminInspectGenesisWaterRevisionV1Args = __Infer; export type AdminInspectGenesisWaterRevisionV1Result = __Infer; +export type AdminPlanWorkerRosterV1Args = __Infer; +export type AdminPlanWorkerRosterV1Result = __Infer; export type AuthResolverGetFidAdmissionV2Args = __Infer; export type AuthResolverGetFidAdmissionV2Result = __Infer; export type GetAlphaBackendInfoArgs = __Infer; @@ -62,10 +70,14 @@ export type GetMyGoldExpeditionStateV1Args = __Infer; export type GetMyResourceStateV1Args = __Infer; export type GetMyResourceStateV1Result = __Infer; +export type GetMyResourceStateV2Args = __Infer; +export type GetMyResourceStateV2Result = __Infer; export type GetMyStoneExpeditionStateV1Args = __Infer; export type GetMyStoneExpeditionStateV1Result = __Infer; export type GetMyWoodExpeditionStateV1Args = __Infer; export type GetMyWoodExpeditionStateV1Result = __Infer; +export type GetMyWorkerRosterV1Args = __Infer; +export type GetMyWorkerRosterV1Result = __Infer; export type QaObserverGetRealmAttestationV2Args = __Infer; export type QaObserverGetRealmAttestationV2Result = __Infer; export type QaObserverGetRealmSnapshotV1Args = __Infer; diff --git a/src/spacetime/module_bindings/types/reducers.ts b/src/spacetime/module_bindings/types/reducers.ts index 5f35bdf7..fec9620b 100644 --- a/src/spacetime/module_bindings/types/reducers.ts +++ b/src/spacetime/module_bindings/types/reducers.ts @@ -40,6 +40,9 @@ import DispatchFoodExpeditionV1Reducer from "../dispatch_food_expedition_v_1_red import DispatchGoldExpeditionV1Reducer from "../dispatch_gold_expedition_v_1_reducer"; import DispatchStoneExpeditionV1Reducer from "../dispatch_stone_expedition_v_1_reducer"; import DispatchWoodExpeditionV1Reducer from "../dispatch_wood_expedition_v_1_reducer"; +import DispatchWorkerV1Reducer from "../dispatch_worker_v_1_reducer"; +import RecallAllWorkersV1Reducer from "../recall_all_workers_v_1_reducer"; +import RecallWorkerV1Reducer from "../recall_worker_v_1_reducer"; export type AcceptAlphaTermsV1Params = __Infer; export type AdminActivateGenesisWaterLayoutV1Params = __Infer; @@ -75,4 +78,7 @@ export type DispatchFoodExpeditionV1Params = __Infer; export type DispatchStoneExpeditionV1Params = __Infer; export type DispatchWoodExpeditionV1Params = __Infer; +export type DispatchWorkerV1Params = __Infer; +export type RecallAllWorkersV1Params = __Infer; +export type RecallWorkerV1Params = __Infer; diff --git a/src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts b/src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts new file mode 100644 index 00000000..c2b24d2a --- /dev/null +++ b/src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + nodeKey: __t.string().primaryKey().name("node_key"), + resourceKind: __t.string().name("resource_kind"), + siteId: __t.string().name("site_id"), + workerId: __t.string().name("worker_id"), + workerOrdinal: __t.u32().name("worker_ordinal"), + originCastleId: __t.u64().name("origin_castle_id"), + phase: __t.string(), + startedAtMicros: __t.u64().name("started_at_micros"), + arrivesAtMicros: __t.u64().name("arrives_at_micros"), + gatheringEndsAtMicros: __t.u64().name("gathering_ends_at_micros"), + timelineRevision: __t.u32().name("timeline_revision"), +}); diff --git a/src/spacetime/playerModuleBindings.ts b/src/spacetime/playerModuleBindings.ts index 353b711b..6a8a6855 100644 --- a/src/spacetime/playerModuleBindings.ts +++ b/src/spacetime/playerModuleBindings.ts @@ -4,7 +4,7 @@ * * The complete generated bindings remain the canonical schema artifact under * `module_bindings/` and are still used by server-side operators. The player - * only needs the public realm tables plus seven read procedures and ten + * only needs the public realm tables plus nine read procedures and fourteen * self-service reducers. Keeping that runtime projection separate prevents * private/admin and machine-bound QA procedure names from becoming part of * the public Vite graph while preserving generated-binding parity unchanged. @@ -32,12 +32,14 @@ import { import AcceptAlphaTermsV1Reducer from './module_bindings/accept_alpha_terms_v_1_reducer' import BootstrapPlayerV2Reducer from './module_bindings/bootstrap_player_v_2_reducer' +import DispatchWorkerV1Reducer from './module_bindings/dispatch_worker_v_1_reducer' import CollectFoodExpeditionV1Reducer from './module_bindings/collect_food_expedition_v_1_reducer' import CollectGoldExpeditionV1Reducer from './module_bindings/collect_gold_expedition_v_1_reducer' import CollectWoodExpeditionV1Reducer from './module_bindings/collect_wood_expedition_v_1_reducer' import CollectStoneExpeditionV1Reducer from './module_bindings/collect_stone_expedition_v_1_reducer' import CollectResourcesV1Reducer from './module_bindings/collect_resources_v_1_reducer' import CastleRow from './module_bindings/castle_table' +import CastleWorkerV1Row from './module_bindings/castle_worker_v_1_table' import * as GetAlphaBackendInfoProcedure from './module_bindings/get_alpha_backend_info_procedure' import * as GetMyAdmissionStatusV2Procedure from './module_bindings/get_my_admission_status_v_2_procedure' import * as GetMyFoodExpeditionStateV1Procedure from './module_bindings/get_my_food_expedition_state_v_1_procedure' @@ -45,10 +47,14 @@ import * as GetMyGoldExpeditionStateV1Procedure from './module_bindings/get_my_g import * as GetMyWoodExpeditionStateV1Procedure from './module_bindings/get_my_wood_expedition_state_v_1_procedure' import * as GetMyStoneExpeditionStateV1Procedure from './module_bindings/get_my_stone_expedition_state_v_1_procedure' import * as GetMyResourceStateV1Procedure from './module_bindings/get_my_resource_state_v_1_procedure' +import * as GetMyResourceStateV2Procedure from './module_bindings/get_my_resource_state_v_2_procedure' +import * as GetMyWorkerRosterV1Procedure from './module_bindings/get_my_worker_roster_v_1_procedure' import DispatchFoodExpeditionV1Reducer from './module_bindings/dispatch_food_expedition_v_1_reducer' import DispatchGoldExpeditionV1Reducer from './module_bindings/dispatch_gold_expedition_v_1_reducer' import DispatchWoodExpeditionV1Reducer from './module_bindings/dispatch_wood_expedition_v_1_reducer' import DispatchStoneExpeditionV1Reducer from './module_bindings/dispatch_stone_expedition_v_1_reducer' +import RecallAllWorkersV1Reducer from './module_bindings/recall_all_workers_v_1_reducer' +import RecallWorkerV1Reducer from './module_bindings/recall_worker_v_1_reducer' import FoodNodeOccupationV1Row from './module_bindings/food_node_occupation_v_1_table' import FoodSiteV1Row from './module_bindings/food_site_v_1_table' import GoldNodeOccupationV1Row from './module_bindings/gold_node_occupation_v_1_table' @@ -58,6 +64,7 @@ import RealmForestInstanceV1Row from './module_bindings/realm_forest_instance_v_ import RealmForestLayoutV1Row from './module_bindings/realm_forest_layout_v_1_table' import RealmProfileV1Row from './module_bindings/realm_profile_v_1_table' import RealmV1Row from './module_bindings/realm_v_1_table' +import RealmWorkerSystemV1Row from './module_bindings/realm_worker_system_v_1_table' import RealmEnvironmentV1Row from './module_bindings/realm_environment_v_1_table' import WorldTileMetaV1Row from './module_bindings/world_tile_meta_v_1_table' import WorldTileRow from './module_bindings/world_tile_table' @@ -69,6 +76,7 @@ import WoodNodeOccupationV1Row from './module_bindings/wood_node_occupation_v_1_ import WoodSiteV1Row from './module_bindings/wood_site_v_1_table' import StoneNodeOccupationV1Row from './module_bindings/stone_node_occupation_v_1_table' import StoneSiteV1Row from './module_bindings/stone_site_v_1_table' +import WorkerNodeOccupationV1Row from './module_bindings/worker_node_occupation_v_1_table' const tablesSchema = __schema({ castle: __table({ @@ -90,6 +98,18 @@ const tablesSchema = __schema({ { name: 'castle_tile_key_key', constraint: 'unique', columns: ['tileKey'] }, ], }, CastleRow), + // Generic workers expose only stable identity, timing, and public node + // occupation. Private assignments and cargo remain procedure-only. + castleWorkerV1: __table({ + name: 'castle_worker_v1', + indexes: [ + { accessor: 'byOriginCastle', name: 'castle_worker_v1_origin_castle_id_idx_btree', algorithm: 'btree', columns: ['originCastleId'] }, + { accessor: 'workerId', name: 'castle_worker_v1_worker_id_idx_btree', algorithm: 'btree', columns: ['workerId'] }, + ], + constraints: [ + { name: 'castle_worker_v1_worker_id_key', constraint: 'unique', columns: ['workerId'] }, + ], + }, CastleWorkerV1Row), // The Food catalog mirrors the Gold boundary: player-visible site geometry // and occupation timing only. Its internal scheduler stays out of Vite. foodNodeOccupationV1: __table({ @@ -206,6 +226,15 @@ const tablesSchema = __schema({ { name: 'realm_v1_realm_id_key', constraint: 'unique', columns: ['realmId'] }, ], }, RealmV1Row), + realmWorkerSystemV1: __table({ + name: 'realm_worker_system_v1', + indexes: [ + { accessor: 'realmId', name: 'realm_worker_system_v1_realm_id_idx_btree', algorithm: 'btree', columns: ['realmId'] }, + ], + constraints: [ + { name: 'realm_worker_system_v1_realm_id_key', constraint: 'unique', columns: ['realmId'] }, + ], + }, RealmWorkerSystemV1Row), realmEnvironmentV1: __table({ name: 'realm_environment_v1', indexes: [ @@ -309,6 +338,17 @@ const tablesSchema = __schema({ { name: 'stone_site_v1_site_id_key', constraint: 'unique', columns: ['siteId'] }, ], }, StoneSiteV1Row), + workerNodeOccupationV1: __table({ + name: 'worker_node_occupation_v1', + indexes: [ + { accessor: 'nodeKey', name: 'worker_node_occupation_v1_node_key_idx_btree', algorithm: 'btree', columns: ['nodeKey'] }, + { accessor: 'byOriginCastle', name: 'worker_node_occupation_v1_origin_castle_id_idx_btree', algorithm: 'btree', columns: ['originCastleId'] }, + { accessor: 'byWorker', name: 'worker_node_occupation_v1_worker_id_idx_btree', algorithm: 'btree', columns: ['workerId'] }, + ], + constraints: [ + { name: 'worker_node_occupation_v1_node_key_key', constraint: 'unique', columns: ['nodeKey'] }, + ], + }, WorkerNodeOccupationV1Row), worldTile: __table({ name: 'world_tile', indexes: [ @@ -352,6 +392,9 @@ const reducersSchema = __reducers( __reducerSchema('dispatch_gold_expedition_v1', DispatchGoldExpeditionV1Reducer), __reducerSchema('dispatch_wood_expedition_v1', DispatchWoodExpeditionV1Reducer), __reducerSchema('dispatch_stone_expedition_v1', DispatchStoneExpeditionV1Reducer), + __reducerSchema('dispatch_worker_v1', DispatchWorkerV1Reducer), + __reducerSchema('recall_worker_v1', RecallWorkerV1Reducer), + __reducerSchema('recall_all_workers_v1', RecallAllWorkersV1Reducer), ) const proceduresSchema = __procedures( @@ -390,6 +433,16 @@ const proceduresSchema = __procedures( GetMyResourceStateV1Procedure.params, GetMyResourceStateV1Procedure.returnType, ), + __procedureSchema( + 'get_my_resource_state_v2', + GetMyResourceStateV2Procedure.params, + GetMyResourceStateV2Procedure.returnType, + ), + __procedureSchema( + 'get_my_worker_roster_v1', + GetMyWorkerRosterV1Procedure.params, + GetMyWorkerRosterV1Procedure.returnType, + ), ) const PLAYER_REMOTE_MODULE = { diff --git a/src/spacetime/warpkeepBackendTypes.ts b/src/spacetime/warpkeepBackendTypes.ts index 71f3bb1d..4177398c 100644 --- a/src/spacetime/warpkeepBackendTypes.ts +++ b/src/spacetime/warpkeepBackendTypes.ts @@ -4,6 +4,14 @@ import type { ReadyGoldExpeditionPresentation } from '../components/realm/realmG import type { ReadyFoodExpeditionPresentation } from '../components/realm/realmFoodExpeditionPresentation'; import type { ReadyWoodExpeditionPresentation } from '../components/realm/realmWoodExpeditionPresentation'; import type { ReadyStoneExpeditionPresentation } from '../components/realm/realmStoneExpeditionPresentation'; +import type { + RealmWorkerNodeOccupation, + RealmWorkerPublicPresentation, + RealmWorkerSystemPresentation, + WorkerRosterPresentation, + ReadyWorkerResourceState, + ReadyWorkerProjection +} from '../components/realm/realmWorkerPresentation'; export type WarpkeepAdmissionStatus = | 'not_admitted' @@ -179,6 +187,10 @@ export type WarpkeepStoneNodeOccupation = Readonly<{ returnsAtMicros: bigint; }>; +export type WarpkeepRealmWorkerSystem = RealmWorkerSystemPresentation; +export type WarpkeepCastleWorker = RealmWorkerPublicPresentation; +export type WarpkeepWorkerNodeOccupation = RealmWorkerNodeOccupation; + /** * Public, immutable realm-wide forest layout metadata. This is visual state * only: server-side seeding authority and all administrative reducers remain @@ -288,6 +300,10 @@ export type WarpkeepRealmEnvironment = Readonly<{ sunDirectionXMicro: number; sunDirectionYMicro: number; sunDirectionZMicro: number; + /** Raw generated timestamp exists only before canonical snapshot normalization. */ + updatedAt?: unknown; + /** Browser-safe canonical timestamp used by renderer-only shared phase. */ + updatedAtMicros?: bigint; }>; /** Public additive policy selecting a reviewed subset of immutable Water v1. */ @@ -342,6 +358,10 @@ export type WarpkeepRealmSnapshotCandidate = Readonly<{ stoneSites?: readonly WarpkeepStoneSite[]; /** Omitted with `stoneSites`; absent/invalid data renders no Stone nodes. */ stoneNodeOccupations?: readonly WarpkeepStoneNodeOccupation[]; + /** Additive generic-worker public projection; absent keeps legacy mode. */ + workerSystem?: WarpkeepRealmWorkerSystem; + workerWorkers?: readonly WarpkeepCastleWorker[]; + workerOccupations?: readonly WarpkeepWorkerNodeOccupation[]; /** * Additive public forest metadata. The connection publishes the pair only * after one atomic subscription applies; a one-sided test/malformed value @@ -389,6 +409,11 @@ export type WarpkeepBackendState = Readonly<{ woodExpedition?: ReadyWoodExpeditionPresentation; /** Caller-only, exact procedure projection for the active Stone expedition. */ stoneExpedition?: ReadyStoneExpeditionPresentation; + /** Caller-private generic roster, never copied into the public snapshot. */ + workerRoster?: WorkerRosterPresentation; + /** v2 resource balances used by the active worker HUD. */ + workerResourceState?: ReadyWorkerResourceState; + workerProjection?: ReadyWorkerProjection; }>; export const IDLE_WARPKEEP_BACKEND_STATE: WarpkeepBackendState = Object.freeze({ From fee18c0d8b67e726b7d6840269f03dff0e7647ab Mon Sep 17 00:00:00 2001 From: Ael <183124839+ael-dev3@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:05:56 +0200 Subject: [PATCH 08/10] Alpha 0.3.14 signed source (8/10) GitHub-signed reconstruction of the exact reviewed Alpha 0.3.14 tree. --- src/spacetime/warpkeepConnection.ts | 434 +++++++++----- src/spacetime/workerCommandIdempotency.ts | 132 +++++ ...rpkeepSpacetimeCanonicalReadiness.test.tsx | 148 +++++ tests/WarpkeepSpacetimeResources.test.tsx | 414 ++++++++++++++ tests/activationToolingSecurity.test.ts | 535 +++++++++++++++++- tests/buildInfo.test.ts | 4 +- tests/canonicalGenesisSnapshot.test.ts | 28 + tests/graphicsPreference.test.ts | 37 ++ tests/hegemonyKeepPrefabRepository.test.ts | 24 + tests/hermesAdminSecurity.test.ts | 74 ++- tests/latestPatchNotes.test.ts | 16 +- tests/localVitePublicBoundary.test.ts | 150 +++++ 12 files changed, 1852 insertions(+), 144 deletions(-) create mode 100644 src/spacetime/workerCommandIdempotency.ts create mode 100644 tests/localVitePublicBoundary.test.ts diff --git a/src/spacetime/warpkeepConnection.ts b/src/spacetime/warpkeepConnection.ts index ff0427fc..387e1183 100644 --- a/src/spacetime/warpkeepConnection.ts +++ b/src/spacetime/warpkeepConnection.ts @@ -83,6 +83,15 @@ import { isRealmStoneNodeOccupationPublicRecord, isRealmStoneSitePublicRecord } from '../components/realm/realmStoneNodePresentation'; +import { + decodeRealmWorkerOccupations, + decodeRealmWorkerPublicRows, + decodeRealmWorkerSystem, + decodeWorkerResourceState, + decodeWorkerRoster, + type ReadyWorkerResourceState, + type WorkerRosterPresentation +} from '../components/realm/realmWorkerPresentation'; import { REALM_FOOD_SITE_COUNT, REALM_GOLD_SITE_COUNT, @@ -176,6 +185,11 @@ type WaterProjectionAvailability = | typeof WATER_PROJECTION_PENDING | typeof WATER_PROJECTION_READY; const waterProjectionAvailability = new WeakMap(); +const WORKER_PROJECTION_UNAVAILABLE = 'unavailable' as const; +const WORKER_PROJECTION_PENDING = 'pending' as const; +const WORKER_PROJECTION_READY = 'ready' as const; +type WorkerProjectionAvailability = typeof WORKER_PROJECTION_UNAVAILABLE | typeof WORKER_PROJECTION_PENDING | typeof WORKER_PROJECTION_READY; +const workerProjectionAvailability = new WeakMap(); const GOLD_SITE_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,95}$/i; const GOLD_IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{15,79}$/; const FOOD_SITE_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,95}$/i; @@ -184,6 +198,8 @@ const WOOD_SITE_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,95}$/i; const WOOD_IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{15,79}$/; const STONE_SITE_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,95}$/i; const STONE_IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{15,79}$/; +const WORKER_ID_PATTERN = /^genesis-001-castle-[1-9][0-9]*-worker-0[1-4]$/; +const WORKER_IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{15,79}$/; export { WARPKEEP_ALPHA_TERMS_VERSION } from '../legal/alphaTermsPolicy'; const admissionStatuses = new Set([ @@ -392,6 +408,76 @@ export async function readWarpkeepResourceState( return decoded; } +/** Read the generic worker's caller-private roster; public rows never carry cargo. */ +export async function readWarpkeepWorkerRoster( + connection: WarpkeepConnection, + ownFid: number +): Promise { + if (!Number.isSafeInteger(ownFid) || ownFid <= 0) return undefined; + const procedure = (connection.procedures as unknown as { + getMyWorkerRosterV1?: (input: Readonly>) => Promise; + }).getMyWorkerRosterV1; + if (typeof procedure !== 'function') return undefined; + return decodeWorkerRoster(await procedure({}), BigInt(ownFid)); +} + +/** v2 balances are the only resource values consumed by the active worker rail. */ +export async function readWarpkeepResourceStateV2( + connection: WarpkeepConnection, + ownFid: number +): Promise { + if (!Number.isSafeInteger(ownFid) || ownFid <= 0) return undefined; + const procedure = (connection.procedures as unknown as { + getMyResourceStateV2?: (input: Readonly>) => Promise; + }).getMyResourceStateV2; + if (typeof procedure !== 'function') return undefined; + return decodeWorkerResourceState(await procedure({}), BigInt(ownFid)); +} + +function workerReducerSurface(connection: WarpkeepConnection) { + return connection.reducers as unknown as { + dispatchWorkerV1?: (input: Readonly<{ workerId: string; resourceKind: string; siteId: string; idempotencyKey: string }>) => Promise | unknown; + recallWorkerV1?: (input: Readonly<{ workerId: string; idempotencyKey: string }>) => Promise | unknown; + recallAllWorkersV1?: (input: Readonly<{ idempotencyKey: string }>) => Promise | unknown; + }; +} + +function assertWorkerIdempotency(workerId: string, idempotencyKey: string) { + if (!WORKER_ID_PATTERN.test(workerId) || !WORKER_IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) { + throw new Error('Worker command is unavailable.'); + } +} + +export async function dispatchWarpkeepWorker( + connection: WarpkeepConnection, + workerId: string, + resourceKind: string, + siteId: string, + idempotencyKey: string +) { + assertWorkerIdempotency(workerId, idempotencyKey); + if (!/^(food|wood|stone|gold)$/.test(resourceKind) || !/^[a-z0-9][a-z0-9:_-]{0,95}$/i.test(siteId)) { + throw new Error('Worker command is unavailable.'); + } + const reducer = workerReducerSurface(connection).dispatchWorkerV1; + if (typeof reducer !== 'function') throw new Error('Worker command is unavailable.'); + await reducer({ workerId, resourceKind, siteId, idempotencyKey }); +} + +export async function recallWarpkeepWorker(connection: WarpkeepConnection, workerId: string, idempotencyKey: string) { + assertWorkerIdempotency(workerId, idempotencyKey); + const reducer = workerReducerSurface(connection).recallWorkerV1; + if (typeof reducer !== 'function') throw new Error('Worker command is unavailable.'); + await reducer({ workerId, idempotencyKey }); +} + +export async function recallAllWarpkeepWorkers(connection: WarpkeepConnection, idempotencyKey: string) { + if (!WORKER_IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) throw new Error('Worker command is unavailable.'); + const reducer = workerReducerSurface(connection).recallAllWorkersV1; + if (typeof reducer !== 'function') throw new Error('Worker command is unavailable.'); + await reducer({ idempotencyKey }); +} + /** Settle server-authoritative yield, then fetch the exact committed view. */ export async function collectWarpkeepResources( connection: WarpkeepConnection, @@ -680,6 +766,7 @@ export function subscribeToWarpkeepRealm( foodProjectionAvailability.set(connection, FOOD_PROJECTION_PENDING); woodProjectionAvailability.set(connection, WOOD_PROJECTION_PENDING); stoneProjectionAvailability.set(connection, STONE_PROJECTION_PENDING); + workerProjectionAvailability.set(connection, WORKER_PROJECTION_PENDING); forestProjectionAvailability.set(connection, FOREST_PROJECTION_PENDING); waterProjectionAvailability.set(connection, WATER_PROJECTION_PENDING); const coreSubscription = connection @@ -818,6 +905,33 @@ export function subscribeToWarpkeepRealm( stoneProjectionAvailability.set(connection, STONE_PROJECTION_UNAVAILABLE); } + let workerSubscription: SubscriptionHandle | undefined; + const workerTables = publicWorkerTables(connection); + if (workerTables !== undefined) { + try { + workerSubscription = connection + .subscriptionBuilder() + .onApplied(() => { + workerProjectionAvailability.set(connection, WORKER_PROJECTION_READY); + if (coreApplied) onApplied(); + }) + .onError(() => { + workerProjectionAvailability.set(connection, WORKER_PROJECTION_UNAVAILABLE); + if (coreApplied) onApplied(); + }) + .subscribe([ + tables.realmWorkerSystemV1, + tables.castleWorkerV1, + tables.workerNodeOccupationV1 + ]); + } catch { + workerProjectionAvailability.set(connection, WORKER_PROJECTION_UNAVAILABLE); + if (coreApplied) onApplied(); + } + } else { + workerProjectionAvailability.set(connection, WORKER_PROJECTION_UNAVAILABLE); + } + let forestSubscription: SubscriptionHandle | undefined; const forestTables = publicForestTables(connection); // A pre-forest service (or a deliberately narrow test double) cannot make @@ -888,6 +1002,7 @@ export function subscribeToWarpkeepRealm( foodProjectionAvailability.delete(connection); woodProjectionAvailability.delete(connection); stoneProjectionAvailability.delete(connection); + workerProjectionAvailability.delete(connection); forestProjectionAvailability.delete(connection); waterProjectionAvailability.delete(connection); try { @@ -901,7 +1016,11 @@ export function subscribeToWarpkeepRealm( try { woodSubscription?.unsubscribe(); } finally { - stoneSubscription?.unsubscribe(); + try { + stoneSubscription?.unsubscribe(); + } finally { + workerSubscription?.unsubscribe(); + } } } } @@ -1282,7 +1401,8 @@ function publicRealmEnvironmentRecord(value: unknown): unknown { seaLevelMilli: row.seaLevelMilli, sunDirectionXMicro: row.sunDirectionXMicro, sunDirectionYMicro: row.sunDirectionYMicro, - sunDirectionZMicro: row.sunDirectionZMicro + sunDirectionZMicro: row.sunDirectionZMicro, + updatedAt: row.updatedAt }) as Partial; } @@ -1415,6 +1535,101 @@ function publicStoneSubscriptionTables() { }); } +type PublicWorkerTable = PublicFoodTable; +function publicWorkerTables(connection: WarpkeepConnection) { + const db = connection.db as unknown as Readonly<{ + realmWorkerSystemV1?: PublicWorkerTable; + castleWorkerV1?: PublicWorkerTable; + workerNodeOccupationV1?: PublicWorkerTable; + }> | undefined; + if (!db?.realmWorkerSystemV1 || !db.castleWorkerV1 || !db.workerNodeOccupationV1) return undefined; + return db; +} + +function publicWorkerRecord(value: unknown): unknown { + const row = value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Readonly> + : undefined; + if (!row) return Object.freeze({}); + return Object.freeze({ + workerId: row.workerId, + originCastleId: row.originCastleId, + ordinal: row.ordinal, + status: row.status, + resourceKind: row.resourceKind, + siteId: row.siteId, + startedAtMicros: row.startedAtMicros, + arrivesAtMicros: row.arrivesAtMicros, + gatheringEndsAtMicros: row.gatheringEndsAtMicros, + returnStartedAtMicros: row.returnStartedAtMicros, + returnsAtMicros: row.returnsAtMicros, + routeSteps: row.routeSteps, + returnStartProgressBasisPoints: row.returnStartProgressBasisPoints, + timelineRevision: row.timelineRevision, + revision: row.revision + }); +} + +function publicWorkerOccupationRecord(value: unknown): unknown { + const row = value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Readonly> + : undefined; + if (!row) return Object.freeze({}); + return Object.freeze({ + nodeKey: row.nodeKey, + resourceKind: row.resourceKind, + siteId: row.siteId, + workerId: row.workerId, + workerOrdinal: row.workerOrdinal, + originCastleId: row.originCastleId, + phase: row.phase, + startedAtMicros: row.startedAtMicros, + arrivesAtMicros: row.arrivesAtMicros, + gatheringEndsAtMicros: row.gatheringEndsAtMicros, + timelineRevision: row.timelineRevision + }); +} + +function readPublicWorkerProjection( + connection: WarpkeepConnection, + castles: readonly WarpkeepCastle[], + ownCastleId: number +) { + if (workerProjectionAvailability.get(connection) !== WORKER_PROJECTION_READY) return undefined; + const db = publicWorkerTables(connection); + if (!db) return undefined; + const systems = readBoundedPublicForestRows( + db.realmWorkerSystemV1!.iter(), + 1, + (value) => value + ); + if (systems?.length !== 1) return undefined; + const system = decodeRealmWorkerSystem(systems[0]); + const castleNames = new Map(castles.map((castle) => [castle.castleId, castle.name] as const)); + const rawWorkers = readBoundedPublicForestRows( + db.castleWorkerV1!.iter(), + castles.length * 4, + publicWorkerRecord + ); + if (rawWorkers === undefined) return undefined; + const workers = decodeRealmWorkerPublicRows( + rawWorkers, + castleNames, + ownCastleId + ); + const rawOccupations = readBoundedPublicForestRows( + db.workerNodeOccupationV1!.iter(), + castles.length * 4, + publicWorkerOccupationRecord + ); + if (rawOccupations === undefined) return undefined; + const occupations = decodeRealmWorkerOccupations( + rawOccupations + ); + if (!system || !workers || !occupations) return undefined; + return Object.freeze({ system, workers, occupations }); +} + /** * Read the additive public Gold catalog as an all-or-nothing visual * projection. Any malformed or duplicate row omits the complete Gold layer; @@ -1800,6 +2015,9 @@ export function readWarpkeepRealmSnapshot( const publicStone = readPublicStoneProjection(connection); const publicForest = readPublicForestProjection(connection); const publicWater = readPublicWaterProjection(connection); + const publicWorkers = ownCastle === undefined + ? undefined + : readPublicWorkerProjection(connection, castles, ownCastle.castleId); const candidate: WarpkeepRealmSnapshotCandidate = { tiles: readWorldTiles(connection), tileMetadata: readWorldTileMetadata(connection), @@ -1836,6 +2054,11 @@ export function readWarpkeepRealmSnapshot( waterRevision: publicWater.waterRevision }) }), + ...(publicWorkers === undefined ? {} : { + workerSystem: publicWorkers.system, + workerWorkers: publicWorkers.workers, + workerOccupations: publicWorkers.occupations + }), ...(ownCastle ? { ownCastle } : {}) }; return validateCanonicalGenesisSnapshot(candidate, { @@ -1875,142 +2098,93 @@ export function observeWarpkeepRealm( onError(); } }; - connection.db.worldTile.onInsert(sync); - connection.db.worldTile.onDelete(sync); - connection.db.worldTile.onUpdate(sync); - connection.db.worldTileMetaV1.onInsert(sync); - connection.db.worldTileMetaV1.onDelete(sync); - connection.db.worldTileMetaV1.onUpdate(sync); - connection.db.playerV2.onInsert(sync); - connection.db.playerV2.onDelete(sync); - connection.db.playerV2.onUpdate(sync); - connection.db.castle.onInsert(sync); - connection.db.castle.onDelete(sync); - connection.db.castle.onUpdate(sync); - connection.db.realmV1.onInsert(sync); - connection.db.realmV1.onDelete(sync); - connection.db.realmV1.onUpdate(sync); - connection.db.realmProfileV1.onInsert(sync); - connection.db.realmProfileV1.onDelete(sync); - connection.db.realmProfileV1.onUpdate(sync); const goldTables = publicGoldTables(connection); - goldTables?.goldSiteV1?.onInsert?.(sync); - goldTables?.goldSiteV1?.onDelete?.(sync); - goldTables?.goldSiteV1?.onUpdate?.(sync); - goldTables?.goldNodeOccupationV1?.onInsert?.(sync); - goldTables?.goldNodeOccupationV1?.onDelete?.(sync); - goldTables?.goldNodeOccupationV1?.onUpdate?.(sync); const foodTables = publicFoodTables(connection); - foodTables?.foodSiteV1?.onInsert?.(sync); - foodTables?.foodSiteV1?.onDelete?.(sync); - foodTables?.foodSiteV1?.onUpdate?.(sync); - foodTables?.foodNodeOccupationV1?.onInsert?.(sync); - foodTables?.foodNodeOccupationV1?.onDelete?.(sync); - foodTables?.foodNodeOccupationV1?.onUpdate?.(sync); const woodTables = publicWoodTables(connection); - woodTables?.woodSiteV1?.onInsert?.(sync); - woodTables?.woodSiteV1?.onDelete?.(sync); - woodTables?.woodSiteV1?.onUpdate?.(sync); - woodTables?.woodNodeOccupationV1?.onInsert?.(sync); - woodTables?.woodNodeOccupationV1?.onDelete?.(sync); - woodTables?.woodNodeOccupationV1?.onUpdate?.(sync); const stoneTables = publicStoneTables(connection); - stoneTables?.stoneSiteV1?.onInsert?.(sync); - stoneTables?.stoneSiteV1?.onDelete?.(sync); - stoneTables?.stoneSiteV1?.onUpdate?.(sync); - stoneTables?.stoneNodeOccupationV1?.onInsert?.(sync); - stoneTables?.stoneNodeOccupationV1?.onDelete?.(sync); - stoneTables?.stoneNodeOccupationV1?.onUpdate?.(sync); + const workerTables = publicWorkerTables(connection); const forestTables = publicForestTables(connection); - forestTables?.realmForestLayoutV1?.onInsert?.(sync); - forestTables?.realmForestLayoutV1?.onDelete?.(sync); - forestTables?.realmForestLayoutV1?.onUpdate?.(sync); - forestTables?.realmForestInstanceV1?.onInsert?.(sync); - forestTables?.realmForestInstanceV1?.onDelete?.(sync); - forestTables?.realmForestInstanceV1?.onUpdate?.(sync); const waterTables = publicWaterTables(connection); - waterTables?.realmWaterLayoutV1?.onInsert?.(sync); - waterTables?.realmWaterLayoutV1?.onDelete?.(sync); - waterTables?.realmWaterLayoutV1?.onUpdate?.(sync); - waterTables?.realmWaterBodyV1?.onInsert?.(sync); - waterTables?.realmWaterBodyV1?.onDelete?.(sync); - waterTables?.realmWaterBodyV1?.onUpdate?.(sync); - waterTables?.realmWaterCellV1?.onInsert?.(sync); - waterTables?.realmWaterCellV1?.onDelete?.(sync); - waterTables?.realmWaterCellV1?.onUpdate?.(sync); - waterTables?.realmEnvironmentV1?.onInsert?.(sync); - waterTables?.realmEnvironmentV1?.onDelete?.(sync); - waterTables?.realmEnvironmentV1?.onUpdate?.(sync); - waterTables?.realmWaterRevisionV1?.onInsert?.(sync); - waterTables?.realmWaterRevisionV1?.onDelete?.(sync); - waterTables?.realmWaterRevisionV1?.onUpdate?.(sync); - - return () => { + type ObserverTable = Readonly<{ + onInsert?: (callback: typeof sync) => void; + onDelete?: (callback: typeof sync) => void; + onUpdate?: (callback: typeof sync) => void; + removeOnInsert?: (callback: typeof sync) => void; + removeOnDelete?: (callback: typeof sync) => void; + removeOnUpdate?: (callback: typeof sync) => void; + }>; + const listenerCleanups: Array<() => void> = []; + let cleaned = false; + const cleanup = () => { + if (cleaned) return; + cleaned = true; active = false; - connection.db.worldTile.removeOnInsert(sync); - connection.db.worldTile.removeOnDelete(sync); - connection.db.worldTile.removeOnUpdate(sync); - connection.db.worldTileMetaV1.removeOnInsert(sync); - connection.db.worldTileMetaV1.removeOnDelete(sync); - connection.db.worldTileMetaV1.removeOnUpdate(sync); - connection.db.playerV2.removeOnInsert(sync); - connection.db.playerV2.removeOnDelete(sync); - connection.db.playerV2.removeOnUpdate(sync); - connection.db.castle.removeOnInsert(sync); - connection.db.castle.removeOnDelete(sync); - connection.db.castle.removeOnUpdate(sync); - connection.db.realmV1.removeOnInsert(sync); - connection.db.realmV1.removeOnDelete(sync); - connection.db.realmV1.removeOnUpdate(sync); - connection.db.realmProfileV1.removeOnInsert(sync); - connection.db.realmProfileV1.removeOnDelete(sync); - connection.db.realmProfileV1.removeOnUpdate(sync); - goldTables?.goldSiteV1?.removeOnInsert?.(sync); - goldTables?.goldSiteV1?.removeOnDelete?.(sync); - goldTables?.goldSiteV1?.removeOnUpdate?.(sync); - goldTables?.goldNodeOccupationV1?.removeOnInsert?.(sync); - goldTables?.goldNodeOccupationV1?.removeOnDelete?.(sync); - goldTables?.goldNodeOccupationV1?.removeOnUpdate?.(sync); - foodTables?.foodSiteV1?.removeOnInsert?.(sync); - foodTables?.foodSiteV1?.removeOnDelete?.(sync); - foodTables?.foodSiteV1?.removeOnUpdate?.(sync); - foodTables?.foodNodeOccupationV1?.removeOnInsert?.(sync); - foodTables?.foodNodeOccupationV1?.removeOnDelete?.(sync); - foodTables?.foodNodeOccupationV1?.removeOnUpdate?.(sync); - woodTables?.woodSiteV1?.removeOnInsert?.(sync); - woodTables?.woodSiteV1?.removeOnDelete?.(sync); - woodTables?.woodSiteV1?.removeOnUpdate?.(sync); - woodTables?.woodNodeOccupationV1?.removeOnInsert?.(sync); - woodTables?.woodNodeOccupationV1?.removeOnDelete?.(sync); - woodTables?.woodNodeOccupationV1?.removeOnUpdate?.(sync); - stoneTables?.stoneSiteV1?.removeOnInsert?.(sync); - stoneTables?.stoneSiteV1?.removeOnDelete?.(sync); - stoneTables?.stoneSiteV1?.removeOnUpdate?.(sync); - stoneTables?.stoneNodeOccupationV1?.removeOnInsert?.(sync); - stoneTables?.stoneNodeOccupationV1?.removeOnDelete?.(sync); - stoneTables?.stoneNodeOccupationV1?.removeOnUpdate?.(sync); - forestTables?.realmForestLayoutV1?.removeOnInsert?.(sync); - forestTables?.realmForestLayoutV1?.removeOnDelete?.(sync); - forestTables?.realmForestLayoutV1?.removeOnUpdate?.(sync); - forestTables?.realmForestInstanceV1?.removeOnInsert?.(sync); - forestTables?.realmForestInstanceV1?.removeOnDelete?.(sync); - forestTables?.realmForestInstanceV1?.removeOnUpdate?.(sync); - waterTables?.realmWaterLayoutV1?.removeOnInsert?.(sync); - waterTables?.realmWaterLayoutV1?.removeOnDelete?.(sync); - waterTables?.realmWaterLayoutV1?.removeOnUpdate?.(sync); - waterTables?.realmWaterBodyV1?.removeOnInsert?.(sync); - waterTables?.realmWaterBodyV1?.removeOnDelete?.(sync); - waterTables?.realmWaterBodyV1?.removeOnUpdate?.(sync); - waterTables?.realmWaterCellV1?.removeOnInsert?.(sync); - waterTables?.realmWaterCellV1?.removeOnDelete?.(sync); - waterTables?.realmWaterCellV1?.removeOnUpdate?.(sync); - waterTables?.realmEnvironmentV1?.removeOnInsert?.(sync); - waterTables?.realmEnvironmentV1?.removeOnDelete?.(sync); - waterTables?.realmEnvironmentV1?.removeOnUpdate?.(sync); - waterTables?.realmWaterRevisionV1?.removeOnInsert?.(sync); - waterTables?.realmWaterRevisionV1?.removeOnDelete?.(sync); - waterTables?.realmWaterRevisionV1?.removeOnUpdate?.(sync); + for (let index = listenerCleanups.length - 1; index >= 0; index -= 1) { + try { + listenerCleanups[index]?.(); + } catch { + // One generated listener must not strand any other table listener. + } + } + listenerCleanups.length = 0; }; + const registerTable = (candidate: unknown) => { + const table = candidate as ObserverTable; + const pairs = [ + ['onInsert', 'removeOnInsert'], + ['onDelete', 'removeOnDelete'], + ['onUpdate', 'removeOnUpdate'] + ] as const; + for (const [addName, removeName] of pairs) { + const add = table?.[addName]; + const remove = table?.[removeName]; + if (add === undefined && remove === undefined) continue; + if (typeof add !== 'function' || typeof remove !== 'function') { + throw new Error('Warpkeep Realm observer surface is incomplete.'); + } + listenerCleanups.push(() => remove.call(table, sync)); + // Register rollback before invoking the generated SDK. A defensive SDK + // wrapper can attach the callback and then throw; cleanup must still + // remove that partially installed listener. + add.call(table, sync); + if (!active) throw new Error('Warpkeep Realm observer became inactive during setup.'); + } + }; + const observedTables: readonly unknown[] = [ + connection.db.worldTile, + connection.db.worldTileMetaV1, + connection.db.playerV2, + connection.db.castle, + connection.db.realmV1, + connection.db.realmProfileV1, + goldTables?.goldSiteV1, + goldTables?.goldNodeOccupationV1, + foodTables?.foodSiteV1, + foodTables?.foodNodeOccupationV1, + woodTables?.woodSiteV1, + woodTables?.woodNodeOccupationV1, + stoneTables?.stoneSiteV1, + stoneTables?.stoneNodeOccupationV1, + workerTables?.realmWorkerSystemV1, + workerTables?.castleWorkerV1, + workerTables?.workerNodeOccupationV1, + forestTables?.realmForestLayoutV1, + forestTables?.realmForestInstanceV1, + waterTables?.realmWaterLayoutV1, + waterTables?.realmWaterBodyV1, + waterTables?.realmWaterCellV1, + waterTables?.realmEnvironmentV1, + waterTables?.realmWaterRevisionV1 + ]; + try { + for (const table of observedTables) { + if (table !== undefined) registerTable(table); + } + } catch (error) { + cleanup(); + throw error; + } + return cleanup; } export function disconnectWarpkeep(connection: WarpkeepConnection | undefined) { diff --git a/src/spacetime/workerCommandIdempotency.ts b/src/spacetime/workerCommandIdempotency.ts new file mode 100644 index 00000000..f2c2bdd0 --- /dev/null +++ b/src/spacetime/workerCommandIdempotency.ts @@ -0,0 +1,132 @@ +import type { RealmEconomicResourceKey } from '../components/realm/realmResourcePresentation'; +import { createExpeditionIdempotencyKey } from './expeditionIdempotencyKey'; + +export type WorkerCommandFingerprint = + | Readonly<{ + kind: 'dispatch'; + workerId: string; + resourceKind: RealmEconomicResourceKey; + siteId: string; + }> + | Readonly<{ kind: 'recall'; workerId: string }> + | Readonly<{ kind: 'recall-all'; castleId: number }>; + +export type WorkerCommandAttempt = Readonly<{ + generation: number; + fingerprint: string; + lifecycleFingerprint: string; + idempotencyKey: string; +}>; + +export type WorkerCommandLifecycleState = Readonly<{ + castleId: number; + workers: readonly Readonly<{ + workerId: string; + status: string; + resourceKind?: RealmEconomicResourceKey; + siteId?: string; + revision: bigint; + }>[]; +}>; + +export function serializeWorkerCommandFingerprint(command: WorkerCommandFingerprint) { + if (command.kind === 'dispatch') { + return `dispatch\u0000${command.workerId}\u0000${command.resourceKind}\u0000${command.siteId}`; + } + if (command.kind === 'recall') return `recall\u0000${command.workerId}`; + return `recall-all\u0000${command.castleId}`; +} + +function workerLifecycleRecord( + worker: WorkerCommandLifecycleState['workers'][number] +) { + return [ + worker.workerId, + worker.status, + worker.resourceKind ?? '', + worker.siteId ?? '', + worker.revision.toString() + ].join('\u0000'); +} + +/** + * Bind a retained retry key to the exact authoritative worker lifecycle that + * first issued it. Once a private roster refresh observes any later revision + * or state, reusing the old receipt can no longer describe the current + * command and must fail closed to a fresh key. + */ +export function workerCommandLifecycleFingerprint( + command: WorkerCommandFingerprint, + state: WorkerCommandLifecycleState +): string | undefined { + if (!Number.isSafeInteger(state.castleId) || state.castleId <= 0) return undefined; + if (command.kind === 'recall-all') { + if (command.castleId !== state.castleId || state.workers.length === 0) return undefined; + const workers = [...state.workers].sort((left, right) => ( + left.workerId.localeCompare(right.workerId) + )); + if (new Set(workers.map((worker) => worker.workerId)).size !== workers.length) return undefined; + return workers.map(workerLifecycleRecord).join('\u0001'); + } + const matches = state.workers.filter((worker) => worker.workerId === command.workerId); + return matches.length === 1 ? workerLifecycleRecord(matches[0]!) : undefined; +} + +export function workerCommandAttemptMatchesLifecycle( + attempt: WorkerCommandAttempt, + generation: number, + state: WorkerCommandLifecycleState +) { + return attempt.generation === generation + && workerCommandLifecycleFingerprint( + deserializeWorkerCommandFingerprint(attempt.fingerprint), + state + ) === attempt.lifecycleFingerprint; +} + +function deserializeWorkerCommandFingerprint(fingerprint: string): WorkerCommandFingerprint { + const fields = fingerprint.split('\u0000'); + if (fields[0] === 'dispatch' && fields.length === 4) { + return { + kind: 'dispatch', + workerId: fields[1]!, + resourceKind: fields[2] as RealmEconomicResourceKey, + siteId: fields[3]! + }; + } + if (fields[0] === 'recall' && fields.length === 2) { + return { kind: 'recall', workerId: fields[1]! }; + } + if (fields[0] === 'recall-all' && fields.length === 2) { + return { kind: 'recall-all', castleId: Number(fields[1]) }; + } + return { kind: 'recall-all', castleId: Number.NaN }; +} + +/** + * Commit-ambiguous retries reuse a key only for the exact same command. + * Changing worker, resource, site, command kind, castle, or connection + * generation necessarily creates a fresh server receipt identity. + */ +export function workerCommandAttemptFor( + retained: WorkerCommandAttempt | undefined, + generation: number, + command: WorkerCommandFingerprint, + lifecycleState: WorkerCommandLifecycleState, + createKey: () => string | undefined = createExpeditionIdempotencyKey +): WorkerCommandAttempt | undefined { + const fingerprint = serializeWorkerCommandFingerprint(command); + const lifecycleFingerprint = workerCommandLifecycleFingerprint(command, lifecycleState); + if (lifecycleFingerprint === undefined) return undefined; + if ( + retained?.generation === generation + && retained.fingerprint === fingerprint + && retained.lifecycleFingerprint === lifecycleFingerprint + ) { + return retained; + } + const idempotencyKey = createKey(); + return idempotencyKey === undefined + ? undefined + : Object.freeze({ generation, fingerprint, lifecycleFingerprint, idempotencyKey }); +} diff --git a/tests/WarpkeepSpacetimeCanonicalReadiness.test.tsx b/tests/WarpkeepSpacetimeCanonicalReadiness.test.tsx index a3f32c31..59f7ae18 100644 --- a/tests/WarpkeepSpacetimeCanonicalReadiness.test.tsx +++ b/tests/WarpkeepSpacetimeCanonicalReadiness.test.tsx @@ -9,6 +9,7 @@ vi.mock('../src/farcaster/FarcasterAuthProvider', () => ({ })); import { + BACKEND_STAGE_OPERATION_TIMEOUT_MILLISECONDS, CANONICAL_REALM_READINESS_TIMEOUT_MILLISECONDS, RESOURCE_OPERATION_TIMEOUT_MILLISECONDS, WarpkeepSpacetimeProvider, @@ -174,6 +175,12 @@ async function beginSubscription(harness: RuntimeHarness) { await waitFor(() => expect(harness.runtime.subscribeRealm).toHaveBeenCalledTimes(1)); } +async function settleMicrotasks(turns = 12) { + for (let turn = 0; turn < turns; turn += 1) { + await Promise.resolve(); + } +} + afterEach(() => { cleanup(); vi.useRealTimers(); @@ -181,6 +188,117 @@ afterEach(() => { }); describe('Warpkeep canonical realm readiness lifecycle', () => { + it('fails closed when backend compatibility metadata never resolves', async () => { + vi.useFakeTimers(); + mockedFarcaster.current = authenticatedFarcaster(); + const harness = createRuntimeHarness(); + vi.mocked(harness.runtime.readBackendInfo).mockReturnValueOnce( + new Promise(() => undefined) + ); + renderProvider(harness); + + await act(settleMicrotasks); + expect(screen.getByTestId('phase').textContent).toBe('connecting'); + expect(harness.runtime.readAdmission).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(BACKEND_STAGE_OPERATION_TIMEOUT_MILLISECONDS); + await settleMicrotasks(); + }); + expect(screen.getByTestId('phase').textContent).toBe('error'); + expect(harness.connection.disconnect).toHaveBeenCalledTimes(1); + expect(harness.runtime.readAdmission).not.toHaveBeenCalled(); + }); + + it('fails closed when the initial admission read never resolves', async () => { + vi.useFakeTimers(); + mockedFarcaster.current = authenticatedFarcaster(); + const harness = createRuntimeHarness(); + vi.mocked(harness.runtime.readAdmission).mockReturnValueOnce( + new Promise(() => undefined) + ); + renderProvider(harness); + + await act(settleMicrotasks); + expect(screen.getByTestId('phase').textContent).toBe('checking-admission'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(BACKEND_STAGE_OPERATION_TIMEOUT_MILLISECONDS); + await settleMicrotasks(); + }); + expect(screen.getByTestId('phase').textContent).toBe('error'); + expect(harness.connection.disconnect).toHaveBeenCalledTimes(1); + expect(harness.runtime.bootstrapPlayer).not.toHaveBeenCalled(); + }); + + it('fails closed when an admitted player bootstrap never resolves', async () => { + vi.useFakeTimers(); + mockedFarcaster.current = authenticatedFarcaster(); + const harness = createRuntimeHarness(); + vi.mocked(harness.runtime.readAdmission).mockResolvedValueOnce('admitted_needs_bootstrap'); + vi.mocked(harness.runtime.bootstrapPlayer).mockReturnValueOnce( + new Promise(() => undefined) + ); + renderProvider(harness); + + await act(settleMicrotasks); + expect(screen.getByTestId('phase').textContent).toBe('bootstrapping'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(BACKEND_STAGE_OPERATION_TIMEOUT_MILLISECONDS); + await settleMicrotasks(); + }); + expect(screen.getByTestId('phase').textContent).toBe('error'); + expect(harness.connection.disconnect).toHaveBeenCalledTimes(1); + expect(harness.runtime.readAdmission).toHaveBeenCalledTimes(1); + }); + + it('fails closed when the post-bootstrap admission read never resolves', async () => { + vi.useFakeTimers(); + mockedFarcaster.current = authenticatedFarcaster(); + const harness = createRuntimeHarness(); + vi.mocked(harness.runtime.readAdmission) + .mockResolvedValueOnce('admitted_needs_bootstrap') + .mockReturnValueOnce(new Promise(() => undefined)); + renderProvider(harness); + + await act(settleMicrotasks); + expect(screen.getByTestId('phase').textContent).toBe('bootstrapping'); + expect(harness.runtime.bootstrapPlayer).toHaveBeenCalledTimes(1); + expect(harness.runtime.readAdmission).toHaveBeenCalledTimes(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(BACKEND_STAGE_OPERATION_TIMEOUT_MILLISECONDS); + await settleMicrotasks(); + }); + expect(screen.getByTestId('phase').textContent).toBe('error'); + expect(harness.connection.disconnect).toHaveBeenCalledTimes(1); + }); + + it('fails closed when an explicit Terms acceptance never resolves', async () => { + vi.useFakeTimers(); + mockedFarcaster.current = authenticatedFarcaster(); + const harness = createRuntimeHarness(); + vi.mocked(harness.runtime.acceptAlphaTerms).mockReturnValueOnce( + new Promise(() => undefined) + ); + renderProvider(harness); + + await act(settleMicrotasks); + expect(screen.getByTestId('phase').textContent).toBe('awaiting-terms'); + fireEvent.click(screen.getByRole('button', { name: 'ACCEPT TERMS' })); + await act(settleMicrotasks); + expect(screen.getByTestId('phase').textContent).toBe('accepting-terms'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(BACKEND_STAGE_OPERATION_TIMEOUT_MILLISECONDS); + await settleMicrotasks(); + }); + expect(screen.getByTestId('phase').textContent).toBe('error'); + expect(harness.connection.disconnect).toHaveBeenCalledTimes(1); + expect(harness.runtime.subscribeRealm).not.toHaveBeenCalled(); + }); + it('starts the expanded public subscription while the private resource read is pending', async () => { mockedFarcaster.current = authenticatedFarcaster(); const harness = createRuntimeHarness(); @@ -332,6 +450,36 @@ describe('Warpkeep canonical realm readiness lifecycle', () => { expect(harness.connection.disconnect).toHaveBeenCalledTimes(1); }); + it('closes a late observer handle and never subscribes after synchronous observer failure', async () => { + vi.useFakeTimers(); + mockedFarcaster.current = authenticatedFarcaster(); + const harness = createRuntimeHarness(); + const pendingResources = new Promise(() => undefined); + const removeObserver = vi.fn(); + vi.mocked(harness.runtime.readResourceState).mockReturnValueOnce(pendingResources); + vi.mocked(harness.runtime.observeRealm).mockImplementationOnce( + (_connection, _fid, _onChange, onError) => { + onError(); + return removeObserver; + } + ); + renderProvider(harness); + + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + fireEvent.click(screen.getByRole('button', { name: 'ACCEPT TERMS' })); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + expect(screen.getByTestId('phase').textContent).toBe('error'); + expect(removeObserver).toHaveBeenCalledTimes(1); + expect(harness.runtime.subscribeRealm).not.toHaveBeenCalled(); + expect(harness.connection.disconnect).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(RESOURCE_OPERATION_TIMEOUT_MILLISECONDS); + }); + expect(screen.getByTestId('phase').textContent).toBe('error'); + expect(harness.connection.disconnect).toHaveBeenCalledTimes(1); + }); + it('ignores observer snapshots until onApplied validates one complete canonical snapshot', async () => { mockedFarcaster.current = authenticatedFarcaster(); const harness = createRuntimeHarness(); diff --git a/tests/WarpkeepSpacetimeResources.test.tsx b/tests/WarpkeepSpacetimeResources.test.tsx index ad2a6b3f..d1724033 100644 --- a/tests/WarpkeepSpacetimeResources.test.tsx +++ b/tests/WarpkeepSpacetimeResources.test.tsx @@ -41,6 +41,13 @@ import type { ReadyGoldExpeditionPresentation } from '../src/components/realm/re import type { ReadyFoodExpeditionPresentation } from '../src/components/realm/realmFoodExpeditionPresentation'; import type { ReadyWoodExpeditionPresentation } from '../src/components/realm/realmWoodExpeditionPresentation'; import type { ReadyStoneExpeditionPresentation } from '../src/components/realm/realmStoneExpeditionPresentation'; +import { + CASTLE_WORKER_POLICY_VERSION, + CASTLE_WORKER_REALM_ID, + workerRosterDigestForCastleIds, + type ReadyWorkerResourceState, + type WorkerRosterPresentation +} from '../src/components/realm/realmWorkerPresentation'; import { RESOURCE_OPERATION_TIMEOUT_MILLISECONDS, RESOURCE_REFRESH_INTERVAL_MILLISECONDS, @@ -53,6 +60,7 @@ import { DEFAULT_SPACETIMEDB_DATABASE, type WarpkeepRuntimeConfig } from '../src/spacetime/warpkeepConfig'; +import type { WarpkeepRealmSnapshot } from '../src/spacetime/warpkeepBackendTypes'; import { createCanonicalGenesisSnapshot } from './fixtures/canonicalGenesisSnapshot'; import { createReadyResourceState } from './fixtures/resourceState'; @@ -271,6 +279,133 @@ function resourceStateWithStone( }); } +function idleWorkerRows(castleId = 1) { + return [1, 2, 3, 4].map((ordinal) => Object.freeze({ + workerId: `genesis-001-castle-${castleId}-worker-0${ordinal}`, + ordinal: ordinal as 1 | 2 | 3 | 4, + originCastleId: castleId, + originCastleName: 'Warpkeeper Bastion', + status: 'idle' as const, + timelineRevision: 0, + revision: 0n, + ownedByViewer: true + })); +} + +function workerRealmSnapshot(fid = 12_345): WarpkeepRealmSnapshot { + const base = createCanonicalGenesisSnapshot(fid); + const castleId = base.ownCastle.castleId; + return Object.freeze({ + ...base, + workerSystem: Object.freeze({ + realmId: CASTLE_WORKER_REALM_ID, + policyVersion: CASTLE_WORKER_POLICY_VERSION, + workersPerCastle: 4 as const, + expectedCastleCount: 1, + expectedWorkerCount: 4, + rosterDigest: workerRosterDigestForCastleIds([castleId]), + mode: 'active' as const, + legacyDrainRequired: false + }), + workerWorkers: Object.freeze(idleWorkerRows(castleId)), + workerOccupations: Object.freeze([]) + }); +} + +function workerRoster(castleId = 1): WorkerRosterPresentation { + return Object.freeze({ + castleId, + observedAtMicros: 100n, + workers: Object.freeze(idleWorkerRows(castleId).map((worker) => Object.freeze({ + workerId: worker.workerId, + ordinal: worker.ordinal, + status: worker.status, + accruedAmount: 0n, + materializedAmount: 0n, + availableAmount: 0n, + observedAtMicros: 100n, + revision: worker.revision + }))) + }); +} + +function workerResourceState(fid = 12_345): ReadyWorkerResourceState { + return Object.freeze({ + status: 'ready' as const, + fid: BigInt(fid), + available: Object.freeze({ food: 0n, wood: 0n, stone: 0n, gold: 0n }), + pending: Object.freeze({ food: 0n, wood: 0n, stone: 0n, gold: 0n }), + observedAtMicros: 100n, + settledThroughMicros: 100n, + revision: 0n, + resourcePolicyVersion: 'genesis-resource-yield-v1', + workerPolicyVersion: CASTLE_WORKER_POLICY_VERSION, + workerSystemMode: 'active' as const + }); +} + +function outboundWorkerRealmSnapshot(fid = 12_345): WarpkeepRealmSnapshot { + const base = workerRealmSnapshot(fid); + const [first, ...rest] = base.workerWorkers!; + const activeWorker = Object.freeze({ + ...first!, + status: 'outbound' as const, + resourceKind: 'stone' as const, + siteId: 'genesis-001:stone:0001', + startedAtMicros: 100n, + arrivesAtMicros: 200n, + gatheringEndsAtMicros: 300n, + returnsAtMicros: 400n, + routeSteps: 10, + timelineRevision: 1, + revision: 1n + }); + return Object.freeze({ + ...base, + workerWorkers: Object.freeze([activeWorker, ...rest]), + workerOccupations: Object.freeze([Object.freeze({ + nodeKey: 'stone:genesis-001:stone:0001', + resourceKind: 'stone' as const, + siteId: 'genesis-001:stone:0001', + workerId: activeWorker.workerId, + workerOrdinal: activeWorker.ordinal, + originCastleId: activeWorker.originCastleId, + phase: 'outbound' as const, + startedAtMicros: activeWorker.startedAtMicros, + arrivesAtMicros: activeWorker.arrivesAtMicros, + gatheringEndsAtMicros: activeWorker.gatheringEndsAtMicros, + timelineRevision: activeWorker.timelineRevision + })]) + }); +} + +function outboundWorkerRoster(castleId = 1): WorkerRosterPresentation { + const base = workerRoster(castleId); + return Object.freeze({ + ...base, + observedAtMicros: 200n, + workers: Object.freeze(base.workers.map((worker, index) => index === 0 + ? Object.freeze({ + ...worker, + status: 'outbound' as const, + resourceKind: 'stone' as const, + siteId: 'genesis-001:stone:0001', + observedAtMicros: 200n, + revision: 1n + }) + : Object.freeze({ ...worker, observedAtMicros: 200n }))) + }); +} + +function newerWorkerResourceState(fid = 12_345): ReadyWorkerResourceState { + return Object.freeze({ + ...workerResourceState(fid), + observedAtMicros: 200n, + settledThroughMicros: 200n, + revision: 1n + }); +} + function createRuntimeHarness() { const disconnect = vi.fn((connection: { disconnect?: () => void } | undefined) => { connection?.disconnect?.(); @@ -339,6 +474,15 @@ function Probe() { ? '' : String(backend.state.stoneExpedition.active)} + + {backend.state.workerProjection?.mode ?? ''} + + + {backend.state.workerRoster?.workers[0]?.status ?? ''} + + + {backend.state.workerRoster?.workers[0]?.revision.toString() ?? ''} + + ); @@ -483,6 +637,266 @@ describe('Warpkeep private resource lifecycle', () => { await waitFor(() => expect(screen.getByTestId('phase').textContent).toBe('ready')); }); + it('does not call generated v12 worker procedures or delay a v11 Realm', async () => { + mockedFarcaster.current = authenticatedFarcaster(); + const { runtime } = createRuntimeHarness(); + const readWorkerRoster = vi.fn(() => new Promise(() => undefined)); + const readResourceStateV2 = vi.fn(() => new Promise(() => undefined)); + Object.assign(runtime, { readWorkerRoster, readResourceStateV2 }); + + renderProvider(runtime); + await enterRealm(); + + expect(screen.getByTestId('phase').textContent).toBe('ready'); + expect(screen.getByTestId('worker-active').textContent).toBe(''); + expect(readWorkerRoster).not.toHaveBeenCalled(); + expect(readResourceStateV2).not.toHaveBeenCalled(); + }); + + it('activates only the exact worker projections and keys retries by command fingerprint', async () => { + mockedFarcaster.current = authenticatedFarcaster(); + const { runtime } = createRuntimeHarness(); + const dispatchWorker = vi.fn().mockRejectedValue(new Error('response unavailable')); + Object.assign(runtime, { + readRealmSnapshot: vi.fn(() => workerRealmSnapshot()), + readWorkerRoster: vi.fn(async () => workerRoster()), + readResourceStateV2: vi.fn(async () => workerResourceState()), + dispatchWorker, + recallWorker: vi.fn(async () => undefined), + recallAllWorkers: vi.fn(async () => undefined) + }); + renderProvider(runtime); + await enterRealm(); + expect(screen.getByTestId('worker-active').textContent).toBe('active'); + + await expect(capturedBackend!.dispatchWorker( + 'genesis-001-castle-1-worker-01', + 'stone', + 'genesis-001:stone:0001' + )).rejects.toThrow('Worker command is unavailable.'); + await expect(capturedBackend!.dispatchWorker( + 'genesis-001-castle-1-worker-01', + 'stone', + 'genesis-001:stone:0001' + )).rejects.toThrow('Worker command is unavailable.'); + await expect(capturedBackend!.dispatchWorker( + 'genesis-001-castle-1-worker-01', + 'stone', + 'genesis-001:stone:0002' + )).rejects.toThrow('Worker command is unavailable.'); + await expect(capturedBackend!.dispatchWorker( + 'genesis-001-castle-1-worker-01', + 'stone', + 'genesis-001:stone:0001' + )).rejects.toThrow('Worker command is unavailable.'); + + expect(dispatchWorker).toHaveBeenCalledTimes(4); + const firstKey = dispatchWorker.mock.calls[0]?.[4]; + expect(firstKey).toEqual(expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + )); + expect(dispatchWorker.mock.calls[1]?.[4]).toBe(firstKey); + expect(dispatchWorker.mock.calls[2]?.[4]).not.toBe(firstKey); + expect(dispatchWorker.mock.calls[3]?.[4]).toBe(firstKey); + expect(screen.getByTestId('worker-active').textContent).toBe('active'); + }); + + it('clears retained worker authority and retry keys across an identity generation change', async () => { + mockedFarcaster.current = authenticatedFarcaster(); + const { runtime } = createRuntimeHarness(); + const dispatchWorker = vi.fn().mockRejectedValue(new Error('response unavailable')); + Object.assign(runtime, { + readRealmSnapshot: vi.fn((_connection, fid: number) => workerRealmSnapshot(fid)), + readWorkerRoster: vi.fn(async () => workerRoster()), + readResourceStateV2: vi.fn(async (_connection, fid: number) => workerResourceState(fid)), + dispatchWorker, + recallWorker: vi.fn(async () => undefined), + recallAllWorkers: vi.fn(async () => undefined) + }); + const view = renderProvider(runtime); + await enterRealm(); + + for (let index = 0; index < 64; index += 1) { + await expect(capturedBackend!.dispatchWorker( + 'genesis-001-castle-1-worker-01', + 'stone', + `genesis-001:stone:retained-${index}` + )).rejects.toThrow('Worker command is unavailable.'); + } + await expect(capturedBackend!.dispatchWorker( + 'genesis-001-castle-1-worker-01', + 'stone', + 'genesis-001:stone:blocked-before-rollover' + )).rejects.toThrow('Worker command is unavailable.'); + expect(dispatchWorker).toHaveBeenCalledTimes(64); + + mockedFarcaster.current = authenticatedFarcaster(54_321, 2); + view.rerender( + + + + ); + await enterRealm(); + await expect(capturedBackend!.dispatchWorker( + 'genesis-001-castle-1-worker-01', + 'stone', + 'genesis-001:stone:first-after-rollover' + )).rejects.toThrow('Worker command is unavailable.'); + expect(dispatchWorker).toHaveBeenCalledTimes(65); + }); + + it('keeps worker controls inactive when the private v2 projection disagrees', async () => { + mockedFarcaster.current = authenticatedFarcaster(); + const { runtime } = createRuntimeHarness(); + Object.assign(runtime, { + readRealmSnapshot: vi.fn(() => workerRealmSnapshot()), + readWorkerRoster: vi.fn(async () => workerRoster()), + readResourceStateV2: vi.fn(async () => Object.freeze({ + ...workerResourceState(), + pending: Object.freeze({ food: 0n, wood: 0n, stone: 1n, gold: 0n }) + })), + dispatchWorker: vi.fn(async () => undefined), + recallWorker: vi.fn(async () => undefined), + recallAllWorkers: vi.fn(async () => undefined) + }); + renderProvider(runtime); + await enterRealm(); + + expect(screen.getByTestId('worker-active').textContent).toBe(''); + await expect(capturedBackend!.dispatchWorker( + 'genesis-001-castle-1-worker-01', + 'stone', + 'genesis-001:stone:0001' + )).rejects.toThrow('Worker command is unavailable.'); + expect(runtime.dispatchWorker).not.toHaveBeenCalled(); + }); + + it('rejects an older periodic worker pair that resolves after a command refresh', async () => { + vi.useFakeTimers(); + mockedFarcaster.current = authenticatedFarcaster(); + const { runtime } = createRuntimeHarness(); + const staleRoster = deferred(); + const staleWorkerResources = deferred(); + let publishObservedRealm: ((snapshot: WarpkeepRealmSnapshot) => void) | undefined; + let currentRealm: WarpkeepRealmSnapshot = workerRealmSnapshot(); + const dispatchWorker = vi.fn(async () => { + currentRealm = outboundWorkerRealmSnapshot(); + publishObservedRealm?.(currentRealm); + }); + Object.assign(runtime, { + readRealmSnapshot: vi.fn(() => currentRealm), + observeRealm: vi.fn((_connection, _fid, onUpdate) => { + publishObservedRealm = onUpdate; + return vi.fn(); + }), + readWorkerRoster: vi.fn() + .mockResolvedValueOnce(workerRoster()) + .mockImplementationOnce(() => staleRoster.promise) + .mockResolvedValueOnce(outboundWorkerRoster()), + readResourceStateV2: vi.fn() + .mockResolvedValueOnce(workerResourceState()) + .mockImplementationOnce(() => staleWorkerResources.promise) + .mockResolvedValueOnce(newerWorkerResourceState()), + dispatchWorker, + recallWorker: vi.fn(async () => undefined), + recallAllWorkers: vi.fn(async () => undefined) + }); + renderProvider(runtime); + + await flushProviderWork(); + expect(screen.getByTestId('phase').textContent).toBe('awaiting-terms'); + fireEvent.click(screen.getByRole('button', { name: 'ACCEPT TERMS' })); + await flushProviderWork(); + expect(screen.getByTestId('phase').textContent).toBe('ready'); + expect(screen.getByTestId('worker-first-status').textContent).toBe('idle'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(RESOURCE_REFRESH_INTERVAL_MILLISECONDS); + }); + expect(runtime.readWorkerRoster).toHaveBeenCalledTimes(2); + expect(runtime.readResourceStateV2).toHaveBeenCalledTimes(2); + + await act(async () => { + await capturedBackend!.dispatchWorker( + 'genesis-001-castle-1-worker-01', + 'stone', + 'genesis-001:stone:0001' + ); + }); + expect(screen.getByTestId('worker-first-status').textContent).toBe('outbound'); + expect(screen.getByTestId('worker-first-revision').textContent).toBe('1'); + + await act(async () => { + staleRoster.resolve(workerRoster()); + staleWorkerResources.resolve(workerResourceState()); + await Promise.all([staleRoster.promise, staleWorkerResources.promise]); + }); + await flushProviderWork(); + expect(screen.getByTestId('worker-first-status').textContent).toBe('outbound'); + expect(screen.getByTestId('worker-first-revision').textContent).toBe('1'); + await expect(capturedBackend!.dispatchWorker( + 'genesis-001-castle-1-worker-01', + 'stone', + 'genesis-001:stone:0001' + )).rejects.toThrow('Worker command is unavailable.'); + expect(dispatchWorker).toHaveBeenCalledTimes(1); + }); + + it('coalesces a newer public worker lifecycle while an older private pair is pending', async () => { + vi.useFakeTimers(); + mockedFarcaster.current = authenticatedFarcaster(); + const { runtime } = createRuntimeHarness(); + const staleRoster = deferred(); + const staleWorkerResources = deferred(); + let publishObservedRealm: ((snapshot: WarpkeepRealmSnapshot) => void) | undefined; + let currentRealm: WarpkeepRealmSnapshot = workerRealmSnapshot(); + Object.assign(runtime, { + readRealmSnapshot: vi.fn(() => currentRealm), + observeRealm: vi.fn((_connection, _fid, onUpdate) => { + publishObservedRealm = onUpdate; + return vi.fn(); + }), + readWorkerRoster: vi.fn() + .mockResolvedValueOnce(workerRoster()) + .mockImplementationOnce(() => staleRoster.promise) + .mockResolvedValueOnce(outboundWorkerRoster()), + readResourceStateV2: vi.fn() + .mockResolvedValueOnce(workerResourceState()) + .mockImplementationOnce(() => staleWorkerResources.promise) + .mockResolvedValueOnce(newerWorkerResourceState()), + dispatchWorker: vi.fn(async () => undefined), + recallWorker: vi.fn(async () => undefined), + recallAllWorkers: vi.fn(async () => undefined) + }); + renderProvider(runtime); + + await flushProviderWork(); + fireEvent.click(screen.getByRole('button', { name: 'ACCEPT TERMS' })); + await flushProviderWork(); + expect(screen.getByTestId('phase').textContent).toBe('ready'); + expect(screen.getByTestId('worker-first-status').textContent).toBe('idle'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(RESOURCE_REFRESH_INTERVAL_MILLISECONDS); + }); + expect(runtime.readWorkerRoster).toHaveBeenCalledTimes(2); + expect(runtime.readResourceStateV2).toHaveBeenCalledTimes(2); + + currentRealm = outboundWorkerRealmSnapshot(); + act(() => publishObservedRealm?.(currentRealm)); + await act(async () => { + staleRoster.resolve(workerRoster()); + staleWorkerResources.resolve(workerResourceState()); + await Promise.all([staleRoster.promise, staleWorkerResources.promise]); + }); + await flushProviderWork(); + + expect(runtime.readWorkerRoster).toHaveBeenCalledTimes(3); + expect(runtime.readResourceStateV2).toHaveBeenCalledTimes(3); + expect(screen.getByTestId('worker-first-status').textContent).toBe('outbound'); + expect(screen.getByTestId('worker-first-revision').textContent).toBe('1'); + }); + it('fails closed and tears down the concurrent public subscription when the private read fails', async () => { mockedFarcaster.current = authenticatedFarcaster(); const { runtime, disconnect } = createRuntimeHarness(); diff --git a/tests/activationToolingSecurity.test.ts b/tests/activationToolingSecurity.test.ts index 3aa0a412..7cdea6a2 100644 --- a/tests/activationToolingSecurity.test.ts +++ b/tests/activationToolingSecurity.test.ts @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { EventEmitter } from 'node:events'; +import { readFileSync, statSync, writeFileSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -9,9 +10,16 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { GENESIS_WORLD_PUBLISH_STAGE, + PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS, RESOURCE_PUBLISH_ROLLOUT_STAGE, + WORKER_PUBLISH_ROLLOUT_STAGE, + WORKER_V12_TABLE_CONTRACTS, alphaV10AggregateChildArguments, + alphaV12AggregateChildArguments, alphaV8AggregateChildArguments, + canonicalSchemaDescribeChildArguments, + createPrivatePublishSnapshot, + parseCanonicalSchemaDescription, parseMigrationProofReceipt, parsePublishArguments, publishChildEnvironment, @@ -23,6 +31,8 @@ import { verifyCanonicalDatabaseList, verifyFreshAlphaStatusV8Aggregate, verifyFreshAlphaStatusV10Aggregate, + verifyFreshAlphaStatusV12Aggregate, + verifyFreshProductionV11Schema, verifyFreshFoundedProtocolV3Aggregate, verifyFreshResourceProtocolV4PrebackfillAggregate, verifyFreshResourceProtocolV4ReadyAggregate, @@ -30,19 +40,27 @@ import { verifyPinnedCliAttestation, verifyPostPublishAlphaStatusV8Aggregate, verifyPostPublishAlphaStatusV10Aggregate, + verifyPostPublishAlphaStatusV12Aggregate, verifyPostPublishFoundedProtocolV3Aggregate, + verifyPostPublishProductionV12Schema, verifyPostPublishResourceProtocolV4PrebackfillAggregate, verifyPostPublishResourceProtocolV4ReadyAggregate, verifyPostPublishResourcePublicationCheckpoints, verifyPrivacySafeAlphaStatusV8Output, verifyPrivacySafeAlphaStatusV10Output, + verifyPrivacySafeAlphaStatusV12Output, + verifyEmptyAlphaStatusV12, + verifyExactProductionV11Schema, + verifyExactProductionV12Schema, } from '../scripts/publish-spacetime-dev.mjs'; // @ts-expect-error Repository JavaScript scripts intentionally expose test hooks. import { ADDITIVE_MIGRATION_PROOF_PROCESS_TIMEOUT_MILLISECONDS, ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION, ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION, formatAdditiveMigrationProofReceipt } from '../scripts/spacetime-additive-migration-proof.mjs'; // @ts-expect-error Repository JavaScript scripts intentionally expose test hooks. +import { canonicalTableSchemaBoundaryDigest } from '../scripts/spacetime-table-schema-attestation.mjs'; +// @ts-expect-error Repository JavaScript scripts intentionally expose test hooks. import { PROTECTED_AGGREGATE_STAGE, parseProductionVerifierArguments, protectedAggregateChildArguments, protectedAggregateChildEnvironment, protectedAggregateChildOptions, requiredProtectedAggregateSecret, resourceV4AggregateChildArguments, resourceV4ReadyAggregateChildEnvironment, resourceV4ReadyAggregateChildOptions, rootAssetUrls, validateProductionSigningKey, verifyBridge, verifyExpectedAlphaAggregate, verifyExpectedAlphaV2Aggregate, verifyExpectedAlphaV3Aggregate, verifyExpectedAlphaV4ResourcePrebackfillAggregate, verifyExpectedAlphaV4ResourceReadyAggregate, verifyPostBackfillResourceAggregateCheckpoints, verifyRootAssets } from '../scripts/verify-alpha-production.mjs'; // @ts-expect-error Repository JavaScript scripts intentionally expose test hooks. -import { cleanupMigrationProofResources, containServerProcessErrors, stopServer } from '../scripts/verify-spacetime-additive-migration.mjs'; +import { cleanupMigrationProofResources, containServerProcessErrors, installMigrationProofSignalCleanup, stopServer } from '../scripts/verify-spacetime-additive-migration.mjs'; import { ALPHA_ACTIVATION_COMPONENTS, ALPHA_ACTIVATION_SCHEMA_PROTOCOL_VERSION, @@ -173,6 +191,84 @@ function alphaStatusV10(overrides: Record = {}) { }; } +function alphaStatusV12(overrides: Record = {}) { + const zeroCounts = Object.fromEntries([ + 'systemRows', 'expectedCastleCount', 'expectedWorkerCount', 'actualWorkerCount', + 'castlesWithExtraWorkers', 'duplicateOrdinals', 'malformedWorkerIds', + 'invalidWorkerStates', 'idleWorkers', 'outboundWorkers', 'gatheringWorkers', + 'returningWorkers', 'assignments', 'occupations', 'schedules', 'orphanWorkers', + 'orphanAssignments', 'assignmentsMissingOccupation', + 'assignmentsWithoutSingleSchedule', 'orphanOccupations', 'orphanSchedules', + 'invalidSchedules', 'assignmentPublicMismatches', 'occupationSiteMismatches', + 'invalidAssignments', 'idempotencyReceipts', 'invalidIdempotencyReceipts', + 'idempotencyOverflowFids', + ].map(field => [field, '0'])); + return { + ...zeroCounts, + mode: 'absent', + systemConfigValid: false, + legacyDrainRequired: true, + expectedCountsMatch: false, + rosterDigestMatches: false, + castlesMissingWorkers: '4', + legacyExpeditions: '2', + legacyOccupations: '1', + legacySchedules: '3', + rosterDigest: '', + rosterDigestExpected: '0123456789abcdef', + ...overrides, + }; +} + +function productionSchemaDescription(includeWorkerV12: boolean) { + const refs: Record = { + ...PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS, + }; + if (includeWorkerV12) { + for (const [name, contract] of Object.entries(WORKER_V12_TABLE_CONTRACTS)) { + refs[name] = contract.productTypeRef; + } + } + const types: Array<{ + Product: { + elements: Array<{ + name: { some: string }; + algebraic_type: Record; + }>; + }; + }> = Array.from({ length: Math.max(...Object.values(refs)) + 1 }, (_unused, ref) => ({ + Product: { elements: [{ name: { some: `legacy_field_${ref}` }, algebraic_type: { U64: {} } }] }, + })); + const tables: Array<{ + name: string; + product_type_ref: number; + table_access: Record; + indexes: Array>; + constraints: Array>; + }> = Object.entries(refs).map(([name, productTypeRef]) => ({ + name, + product_type_ref: productTypeRef, + table_access: { [name === 'admin_audit' ? 'Private' : 'Public']: {} }, + indexes: [{ name: `${name}_by_primary`, algorithm: { BTree: { columns: [0] } } }], + constraints: [{ name: `${name}_primary`, data: { Unique: { columns: [0] } } }], + })); + if (includeWorkerV12) { + for (const [name, contract] of Object.entries(WORKER_V12_TABLE_CONTRACTS)) { + types[contract.productTypeRef] = { + Product: { + elements: contract.fields.map(field => ({ + name: { some: field }, + algebraic_type: { String: {} }, + })), + }, + }; + const table = tables.find(candidate => candidate.name === name)!; + table.table_access = { [contract.access]: {} }; + } + } + return { tables, typespace: { types } }; +} + beforeAll(async () => { const pair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, @@ -434,6 +530,8 @@ function withNonCanonicalPaddingBits(value: string) { async function withTestProvenArtifact(callback: (receipt: { artifactPath: string; + v11TableSchemaDigest: string; + v12TableSchemaDigest: string; artifactDigest: string; }) => Promise | T): Promise { let previous: Buffer | undefined; @@ -447,6 +545,8 @@ async function withTestProvenArtifact(callback: (receipt: { await writeFile(provenArtifactPath, content, { mode: 0o600 }); const receipt = Object.freeze({ artifactPath: provenArtifactPath, + v11TableSchemaDigest: 'a'.repeat(64), + v12TableSchemaDigest: 'b'.repeat(64), artifactDigest: createHash('sha256').update(content).digest('hex'), }); try { @@ -551,12 +651,24 @@ describe('activation publish safety', () => { .rejects.toThrow(/invalid or private signing key/i); }); - it('uses one non-destructive bounded publish attempt with exact arguments', async () => { + it('publishes only an owner-private artifact snapshot after the proven source is replaced', async () => { const calls: unknown[][] = []; + let snapshotPath = ''; + let snapshotDirectory = ''; + let snapshotBytes = Buffer.alloc(0); + let snapshotFileMode = 0; + let snapshotDirectoryMode = 0; const child = new EventEmitter() as EventEmitter & { kill: ReturnType }; child.kill = vi.fn(); const fakeSpawn = (...args: unknown[]) => { calls.push(args); + const publishArguments = args[1] as string[]; + snapshotPath = publishArguments[publishArguments.indexOf('--js-path') + 1] ?? ''; + snapshotDirectory = dirname(snapshotPath); + writeFileSync(provenArtifactPath, 'test-only-replacement-after-attestation'); + snapshotBytes = readFileSync(snapshotPath); + snapshotFileMode = statSync(snapshotPath).mode & 0o777; + snapshotDirectoryMode = statSync(snapshotDirectory).mode & 0o777; queueMicrotask(() => child.emit('close', 0, null)); return child; }; @@ -571,10 +683,15 @@ describe('activation publish safety', () => { }); expect(calls).toHaveLength(1); expect(calls[0]?.[0]).toBe('spacetime'); + expect(snapshotPath).not.toBe(provenArtifactPath); + expect(snapshotPath).toBe(join(snapshotDirectory, 'module.js')); + expect(snapshotBytes).toEqual(Buffer.from('test-only-proven-spacetimedb-artifact')); + expect(snapshotFileMode).toBe(0o400); + expect(snapshotDirectoryMode).toBe(0o700); expect(calls[0]?.[1]).toEqual([ 'publish', '--server', 'https://maincloud.spacetimedb.com', - '--js-path', provenArtifactPath, + '--js-path', snapshotPath, '--delete-data=never', '--yes=remote', '--no-config', @@ -586,12 +703,237 @@ describe('activation publish safety', () => { }); expect(calls[0]?.[2]).not.toHaveProperty('shell'); expect(calls[0]?.[2]).toHaveProperty('env'); + expect(() => statSync(snapshotPath)).toThrow(); + expect(() => statSync(snapshotDirectory)).toThrow(); + }); + + it('executes only the attested CLI snapshot after its source path is replaced', async () => { + const sourceDirectory = await mkdtemp(join(tmpdir(), 'warpkeep-cli-source-')); + const sourcePath = join(sourceDirectory, 'spacetime-test'); + const original = Buffer.from('#!/bin/sh\nprintf original-cli'); + await writeFile(sourcePath, original, { mode: 0o700 }); + const digest = createHash('sha256').update(original).digest('hex'); + const snapshot = createPrivatePublishSnapshot(sourcePath, digest, 'executable'); + const snapshotPath = snapshot.path; + const snapshotDirectory = snapshot.directory; + try { + expect(snapshotPath).not.toBe(sourcePath); + expect(snapshotPath).toBe(join(snapshotDirectory, 'spacetime')); + expect(statSync(snapshotDirectory).mode & 0o777).toBe(0o700); + expect(statSync(snapshotPath).mode & 0o777).toBe(0o500); + + await writeFile(sourcePath, '#!/bin/sh\nprintf replaced-cli', { mode: 0o700 }); + const result = spawnSync(snapshotPath, [], { encoding: 'utf8' }); + expect(result.status).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stdout).toBe('original-cli'); + expect(readFileSync(snapshotPath)).toEqual(original); + } finally { + snapshot.cleanup(); + await rm(sourceDirectory, { recursive: true, force: true }); + } + expect(() => statSync(snapshotPath)).toThrow(); + expect(() => statSync(snapshotDirectory)).toThrow(); + }); + + it('requires an anonymous immutable-identity v11 schema and one exact additive v12 suffix', () => { + expect(canonicalSchemaDescribeChildArguments()).toEqual([ + 'describe', + '--json', + '--anonymous', + '--server', 'https://maincloud.spacetimedb.com', + '--no-config', + CANONICAL_DATABASE_IDENTITY, + ]); + expect(canonicalSchemaDescribeChildArguments()).not.toContain('warpkeep-89e4u'); + + const v11 = productionSchemaDescription(false); + const parsed = parseCanonicalSchemaDescription(JSON.stringify(v11)); + const v11TableNames = Object.keys(PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS); + const v11TableSchemaDigest = canonicalTableSchemaBoundaryDigest(parsed, v11TableNames); + const predecessor = verifyExactProductionV11Schema(parsed, v11TableSchemaDigest); + expect(Object.keys(predecessor)).toHaveLength(47); + + const calls: unknown[][] = []; + const v11Spawn = (...args: unknown[]) => { + calls.push(args); + return { status: 0, signal: null, stdout: JSON.stringify(v11), stderr: '' }; + }; + expect(verifyFreshProductionV11Schema( + 'spacetime', + v11TableSchemaDigest, + v11Spawn as never, + )) + .toEqual(predecessor); + expect(calls).toHaveLength(1); + expect(calls[0]?.[1]).toEqual(canonicalSchemaDescribeChildArguments()); + const options = calls[0]?.[2] as { env?: Record; input?: string }; + expect(options.input).toBe(''); + expect(options.env).not.toHaveProperty('WARPKEEP_ADMIN_TOKEN_SECRET'); + expect(options.env).not.toHaveProperty('WARPKEEP_ADMIN_TOKEN_SECRET_STDIN'); + + const v12 = productionSchemaDescription(true); + const v12TableNames = [ + ...v11TableNames, + ...Object.keys(WORKER_V12_TABLE_CONTRACTS), + ]; + const v12TableSchemaDigest = canonicalTableSchemaBoundaryDigest(v12, v12TableNames); + expect(verifyExactProductionV12Schema( + predecessor, + v12, + v12TableSchemaDigest, + )).toEqual({ + predecessorTableCount: 47, + appendedWorkerTableCount: 6, + totalTableCount: 53, + }); + expect(verifyPostPublishProductionV12Schema( + 'spacetime', + predecessor, + v12TableSchemaDigest, + (() => ({ status: 0, signal: null, stdout: JSON.stringify(v12), stderr: '' })) as never, + )).toEqual({ + predecessorTableCount: 47, + appendedWorkerTableCount: 6, + totalTableCount: 53, + }); + + const extraV11Table = structuredClone(v11); + extraV11Table.tables.push({ + name: 'unexpected_table', + product_type_ref: 47, + table_access: { Public: {} }, + indexes: [], + constraints: [], + }); + expect(() => verifyExactProductionV11Schema(extraV11Table, v11TableSchemaDigest)) + .toThrow(/table set/i); + + const allPublicV11 = structuredClone(v11); + allPublicV11.tables.find(table => table.name === 'admin_audit')!.table_access = { Public: {} }; + expect(() => verifyExactProductionV11Schema(allPublicV11, v11TableSchemaDigest)) + .toThrow(/proven publication boundary/i); + + const changedV11FieldType = structuredClone(v11); + const castleRef = PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS.castle; + changedV11FieldType.typespace.types[castleRef].Product.elements[0].algebraic_type = { + String: {}, + }; + expect(() => verifyExactProductionV11Schema(changedV11FieldType, v11TableSchemaDigest)) + .toThrow(/proven publication boundary/i); + + const changedV11Index = structuredClone(v11); + changedV11Index.tables.find(table => table.name === 'castle')!.indexes[0] = { + name: 'castle_by_primary', + algorithm: { BTree: { columns: [1] } }, + }; + expect(() => verifyExactProductionV11Schema(changedV11Index, v11TableSchemaDigest)) + .toThrow(/proven publication boundary/i); + + const changedV11Constraint = structuredClone(v11); + changedV11Constraint.tables.find(table => table.name === 'castle')!.constraints[0] = { + name: 'castle_primary', + data: { Unique: { columns: [1] } }, + }; + expect(() => verifyExactProductionV11Schema(changedV11Constraint, v11TableSchemaDigest)) + .toThrow(/proven publication boundary/i); + + const changedPredecessor = structuredClone(v12); + changedPredecessor.tables.find(table => table.name === 'castle')!.table_access = { Private: {} }; + expect(() => verifyExactProductionV12Schema( + predecessor, + changedPredecessor, + v12TableSchemaDigest, + )) + .toThrow(/pre-existing production table changed/i); + + const changedWorker = structuredClone(v12); + changedWorker.tables.find(table => table.name === 'worker_assignment_v1')!.table_access = { Public: {} }; + expect(() => verifyExactProductionV12Schema( + predecessor, + changedWorker, + v12TableSchemaDigest, + )) + .toThrow(/exact v12 contract/i); + + const workerRef = WORKER_V12_TABLE_CONTRACTS.castle_worker_v1.productTypeRef; + const changedWorkerFieldType = structuredClone(v12); + changedWorkerFieldType.typespace.types[workerRef].Product.elements[0].algebraic_type = { + U64: {}, + }; + expect(() => verifyExactProductionV12Schema( + predecessor, + changedWorkerFieldType, + v12TableSchemaDigest, + )).toThrow(/proven publication boundary/i); + + const changedWorkerIndex = structuredClone(v12); + changedWorkerIndex.tables.find(table => table.name === 'castle_worker_v1')!.indexes[0] = { + name: 'castle_worker_v1_by_primary', + algorithm: { BTree: { columns: [1] } }, + }; + expect(() => verifyExactProductionV12Schema( + predecessor, + changedWorkerIndex, + v12TableSchemaDigest, + )).toThrow(/proven publication boundary/i); + + const changedWorkerConstraint = structuredClone(v12); + changedWorkerConstraint.tables + .find(table => table.name === 'castle_worker_v1')!.constraints[0] = { + name: 'castle_worker_v1_primary', + data: { Unique: { columns: [1] } }, + }; + expect(() => verifyExactProductionV12Schema( + predecessor, + changedWorkerConstraint, + v12TableSchemaDigest, + )).toThrow(/proven publication boundary/i); + + const nestedV11 = structuredClone(v11); + const nestedRef = nestedV11.typespace.types.length; + nestedV11.typespace.types.push({ + Product: { elements: [{ name: { some: 'nested' }, algebraic_type: { U64: {} } }] }, + }); + nestedV11.typespace.types[castleRef].Product.elements[0].algebraic_type = { Ref: nestedRef }; + const nestedDigest = canonicalTableSchemaBoundaryDigest(nestedV11, v11TableNames); + expect(() => verifyExactProductionV11Schema(nestedV11, nestedDigest)).not.toThrow(); + const changedNestedType = structuredClone(nestedV11); + changedNestedType.typespace.types[nestedRef].Product.elements[0].algebraic_type = { + String: {}, + }; + expect(() => verifyExactProductionV11Schema(changedNestedType, nestedDigest)) + .toThrow(/proven publication boundary/i); + + const unrelatedSchema = structuredClone(nestedV11); + unrelatedSchema.typespace.types.push({ + Product: { elements: [{ name: { some: 'reducer_only' }, algebraic_type: { Bool: {} } }] }, + }); + Object.assign(unrelatedSchema, { + reducers: [{ name: 'unrelated_reducer' }], + procedures: [{ name: 'unrelated_procedure' }], + }); + expect(canonicalTableSchemaBoundaryDigest(unrelatedSchema, v11TableNames)) + .toBe(nestedDigest); + + const indeterminate = () => verifyPostPublishProductionV12Schema( + 'spacetime', + predecessor, + v12TableSchemaDigest, + (() => ({ status: 1, signal: null, stdout: 'private', stderr: 'private' })) as never, + ); + expect(indeterminate).toThrow(/indeterminate.*anonymous read-only schema inspection/i); + expect(indeterminate).not.toThrow(/private|retry/i); + expect(() => parseCanonicalSchemaDescription('private, not json')) + .toThrow(/machine-readable JSON/i); }); it('binds an exact single migration receipt and rejects artifact changes before spawn', async () => { await withTestProvenArtifact(async receipt => { const success = `${formatAdditiveMigrationProofReceipt({ summary: 'test-only receipt.', + v11TableSchemaDigest: receipt.v11TableSchemaDigest, + v12TableSchemaDigest: receipt.v12TableSchemaDigest, artifactDigest: receipt.artifactDigest, })}\n`; const parsed = parseMigrationProofReceipt(success); @@ -610,6 +952,25 @@ describe('activation publish safety', () => { .toThrow(/exact success receipt/i); expect(() => parseMigrationProofReceipt(success.replace('artifact_sha256=', 'artifact_digest='))) .toThrow(/exact success receipt/i); + expect(() => parseMigrationProofReceipt(success.replace( + ` v11_table_schema_sha256=${receipt.v11TableSchemaDigest}`, + '', + ))).toThrow(/exact success receipt/i); + expect(() => parseMigrationProofReceipt(success.replace( + ` v12_table_schema_sha256=${receipt.v12TableSchemaDigest}`, + ` v11_table_schema_sha256=${receipt.v11TableSchemaDigest}` + + ` v12_table_schema_sha256=${receipt.v12TableSchemaDigest}`, + ))).toThrow(/exact success receipt/i); + expect(() => parseMigrationProofReceipt(success.replace( + ` v11_table_schema_sha256=${receipt.v11TableSchemaDigest}` + + ` v12_table_schema_sha256=${receipt.v12TableSchemaDigest}`, + ` v12_table_schema_sha256=${receipt.v12TableSchemaDigest}` + + ` v11_table_schema_sha256=${receipt.v11TableSchemaDigest}`, + ))).toThrow(/exact success receipt/i); + expect(() => parseMigrationProofReceipt(success.replace( + receipt.v11TableSchemaDigest, + 'not-a-digest', + ))).toThrow(/exact success receipt/i); expect(() => parseMigrationProofReceipt(success.replace(receipt.artifactDigest, '0'.repeat(64)))) .toThrow(/changed after migration/i); expect(() => parseMigrationProofReceipt(success.replace(receipt.artifactDigest, 'not-a-digest'))) @@ -622,6 +983,10 @@ describe('activation publish safety', () => { ...receipt, artifactDigest: receipt.artifactDigest.toUpperCase(), })).toThrow(/receipt was invalid/i); + expect(() => verifyMigrationArtifactReceipt({ + ...receipt, + v12TableSchemaDigest: receipt.v12TableSchemaDigest.toUpperCase(), + })).toThrow(/receipt was invalid/i); expect(() => verifyMigrationArtifactReceipt({ ...receipt, extra: true })) .toThrow(/receipt was invalid/i); await expect(publishModule( @@ -649,6 +1014,8 @@ describe('activation publish safety', () => { const calls: unknown[][] = []; const success = `${formatAdditiveMigrationProofReceipt({ summary: 'test-only scheduler receipt.', + v11TableSchemaDigest: receipt.v11TableSchemaDigest, + v12TableSchemaDigest: receipt.v12TableSchemaDigest, artifactDigest: receipt.artifactDigest, })}\n`; const fakeSpawnSync = (...args: unknown[]) => { @@ -689,6 +1056,7 @@ describe('activation publish safety', () => { it('kills and rejects a publish whose combined output exceeds the fixed bound', async () => { await withTestProvenArtifact(async receipt => { + let snapshotPath = ''; const child = new EventEmitter() as EventEmitter & { kill: ReturnType; stdout: EventEmitter; @@ -701,12 +1069,18 @@ describe('activation publish safety', () => { 'spacetime', CANONICAL_DATABASE_IDENTITY, receipt, - (() => child) as never, + ((...args: unknown[]) => { + const publishArguments = args[1] as string[]; + snapshotPath = publishArguments[publishArguments.indexOf('--js-path') + 1] ?? ''; + return child; + }) as never, ); child.stdout.emit('data', Buffer.alloc(1_000_001)); child.emit('close', 1, 'SIGKILL'); await expect(publish).rejects.toThrow(/did not complete successfully/i); expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + expect(snapshotPath).not.toBe(''); + expect(() => statSync(snapshotPath)).toThrow(); }); }); @@ -714,19 +1088,23 @@ describe('activation publish safety', () => { expect(parsePublishArguments([ '--resource-rollout-stage=prebackfill', '--genesis-world-stage=pre-expansion', + '--worker-rollout-stage=empty', ])).toEqual({ dryRun: false, resourceRolloutStage: RESOURCE_PUBLISH_ROLLOUT_STAGE.PREBACKFILL, genesisWorldRolloutStage: GENESIS_WORLD_PUBLISH_STAGE.PRE_EXPANSION, + workerRolloutStage: WORKER_PUBLISH_ROLLOUT_STAGE.EMPTY, }); expect(parsePublishArguments([ '--resource-rollout-stage=ready', '--genesis-world-stage=expanded', + '--worker-rollout-stage=empty', '--dry-run', ])).toEqual({ dryRun: true, resourceRolloutStage: RESOURCE_PUBLISH_ROLLOUT_STAGE.READY, genesisWorldRolloutStage: GENESIS_WORLD_PUBLISH_STAGE.EXPANDED, + workerRolloutStage: WORKER_PUBLISH_ROLLOUT_STAGE.EMPTY, }); expect(() => parsePublishArguments([])).toThrow(/explicit resource rollout stage/i); expect(() => parsePublishArguments(['--dry-run'])).toThrow(/explicit resource rollout stage/i); @@ -736,23 +1114,43 @@ describe('activation publish safety', () => { '--dry-run', '--resource-rollout-stage=prebackfill', '--genesis-world-stage=pre-expansion', + '--worker-rollout-stage=empty', ])).toThrow(/unknown or duplicate/i); expect(() => parsePublishArguments([ '--resource-rollout-stage=prebackfill', '--resource-rollout-stage=ready', '--genesis-world-stage=pre-expansion', + '--worker-rollout-stage=empty', ])).toThrow(/unknown or duplicate/i); expect(() => parsePublishArguments([ '--resource-rollout-stage=unknown', '--genesis-world-stage=pre-expansion', + '--worker-rollout-stage=empty', ])).toThrow(/unknown or duplicate/i); expect(() => parsePublishArguments([ '--resource-rollout-stage=ready', + '--worker-rollout-stage=empty', ])).toThrow(/explicit Genesis world stage/i); expect(() => parsePublishArguments([ '--resource-rollout-stage=ready', '--genesis-world-stage=pre-expansion', '--genesis-world-stage=expanded', + '--worker-rollout-stage=empty', + ])).toThrow(/unknown or duplicate/i); + expect(() => parsePublishArguments([ + '--resource-rollout-stage=ready', + '--genesis-world-stage=expanded', + ])).toThrow(/explicit empty Worker rollout stage/i); + expect(() => parsePublishArguments([ + '--resource-rollout-stage=ready', + '--genesis-world-stage=expanded', + '--worker-rollout-stage=unknown', + ])).toThrow(/unknown or duplicate/i); + expect(() => parsePublishArguments([ + '--resource-rollout-stage=ready', + '--genesis-world-stage=expanded', + '--worker-rollout-stage=empty', + '--worker-rollout-stage=empty', ])).toThrow(/unknown or duplicate/i); expect(() => requireCanonicalPublishCoordinates({ WARPKEEP_SPACETIMEDB_DATABASE: 'warpkeep-lookalike', @@ -1123,6 +1521,7 @@ describe('activation publish safety', () => { expectedTermsAcceptanceCount: 1, }, RESOURCE_PUBLISH_ROLLOUT_STAGE.PREBACKFILL, + WORKER_PUBLISH_ROLLOUT_STAGE.EMPTY, orderedFailureSpawn as never, )).toThrow(/protocol-v3 verification is indeterminate/i); expect(orderedFailureCalls).toHaveLength(1); @@ -1140,8 +1539,20 @@ describe('activation publish safety', () => { expectedTermsAcceptanceCount: 1, }, 'unknown', + WORKER_PUBLISH_ROLLOUT_STAGE.EMPTY, orderedFailureSpawn as never, )).toThrow(/rollout stage was invalid/i); + expect(() => verifyPostPublishResourcePublicationCheckpoints( + testSecret, + { + expectedFounderCount: 4, + expectedPlayerCount: 1, + expectedTermsAcceptanceCount: 1, + }, + RESOURCE_PUBLISH_ROLLOUT_STAGE.PREBACKFILL, + 'staged', + orderedFailureSpawn as never, + )).toThrow(/Worker rollout stage was invalid/i); }); it('requires one closed, privacy-safe v8 checkpoint after publication and before seeding', () => { @@ -1250,6 +1661,84 @@ describe('activation publish safety', () => { expect(postPublishFailure).not.toThrow(/retry/i); }); + it('requires the exact empty and inert Worker v12 checkpoint after publication', () => { + const calls: unknown[][] = []; + const aggregate = alphaStatusV12(); + const fakeSpawnSync = (...args: unknown[]) => { + calls.push(args); + return { + status: 0, + signal: null, + stdout: JSON.stringify(aggregate), + stderr: '', + }; + }; + const secret = 'TEST_ONLY_HERMES_SECRET_'.repeat(2); + expect(verifyFreshAlphaStatusV12Aggregate(secret, 4, fakeSpawnSync)) + .toEqual(aggregate); + expect(calls).toHaveLength(1); + expect(calls[0]?.[1]).toEqual(alphaV12AggregateChildArguments( + resolve(repositoryRoot, 'node_modules/tsx/dist/cli.mjs'), + )); + const options = calls[0]?.[2] as { env?: Record; input?: string }; + expect(options.input).toBe(secret); + expect(options.env).toEqual({ + WARPKEEP_SPACETIMEDB_URI: 'https://maincloud.spacetimedb.com', + WARPKEEP_SPACETIMEDB_DATABASE: CANONICAL_DATABASE_IDENTITY, + WARPKEEP_AUTH_BRIDGE_URL: ISSUER, + WARPKEEP_ADMIN_TOKEN_SECRET_STDIN: '1', + }); + expect(JSON.stringify(calls[0]?.[1])).not.toContain(secret); + expect(JSON.stringify(options.env)).not.toContain(secret); + expect(() => verifyPostPublishAlphaStatusV12Aggregate(secret, 4, fakeSpawnSync)) + .not.toThrow(); + + for (const invalid of [ + { ...aggregate, fid: '424242424242' }, + { ...aggregate, assignments: 0 }, + { ...aggregate, assignments: '00' }, + { ...aggregate, assignments: '18446744073709551616' }, + { ...aggregate, systemConfigValid: 'false' }, + { ...aggregate, mode: 'disabled' }, + { ...aggregate, rosterDigestExpected: 'not-a-digest' }, + ]) { + expect(() => verifyPrivacySafeAlphaStatusV12Output(JSON.stringify(invalid))) + .toThrow(); + } + for (const nonempty of [ + { ...aggregate, systemRows: '1' }, + { ...aggregate, mode: 'staged' }, + { ...aggregate, systemConfigValid: true }, + { ...aggregate, legacyDrainRequired: false }, + { ...aggregate, expectedCountsMatch: true }, + { ...aggregate, rosterDigestMatches: true }, + { ...aggregate, castlesMissingWorkers: '3' }, + { ...aggregate, actualWorkerCount: '1' }, + { ...aggregate, assignments: '1' }, + { ...aggregate, occupations: '1' }, + { ...aggregate, schedules: '1' }, + { ...aggregate, idempotencyReceipts: '1' }, + { ...aggregate, rosterDigest: '0123456789abcdef' }, + ]) { + expect(() => verifyEmptyAlphaStatusV12( + verifyPrivacySafeAlphaStatusV12Output(JSON.stringify(nonempty)), + 4, + )).toThrow(/empty, inert Worker suffix/i); + } + expect(() => verifyEmptyAlphaStatusV12( + verifyPrivacySafeAlphaStatusV12Output(JSON.stringify(aggregate)), + 0, + )).toThrow(/expected founder count/i); + + const postPublishFailure = () => verifyPostPublishAlphaStatusV12Aggregate( + secret, + 4, + (() => ({ status: 1, signal: null, stdout: 'private', stderr: 'private' })) as never, + ); + expect(postPublishFailure).toThrow(/read-only v12 inspection.*before any merge/i); + expect(postPublishFailure).not.toThrow(/private|retry/i); + }); + it('enforces a hard deadline with graceful then forced termination', async () => { vi.useFakeTimers(); const child = new EventEmitter() as EventEmitter & { kill: ReturnType }; @@ -1366,12 +1855,49 @@ describe('activation publish safety', () => { expect(removeDirectory).toHaveBeenCalledTimes(1); }); + it('runs one synchronous cleanup and exits with the received signal status', () => { + const processTarget = new EventEmitter() as EventEmitter & { + exit: ReturnType; + }; + processTarget.exit = vi.fn(); + const cleanup = vi.fn(); + const remove = installMigrationProofSignalCleanup(cleanup, processTarget); + + processTarget.emit('SIGINT'); + processTarget.emit('SIGTERM'); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect(processTarget.exit).toHaveBeenCalledTimes(1); + expect(processTarget.exit).toHaveBeenCalledWith(130); + expect(processTarget.listenerCount('SIGINT')).toBe(0); + expect(processTarget.listenerCount('SIGTERM')).toBe(0); + expect(() => remove()).not.toThrow(); + }); + + it('fails closed without exposing signal-cleanup errors', () => { + const processTarget = new EventEmitter() as EventEmitter & { + exit: ReturnType; + }; + processTarget.exit = vi.fn(); + const cleanup = vi.fn(() => { + throw new Error('test-only-private-cleanup-detail'); + }); + installMigrationProofSignalCleanup(cleanup, processTarget); + + expect(() => processTarget.emit('SIGTERM')).not.toThrow(); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(processTarget.exit).toHaveBeenCalledWith(1); + expect(processTarget.listenerCount('SIGINT')).toBe(0); + expect(processTarget.listenerCount('SIGTERM')).toBe(0); + }); + it('returns a failing status when dry-run issuer configuration is absent', () => { const result = spawnSync(process.execPath, [ 'scripts/publish-spacetime-dev.mjs', '--dry-run', '--resource-rollout-stage=prebackfill', '--genesis-world-stage=pre-expansion', + '--worker-rollout-stage=empty', ], { cwd: repositoryRoot, encoding: 'utf8', @@ -1389,6 +1915,7 @@ describe('activation publish safety', () => { '--dry-run', '--resource-rollout-stage=prebackfill', '--genesis-world-stage=pre-expansion', + '--worker-rollout-stage=empty', ], { cwd: repositoryRoot, encoding: 'utf8', diff --git a/tests/buildInfo.test.ts b/tests/buildInfo.test.ts index 048debe7..7e9b8041 100644 --- a/tests/buildInfo.test.ts +++ b/tests/buildInfo.test.ts @@ -24,7 +24,7 @@ describe('Warpkeep build identity', () => { ); }); - it('reads the 0.3.13 product version from package metadata', () => { + it('reads the 0.3.14 product version from package metadata', () => { const packageJson = JSON.parse( readFileSync(resolve(process.cwd(), 'package.json'), 'utf8') ) as { version?: unknown }; @@ -32,7 +32,7 @@ describe('Warpkeep build identity', () => { readFileSync(resolve(process.cwd(), 'package-lock.json'), 'utf8') ) as { version?: unknown; packages?: Record }; - expect(readWarpkeepProductVersion(packageJson.version)).toBe('0.3.13'); + expect(readWarpkeepProductVersion(packageJson.version)).toBe('0.3.14'); expect(packageLock.version).toBe(packageJson.version); expect(packageLock.packages?.['']?.version).toBe(packageJson.version); expect(readWarpkeepProductVersion('1.0.0-alpha.1+build.7')).toBe('1.0.0-alpha.1+build.7'); diff --git a/tests/canonicalGenesisSnapshot.test.ts b/tests/canonicalGenesisSnapshot.test.ts index 9d050571..f556eb30 100644 --- a/tests/canonicalGenesisSnapshot.test.ts +++ b/tests/canonicalGenesisSnapshot.test.ts @@ -16,6 +16,12 @@ import type { WarpkeepRealmSnapshotCandidate } from '../src/spacetime/warpkeepBa import { CANONICAL_GENESIS_FOREST_LAYOUT_V1 } from '../spacetimedb/src/forestLayoutPolicy'; +import { + GENESIS_WATER_BODIES_V1, + GENESIS_WATER_CELLS_V1, + GENESIS_WATER_ENVIRONMENT_V1, + GENESIS_WATER_LAYOUT_V1 +} from '../spacetimedb/src/waterWorld'; import { CANONICAL_TEST_CASTLE_ID, CANONICAL_TEST_FID, @@ -91,6 +97,28 @@ describe('canonical Genesis 001 browser snapshot boundary', () => { .toBe(GENESIS_GENERATION_V3_SNAPSHOT_FINGERPRINT); }); + it('normalizes the Water environment timestamp at the canonical browser boundary', () => { + const candidate = createCanonicalGenesisCandidate(); + const snapshot = validate({ + ...candidate, + waterLayout: { ...GENESIS_WATER_LAYOUT_V1, activated: true }, + waterBodies: GENESIS_WATER_BODIES_V1.map((row) => ({ ...row })), + waterCells: GENESIS_WATER_CELLS_V1.map((row) => ({ ...row })), + realmEnvironment: { + ...GENESIS_WATER_ENVIRONMENT_V1, + updatedAt: { microsSinceUnixEpoch: 1_752_408_000_123_456n }, + privateSdkDetail: 'must-not-cross' + } + }); + + expect(snapshot.realmEnvironment).toMatchObject({ + updatedAtMicros: 1_752_408_000_123_456n + }); + expect(snapshot.realmEnvironment).not.toHaveProperty('updatedAt'); + expect(snapshot.realmEnvironment).not.toHaveProperty('privateSdkDetail'); + expect(Object.isFrozen(snapshot.realmEnvironment)).toBe(true); + }); + it('treats radius 58 as a maximum envelope, not as a complete radius-58 disc', () => { const snapshot = validate(createCanonicalGenesisCandidate({ generationVersion: 3 })); const ring58 = snapshot.tiles.filter((tile) => ( diff --git a/tests/graphicsPreference.test.ts b/tests/graphicsPreference.test.ts index 7c94dae8..286b59d1 100644 --- a/tests/graphicsPreference.test.ts +++ b/tests/graphicsPreference.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_GRAPHICS_PREFERENCE, + probeWebGL2Capability, + resetWebGL2CapabilityForTests, WARPKEEP_GRAPHICS_PREFERENCE_KEY, parseGraphicsPreference, readGraphicsPreference, @@ -23,9 +25,44 @@ function memoryStorage() { afterEach(() => { vi.restoreAllMocks(); + resetWebGL2CapabilityForTests(); }); describe('graphics preference', () => { + it('shares one non-destructive WebGL2 capability probe', () => { + const context = { + MAX_TEXTURE_SIZE: 0x0d33, + getParameter: vi.fn(() => 8_192), + getExtension: vi.fn() + }; + const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockImplementation(((contextId: string) => ( + contextId === 'webgl2' ? context : null + )) as typeof HTMLCanvasElement.prototype.getContext); + + expect(probeWebGL2Capability()).toEqual({ available: true, maxTextureSize: 8_192 }); + expect(probeWebGL2Capability()).toEqual({ available: true, maxTextureSize: 8_192 }); + expect(getContext).toHaveBeenCalledTimes(1); + expect(context.getExtension).not.toHaveBeenCalled(); + }); + + it('reprobes a transient negative WebGL2 result and caches only recovery success', () => { + const context = { + MAX_TEXTURE_SIZE: 0x0d33, + getParameter: vi.fn(() => 8_192) + }; + const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockReturnValueOnce(null) + .mockImplementation(((contextId: string) => ( + contextId === 'webgl2' ? context : null + )) as typeof HTMLCanvasElement.prototype.getContext); + + expect(probeWebGL2Capability()).toEqual({ available: false }); + expect(probeWebGL2Capability()).toEqual({ available: true, maxTextureSize: 8_192 }); + expect(probeWebGL2Capability()).toEqual({ available: true, maxTextureSize: 8_192 }); + expect(getContext).toHaveBeenCalledTimes(2); + }); + it('validates and persists only the versioned visual preference', () => { const storage = memoryStorage(); expect(parseGraphicsPreference('obsolete')).toBe(DEFAULT_GRAPHICS_PREFERENCE); diff --git a/tests/hegemonyKeepPrefabRepository.test.ts b/tests/hegemonyKeepPrefabRepository.test.ts index 658e6de3..82005141 100644 --- a/tests/hegemonyKeepPrefabRepository.test.ts +++ b/tests/hegemonyKeepPrefabRepository.test.ts @@ -466,4 +466,28 @@ describe('Hegemony keep prefab repository', () => { await expect(repository.acquire('compact')).rejects.toBe(failure); expect(loader).toHaveBeenCalledTimes(1); }); + + it('replaces a settled failed entry only for an explicit coalesced retry', async () => { + const failure = new Error('transient compact transport failure'); + const root = new THREE.Group(); + root.add(new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial() + )); + const loader: HegemonyKeepPrefabLoader = vi.fn() + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce(loadedKeep(root, 'compact')); + const repository = createHegemonyKeepPrefabRepository({ loader }); + + await expect(repository.acquire('compact')).rejects.toBe(failure); + const [first, second] = await Promise.all([ + repository.retryFailed('compact'), + repository.retryFailed('compact') + ]); + + expect(loader).toHaveBeenCalledTimes(2); + expect(first.prefab).toBe(second.prefab); + first.release(); + second.release(); + }); }); diff --git a/tests/hermesAdminSecurity.test.ts b/tests/hermesAdminSecurity.test.ts index d32d94f3..840ad721 100644 --- a/tests/hermesAdminSecurity.test.ts +++ b/tests/hermesAdminSecurity.test.ts @@ -15,6 +15,7 @@ import { FOUNDER_ADMISSION_SOURCE_CONFIGURATION_DIGEST, parseHermesArguments, privacySafeHermesErrorMessage, + projectWorkerSystemStatusV12, readStatus, requestAdminToken, requireAlphaComponentActivationProductionTarget, @@ -128,6 +129,32 @@ function foundedGenerationV3Status(overrides: Record = {}) { + const counts = Object.fromEntries([ + 'systemRows', 'expectedCastleCount', 'expectedWorkerCount', 'actualWorkerCount', + 'castlesMissingWorkers', 'castlesWithExtraWorkers', 'duplicateOrdinals', + 'malformedWorkerIds', 'invalidWorkerStates', 'idleWorkers', 'outboundWorkers', + 'gatheringWorkers', 'returningWorkers', 'assignments', 'occupations', 'schedules', + 'orphanWorkers', 'orphanAssignments', 'assignmentsMissingOccupation', + 'assignmentsWithoutSingleSchedule', 'orphanOccupations', 'orphanSchedules', + 'invalidSchedules', 'assignmentPublicMismatches', 'occupationSiteMismatches', + 'invalidAssignments', 'idempotencyReceipts', 'invalidIdempotencyReceipts', + 'idempotencyOverflowFids', 'legacyExpeditions', 'legacyOccupations', + 'legacySchedules', + ].map(field => [field, 0n])); + return { + ...counts, + mode: 'absent', + systemConfigValid: false, + legacyDrainRequired: true, + expectedCountsMatch: false, + rosterDigestMatches: false, + rosterDigest: '', + rosterDigestExpected: '0123456789abcdef', + ...overrides, + }; +} + describe('Hermes machine-readable output', () => { afterEach(() => { setGlobalLogLevel('info'); @@ -309,6 +336,46 @@ describe('Hermes machine-readable output', () => { expect(rendered).not.toContain('must-not-escape'); }); + it('projects the Worker v12 inspection to one exact aggregate-only contract', async () => { + const output = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const status = workerSystemStatusV12({ + castlesMissingWorkers: 4n, + legacyExpeditions: 2n, + }); + const procedure = vi.fn(async () => status); + const connection = { + procedures: { adminGetWorkerSystemStatusV1: procedure }, + }; + + const projected = await readStatus(connection as never, 'v12', true); + expect(procedure).toHaveBeenCalledWith({}); + expect(output).toHaveBeenCalledOnce(); + const rendered = output.mock.calls[0]?.[0] as string; + const parsed = JSON.parse(rendered) as Record; + expect(parsed.castlesMissingWorkers).toBe('4'); + expect(parsed.legacyExpeditions).toBe('2'); + expect(parsed.mode).toBe('absent'); + expect(parsed).not.toHaveProperty('fid'); + expect((projected as Readonly>).castlesMissingWorkers).toBe(4n); + }); + + it('rejects changed Worker v12 keys, non-u64 counts, flags, modes, and digests', () => { + const valid = workerSystemStatusV12(); + expect(projectWorkerSystemStatusV12(valid)).toMatchObject(valid); + for (const invalid of [ + { ...valid, fid: 424_242n }, + { ...valid, assignments: '0' }, + { ...valid, assignments: -1n }, + { ...valid, assignments: 1n << 64n }, + { ...valid, systemConfigValid: 'false' }, + { ...valid, mode: 'disabled' }, + { ...valid, rosterDigest: 'ABCDEF0123456789' }, + { ...valid, rosterDigestExpected: '' }, + ]) { + expect(() => projectWorkerSystemStatusV12(invalid)).toThrow(); + } + }); + it('requires the exact post-backfill founder graph before reporting success', () => { const valid = { allowedFids: 4n, @@ -560,6 +627,11 @@ describe('Hermes command-line boundary', () => { inspection: true, machineReadableInspection: true, }); + expect(parseHermesArguments(['inspect-alpha-v12', '--json'])).toMatchObject({ + command: 'inspect-alpha-v12', + inspection: true, + machineReadableInspection: true, + }); expect(parseHermesArguments(['seed-alpha-component', 'gold', '--dry-run'])).toMatchObject({ command: 'seed-alpha-component', inspection: false, @@ -863,7 +935,7 @@ describe('Hermes credential destination policy', () => { expect(result.stderr).toBe(''); }); - it.each(['v2', 'v3', 'v4', 'v8', 'v10'])('rejects misleading dry-run use on read-only protocol-%s inspection', (version) => { + it.each(['v2', 'v3', 'v4', 'v8', 'v10', 'v12'])('rejects misleading dry-run use on read-only protocol-%s inspection', (version) => { const result = runHermes([`inspect-alpha-${version}`, '--json', '--dry-run'], { WARPKEEP_AUTH_BRIDGE_URL: undefined, WARPKEEP_ADMIN_TOKEN_SECRET: undefined, diff --git a/tests/latestPatchNotes.test.ts b/tests/latestPatchNotes.test.ts index a8997e4e..725c7593 100644 --- a/tests/latestPatchNotes.test.ts +++ b/tests/latestPatchNotes.test.ts @@ -17,26 +17,28 @@ describe('latest in-menu patch notes', () => { expect(Object.keys(WARPKEEP_PATCH_NOTES_BY_VERSION)).toContain(packageJson.version); expect(getLatestPatchNotes(packageJson.version)).toMatchObject({ - releasedOn: '19 JUL 2026', - title: 'THE LIVING LOWLANDS' + releasedOn: '22 JUL 2026', + title: 'A STEADIER FRONTIER' }); expect(getLatestPatchNotes(packageJson.version)?.highlights.join(' ')).toMatch( - /lakes have receded.*twelve persistent one-cell rivers.*ocean/i + /recovers from temporary graphics interruptions.*selection.*camera intent/i ); expect(getLatestPatchNotes(packageJson.version)?.highlights.join(' ')).toMatch( - /pan through the coast and open water.*full fog boundary.*strategic overview/i + /Castle rendering.*compact detail.*optional richer models/i ); expect(getLatestPatchNotes(packageJson.version)?.highlights.join(' ')).toMatch( - /Grass.*biome patterns.*forests.*natural groves.*rivers.*roads.*keeps.*resource sites stay clear/i + /Water surfaces.*move gently.*river and ocean cells.*read-only public records/i ); expect(getLatestPatchNotes(packageJson.version)?.highlights.join(' ')).toMatch( - /Moving supply wagons can be selected.*active expedition/i + /clearer green palette.*denser grass.*without changing authoritative terrain, ownership, or resource rules/i ); expect(getLatestPatchNotes(packageJson.version)?.highlights.join(' ')).not.toMatch( /released to players|deployed to players|public balances|guaranteed rewards/i ); - expect(getLatestPatchNotes(packageJson.version)?.summary).toContain('frontier'); + expect(getLatestPatchNotes(packageJson.version)?.summary).toContain('Genesis 001'); expect(getLatestPatchNotes(packageJson.version)?.alphaNotice).toContain('unfinished'); + expect(getLatestPatchNotes(packageJson.version)?.alphaNotice).toContain('not live yet'); + expect(getLatestPatchNotes('0.3.13')).toBeUndefined(); expect(getLatestPatchNotes('0.3.12')).toBeUndefined(); expect(getLatestPatchNotes('0.3.8')).toBeUndefined(); expect(getLatestPatchNotes('0.0.0')).toBeUndefined(); diff --git a/tests/localVitePublicBoundary.test.ts b/tests/localVitePublicBoundary.test.ts new file mode 100644 index 00000000..c6eac7bc --- /dev/null +++ b/tests/localVitePublicBoundary.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + WARPKEEP_LOCAL_VITE_FS_DENY, + warpkeepLocalPublicBoundaryPlugin, +} from '../scripts/qa-observer/local-vite-fs-deny.mjs'; + +type BoundaryMiddleware = ( + request: Readonly<{ url?: string }>, + response: Readonly<{ + end: ReturnType; + setHeader: ReturnType; + }> & { statusCode: number }, + next: ReturnType, +) => void; + +function configuredBoundary(publicDirectory: string, base = '/') { + const plugin = warpkeepLocalPublicBoundaryPlugin(); + if (typeof plugin.configResolved !== 'function' || typeof plugin.configureServer !== 'function') { + throw new Error('Expected the Warpkeep local public boundary hooks.'); + } + plugin.configResolved.call({} as never, { publicDir: publicDirectory, base } as never); + let middleware: BoundaryMiddleware | undefined; + plugin.configureServer.call({} as never, { + middlewares: { + use(candidate: BoundaryMiddleware) { + middleware = candidate; + }, + }, + } as never); + if (!middleware) throw new Error('Expected the Warpkeep local public boundary middleware.'); + return middleware; +} + +function invoke(middleware: BoundaryMiddleware, url: string) { + const response = { + statusCode: 200, + end: vi.fn(), + setHeader: vi.fn(), + }; + const next = vi.fn(); + middleware({ url }, response, next); + return { next, response }; +} + +describe('local Vite public boundary', () => { + it('denies every ignored credential, capture, database, and recovery class', () => { + expect(WARPKEEP_LOCAL_VITE_FS_DENY).toEqual(expect.arrayContaining([ + '.env', + '.env.*', + '.dev.vars*', + '.npmrc', + 'credentials.json', + '*.{cer,key,p12,pfx,jks,keystore,jwk,token}', + '*.local', + '*.{log,har,trace}', + '*.{bak,backup,tmp}', + '*.{sqlite,sqlite3,db,dump}', + '*.{zip,tar,tar.gz,tgz,7z}', + '**/.git/**', + '**/.cache/**', + '**/.wrangler/**', + '**/.secrets/**', + ])); + }); + + it.each([ + 'admin-secret.txt', + 'private-session.har', + 'operator.trace', + 'session.sqlite3', + 'recovery.tar.gz', + ])('refuses a pre-existing sensitive public artifact: %s', (filename) => { + const root = mkdtempSync(join(tmpdir(), 'warpkeep-public-boundary-')); + const publicDirectory = join(root, 'public'); + mkdirSync(publicDirectory); + writeFileSync(join(publicDirectory, filename), 'controlled non-secret fixture'); + try { + expect(() => configuredBoundary(publicDirectory)).toThrow( + 'Warpkeep public directory contains a prohibited local artifact.', + ); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it('blocks sensitive and encoded filenames created after startup', () => { + const root = mkdtempSync(join(tmpdir(), 'warpkeep-public-request-')); + const publicDirectory = join(root, 'public'); + mkdirSync(publicDirectory); + try { + const middleware = configuredBoundary(publicDirectory); + writeFileSync(join(publicDirectory, 'private-session.har'), 'controlled non-secret fixture'); + for (const requestPath of ['/private-session.har', '/private-session%2Ehar']) { + const { next, response } = invoke(middleware, requestPath); + expect(next).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(404); + expect(response.setHeader).toHaveBeenCalledWith('cache-control', 'no-store'); + expect(response.end).toHaveBeenCalledWith('Not Found\n'); + } + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it('blocks a public root swapped to an external symlink after startup', () => { + const root = mkdtempSync(join(tmpdir(), 'warpkeep-public-root-swap-')); + const publicDirectory = join(root, 'public'); + const outside = join(root, 'outside'); + mkdirSync(publicDirectory); + mkdirSync(outside); + writeFileSync(join(outside, 'innocent.txt'), 'controlled non-secret fixture'); + try { + const middleware = configuredBoundary(publicDirectory); + rmSync(publicDirectory, { recursive: true }); + symlinkSync(outside, publicDirectory, 'dir'); + const { next, response } = invoke(middleware, '/innocent.txt'); + expect(next).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(404); + expect(response.setHeader).toHaveBeenCalledWith('cache-control', 'no-store'); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it('allows an ordinary regular public asset and fails closed on malformed encoding', () => { + const root = mkdtempSync(join(tmpdir(), 'warpkeep-public-ordinary-')); + const publicDirectory = join(root, 'public'); + mkdirSync(publicDirectory); + writeFileSync(join(publicDirectory, 'ordinary.svg'), ''); + try { + const middleware = configuredBoundary(publicDirectory); + expect(invoke(middleware, '/ordinary.svg').next).toHaveBeenCalledTimes(1); + const malformed = invoke(middleware, '/%E0%A4%A'); + expect(malformed.next).not.toHaveBeenCalled(); + expect(malformed.response.statusCode).toBe(404); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); +}); From 238d76eeb430f96e2819674279b1909ba32ac889 Mon Sep 17 00:00:00 2001 From: Ael <183124839+ael-dev3@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:05:57 +0200 Subject: [PATCH 09/10] Alpha 0.3.14 signed source (9/10) GitHub-signed reconstruction of the exact reviewed Alpha 0.3.14 tree. --- tests/menuFarcasterAuthIntegration.test.tsx | 2 +- tests/menuMainMenu.test.tsx | 40 +- tests/playerModuleBindings.test.ts | 32 ++ tests/qaJourneyLab.test.tsx | 2 +- tests/realmAccessibilityControls.test.tsx | 65 ++- tests/realmCameraController.test.ts | 57 +++ tests/realmCastlePresentation.test.ts | 14 + tests/realmGrassGenesisBounds.test.ts | 8 +- tests/realmGrassLayer.test.ts | 20 + tests/realmGrassVisualContract.test.ts | 12 +- tests/realmHud.test.tsx | 262 ++++++++++- tests/realmInteractionState.test.ts | 59 +++ tests/realmMapScreen.test.tsx | 21 +- tests/realmObserverUi.test.tsx | 2 +- tests/realmPickArbitration.test.ts | 54 +++ tests/realmQualityRecreation.test.tsx | 65 ++- tests/realmRendererRecovery.test.ts | 129 +++++ tests/realmRendererRecoveryUi.test.tsx | 130 ++++++ tests/realmSceneCleanup.test.ts | 388 ++++++++++++++- tests/realmTerrainFeatures.test.ts | 48 +- .../realmWaterInspectionPresentation.test.ts | 98 ++++ tests/realmWaterLayer.test.ts | 442 +++++++++++++++++- tests/realmWaterNavigation.test.ts | 11 +- tests/realmWaterPhase.test.ts | 88 ++++ 24 files changed, 1987 insertions(+), 62 deletions(-) create mode 100644 tests/realmRendererRecovery.test.ts create mode 100644 tests/realmRendererRecoveryUi.test.tsx create mode 100644 tests/realmWaterInspectionPresentation.test.ts create mode 100644 tests/realmWaterPhase.test.ts diff --git a/tests/menuFarcasterAuthIntegration.test.tsx b/tests/menuFarcasterAuthIntegration.test.tsx index fcd2aaa7..d36ba05e 100644 --- a/tests/menuFarcasterAuthIntegration.test.tsx +++ b/tests/menuFarcasterAuthIntegration.test.tsx @@ -791,7 +791,7 @@ describe('WarpkeepMainMenu Farcaster authentication integration', () => { 'CREDITS' ]); expect(screen.getByRole('button', { - name: 'Open patch notes for Warpkeep ALPHA 0.3.13' + name: 'Open patch notes for Warpkeep ALPHA 0.3.14' })).not.toBeNull(); expect(screen.queryByRole('button', { name: 'CONTINUE' })).toBeNull(); diff --git a/tests/menuMainMenu.test.tsx b/tests/menuMainMenu.test.tsx index 5137b7dc..65809038 100644 --- a/tests/menuMainMenu.test.tsx +++ b/tests/menuMainMenu.test.tsx @@ -22,7 +22,7 @@ function installMotionPreference(matches = false) { function getPatchNotesTrigger(options: { hidden?: boolean } = {}) { return screen.getByRole('button', { ...options, - name: 'Open patch notes for Warpkeep ALPHA 0.3.13' + name: 'Open patch notes for Warpkeep ALPHA 0.3.14' }); } @@ -131,11 +131,11 @@ describe('WarpkeepMainMenu', () => { act(() => patchNotes.focus()); expect(screen.getByRole('status').textContent).toContain('living frontier'); - expect(screen.queryByRole('region', { name: 'THE LIVING LOWLANDS' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'A STEADIER FRONTIER' })).toBeNull(); fireEvent.click(patchNotes, { detail: 0 }); expect(screen.queryByRole('status')).toBeNull(); - expect(screen.getByRole('region', { name: 'THE LIVING LOWLANDS' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'A STEADIER FRONTIER' })).not.toBeNull(); expect(document.activeElement).toBe(patchNotes); }); @@ -239,25 +239,25 @@ describe('WarpkeepMainMenu', () => { expect(patchNotes.getAttribute('aria-expanded')).toBe('false'); expect(patchNotes.getAttribute('aria-controls')).toBe('warpkeep-latest-patch-notes'); - expect(screen.queryByRole('region', { name: 'THE LIVING LOWLANDS' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'A STEADIER FRONTIER' })).toBeNull(); act(() => patchNotes.focus()); - expect(screen.queryByRole('region', { name: 'THE LIVING LOWLANDS' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'A STEADIER FRONTIER' })).toBeNull(); fireEvent.click(patchNotes, { detail: 0 }); - const notes = screen.getByRole('region', { name: 'THE LIVING LOWLANDS' }); + const notes = screen.getByRole('region', { name: 'A STEADIER FRONTIER' }); expect(patchNotes.getAttribute('aria-expanded')).toBe('true'); - expect(notes.textContent).toContain('LATEST PATCH · ALPHA 0.3.13'); - expect(notes.textContent).toContain('The old scattered lakes have receded'); - expect(notes.textContent).toContain('Moving supply wagons can be selected'); - expect(notes.textContent).toContain('Alpha 0.3.13 is an unfinished'); + expect(notes.textContent).toContain('LATEST PATCH · ALPHA 0.3.14'); + expect(notes.textContent).toContain('recovers from temporary graphics interruptions'); + expect(notes.textContent).toContain('read-only public records'); + expect(notes.textContent).toContain('Alpha 0.3.14 is an unfinished'); expect(notes.getAttribute('tabindex')).toBe('0'); expect(within(notes).queryByRole('link')).toBeNull(); act(() => notes.focus()); expect(document.activeElement).toBe(notes); fireEvent.keyDown(document, { key: 'Escape' }); - expect(screen.queryByRole('region', { name: 'THE LIVING LOWLANDS' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'A STEADIER FRONTIER' })).toBeNull(); expect(document.activeElement).toBe(patchNotes); expect(onRequestReturn).not.toHaveBeenCalled(); @@ -270,19 +270,19 @@ describe('WarpkeepMainMenu', () => { const patchNotes = getPatchNotesTrigger(); fireEvent.pointerEnter(patchNotes, { pointerType: 'mouse' }); - expect(screen.getByRole('region', { name: 'THE LIVING LOWLANDS' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'A STEADIER FRONTIER' })).not.toBeNull(); fireEvent.pointerDown(document.body, { pointerType: 'mouse' }); - expect(screen.queryByRole('region', { name: 'THE LIVING LOWLANDS' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'A STEADIER FRONTIER' })).toBeNull(); fireEvent.pointerDown(patchNotes, { pointerType: 'touch' }); patchNotes.focus(); fireEvent.click(patchNotes); - expect(screen.getByRole('region', { name: 'THE LIVING LOWLANDS' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'A STEADIER FRONTIER' })).not.toBeNull(); fireEvent.pointerDown(patchNotes, { pointerType: 'touch' }); fireEvent.click(patchNotes); - expect(screen.queryByRole('region', { name: 'THE LIVING LOWLANDS' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'A STEADIER FRONTIER' })).toBeNull(); }); it('keeps hover notes reachable across the anchor gap and toggles by activation', () => { @@ -291,21 +291,21 @@ describe('WarpkeepMainMenu', () => { const patchNotes = getPatchNotesTrigger(); fireEvent.pointerEnter(patchNotes, { pointerType: 'mouse' }); - expect(screen.getByRole('region', { name: 'THE LIVING LOWLANDS' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'A STEADIER FRONTIER' })).not.toBeNull(); fireEvent.pointerLeave(patchNotes, { pointerType: 'mouse' }); act(() => vi.advanceTimersByTime(250)); - const panel = screen.getByRole('region', { name: 'THE LIVING LOWLANDS' }); + const panel = screen.getByRole('region', { name: 'A STEADIER FRONTIER' }); fireEvent.pointerEnter(panel, { pointerType: 'mouse' }); act(() => vi.advanceTimersByTime(200)); - expect(screen.getByRole('region', { name: 'THE LIVING LOWLANDS' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'A STEADIER FRONTIER' })).not.toBeNull(); fireEvent.click(patchNotes); fireEvent.pointerLeave(patchNotes, { pointerType: 'mouse' }); act(() => vi.advanceTimersByTime(500)); - expect(screen.getByRole('region', { name: 'THE LIVING LOWLANDS' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'A STEADIER FRONTIER' })).not.toBeNull(); fireEvent.click(patchNotes); - expect(screen.queryByRole('region', { name: 'THE LIVING LOWLANDS' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'A STEADIER FRONTIER' })).toBeNull(); }); it('keeps inactive menu controls hidden, inert, and outside the tab order', () => { diff --git a/tests/playerModuleBindings.test.ts b/tests/playerModuleBindings.test.ts index 3c041514..8e3919c9 100644 --- a/tests/playerModuleBindings.test.ts +++ b/tests/playerModuleBindings.test.ts @@ -8,6 +8,7 @@ import { DbConnection, tables as playerTables } from '../src/spacetime/playerMod const PLAYER_TABLE_KEYS = [ 'castle', + 'castleWorkerV1', 'foodNodeOccupationV1', 'foodSiteV1', 'goldNodeOccupationV1', @@ -18,6 +19,7 @@ const PLAYER_TABLE_KEYS = [ 'realmForestLayoutV1', 'realmProfileV1', 'realmV1', + 'realmWorkerSystemV1', 'realmWaterBodyV1', 'realmWaterCellV1', 'realmWaterLayoutV1', @@ -26,6 +28,7 @@ const PLAYER_TABLE_KEYS = [ 'stoneSiteV1', 'woodNodeOccupationV1', 'woodSiteV1', + 'workerNodeOccupationV1', 'worldTile', 'worldTileMetaV1', ] as const @@ -47,6 +50,23 @@ describe('player SpacetimeDB bindings', () => { for (const key of PLAYER_TABLE_KEYS) { expect(tableContract(playerTables[key])).toEqual(tableContract(generatedTables[key])) } + expect(Object.keys(playerTables.castleWorkerV1.columns)).toEqual([ + 'workerId', + 'originCastleId', + 'ordinal', + 'status', + 'resourceKind', + 'siteId', + 'startedAtMicros', + 'arrivesAtMicros', + 'gatheringEndsAtMicros', + 'returnStartedAtMicros', + 'returnsAtMicros', + 'routeSteps', + 'returnStartProgressBasisPoints', + 'timelineRevision', + 'revision', + ]) }) it('keeps the public Vite path on the narrow projection, not the generated barrel', () => { @@ -80,6 +100,11 @@ describe('player SpacetimeDB bindings', () => { expect(playerBindings).toContain("'get_my_wood_expedition_state_v1'") expect(playerBindings).toContain("'get_my_stone_expedition_state_v1'") expect(playerBindings).toContain("'get_my_resource_state_v1'") + expect(playerBindings).toContain("'get_my_resource_state_v2'") + expect(playerBindings).toContain("'get_my_worker_roster_v1'") + expect(playerBindings).toContain("'dispatch_worker_v1'") + expect(playerBindings).toContain("'recall_worker_v1'") + expect(playerBindings).toContain("'recall_all_workers_v1'") expect(playerBindings).toContain("'realm_forest_layout_v1'") expect(playerBindings).toContain("'realm_forest_instance_v1'") expect(playerBindings).toContain("'realm_water_revision_v1'") @@ -103,6 +128,8 @@ describe('player SpacetimeDB bindings', () => { expect(playerBindings).not.toContain('qa_observer_') expect(playerBindings).not.toContain('QA_OBSERVER') expect(playerBindings).not.toContain('/v1/qa/') + expect(playerBindings).not.toContain('worker_assignment_schedule_v_1') + expect(playerBindings).not.toContain('worker_command_idempotency_v_1') }) it('exposes only the player reducer and procedure accessors at runtime', () => { @@ -134,6 +161,9 @@ describe('player SpacetimeDB bindings', () => { 'dispatchGoldExpeditionV1', 'dispatchStoneExpeditionV1', 'dispatchWoodExpeditionV1', + 'dispatchWorkerV1', + 'recallAllWorkersV1', + 'recallWorkerV1', ]) expect(Object.keys(connection.procedures).sort()).toEqual([ 'getAlphaBackendInfo', @@ -141,8 +171,10 @@ describe('player SpacetimeDB bindings', () => { 'getMyFoodExpeditionStateV1', 'getMyGoldExpeditionStateV1', 'getMyResourceStateV1', + 'getMyResourceStateV2', 'getMyStoneExpeditionStateV1', 'getMyWoodExpeditionStateV1', + 'getMyWorkerRosterV1', ]) connection.disconnect() diff --git a/tests/qaJourneyLab.test.tsx b/tests/qaJourneyLab.test.tsx index f107bae0..41d10eec 100644 --- a/tests/qaJourneyLab.test.tsx +++ b/tests/qaJourneyLab.test.tsx @@ -395,7 +395,7 @@ describe('Warpkeep local QA journey lab', () => { fireEvent.click(within(realmMenu).getByRole('button', { name: /EXPLORE/i })); const explore = screen.getByRole('dialog', { name: 'Explore' }); fireEvent.change(within(explore).getByRole('searchbox', { - name: 'Search founded castles' + name: 'Search castles, workers, resources, and water' }), { target: { value: 'sentinel-two' } }); const target = within(explore).getByRole('button', { name: /Inspect @sentinel-two, Cinderwatch Keep/ diff --git a/tests/realmAccessibilityControls.test.tsx b/tests/realmAccessibilityControls.test.tsx index d20a99f1..53fbe8fc 100644 --- a/tests/realmAccessibilityControls.test.tsx +++ b/tests/realmAccessibilityControls.test.tsx @@ -11,7 +11,8 @@ import { type RealmNavigatorCameraPreset, type RealmNavigatorCloseReason, type RealmNavigatorCoordinateJump, - type RealmNavigatorCastle + type RealmNavigatorCastle, + type RealmNavigatorWorker } from '../src/components/realm/RealmAccessibilityControls'; const CASTLES = Object.freeze([ @@ -25,6 +26,8 @@ function ControlledNavigator({ onRequestClose, coordinateJump, cameraPresets, + workers, + onActivateWorker, triggerRef, onOuterEscape }: Readonly<{ @@ -32,6 +35,8 @@ function ControlledNavigator({ onRequestClose: (reason: RealmNavigatorCloseReason) => void; coordinateJump?: RealmNavigatorCoordinateJump; cameraPresets?: readonly RealmNavigatorCameraPreset[]; + workers?: readonly RealmNavigatorWorker[]; + onActivateWorker?: (worker: RealmNavigatorWorker) => void; triggerRef?: RefObject; onOuterEscape?: () => void; }>) { @@ -42,6 +47,7 @@ function ControlledNavigator({ id="realm-navigator" open={open} castles={CASTLES} + workers={workers} ownCastleId={1} selectedCastleId={2} onRequestOpen={() => setOpen(true)} @@ -50,6 +56,7 @@ function ControlledNavigator({ setOpen(false); }} onActivateCastle={onActivateCastle} + onActivateWorker={onActivateWorker} coordinateJump={coordinateJump} cameraPresets={cameraPresets} triggerRef={triggerRef} @@ -119,7 +126,7 @@ describe('RealmAccessibilityControls', () => { expect(dialog.getAttribute('aria-modal')).toBe('false'); expect(screen.queryByRole('button', { name: /Explore realm/i })).toBeNull(); await waitFor(() => expect(document.activeElement).toBe( - screen.getByRole('searchbox', { name: 'Search founded castles' }) + screen.getByRole('searchbox', { name: 'Search castles, workers, resources, and water' }) )); fireEvent.click(within(dialog).getByRole('button', { name: 'CLOSE EXPLORE' })); @@ -154,7 +161,7 @@ describe('RealmAccessibilityControls', () => { const dialog = screen.getByRole('dialog', { name: 'Explore' }); expect(dialog.getAttribute('aria-modal')).toBe('false'); expect(trigger.getAttribute('aria-expanded')).toBe('true'); - const search = screen.getByRole('searchbox', { name: 'Search founded castles' }); + const search = screen.getByRole('searchbox', { name: 'Search castles, workers, resources, and water' }); await waitFor(() => expect(document.activeElement).toBe(search)); const list = screen.getByRole('list', { name: 'Founded castles' }); @@ -237,6 +244,56 @@ describe('RealmAccessibilityControls', () => { await waitFor(() => expect(document.activeElement).toBe(trigger)); }); + it('lists every public worker as a keyboard-operable identity target', () => { + const onActivateWorker = vi.fn(); + const workers = Object.freeze([ + { + workerId: 'genesis-001-castle-1-worker-01', + ordinal: 1, + originCastleId: 1, + originCastleName: 'Genesis Bastion', + status: 'idle' as const, + coord: Object.freeze({ q: 0, r: 0 }), + ownedByViewer: true + }, + { + workerId: 'genesis-001-castle-2-worker-02', + ordinal: 2, + originCastleId: 2, + originCastleName: 'Peer Watch', + status: 'outbound' as const, + coord: Object.freeze({ q: 3, r: -1 }), + ownedByViewer: false + } + ]) satisfies readonly RealmNavigatorWorker[]; + render( + + ); + fireEvent.click(screen.getByRole('button', { name: /Explore realm/i })); + + const list = screen.getByRole('list', { name: 'Public workers' }); + const ownWorker = within(list).getByRole('button', { + name: 'Inspect worker 1, Genesis Bastion, idle, q 0, r 0, your worker' + }); + const peerWorker = within(list).getByRole('button', { + name: 'Inspect worker 2, Peer Watch, outbound, q 3, r -1' + }); + fireEvent.focus(peerWorker); + expect(onActivateWorker).not.toHaveBeenCalled(); + fireEvent.click(peerWorker); + expect(onActivateWorker).toHaveBeenCalledWith(workers[1]); + expect(ownWorker.getAttribute('data-own')).toBe('true'); + + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'outbound' } }); + expect(within(list).queryByRole('button', { name: /Genesis Bastion/ })).toBeNull(); + expect(within(list).getAllByRole('button')).toHaveLength(1); + }); + it('offers an optional strict q/r jump and activates only after validation', () => { const onActivateCastle = vi.fn(); const onRequestClose = vi.fn(); @@ -295,7 +352,7 @@ describe('RealmAccessibilityControls', () => { const trigger = screen.getByRole('button', { name: /Explore realm, 3 founded castles/i }); expect(triggerRef.current).toBe(trigger); fireEvent.click(trigger); - const search = screen.getByRole('searchbox', { name: 'Search founded castles' }); + const search = screen.getByRole('searchbox', { name: 'Search castles, workers, resources, and water' }); fireEvent.keyDown(search, { key: 'Escape' }); expect(onRequestClose).toHaveBeenCalledWith('escape'); diff --git a/tests/realmCameraController.test.ts b/tests/realmCameraController.test.ts index c5f9e32e..4b0a1f48 100644 --- a/tests/realmCameraController.test.ts +++ b/tests/realmCameraController.test.ts @@ -832,4 +832,61 @@ describe('realm perspective camera math', () => { expect(scheduled.size).toBe(1); controller.dispose(); }); + + it('restores complete controller state so projections and future frames survive a rebuild', () => { + let nextFrame = 1; + const scheduled = new Map(); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + const id = nextFrame; + nextFrame += 1; + scheduled.set(id, callback); + return id; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => { + scheduled.delete(id); + }); + const createController = () => createRealmCameraController({ + bounds: BOUNDS, + keepFocus: KEEP, + fog: new THREE.Fog('#a6bcaf', 1, 2), + reducedMotion: false, + render: vi.fn() + }); + const source = createController(); + source.setViewport(1_280, 720); + source.setComposition({ + insets: { top: 18, right: 260, bottom: 72, left: 180 }, + focusPadding: 20 + }); + source.focusAt(SELECTED_CASTLE); + const firstSourceFrame = [...scheduled.entries()].at(-1); + expect(firstSourceFrame).toBeDefined(); + scheduled.delete(firstSourceFrame![0]); + firstSourceFrame![1](16); + + const state = source.captureState(); + const expectedPose = source.getPose(); + const expectedProjection = source.projectPoint({ x: 1.1, y: 0, z: -0.7 }); + source.dispose(); + + const restored = createController(); + restored.setViewport(1_280, 720); + restored.restoreState(state); + + expect(restored.getZoom()).toBe(state.targetZoom); + expect(restored.getMode()).toBe(expectedPose.mode); + expect(restored.getPose().position).toEqual(expectedPose.position); + expect(restored.getPose().target).toEqual(expectedPose.target); + expect(restored.getPose().safeViewport).toEqual(expectedPose.safeViewport); + expect(restored.projectPoint({ x: 1.1, y: 0, z: -0.7 })).toEqual(expectedProjection); + + const restoredFrame = [...scheduled.entries()].at(-1); + expect(restoredFrame).toBeDefined(); + const distanceBeforeFrame = restored.getPose().distance; + scheduled.delete(restoredFrame![0]); + restoredFrame![1](32); + expect(restored.getPose().distance).not.toBe(distanceBeforeFrame); + expect(restored.getPose().focus.x).toBeGreaterThan(expectedPose.focus.x); + restored.dispose(); + }); }); diff --git a/tests/realmCastlePresentation.test.ts b/tests/realmCastlePresentation.test.ts index 043093e5..f20ce43d 100644 --- a/tests/realmCastlePresentation.test.ts +++ b/tests/realmCastlePresentation.test.ts @@ -369,6 +369,20 @@ describe('realm castle public presentation', () => { expect(projection.conservativeCastleBounds).toEqual(projection.castleBounds); }); + it('marks castles outside the bounded fallback SVG crop as projection-ineligible', () => { + const viewBox = { x: -4, y: -4, width: 8, height: 8 }; + expect(fallbackCastleProjection( + { castleId: 1, q: 0, r: 0 }, + viewBox, + { width: 800, height: 600 } + ).visible).toBe(true); + expect(fallbackCastleProjection( + { castleId: 2, q: 20, r: 20 }, + viewBox, + { width: 800, height: 600 } + ).visible).toBe(false); + }); + it('anchors labels below the calibrated foundation while retaining conservative bounds separately', () => { const conservative = { left: 360, top: 180, right: 440, bottom: 300 }; const bounds = resolveCastleLabelOcclusionBounds(conservative, 240); diff --git a/tests/realmGrassGenesisBounds.test.ts b/tests/realmGrassGenesisBounds.test.ts index 706b027c..c0892477 100644 --- a/tests/realmGrassGenesisBounds.test.ts +++ b/tests/realmGrassGenesisBounds.test.ts @@ -113,7 +113,8 @@ describe('canonical Genesis 001 grass bounds', () => { layer.updateView({ x: 0, z: 0 }, 'keep'); const first = layer.getTelemetry(); - expect(first.activeCellCount).toBe(469); + expect(first.candidateCellCount).toBe(469); + expect(first.activeCellCount).toBe(397); expect(first.cacheEntries).toBeLessThanOrEqual(REALM_GRASS_RENDER_PLANS.high.cacheLimit); expect(first.cacheEntries).toBeLessThan(10_000); expect(first.instanceCount).toBeLessThanOrEqual( @@ -125,12 +126,13 @@ describe('canonical Genesis 001 grass bounds', () => { expect(first.drawCalls).toBeLessThanOrEqual(3); expect(first.variantCounts).toHaveLength(3); expect(digestPackedGrass(layer)).toBe( - 'd27befa17b44b0eaba5d24695c983c23c683c723472bb426e38d04ed2273594b' + '41d786fb5f5095bfbddb961b08f4ea363ed99c850865c1a0af8d85ef240b64ef' ); layer.updateView(axialToWorld({ q: 30, r: -10 }, 1), 'keep'); const traversed = layer.getTelemetry(); - expect(traversed.activeCellCount).toBe(469); + expect(traversed.candidateCellCount).toBe(469); + expect(traversed.activeCellCount).toBe(397); expect(traversed.cacheEntries).toBeLessThanOrEqual(REALM_GRASS_RENDER_PLANS.high.cacheLimit); expect(traversed.cacheEntries).toBeLessThan(10_000); expect(traversed.instanceCount).toBeLessThanOrEqual( diff --git a/tests/realmGrassLayer.test.ts b/tests/realmGrassLayer.test.ts index eaec52ac..62e228fe 100644 --- a/tests/realmGrassLayer.test.ts +++ b/tests/realmGrassLayer.test.ts @@ -50,6 +50,26 @@ describe('camera-local procedural grass layer', () => { expect(telemetry.triangleCount).toBeLessThanOrEqual(2_016); expect(telemetry.drawCalls).toBeLessThanOrEqual(2); expect(telemetry.cacheEntries).toBeLessThanOrEqual(8); + expect(Object.values(telemetry.candidateCellsByTerrain) + .reduce((total, count) => total + count, 0)).toBe(telemetry.candidateCellCount); + expect(Object.values(telemetry.activeCellsByTerrain) + .reduce((total, count) => total + count, 0)).toBe(telemetry.activeCellCount); + expect(telemetry.activeCellCount).toBeLessThanOrEqual(telemetry.candidateCellCount); + expect(telemetry.averageRetainedPatchesPerActiveCell).toBeCloseTo( + telemetry.instanceCount / telemetry.activeCellCount, + 10 + ); + expect(Object.values(telemetry.countsByTerrain) + .reduce((total, count) => total + count, 0)).toBe(telemetry.instanceCount); + expect(telemetry.averageRetainedPatchesByTerrain.meadow).toBeCloseTo( + telemetry.countsByTerrain.meadow / telemetry.activeCellsByTerrain.meadow, + 10 + ); + expect(telemetry.paletteGreenMin).toBeGreaterThan(0); + expect(telemetry.paletteGreenMax).toBeGreaterThanOrEqual(telemetry.paletteGreenMin); + expect(telemetry.paletteLuminanceMax).toBeGreaterThanOrEqual( + telemetry.paletteLuminanceMin + ); expect(layer.meshes.reduce((sum, currentMesh) => sum + currentMesh.count, 0)) .toBe(telemetry.instanceCount); expect(layer.mesh.geometry.getAttribute('grassPhase')).toBeDefined(); diff --git a/tests/realmGrassVisualContract.test.ts b/tests/realmGrassVisualContract.test.ts index e7b8f3ee..5d584241 100644 --- a/tests/realmGrassVisualContract.test.ts +++ b/tests/realmGrassVisualContract.test.ts @@ -38,7 +38,17 @@ describe('luminous broad grass visual contract', () => { expect(meadow).toHaveLength(4); expect(meadow[0]!.r).toBeCloseTo(0.3916, 3); meadow.forEach((colour) => expect(colour.g).toBeGreaterThan(colour.r)); - expect(REALM_GRASS_BIOME_PROFILES.heath.palette).toHaveLength(3); + const heath = REALM_GRASS_BIOME_PROFILES.heath; + expect(heath.palette).toHaveLength(3); + heath.palette.forEach((colour) => { + expect(colour.g).toBeGreaterThan(colour.r); + expect(colour.g).toBeGreaterThan(colour.b); + }); + expect(heath.highCandidateCount).toBe(22); + expect(heath.completelyBareThreshold).toBe(0.28); + expect(heath.retention).toBe(0.8); + expect(REALM_GRASS_BIOME_PROFILES.lake.highCandidateCount).toBe(0); + expect(REALM_GRASS_BIOME_PROFILES.lake.retention).toBe(0); }); it('uses coverage alpha without switching the foliage layer to blending', () => { diff --git a/tests/realmHud.test.tsx b/tests/realmHud.test.tsx index 1b22448b..27dda0ba 100644 --- a/tests/realmHud.test.tsx +++ b/tests/realmHud.test.tsx @@ -2,6 +2,11 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { afterEach, describe, expect, it, vi } from 'vitest'; import { RealmHud } from '../src/components/realm/RealmHud'; +import type { + ReadyWorkerProjection, + ReadyWorkerResourceState, + WorkerRosterPresentation +} from '../src/components/realm/realmWorkerPresentation'; import { generateRealmTerrainMap, terrainCellByCoord } from '../src/game/map/generateTerrainMap'; import { HEGEMONY_GENESIS_001 } from '../src/game/map/realmSeed'; import { createReadyResourceState } from './fixtures/resourceState'; @@ -25,6 +30,133 @@ function commonProps() { }; } +function workerUiFixture() { + const ownWorkers = [1, 2, 3, 4].map((ordinal) => ({ + workerId: `genesis-001-castle-7-worker-0${ordinal}`, + ordinal: ordinal as 1 | 2 | 3 | 4, + originCastleId: 7, + originCastleName: 'Warpkeeper Bastion', + status: 'idle' as const, + timelineRevision: 0, + revision: 0n, + ownedByViewer: true + })); + const peerWorkers = [1, 2, 3, 4].map((ordinal) => ({ + workerId: `genesis-001-castle-8-worker-0${ordinal}`, + ordinal: ordinal as 1 | 2 | 3 | 4, + originCastleId: 8, + originCastleName: 'Peer Keep', + status: 'idle' as const, + timelineRevision: 0, + revision: 0n, + ownedByViewer: false + })); + const workerProjection: ReadyWorkerProjection = { + mode: 'active', + system: { + realmId: 'GENESIS_001', + policyVersion: 'genesis-001-castle-workers-v1', + workersPerCastle: 4, + expectedCastleCount: 2, + expectedWorkerCount: 8, + rosterDigest: '0000000000000000', + mode: 'active', + legacyDrainRequired: false + }, + workers: [...ownWorkers, ...peerWorkers], + ownedWorkers: ownWorkers, + occupations: [] + }; + const workerRoster: WorkerRosterPresentation = { + castleId: 7, + observedAtMicros: 10n, + workers: ownWorkers.map((worker) => ({ + workerId: worker.workerId, + ordinal: worker.ordinal, + status: worker.status, + accruedAmount: 0n, + materializedAmount: 0n, + availableAmount: 0n, + observedAtMicros: 10n, + revision: worker.revision + })) + }; + const workerResourceState: ReadyWorkerResourceState = { + status: 'ready', + fid: 12_345n, + available: { food: 0n, wood: 0n, stone: 0n, gold: 0n }, + pending: { food: 0n, wood: 0n, stone: 0n, gold: 0n }, + observedAtMicros: 10n, + settledThroughMicros: 10n, + revision: 0n, + resourcePolicyVersion: 'genesis-resource-yield-v1', + workerPolicyVersion: 'genesis-001-castle-workers-v1', + workerSystemMode: 'active' + }; + return { workerProjection, workerRoster, workerResourceState }; +} + +function assignedWorkerUiFixture() { + const base = workerUiFixture(); + const assigned = { + ...base.workerProjection.ownedWorkers[0]!, + status: 'gathering' as const, + resourceKind: 'stone' as const, + siteId: 'genesis-001:stone:0001', + startedAtMicros: 10n, + arrivesAtMicros: 20n, + gatheringEndsAtMicros: 100n, + returnsAtMicros: 120n, + routeSteps: 1, + timelineRevision: 1, + revision: 2n + }; + const ownedWorkers = [ + assigned, + ...base.workerProjection.ownedWorkers.slice(1) + ]; + const workerProjection: ReadyWorkerProjection = { + ...base.workerProjection, + workers: [ + assigned, + ...base.workerProjection.workers.filter((worker) => worker.workerId !== assigned.workerId) + ], + ownedWorkers, + occupations: [{ + nodeKey: 'stone:genesis-001:stone:0001', + resourceKind: 'stone', + siteId: 'genesis-001:stone:0001', + workerId: assigned.workerId, + workerOrdinal: 1, + originCastleId: 7, + phase: 'gathering', + startedAtMicros: 10n, + arrivesAtMicros: 20n, + gatheringEndsAtMicros: 100n, + timelineRevision: 1 + }] + }; + const workerRoster: WorkerRosterPresentation = { + ...base.workerRoster, + workers: base.workerRoster.workers.map((worker) => worker.workerId === assigned.workerId + ? { + ...worker, + status: 'gathering', + resourceKind: 'stone', + siteId: 'genesis-001:stone:0001', + accruedAmount: 3n, + availableAmount: 3n, + revision: 2n + } + : worker) + }; + const workerResourceState: ReadyWorkerResourceState = { + ...base.workerResourceState, + pending: { ...base.workerResourceState.pending, stone: 3n } + }; + return { workerProjection, workerRoster, workerResourceState }; +} + function selectionAnnouncement(container: HTMLElement) { const announcement = container.querySelector( '.realm-player-chrome__selection-announcement' @@ -59,6 +191,7 @@ describe('RealmHud', () => { }); expect(trigger.getAttribute('aria-haspopup')).toBe('dialog'); expect(trigger.getAttribute('aria-expanded')).toBe('false'); + expect(trigger.getAttribute('aria-controls')).toBeNull(); expect(trigger.querySelector('.realm-castle-avatar')?.textContent).toBe('W'); expect(container.querySelector('.realm-hud')).toBeNull(); expect(container.querySelector('.realm-hud__actions')).toBeNull(); @@ -137,6 +270,7 @@ describe('RealmHud', () => { let opened = openRealmMenu(); expect(opened.trigger.getAttribute('aria-expanded')).toBe('true'); + expect(opened.trigger.getAttribute('aria-controls')).toBe('realm-player-menu'); expect(within(opened.dialog).getByRole('button', { name: /MY KEEP/i })).not.toBeNull(); expect(within(opened.dialog).getByRole('button', { name: /EXPLORE.*2 founded castles/i })) .not.toBeNull(); @@ -153,8 +287,13 @@ describe('RealmHud', () => { await waitFor(() => expect(document.activeElement).toBe(opened.trigger)); opened = openRealmMenu(); - fireEvent.click(within(opened.dialog).getByRole('button', { name: /SETTINGS/i })); - expect(screen.getByRole('dialog', { name: 'SETTINGS' })).not.toBeNull(); + const settingsTrigger = within(opened.dialog).getByRole('button', { name: /SETTINGS/i }); + expect(settingsTrigger.getAttribute('aria-controls')).toBe('realm-player-settings'); + expect(settingsTrigger.getAttribute('aria-haspopup')).toBe('dialog'); + fireEvent.click(settingsTrigger); + const settings = screen.getByRole('dialog', { name: 'SETTINGS' }); + expect(settings.id).toBe('realm-player-settings'); + expect(opened.trigger.getAttribute('aria-controls')).toBe('realm-player-settings'); expect(document.activeElement).toBe(screen.getByRole('heading', { name: 'SETTINGS' })); fireEvent.click(screen.getByRole('button', { name: 'BACK TO REALM MENU' })); expect(screen.getByRole('dialog', { name: 'REALM MENU' })).not.toBeNull(); @@ -221,6 +360,125 @@ describe('RealmHud', () => { expect(group.textContent).toContain('select a resource site to dispatch'); }); + it('puts the exact owner roster inside the PFP menu and wires worker dispatch', async () => { + const fixture = workerUiFixture(); + const onDispatchWorker = vi.fn().mockResolvedValue(undefined); + render( + + ); + + const profileTrigger = screen.getByRole('button', { name: /Open Realm menu/i }); + expect(screen.getAllByRole('button')).toEqual([profileTrigger]); + fireEvent.click(profileTrigger); + const menu = screen.getByRole('dialog', { name: 'REALM MENU' }); + expect(within(menu).getByRole('button', { name: /WORKERS.*4 of 4 available/i })) + .not.toBeNull(); + expect(screen.queryByRole('button', { name: /Open workers/i })).toBeNull(); + + fireEvent.click(within(menu).getByRole('button', { name: /WORKERS/i })); + const commandCenter = screen.getByRole('dialog', { name: 'WORKERS' }); + expect(commandCenter.id).toBe('realm-worker-command-center'); + expect(profileTrigger.getAttribute('aria-controls')).toBe('realm-worker-command-center'); + const roster = within(commandCenter).getByRole('list', { name: 'Your four workers' }); + expect(within(roster).getAllByRole('listitem')).toHaveLength(4); + expect(within(roster).getAllByText('0 resource units')).toHaveLength(4); + expect(commandCenter.textContent).not.toContain('Peer Keep'); + + fireEvent.click(within(roster).getByRole('button', { name: /Worker 1/i })); + const inspector = screen.getByRole('dialog', { name: 'Worker 1' }); + expect(inspector.id).toBe('realm-worker-inspection'); + expect(profileTrigger.getAttribute('aria-controls')).toBe('realm-worker-inspection'); + fireEvent.change(within(inspector).getByRole('combobox', { name: 'ASSIGN TO RESOURCE SITE' }), { + target: { value: 'stone|genesis-001:stone:0001' } + }); + fireEvent.click(within(inspector).getByRole('button', { name: 'ASSIGN WORKER' })); + await waitFor(() => expect(onDispatchWorker).toHaveBeenCalledWith( + 'genesis-001-castle-7-worker-01', + { + resourceKind: 'stone', + siteId: 'genesis-001:stone:0001', + label: 'Stone Quarry · Tier 1 · cell 4, -2' + } + )); + await waitFor(() => expect( + within(inspector).getByRole('button', { name: 'ASSIGN WORKER' }) + .hasAttribute('disabled') + ).toBe(false)); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.getByRole('dialog', { name: 'WORKERS' })).not.toBeNull(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.getByRole('dialog', { name: 'REALM MENU' })).not.toBeNull(); + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(document.activeElement).toBe(profileTrigger)); + }); + + it('fails closed when private Worker totals belong to another authenticated FID', () => { + const fixture = workerUiFixture(); + const common = commonProps(); + render( + + ); + + const { dialog } = openRealmMenu(); + expect(within(dialog).queryByRole('button', { name: /WORKERS/i })).toBeNull(); + expect(screen.queryByRole('dialog', { name: 'WORKERS' })).toBeNull(); + expect(document.body.textContent).not.toContain('resource units'); + }); + + it('guards duplicate recalls and keeps Escape inside a pending command', async () => { + const fixture = assignedWorkerUiFixture(); + let resolveRecall!: () => void; + const pendingRecall = new Promise((resolve) => { resolveRecall = resolve; }); + const onRecallWorker = vi.fn(() => pendingRecall); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /Open Realm menu/i })); + fireEvent.click(screen.getByRole('button', { name: /WORKERS/i })); + const commandCenter = screen.getByRole('dialog', { name: 'WORKERS' }); + expect(within(commandCenter).getByText('3 Stone')).not.toBeNull(); + const recall = within(commandCenter).getByRole('button', { name: 'RETURN' }); + fireEvent.click(recall); + fireEvent.click(recall); + expect(onRecallWorker).toHaveBeenCalledOnce(); + expect(within(commandCenter).getByRole('button', { name: 'RETURNING…' }) + .hasAttribute('disabled')).toBe(true); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.getByRole('dialog', { name: 'WORKERS' })).toBe(commandCenter); + await act(async () => { + resolveRecall(); + await pendingRecall; + }); + await waitFor(() => expect( + within(commandCenter).getByRole('button', { name: 'RETURN' }) + .hasAttribute('disabled') + ).toBe(false)); + }); + it('renders zero-valued caller-bound resources in the fixed top-rail order', () => { const resources = createReadyResourceState(); const { container } = render( diff --git a/tests/realmInteractionState.test.ts b/tests/realmInteractionState.test.ts index 92a13ff7..8399ec0b 100644 --- a/tests/realmInteractionState.test.ts +++ b/tests/realmInteractionState.test.ts @@ -65,6 +65,37 @@ describe('realm interaction state', () => { }); }); + it('activates one public worker identity and directs focus to its inspector', () => { + const state = realmInteractionReducer(createRealmInteractionState({ q: 0, r: 0 }), { + type: 'activate-worker', + workerId: 'genesis-001-castle-7-worker-02', + workerOrdinal: 2, + originCastleId: 7, + coord: { q: 2, r: -1 } + }); + + expect(state.selectedCell).toEqual({ q: 2, r: -1 }); + expect(state.selectedCastle).toBeNull(); + expect(state.inspectorTarget).toEqual({ + workerId: 'genesis-001-castle-7-worker-02', + workerOrdinal: 2, + originCastleId: 7, + coord: { q: 2, r: -1 } + }); + expect(state.cameraTarget).toEqual({ + kind: 'worker', + workerId: 'genesis-001-castle-7-worker-02', + coord: { q: 2, r: -1 } + }); + expect(state.keyboardIntent).toEqual({ + sequence: 1, + target: { + kind: 'worker-inspector', + workerId: 'genesis-001-castle-7-worker-02' + } + }); + }); + it('opens a public Gold-site inspector without turning the site into a castle or local authority', () => { const state = realmInteractionReducer(createRealmInteractionState({ q: 0, r: 0 }), { type: 'activate-gold-site', @@ -86,6 +117,34 @@ describe('realm interaction state', () => { expect(resolveRealmEscape(state).state.keyboardIntent.target).toEqual({ kind: 'map' }); }); + it('opens a read-only water record and preserves its canonical body identity', () => { + const state = realmInteractionReducer(createRealmInteractionState({ q: 0, r: 0 }), { + type: 'activate-water-cell', + cellKey: 'genesis-001:river:01:0001', + bodyId: 'genesis-001:river:01', + regime: 'river', + coord: { q: 4, r: -2 } + }); + + expect(state.selectedCell).toEqual({ q: 4, r: -2 }); + expect(state.selectedCastle).toBeNull(); + expect(state.inspectorTarget).toEqual({ + cellKey: 'genesis-001:river:01:0001', + bodyId: 'genesis-001:river:01', + regime: 'river', + coord: { q: 4, r: -2 } + }); + expect(state.cameraTarget).toEqual({ + kind: 'water', + cellKey: 'genesis-001:river:01:0001', + coord: { q: 4, r: -2 } + }); + expect(state.keyboardIntent).toEqual({ + sequence: 1, + target: { kind: 'water-inspector', cellKey: 'genesis-001:river:01:0001' } + }); + }); + it('opens a Food Farm inspector through a distinct target shape from Gold', () => { const state = realmInteractionReducer(createRealmInteractionState({ q: 0, r: 0 }), { type: 'activate-food-site', diff --git a/tests/realmMapScreen.test.tsx b/tests/realmMapScreen.test.tsx index a83c522d..4750ff5a 100644 --- a/tests/realmMapScreen.test.tsx +++ b/tests/realmMapScreen.test.tsx @@ -116,8 +116,11 @@ describe('RealmMapScreen', () => { expect(screen.getByRole('region', { name: 'Your resources' })).not.toBeNull(); const fallback = screen.getByTestId('realm-static-fallback'); expect(within(fallback).getByText( - 'Detailed terrain is unavailable. Showing the canonical Genesis 001 realm map.' + 'WebGL is unavailable on this device. Showing a bounded, accessible view of the canonical Genesis 001 region around your keep.' )).not.toBeNull(); + expect(fallback.textContent).not.toContain( + 'Detailed terrain is unavailable. Showing the canonical Genesis 001 realm map.' + ); expect(fallback.textContent).not.toMatch(/\b(?:traversable cells|realm cells|rendered)\b/i); const polygons = container.querySelectorAll( '.realm-map-screen__fallback-map polygon' @@ -176,7 +179,7 @@ describe('RealmMapScreen', () => { expect(screen.getByRole('button', { name: 'CLOSE RECORD' })).toBe(document.activeElement); }); - it('renders every 100-castle fallback marker with complete direct-label coverage', async () => { + it('keeps fallback markers and labels bounded to the same cropped region', async () => { const realm = createRenderedWebglQaFixtureRealm(); const { container } = renderFallbackRealm(realm); const markers = container.querySelectorAll( @@ -187,14 +190,20 @@ describe('RealmMapScreen', () => { ); expect(realm.snapshot.castles).toHaveLength(100); - expect(markers).toHaveLength(realm.snapshot.castles.length); - expect(markerCastleIds.size).toBe(realm.snapshot.castles.length); + expect(markers.length).toBeGreaterThan(0); + expect(markers.length).toBeLessThan(realm.snapshot.castles.length); + expect(markerCastleIds.size).toBe(markers.length); await waitFor(() => { const map = screen.getByRole('main', { name: 'Hegemony realm' }); - expect(map.getAttribute('data-label-placed-count')).toBe('100'); - expect(map.getAttribute('data-label-unplaced-count')).toBe('0'); + const placed = Number(map.getAttribute('data-label-placed-count')); + expect(placed).toBeGreaterThan(0); + expect(placed).toBeLessThanOrEqual(markers.length); expect(map.getAttribute('data-label-clustered-count')).toBe('0'); }); + const labelCastleIds = [...container.querySelectorAll( + '.realm-castle-label[data-castle-id]' + )].map((label) => label.dataset.castleId); + expect(labelCastleIds.every((castleId) => markerCastleIds.has(castleId))).toBe(true); }); it('changes camera presets without opening a castle record', async () => { diff --git a/tests/realmObserverUi.test.tsx b/tests/realmObserverUi.test.tsx index 16768c70..2faf3287 100644 --- a/tests/realmObserverUi.test.tsx +++ b/tests/realmObserverUi.test.tsx @@ -68,7 +68,7 @@ describe('Realm read-only observer presentation', () => { expect(screen.queryByRole('button', { name: 'Return to Menu' })).toBeNull(); fireEvent.click(screen.getByRole('button', { - name: 'Explore realm, 4 founded castles' + name: 'Explore realm, 4 founded castles and 12 public rivers' })); const explore = screen.getByRole('dialog', { name: 'Explore' }); expect(within(explore).queryByRole('button', { name: 'My Keep' })).toBeNull(); diff --git a/tests/realmPickArbitration.test.ts b/tests/realmPickArbitration.test.ts index 9ea8aa1c..6307b5d7 100644 --- a/tests/realmPickArbitration.test.ts +++ b/tests/realmPickArbitration.test.ts @@ -13,6 +13,19 @@ const RESOURCE_KINDS = [ ] as const satisfies readonly RealmResourcePickKind[]; describe('realm scene pick arbitration', () => { + it('gives a worker identity lane priority over resource and castle colliders', () => { + expect(arbitrateRealmPick({ + workerHits: [ + { workerId: 'worker-far', workerOrdinal: 2, originCastleId: 77, coord: { q: 2, r: 0 }, distance: 20 }, + { workerId: 'worker-invalid', workerOrdinal: 3, originCastleId: 77, coord: { q: 3, r: 0 }, distance: -1 }, + { workerId: 'worker-1', workerOrdinal: 1, originCastleId: 77, coord: { q: 1, r: 0 }, distance: 2 } + ], + resourceHits: [], + castleHit: { castleId: 77, coord: { q: 0, r: 0 } }, + terrainHit: { coord: { q: 2, r: 0 } } + })).toEqual({ kind: 'worker', workerId: 'worker-1', workerOrdinal: 1, originCastleId: 77, coord: { q: 1, r: 0 } }); + }); + it.each(RESOURCE_KINDS)( 'lets the nearest moving %s wagon win over castle, static site, and terrain', (kind) => { @@ -92,4 +105,45 @@ describe('realm scene pick arbitration', () => { terrainHit: { coord: { q: -2, r: 1 } } })).toEqual({ kind: 'terrain', coord: { q: -2, r: 1 } }); }); + + it('places a visible water cell after static sites and before terrain', () => { + expect(arbitrateRealmPick({ + resourceHits: [], + waterHit: { + cellKey: 'genesis-001:river:01:0001', + bodyId: 'genesis-001:river:01', + regime: 'river', + coord: { q: 2, r: -1 }, + distance: 8 + }, + terrainHit: { coord: { q: 2, r: -1 } } + })).toEqual({ + kind: 'water-cell', + cellKey: 'genesis-001:river:01:0001', + bodyId: 'genesis-001:river:01', + regime: 'river', + coord: { q: 2, r: -1 } + }); + expect(arbitrateRealmPick({ + resourceHits: [{ + kind: 'gold-site', + siteId: 'gold-1', + coord: { q: 2, r: -1 }, + source: 'site', + distance: 12 + }], + waterHit: { + cellKey: 'ocean:1', + bodyId: 'genesis-001:ocean', + regime: 'ocean', + coord: { q: 2, r: -1 }, + distance: 1 + } + })).toEqual({ + kind: 'gold-site', + siteId: 'gold-1', + coord: { q: 2, r: -1 }, + source: 'site' + }); + }); }); diff --git a/tests/realmQualityRecreation.test.tsx b/tests/realmQualityRecreation.test.tsx index 59c2cf30..a14e8ce1 100644 --- a/tests/realmQualityRecreation.test.tsx +++ b/tests/realmQualityRecreation.test.tsx @@ -14,6 +14,9 @@ const mocked = vi.hoisted(() => { staticContentKind: string; }[]; isCoordPassable?: (coord: { q: number; r: number }) => boolean; + waterCells?: readonly unknown[]; + waterBodies?: readonly unknown[]; + waterEnvironment?: unknown; quality: { id: string }; reducedMotion: boolean; onCastlesReady?: (castleCount: number) => void; @@ -43,8 +46,11 @@ const mocked = vi.hoisted(() => { }[]; }) => void; onTargetHover?: (target: { - kind: 'castle' | 'terrain'; + kind: 'castle' | 'terrain' | 'worker'; castleId?: number; + workerId?: string; + workerOrdinal?: number; + originCastleId?: number; coord: { q: number; r: number }; } | null) => void; onTargetSelect?: (target: { @@ -63,6 +69,7 @@ const mocked = vi.hoisted(() => { focusKeep: ReturnType; recenterKeep: ReturnType; setHovered: ReturnType; + setHoveredWorkerId: ReturnType; setPresentedCastleIds: ReturnType; setSelected: ReturnType; setSelectedCastleId: ReturnType; @@ -78,6 +85,7 @@ const mocked = vi.hoisted(() => { focusKeep: vi.fn(), recenterKeep: vi.fn(), setHovered: vi.fn(), + setHoveredWorkerId: vi.fn(), setPresentedCastleIds: vi.fn(), setSelected: vi.fn(), setSelectedCastleId: vi.fn(), @@ -399,6 +407,46 @@ describe('live realm quality recreation', () => { expect(scene.focusCell).toHaveBeenLastCalledWith({ q: lakeCell.q, r: lakeCell.r }); }); + it('passes canonical Water phase inputs and recreates when body metadata is refreshed', () => { + installWebGlProbe(); + const snapshot = waterRevisionRealm(true); + const { rerender } = render( + + ); + const firstOptions = mocked.createRealmScene.mock.calls[0]![0]; + expect(firstOptions.waterBodies).toBe(snapshot.waterBodies); + expect(firstOptions.waterEnvironment).toBe(snapshot.realmEnvironment); + expect(firstOptions.waterCells).toBeDefined(); + + const descriptors = Object.getOwnPropertyDescriptors(snapshot) as PropertyDescriptorMap; + descriptors.waterBodies = { + ...descriptors.waterBodies!, + value: Object.freeze([...(snapshot.waterBodies ?? [])]) + }; + const refreshedSnapshot = Object.freeze(Object.defineProperties( + {}, + descriptors + )) as CanonicalWarpkeepRealmSnapshot; + rerender( + + ); + + expect(mocked.createRealmScene).toHaveBeenCalledTimes(2); + expect(mocked.handles[0]!.dispose).toHaveBeenCalledOnce(); + expect(mocked.createRealmScene.mock.calls[1]![0].waterBodies) + .toBe(refreshedSnapshot.waterBodies); + }); + it('disposes one scene, preserves selection, and mounts the requested model tier', () => { installWebGlProbe(); const snapshot = createCanonicalGenesisSnapshot(CANONICAL_TEST_FID); @@ -691,6 +739,21 @@ describe('live realm quality recreation', () => { expect(scene.setHovered).toHaveBeenCalledTimes(3); expect(scene.setHovered).toHaveBeenNthCalledWith(2, { q: 1, r: 0 }); expect(scene.setHovered).toHaveBeenLastCalledWith({ q: 2, r: -1 }); + act(() => { + options.onTargetHover?.({ + kind: 'worker', + workerId: 'genesis-001-castle-1-worker-01', + workerOrdinal: 1, + originCastleId: 1, + coord: { q: 0, r: 0 } + }); + }); + expect(scene.setHovered).toHaveBeenLastCalledWith(null); + expect(scene.setHoveredWorkerId).toHaveBeenLastCalledWith( + 'genesis-001-castle-1-worker-01' + ); + expect(scene.setHovered.mock.invocationCallOrder.at(-1)) + .toBeLessThan(scene.setHoveredWorkerId.mock.invocationCallOrder.at(-1)!); expect(selectionAnnouncement().textContent) .toContain('Warpkeeper Bastion. Your keep is selected at cell 0, 0'); expect(screen.queryByRole('button', { name: 'CLOSE RECORD' })).toBeNull(); diff --git a/tests/realmRendererRecovery.test.ts b/tests/realmRendererRecovery.test.ts new file mode 100644 index 00000000..44520183 --- /dev/null +++ b/tests/realmRendererRecovery.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { + classifyRealmRendererFailure, + initialRealmRendererLifecycle, + REALM_RENDERER_MAX_RECOVERY_ATTEMPTS, + shouldRetryRealmRenderer, + transitionRealmRendererLifecycle +} from '../src/components/realm/realmRendererRecovery'; + +describe('Realm renderer recovery lifecycle', () => { + it('keeps no-WebGL devices in the explicit static mode', () => { + const initial = initialRealmRendererLifecycle(); + const state = transitionRealmRendererLifecycle(initial, { + type: 'webgl-unsupported', + failure: { + code: 'webgl-unavailable', + retryable: false, + phase: 'probing' + } + }); + expect(state.state).toBe('static-unsupported'); + expect(state.everReady).toBe(false); + }); + + it('recovers after a loss without changing the ready history', () => { + const ready = transitionRealmRendererLifecycle(initialRealmRendererLifecycle(), { + type: 'ready' + }); + const recovering = transitionRealmRendererLifecycle(ready, { + type: 'recover', + attempt: 1, + failure: { code: 'context-lost', retryable: true, phase: 'ready' } + }); + expect(recovering.state).toBe('recovering'); + expect(recovering.everReady).toBe(true); + expect(transitionRealmRendererLifecycle(recovering, { type: 'ready' }).state).toBe('ready'); + }); + + it('never downgrades a previously-ready renderer to static unsupported mode', () => { + const ready = transitionRealmRendererLifecycle(initialRealmRendererLifecycle(), { + type: 'ready' + }); + const state = transitionRealmRendererLifecycle(ready, { + type: 'webgl-unsupported', + failure: { + code: 'webgl-unavailable', + retryable: false, + phase: 'probing' + } + }); + expect(state.state).toBe('failed'); + expect(state.state).not.toBe('static-unsupported'); + expect(state.everReady).toBe(true); + expect(state.failure?.code).toBe('renderer-construction-failed'); + }); + + it('increments the generation when a scene load begins', () => { + const initial = initialRealmRendererLifecycle(); + const first = transitionRealmRendererLifecycle(initial, { + type: 'load-start', + attempt: 0 + }); + const second = transitionRealmRendererLifecycle(first, { + type: 'load-start', + attempt: 1, + generation: 9 + }); + expect(first.generation).toBe(1); + expect(second.generation).toBe(9); + }); + + it('ignores readiness, recovery, and failure callbacks from retired generations', () => { + const first = transitionRealmRendererLifecycle(initialRealmRendererLifecycle(), { + type: 'load-start', + generation: 1 + }); + const current = transitionRealmRendererLifecycle(first, { + type: 'load-start', + generation: 2 + }); + expect(transitionRealmRendererLifecycle(current, { + type: 'ready', + generation: 1 + })).toBe(current); + expect(transitionRealmRendererLifecycle(current, { + type: 'recover', + generation: 1, + failure: { code: 'context-lost', retryable: true, phase: 'loading' } + })).toBe(current); + expect(transitionRealmRendererLifecycle(current, { + type: 'failed', + generation: 1, + failure: { code: 'scene-build-failed', retryable: true, phase: 'loading' } + })).toBe(current); + expect(current.state).toBe('loading'); + expect(current.generation).toBe(2); + }); + + it('classifies integrity and pairing failures as explicit non-retryable failures', () => { + expect(classifyRealmRendererFailure(new Error('sha256 integrity mismatch'), 'loading')) + .toMatchObject({ code: 'castle-integrity-failed', retryable: false }); + expect(classifyRealmRendererFailure(new Error('landscape base pairing failed'), 'loading').code) + .toBe('castle-pairing-failed'); + expect(classifyRealmRendererFailure( + new Error('Hegemony keep compact prefab contains no renderable meshes.'), + 'loading' + )).toMatchObject({ + code: 'castle-prefab-assembly-failed', + retryable: false + }); + expect(classifyRealmRendererFailure(new Error('request timed out'), 'loading').code) + .toBe('castle-compact-load-failed'); + }); + + it('bounds automatic retries and leaves manual retry available', () => { + const current = { + ...initialRealmRendererLifecycle(), + state: 'recovering' as const, + attempt: REALM_RENDERER_MAX_RECOVERY_ATTEMPTS, + everReady: true + }; + expect(shouldRetryRealmRenderer(current, { + code: 'scene-build-failed', + retryable: true, + phase: 'recovering' + })).toBe(false); + }); +}); diff --git a/tests/realmRendererRecoveryUi.test.tsx b/tests/realmRendererRecoveryUi.test.tsx new file mode 100644 index 00000000..effe4d81 --- /dev/null +++ b/tests/realmRendererRecoveryUi.test.tsx @@ -0,0 +1,130 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const sceneState = vi.hoisted(() => ({ + create: vi.fn(), + webglAvailable: true +})); + +vi.mock('../src/components/realm/createRealmScene', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createRealmScene: sceneState.create }; +}); + +vi.mock('../src/components/realm/realmMapPresentationHelpers', async (importOriginal) => { + const actual = await importOriginal< + typeof import('../src/components/realm/realmMapPresentationHelpers') + >(); + return { ...actual, canUseWebGL: () => sceneState.webglAvailable }; +}); + +import { RealmMapScreen } from '../src/components/realm/RealmMapScreen'; +import type { CreateRealmSceneOptions } from '../src/components/realm/createRealmScene'; +import { createCanonicalGenesisSnapshot, CANONICAL_TEST_FID } from './fixtures/canonicalGenesisSnapshot'; +import { createReadyResourceState } from './fixtures/resourceState'; + +function sceneHandle() { + const noOp = () => undefined; + return { + dispose: vi.fn(), + reconcileLiveGatheringState: noOp, + getCameraAttestation: () => null, + getSceneBuildSequence: () => 1, + focusCastle: noOp, + focusCell: noOp, + frameFoundingDistrict: noOp, + focusKeep: noOp, + recenterKeep: noOp, + setHovered: noOp, + setPresentedCastleIds: noOp, + setSelected: noOp, + setSelectedCastleId: noOp, + setSelectedGoldSiteId: noOp, + setSelectedFoodSiteId: noOp, + setSelectedWoodSiteId: noOp, + setSelectedStoneSiteId: noOp, + setComposition: noOp, + showRealm: noOp + }; +} + +describe('Realm renderer recovery UI', () => { + beforeEach(() => { + sceneState.create.mockReset(); + sceneState.create.mockImplementation(() => sceneHandle()); + sceneState.webglAvailable = true; + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('offers a functional manual retry after context restoration times out', () => { + vi.useFakeTimers(); + const snapshot = createCanonicalGenesisSnapshot(CANONICAL_TEST_FID); + const onRequestReturn = vi.fn(); + render( + + ); + expect(sceneState.create).toHaveBeenCalledOnce(); + const firstOptions = sceneState.create.mock.calls[0]![0] as CreateRealmSceneOptions; + + act(() => firstOptions.onCastlesReady?.(snapshot.castles.length)); + const realm = screen.getByRole('main', { name: 'Hegemony realm' }); + expect(realm.getAttribute('data-renderer-state')).toBe('ready'); + expect(realm.getAttribute('aria-busy')).toBe('false'); + + act(() => firstOptions.onRendererFailure?.({ + code: 'context-lost', + retryable: true, + phase: 'ready' + })); + expect(screen.getByRole('status').textContent).toMatch(/RESTORING THE REALM/i); + expect(realm.getAttribute('aria-busy')).toBe('true'); + act(() => firstOptions.onCastleLodChange?.('high')); + expect(realm.getAttribute('data-renderer-state')).toBe('recovering'); + + act(() => vi.advanceTimersByTime(8_000)); + expect(screen.getByRole('alert').textContent).toMatch(/THE REALM COULD NOT BE RESTORED/i); + expect(realm.getAttribute('aria-busy')).toBe('false'); + const retry = screen.getByRole('button', { name: 'Retry 3D Realm' }); + fireEvent.click(screen.getByRole('button', { name: 'Return to Menu' })); + expect(onRequestReturn).toHaveBeenCalledOnce(); + + fireEvent.click(retry); + expect(sceneState.create).toHaveBeenCalledTimes(2); + expect(realm.getAttribute('data-renderer-state')).toBe('loading'); + expect(realm.getAttribute('aria-busy')).toBe('true'); + }); + + it('lets a transient negative WebGL probe recover through explicit retry', () => { + sceneState.webglAvailable = false; + const snapshot = createCanonicalGenesisSnapshot(CANONICAL_TEST_FID); + render( + + ); + + const realm = screen.getByRole('main', { name: 'Hegemony realm' }); + expect(realm.getAttribute('data-renderer-state')).toBe('static-unsupported'); + expect(realm.getAttribute('aria-busy')).toBe('false'); + expect(sceneState.create).not.toHaveBeenCalled(); + + sceneState.webglAvailable = true; + fireEvent.click(screen.getByRole('button', { name: 'Retry 3D Realm' })); + expect(sceneState.create).toHaveBeenCalledOnce(); + expect(realm.getAttribute('data-renderer-state')).toBe('loading'); + expect(realm.getAttribute('aria-busy')).toBe('true'); + }); +}); diff --git a/tests/realmSceneCleanup.test.ts b/tests/realmSceneCleanup.test.ts index 501ee553..f905a6c2 100644 --- a/tests/realmSceneCleanup.test.ts +++ b/tests/realmSceneCleanup.test.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const webglState = vi.hoisted(() => ({ failGrassShaderContractOnce: false, + failAfterGrassShaderFallbackOnce: false, + failGenericRenderOnce: false, instances: [] as Array<{ dispose: ReturnType; render: ReturnType; @@ -17,6 +19,7 @@ const keepLoadState = vi.hoisted(() => ({ const environmentState = vi.hoisted(() => ({ failNext: false })); const grassLayerState = vi.hoisted(() => ({ failNextCreation: false })); +const waterLayerState = vi.hoisted(() => ({ failNextCreation: false })); const ambientSchedulerState = vi.hoisted(() => ({ creations: [] as Array<{ @@ -34,8 +37,16 @@ vi.mock('three', async (importOriginal) => { dispose = vi.fn(); outputColorSpace = ''; render = vi.fn(() => { + if (webglState.failGenericRenderOnce) { + webglState.failGenericRenderOnce = false; + throw new Error('synthetic renderer failure'); + } if (!webglState.failGrassShaderContractOnce) return; webglState.failGrassShaderContractOnce = false; + if (webglState.failAfterGrassShaderFallbackOnce) { + webglState.failAfterGrassShaderFallbackOnce = false; + webglState.failGenericRenderOnce = true; + } throw new Error('REALM_GRASS_SHADER_BEGIN_VERTEX_CONTRACT_CHANGED'); }); setClearColor = vi.fn(); @@ -88,6 +99,20 @@ vi.mock('../src/components/realm/createRealmGrassLayer', async (importOriginal) }; }); +vi.mock('../src/components/realm/realmWaterLayer', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRealmWaterLayer: (...args: Parameters) => { + if (waterLayerState.failNextCreation) { + waterLayerState.failNextCreation = false; + throw new Error('synthetic water allocation failure'); + } + return actual.createRealmWaterLayer(...args); + } + }; +}); + vi.mock('../src/components/realm/realmAmbientScheduler', async (importOriginal) => { const actual = await importOriginal(); return { @@ -135,6 +160,8 @@ import { CANONICAL_GENESIS_FOREST_LAYOUT_V1 } from '../spacetimedb/src/forestLayoutPolicy'; import { createCanonicalGenesisSnapshot } from './fixtures/canonicalGenesisSnapshot'; +import { GENESIS_WATER_REVISION_ENABLED_CELLS_V1 } from '../spacetimedb/src/waterRevision'; +import type { RealmWorkerSceneRecord } from '../src/components/realm/realmWorkerLayer'; type ListenerSpy = ReturnType; @@ -164,6 +191,7 @@ function dispatchPointer( buttons: { value: input.buttons ?? (type === 'pointerup' ? 0 : 1) } }); target.dispatchEvent(event); + return event; } function createOptions( @@ -243,6 +271,20 @@ function movingResourceNode(siteId: string) { }); } +function idleWorkerRecord(revision = 0n): RealmWorkerSceneRecord { + return Object.freeze({ + workerId: 'genesis-001-castle-1-worker-01', + ordinal: 1, + originCastleId: 1, + originCastleName: 'Hegemony Keep 001', + status: 'idle', + timelineRevision: Number(revision), + revision, + ownedByViewer: true, + originCoord: Object.freeze({ q: 0, r: 0 }) + }); +} + describe('realm scene setup cleanup', () => { const resizeObservers: Array<{ disconnect: ReturnType; @@ -251,11 +293,14 @@ describe('realm scene setup cleanup', () => { beforeEach(() => { webglState.failGrassShaderContractOnce = false; + webglState.failAfterGrassShaderFallbackOnce = false; + webglState.failGenericRenderOnce = false; webglState.instances.length = 0; keepLoadState.load.mockReset(); keepLoadState.load.mockImplementation(() => new Promise(() => undefined)); environmentState.failNext = false; grassLayerState.failNextCreation = false; + waterLayerState.failNextCreation = false; ambientSchedulerState.creations.length = 0; resizeObservers.length = 0; vi.stubGlobal('ResizeObserver', class ResizeObserver { @@ -332,6 +377,24 @@ describe('realm scene setup cleanup', () => { animated.dispose(); }); + it('keeps land-only navigation and rejects Water focus when its layer fails to construct', () => { + waterLayerState.failNextCreation = true; + const canvas = document.createElement('canvas'); + const scene = createRealmScene(createOptions(canvas, { + waterCells: GENESIS_WATER_REVISION_ENABLED_CELLS_V1, + reducedMotion: true + })); + const renderer = webglState.instances[0]!; + + expect(canvas.dataset.waterPresentation).toBe('unavailable'); + expect(canvas.dataset.waterNavigation).toBe('land-only'); + renderer.render.mockClear(); + scene.focusWaterCell(GENESIS_WATER_REVISION_ENABLED_CELLS_V1[0]!.cellKey); + expect(renderer.render).not.toHaveBeenCalled(); + + scene.dispose(); + }); + it('renders the procedural environment centred on the active camera', () => { const canvas = document.createElement('canvas'); const onCastlePresentationTelemetry = vi.fn(); @@ -368,6 +431,13 @@ describe('realm scene setup cleanup', () => { semanticKindCount: 1, semanticFeatureCount: 0, semanticFeatureDrawCalls: 0, + semanticFeatureCounts: { + 'forest-tree': 0, + 'heath-bloom': 0, + 'ridge-outcrop': 0, + 'lake-sheen': 0, + 'ancient-monolith': 0 + }, totalDetailInstanceCount: expect.any(Number), totalDetailDrawCalls: expect.any(Number), forestPlacementSource: 'legacy-fallback', @@ -380,6 +450,14 @@ describe('realm scene setup cleanup', () => { grassCacheEntries: 0, grassAnimated: false, grassTargetAnimationCadence: 0, + grassCandidateCellsByTerrain: { + meadow: 0, lowland: 0, forest: 0, heath: 0, ridge: 0, lake: 0, + 'ancient-stone': 0, apron: 0 + }, + grassActiveCellsByTerrain: { + meadow: 0, lowland: 0, forest: 0, heath: 0, ridge: 0, lake: 0, + 'ancient-stone': 0, apron: 0 + }, grassCountsByTerrain: { meadow: 0, lowland: 0, @@ -390,6 +468,14 @@ describe('realm scene setup cleanup', () => { 'ancient-stone': 0, apron: 0 }, + grassAverageRetainedPatchesByTerrain: { + meadow: 0, lowland: 0, forest: 0, heath: 0, ridge: 0, lake: 0, + 'ancient-stone': 0, apron: 0 + }, + grassPaletteLuminanceMin: 0, + grassPaletteLuminanceMax: 0, + grassPaletteGreenMin: 0, + grassPaletteGreenMax: 0, grassCompletelyBareActiveCells: 0, grassRejectedByStructureClearance: 0, grassRejectedBySlope: 0, @@ -428,6 +514,10 @@ describe('realm scene setup cleanup', () => { totalDetailDrawCalls: expect.any(Number) }); expect(telemetry.semanticFeatureCount).toBeGreaterThan(0); + expect(telemetry.semanticFeatureCounts['heath-bloom']).toBe(0); + expect(Object.values(telemetry.semanticFeatureCounts as Record) + .reduce((total, count) => total + count, 0)) + .toBe(telemetry.semanticFeatureCount); expect(fallback).toBeInstanceOf(THREE.InstancedMesh); expect(fallback?.castShadow).toBe(false); expect(fallback?.receiveShadow).toBe(false); @@ -643,6 +733,31 @@ describe('realm scene setup cleanup', () => { sceneHandle.dispose(); }); + it('disposes the scene when the grass-free fallback render also fails', () => { + const canvas = document.createElement('canvas'); + const onRendererFailure = vi.fn(); + webglState.failGrassShaderContractOnce = true; + webglState.failAfterGrassShaderFallbackOnce = true; + + const sceneHandle = createRealmScene(createOptions(canvas, { + reducedMotion: true, + onRendererFailure, + })); + + expect(webglState.instances[0].render).toHaveBeenCalledTimes(2); + expect(onRendererFailure).toHaveBeenCalledWith(expect.objectContaining({ + code: 'scene-build-failed', + retryable: true, + phase: 'ready', + })); + expect(webglState.instances[0].dispose).toHaveBeenCalledOnce(); + expect(resizeObservers[0]?.disconnect).toHaveBeenCalledOnce(); + expect(ambientSchedulerState.creations.at(-1)?.isActive()).toBe(false); + + sceneHandle.dispose(); + expect(webglState.instances[0].dispose).toHaveBeenCalledOnce(); + }); + it.each(['food', 'wood'] as const)( 'keeps the ambient scheduler live for a moving %s wagon when grass creation fails', (resourceKind) => { @@ -739,6 +854,50 @@ describe('realm scene setup cleanup', () => { sceneHandle.dispose(); }); + it('replaces the worker layer across full, unavailable, and recovered catalogs', () => { + const canvas = document.createElement('canvas'); + const worker = idleWorkerRecord(); + const sceneHandle = createRealmScene(createOptions(canvas, { + reducedMotion: true, + workers: [worker] + })); + const before = sceneHandle.getCameraAttestation(); + const buildSequence = sceneHandle.getSceneBuildSequence(); + expect(canvas.dataset.realmWorkerMarkerCount).toBe('1'); + + sceneHandle.reconcileLiveGatheringState({ + goldNodes: [], + foodNodes: [], + woodNodes: [], + stoneNodes: [], + workers: [], + observedAtMicros: 1n + }); + expect(canvas.dataset.realmWorkerMarkerCount).toBe('0'); + expect(canvas.dataset.realmDynamicReconciliationCount).toBe('1'); + + sceneHandle.reconcileLiveGatheringState({ + goldNodes: [], + foodNodes: [], + woodNodes: [], + stoneNodes: [], + workers: [idleWorkerRecord(1n)], + observedAtMicros: 2n + }); + const after = sceneHandle.getCameraAttestation(); + expect(canvas.dataset.realmWorkerMarkerCount).toBe('1'); + expect(canvas.dataset.realmDynamicReconciliationCount).toBe('2'); + expect(canvas.dataset.realmDynamicReconciliationRejected).toBe('0'); + expect(sceneHandle.getSceneBuildSequence()).toBe(buildSequence); + expect(after.sceneId).toBe(before.sceneId); + expect(after.canvasId).toBe(before.canvasId); + expect(after.position).toEqual(before.position); + expect(after.target).toEqual(before.target); + expect(after.zoom).toBe(before.zoom); + + sceneHandle.dispose(); + }); + it('rejects a mixed invalid resource snapshot before any layer mutates', () => { const canvas = document.createElement('canvas'); const goldNode = movingResourceNode('atomic-gold-site'); @@ -844,6 +1003,19 @@ describe('realm scene setup cleanup', () => { expect(listenerCalls(documentRemove, 'visibilitychange')).toBe(4); }); + it('disposes the scene even when a renderer-failure observer throws', () => { + webglState.failGenericRenderOnce = true; + const observerFailure = new Error('synthetic renderer observer failure'); + + expect(() => createRealmScene(createOptions(document.createElement('canvas'), { + onRendererFailure: () => { throw observerFailure; } + }))).toThrow(observerFailure); + expect(webglState.instances).toHaveLength(1); + expect(webglState.instances[0].dispose).toHaveBeenCalledOnce(); + expect(resizeObservers[0]?.disconnect).toHaveBeenCalledOnce(); + expect(ambientSchedulerState.creations.at(-1)?.isActive()).toBe(false); + }); + it('keeps normal scene disposal idempotent', async () => { const canvas = document.createElement('canvas'); const canvasRemove = vi.spyOn(canvas, 'removeEventListener'); @@ -921,6 +1093,75 @@ describe('realm scene setup cleanup', () => { scene.dispose(); }); + it('suspends input and ambience during context loss, then reports restoration', () => { + const root = document.createElement('main'); + root.className = 'realm-map-screen'; + const canvas = document.createElement('canvas'); + const castleLabel = document.createElement('button'); + castleLabel.className = 'realm-castle-label'; + const overlayRetry = document.createElement('button'); + overlayRetry.className = 'realm-map-screen__retry'; + root.append(canvas, castleLabel, overlayRetry); + document.body.append(root); + const castleLabelClick = vi.fn(); + const overlayClick = vi.fn(); + castleLabel.addEventListener('click', castleLabelClick); + overlayRetry.addEventListener('click', overlayClick); + const onRendererFailure = vi.fn(); + const onRendererContextRestored = vi.fn(); + const onRendererUnavailable = vi.fn(); + const scene = createRealmScene(createOptions(canvas, { + onRendererFailure, + onRendererContextRestored, + onRendererUnavailable + })); + const ambient = ambientSchedulerState.creations.at(-1)!; + + const lost = new Event('webglcontextlost', { cancelable: true }); + canvas.dispatchEvent(lost); + + expect(lost.defaultPrevented).toBe(true); + expect(canvas.dataset.realmRendererContextLost).toBe('true'); + expect(canvas.dataset.realmRendererContextLossCount).toBe('1'); + expect(ambient.isActive()).toBe(false); + expect(onRendererFailure).toHaveBeenCalledWith(expect.objectContaining({ + code: 'context-lost', + retryable: true + })); + expect(onRendererUnavailable).not.toHaveBeenCalled(); + + const wheel = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: 120 + }); + canvas.dispatchEvent(wheel); + expect(wheel.defaultPrevented).toBe(true); + + const canvasPointer = dispatchPointer(canvas, 'pointerdown', { + pointerId: 81, + clientX: 30, + clientY: 30 + }); + expect(canvasPointer.defaultPrevented).toBe(true); + const labelClick = new MouseEvent('click', { bubbles: true, cancelable: true }); + castleLabel.dispatchEvent(labelClick); + expect(labelClick.defaultPrevented).toBe(true); + expect(castleLabelClick).not.toHaveBeenCalled(); + const overlayPointer = new Event('pointerdown', { bubbles: true, cancelable: true }); + overlayRetry.dispatchEvent(overlayPointer); + expect(overlayPointer.defaultPrevented).toBe(false); + overlayRetry.click(); + expect(overlayClick).toHaveBeenCalledOnce(); + + canvas.dispatchEvent(new Event('webglcontextrestored')); + expect(canvas.dataset.realmRendererContextLost).toBe('false'); + expect(canvas.dataset.realmRendererContextRestoreCount).toBe('1'); + expect(onRendererContextRestored).toHaveBeenCalledOnce(); + scene.dispose(); + root.remove(); + }); + it('aborts a pending castle-family load when the Realm unmounts', async () => { const onRendererUnavailable = vi.fn(); const scene = createRealmScene(createOptions(document.createElement('canvas'), { @@ -1029,6 +1270,124 @@ describe('realm scene setup cleanup', () => { expect(materialDispose).toHaveBeenCalledTimes(1); }); + it('starts optional castle LODs only after Compact reaches playable readiness', async () => { + const resolvers = new Map void>(); + keepLoadState.load.mockImplementation((input: unknown) => { + const quality = (input as { quality: { id: string } }).quality.id; + return new Promise((resolve) => { + resolvers.set(quality, resolve); + }); + }); + const canvas = document.createElement('canvas'); + const onCastlesReady = vi.fn(); + const scene = createRealmScene(createOptions(canvas, { + quality: REALM_QUALITY_SPECS.high, + onCastlesReady + })); + + await vi.waitFor(() => expect(keepLoadState.load).toHaveBeenCalledOnce()); + expect((keepLoadState.load.mock.calls[0]?.[0] as { quality: { id: string } }).quality.id) + .toBe('reduced'); + const compactRoot = new THREE.Group(); + compactRoot.add(new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial() + )); + resolvers.get('reduced')?.(loadedCastleAssembly(compactRoot, 'compact')); + + await vi.waitFor(() => expect(onCastlesReady).toHaveBeenCalledWith(1)); + await vi.waitFor(() => expect(keepLoadState.load).toHaveBeenCalledTimes(3)); + expect(new Set(keepLoadState.load.mock.calls.map(([input]) => ( + (input as { quality: { id: string } }).quality.id + )))).toEqual(new Set(['reduced', 'balanced', 'high'])); + expect(canvas.dataset.realmCastleActiveLod).toBe('compact'); + scene.dispose(); + }); + + it('keeps the Realm ready at Compact and releases High when Balanced fails', async () => { + const resolvers = new Map void>(); + const rejecters = new Map void>(); + keepLoadState.load.mockImplementation((input: unknown) => { + const quality = (input as { quality: { id: string } }).quality.id; + return new Promise((resolve, reject) => { + resolvers.set(quality, resolve); + rejecters.set(quality, reject); + }); + }); + const highGeometry = new THREE.BoxGeometry(1, 1, 1); + const highGeometryDispose = vi.spyOn(highGeometry, 'dispose'); + const highRoot = new THREE.Group(); + highRoot.add(new THREE.Mesh(highGeometry, new THREE.MeshBasicMaterial())); + const compactRoot = new THREE.Group(); + compactRoot.add(new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial() + )); + const canvas = document.createElement('canvas'); + const onCastlesReady = vi.fn(); + const onRendererUnavailable = vi.fn(); + const scene = createRealmScene(createOptions(canvas, { + quality: REALM_QUALITY_SPECS.high, + onCastlesReady, + onRendererUnavailable + })); + + await vi.waitFor(() => expect(keepLoadState.load).toHaveBeenCalledOnce()); + resolvers.get('reduced')?.(loadedCastleAssembly(compactRoot, 'compact')); + + await vi.waitFor(() => expect(onCastlesReady).toHaveBeenCalledWith(1)); + await vi.waitFor(() => expect(keepLoadState.load).toHaveBeenCalledTimes(3)); + resolvers.get('high')?.(loadedCastleAssembly(highRoot, 'high')); + await Promise.resolve(); + rejecters.get('balanced')?.(new Error('synthetic Balanced transport failure')); + await vi.waitFor(() => expect(highGeometryDispose).toHaveBeenCalledOnce()); + expect(canvas.dataset.realmCastleActiveLod).toBe('compact'); + expect(canvas.dataset.realmCastlebalancedLod).toBe('unavailable'); + expect(canvas.dataset.realmCastlehighLod).toBe('unavailable'); + expect(onRendererUnavailable).not.toHaveBeenCalled(); + scene.dispose(); + }); + + it('genuinely reloads Compact once after a cached retryable rejection', async () => { + const compactRoot = new THREE.Group(); + compactRoot.add(new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial() + )); + keepLoadState.load + .mockRejectedValueOnce(new Error('synthetic request timed out')) + .mockResolvedValueOnce(loadedCastleAssembly(compactRoot, 'compact')); + const onCastlesReady = vi.fn(); + const onRendererFailure = vi.fn(); + const scene = createRealmScene(createOptions(document.createElement('canvas'), { + onCastlesReady, + onRendererFailure + })); + + await vi.waitFor(() => expect(onCastlesReady).toHaveBeenCalledWith(1)); + expect(keepLoadState.load).toHaveBeenCalledTimes(2); + expect(onRendererFailure).not.toHaveBeenCalled(); + scene.dispose(); + }); + + it('does not retry or blur a Compact integrity failure into a transport code', async () => { + keepLoadState.load.mockRejectedValue(new Error('sha256 integrity mismatch')); + const onRendererFailure = vi.fn(); + const onRendererUnavailable = vi.fn(); + const scene = createRealmScene(createOptions(document.createElement('canvas'), { + onRendererFailure, + onRendererUnavailable + })); + + await vi.waitFor(() => expect(onRendererUnavailable).toHaveBeenCalledOnce()); + expect(keepLoadState.load).toHaveBeenCalledOnce(); + expect(onRendererFailure).toHaveBeenCalledWith(expect.objectContaining({ + code: 'castle-integrity-failed', + retryable: false + })); + scene.dispose(); + }); + it('marks a direct label visible only after the live instance frustum admits its model', async () => { let resolveLoad: ((value: unknown) => void) | undefined; keepLoadState.load.mockImplementation(() => new Promise((resolve) => { @@ -1556,11 +1915,12 @@ describe('realm scene setup cleanup', () => { }); it('fails readiness when a castle instance is present without its matching landscape base', async () => { + const geometry = new THREE.BoxGeometry(1, 1, 1); + const material = new THREE.MeshBasicMaterial(); + const geometryDispose = vi.spyOn(geometry, 'dispose'); + const materialDispose = vi.spyOn(material, 'dispose'); const root = new THREE.Group(); - root.add(new THREE.Mesh( - new THREE.BoxGeometry(1, 1, 1), - new THREE.MeshBasicMaterial() - )); + root.add(new THREE.Mesh(geometry, material)); keepLoadState.load.mockResolvedValue({ root, visualHeight: 1, @@ -1570,7 +1930,9 @@ describe('realm scene setup cleanup', () => { const onCastlesReady = vi.fn(); const onCastlePresentationTelemetry = vi.fn(); const onRendererUnavailable = vi.fn(); - const scene = createRealmScene(createOptions(document.createElement('canvas'), { + const canvas = document.createElement('canvas'); + const canvasRemove = vi.spyOn(canvas, 'removeEventListener'); + const scene = createRealmScene(createOptions(canvas, { onCastlesReady, onCastlePresentationTelemetry, onRendererUnavailable @@ -1585,8 +1947,15 @@ describe('realm scene setup cleanup', () => { presentedLandscapeBaseCount: 0, raycastTargetCount: 1 }); + expect(webglState.instances[0].dispose).toHaveBeenCalledOnce(); + expect(ambientSchedulerState.creations.at(-1)?.isActive()).toBe(false); + expect(listenerCalls(canvasRemove, 'pointerdown')).toBe(1); + expect(listenerCalls(canvasRemove, 'wheel')).toBe(1); + expect(geometryDispose).toHaveBeenCalledOnce(); + expect(materialDispose).toHaveBeenCalledOnce(); scene.dispose(); + expect(webglState.instances[0].dispose).toHaveBeenCalledOnce(); }); it('fails readiness when equal castle/base counts hide a mismatched base transform', async () => { @@ -1612,7 +1981,9 @@ describe('realm scene setup cleanup', () => { const onCastlesReady = vi.fn(); const onCastlePresentationTelemetry = vi.fn(); const onRendererUnavailable = vi.fn(); - const scene = createRealmScene(createOptions(document.createElement('canvas'), { + const canvas = document.createElement('canvas'); + const canvasRemove = vi.spyOn(canvas, 'removeEventListener'); + const scene = createRealmScene(createOptions(canvas, { onCastlesReady, onCastlePresentationTelemetry, onRendererUnavailable @@ -1625,8 +1996,13 @@ describe('realm scene setup cleanup', () => { presentedLandscapeBaseCount: 1, raycastTargetCount: 1 }); + expect(webglState.instances[0].dispose).toHaveBeenCalledOnce(); + expect(ambientSchedulerState.creations.at(-1)?.isActive()).toBe(false); + expect(listenerCalls(canvasRemove, 'pointerdown')).toBe(1); + expect(listenerCalls(canvasRemove, 'wheel')).toBe(1); scene.dispose(); + expect(webglState.instances[0].dispose).toHaveBeenCalledOnce(); }); it('fails closed to the illustrated renderer when prefab initialization fails', async () => { diff --git a/tests/realmTerrainFeatures.test.ts b/tests/realmTerrainFeatures.test.ts index b8efd1a1..4289794d 100644 --- a/tests/realmTerrainFeatures.test.ts +++ b/tests/realmTerrainFeatures.test.ts @@ -87,7 +87,11 @@ describe('semantic realm terrain features', () => { expect(high.points.length).toBeLessThanOrEqual(REALM_TERRAIN_FEATURE_BUDGETS.high); expect(balanced.points.length).toBeLessThanOrEqual(REALM_TERRAIN_FEATURE_BUDGETS.balanced); expect(reduced.points.length).toBeLessThanOrEqual(REALM_TERRAIN_FEATURE_BUDGETS.reduced); - expect(Object.values(high.counts).every((count) => count > 0)).toBe(true); + expect(high.counts['heath-bloom']).toBe(0); + expect(high.points.some((point) => point.kind === 'heath-bloom')).toBe(false); + expect(Object.entries(high.counts) + .filter(([kind]) => kind !== 'heath-bloom') + .every(([, count]) => count > 0)).toBe(true); expect(high.points.every((point) => !semantics.castleSlotKeys.has(hexKey(point.coord)))) .toBe(true); expect(high.points.every((point) => ( @@ -152,6 +156,46 @@ describe('semantic realm terrain features', () => { expect(semantics.terrainKindCounts.lake).toBeGreaterThan(0); }); + it('ignores historical heath blooms and presents ancient stone as neutral moss-slate', () => { + const { placements, semantics, surface } = canonicalInput(); + const generated = generateRealmTerrainFeatures( + surface.renderMap, + semantics.terrainKindsByKey, + 'high', + 1, + placements, + semantics.castleSlotKeys + ); + const historicalBloom = Object.freeze({ + kind: 'heath-bloom' as const, + coord: Object.freeze({ q: 0, r: 0 }), + world: Object.freeze({ x: 0, z: 0 }), + rotation: 0, + scale: 1 + }); + const layer = createRealmTerrainFeatureLayers( + Object.freeze({ + points: Object.freeze([...generated.points, historicalBloom]), + counts: Object.freeze({ ...generated.counts, 'heath-bloom': 1 }), + budget: generated.budget + }), + surface.renderMap, + REALM_QUALITY_SPECS.high, + 1, + placements + ); + const monolith = layer.group.getObjectByName( + 'realm-ancient-monoliths' + ) as THREE.InstancedMesh | undefined; + + expect(layer.counts['heath-bloom']).toBe(0); + expect(layer.instanceCount).toBe(generated.points.length); + expect(layer.group.getObjectByName('realm-heath-blooms')).toBeUndefined(); + expect(monolith).toBeInstanceOf(THREE.InstancedMesh); + expect((monolith!.material as THREE.MeshStandardMaterial).color.getHexString()).toBe('6f746c'); + layer.dispose(); + }); + it('preserves established generation-v2 semantic features and order inside radius twenty', () => { const { placements, semantics, snapshot, surface } = canonicalInput(); const establishedSurface = createAuthoritativeRealmTerrainSurface( @@ -216,7 +260,7 @@ describe('semantic realm terrain features', () => { expect(layer.group.name).toBe('realm-semantic-terrain-features'); expect(layer.instanceCount).toBe(data.points.length); expect(layer.drawCalls).toBeGreaterThan(0); - expect(layer.drawCalls).toBeLessThanOrEqual(5); + expect(layer.drawCalls).toBeLessThanOrEqual(4); layer.group.children.forEach((child) => { const mesh = child as THREE.InstancedMesh; expect(mesh.boundingBox).not.toBeNull(); diff --git a/tests/realmWaterInspectionPresentation.test.ts b/tests/realmWaterInspectionPresentation.test.ts new file mode 100644 index 00000000..5c339916 --- /dev/null +++ b/tests/realmWaterInspectionPresentation.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import { + GENESIS_WATER_BODIES_V1, + GENESIS_WATER_CELLS_V1 +} from '../spacetimedb/src/waterWorld'; +import { GENESIS_WATER_REVISION_ENABLED_CELLS_V1 } from '../spacetimedb/src/waterRevision'; +import { + realmWaterNavigatorBodies, + resolveRealmWaterInspectionRecords +} from '../src/components/realm/realmWaterInspectionPresentation'; + +describe('public water inspection presentation', () => { + it('creates bounded river records with source, mouth, flow, and fog facts', () => { + const records = resolveRealmWaterInspectionRecords(GENESIS_WATER_REVISION_ENABLED_CELLS_V1); + const rivers = records.filter((record) => record.regime === 'river'); + expect(rivers).toHaveLength(400); + expect(rivers[0]).toMatchObject({ + displayType: 'river', + displayName: expect.stringMatching(/^Genesis River /), + sourceCellKey: expect.any(String), + mouthCellKey: expect.any(String), + riverCellCount: expect.any(Number), + riverPosition: expect.any(String), + flowClass: expect.any(String), + gameplayBoundary: expect.stringContaining('does not add') + }); + }); + + it('shows clear and haze public ocean cells but never full-fog ocean cells', () => { + const records = resolveRealmWaterInspectionRecords(GENESIS_WATER_REVISION_ENABLED_CELLS_V1); + expect(records.some((record) => record.regime === 'ocean' && record.fogBand === 'clear')).toBe(true); + expect(records.some((record) => record.regime === 'ocean' && record.fogBand === 'haze')).toBe(true); + }); + + it('creates one navigator entry per river body rather than one row per cell', () => { + const records = resolveRealmWaterInspectionRecords(GENESIS_WATER_REVISION_ENABLED_CELLS_V1); + const bodies = realmWaterNavigatorBodies(records); + expect(bodies).toHaveLength(GENESIS_WATER_BODIES_V1.filter((body) => body.regime === 'river').length); + expect(new Set(bodies.map((body) => body.bodyId)).size).toBe(bodies.length); + expect(bodies[0]).toMatchObject({ + label: expect.stringMatching(/^Genesis River /), + sourceCellKey: expect.any(String), + mouthCellKey: expect.any(String) + }); + }); + + it('fails closed for lake rows or malformed body identity', () => { + expect(resolveRealmWaterInspectionRecords(GENESIS_WATER_CELLS_V1)).toEqual([]); + const lake = GENESIS_WATER_CELLS_V1.find((cell) => cell.regime === 'lake'); + expect(lake).toBeDefined(); + expect(resolveRealmWaterInspectionRecords([lake!])).toEqual([]); + const river = GENESIS_WATER_REVISION_ENABLED_CELLS_V1.find((cell) => cell.regime === 'river'); + expect(river).toBeDefined(); + expect(resolveRealmWaterInspectionRecords([ + { ...river!, bodyId: 'unexpected-body' } + ])).toEqual([]); + }); + + it('fails closed when a river body points at an unavailable endpoint', () => { + const river = GENESIS_WATER_REVISION_ENABLED_CELLS_V1.find( + (cell) => cell.regime === 'river' + )!; + const malformedBodies = GENESIS_WATER_BODIES_V1.map((body) => ( + body.bodyId === river.bodyId + ? { ...body, mouthCellKey: 'missing-water-endpoint' } + : body + )); + + expect(resolveRealmWaterInspectionRecords( + GENESIS_WATER_REVISION_ENABLED_CELLS_V1, + [], + malformedBodies + )).toEqual([]); + }); + + it('normalizes the preserved underlying terrain metadata for river records', () => { + const river = GENESIS_WATER_REVISION_ENABLED_CELLS_V1.find( + (cell) => cell.regime === 'river' && cell.underlyingTileKey + )!; + const record = resolveRealmWaterInspectionRecords( + GENESIS_WATER_REVISION_ENABLED_CELLS_V1, + [{ + tileKey: river.underlyingTileKey!, + terrainKind: 'forest', + staticContentKind: 'empty', + passable: true + }] + ).find((candidate) => candidate.cellKey === river.cellKey); + + expect(record).toMatchObject({ + underlyingTileKey: river.underlyingTileKey, + underlyingTerrainKind: 'forest', + underlyingTerrainLabel: 'Lowland Forest', + underlyingPassable: true + }); + }); +}); diff --git a/tests/realmWaterLayer.test.ts b/tests/realmWaterLayer.test.ts index fdaec85e..3390b0a8 100644 --- a/tests/realmWaterLayer.test.ts +++ b/tests/realmWaterLayer.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it, vi } from 'vitest'; import { GENESIS_RIVERS_V1, + GENESIS_WATER_BODIES_V1, GENESIS_WATER_CELLS_V1, + GENESIS_WATER_ENVIRONMENT_V1, GENESIS_WATER_SEA_LEVEL_MILLI } from '../spacetimedb/src/waterWorld'; import { @@ -16,6 +18,7 @@ import { waterSurfaceLevelToWorldY } from '../src/components/realm/realmWaterLayer'; import { pointyHexCorners } from '../src/components/realm/createTerrainGeometry'; +import { DEFAULT_REALM_CAMERA_SPEC } from '../src/components/realm/realmCameraController'; import { REALM_QUALITY_SPECS } from '../src/components/realm/realmQuality'; import { axialToWorld, @@ -66,7 +69,11 @@ function createLayer(quality: 'high' | 'balanced' | 'reduced', reducedMotion = f function compileMaterial(material: THREE.MeshStandardMaterial) { const shader = { uniforms: {}, - vertexShader: '#include ', + vertexShader: [ + '#include ', + '#include ', + '#include ' + ].join('\n'), fragmentShader: '#include \n#include ' }; material.onBeforeCompile( @@ -89,6 +96,103 @@ function firstTriangleNormalY(geometry: THREE.BufferGeometry) { return abZ * acX - abX * acZ; } +function renderedRiverTriangleHit( + mesh: THREE.Mesh, + raycaster: THREE.Raycaster +) { + const positions = mesh.geometry.getAttribute('position'); + const index = mesh.geometry.index; + const cellKeys = mesh.geometry.userData.realmWaterCellKeys as readonly string[] | undefined; + if (!index || !cellKeys) throw new Error('TEST_RIVER_GEOMETRY_CONTRACT_MISSING'); + const triangleA = new THREE.Vector3(); + const triangleB = new THREE.Vector3(); + const triangleC = new THREE.Vector3(); + const hitPoint = new THREE.Vector3(); + let nearest: Readonly<{ cellKey: string; distance: number }> | null = null; + for (let triangleIndex = 0; triangleIndex < index.count / 3; triangleIndex += 1) { + triangleA.fromBufferAttribute(positions, index.getX(triangleIndex * 3)); + triangleB.fromBufferAttribute(positions, index.getX(triangleIndex * 3 + 1)); + triangleC.fromBufferAttribute(positions, index.getX(triangleIndex * 3 + 2)); + const point = raycaster.ray.intersectTriangle( + triangleA, + triangleB, + triangleC, + true, + hitPoint + ); + if (!point) continue; + const distance = raycaster.ray.origin.distanceTo(point); + if ( + distance < Math.max(0, raycaster.near) + || distance > raycaster.far + ) continue; + const cellKey = cellKeys[Math.floor(triangleIndex / 6)]; + if (!cellKey) throw new Error('TEST_RIVER_TRIANGLE_IDENTITY_MISSING'); + if ( + nearest !== null + && (distance > nearest.distance + || (distance === nearest.distance && cellKey >= nearest.cellKey)) + ) continue; + nearest = Object.freeze({ cellKey, distance }); + } + return nearest; +} + +function angledRiverRay( + cellKey: string, + targetOffset: readonly [number, number], + originOffset: readonly [number, number, number], + near = 0, + far = 30 +) { + const cell = activeRiverCells.find((candidate) => candidate.cellKey === cellKey); + if (!cell) throw new Error(`TEST_RIVER_CELL_MISSING:${cellKey}`); + const center = axialToWorld(cell, 1); + const target = new THREE.Vector3( + center.x + targetOffset[0], + 0, + center.z + targetOffset[1] + ); + const origin = target.clone().add(new THREE.Vector3(...originOffset)); + return new THREE.Raycaster( + origin, + target.clone().sub(origin).normalize(), + near, + far + ); +} + +function cameraPitchRiverRay( + mesh: THREE.Mesh, + cellKey: string, + pitchDegrees: number +) { + const cellKeys = mesh.geometry.userData.realmWaterCellKeys as readonly string[] | undefined; + const cellIndex = cellKeys?.indexOf(cellKey) ?? -1; + if (cellIndex < 0) throw new Error(`TEST_RIVER_CELL_MISSING:${cellKey}`); + const positions = mesh.geometry.getAttribute('position'); + const target = new THREE.Vector3( + positions.getX(cellIndex * 7), + positions.getY(cellIndex * 7), + positions.getZ(cellIndex * 7) + ); + const pitch = THREE.MathUtils.degToRad(pitchDegrees); + const azimuth = THREE.MathUtils.degToRad(DEFAULT_REALM_CAMERA_SPEC.azimuthDegrees); + const distance = 35; + const horizontalDistance = Math.cos(pitch) * distance; + const origin = target.clone().add(new THREE.Vector3( + Math.sin(azimuth) * horizontalDistance, + Math.sin(pitch) * distance, + Math.cos(azimuth) * horizontalDistance + )); + return new THREE.Raycaster( + origin, + target.clone().sub(origin).normalize(), + 0, + distance + 1 + ); +} + describe('Realm canonical water layer', () => { it('converts the persisted fixed-point datum into terrain world height', () => { expect(waterSurfaceLevelToWorldY(1_000)).toBe(0); @@ -140,8 +244,9 @@ describe('Realm canonical water layer', () => { const shader = compileMaterial(ocean.material); expect(ocean.material.userData.waterWaveComponents).toBe(0); - expect(shader.fragmentShader).not.toContain('uniform float uWaterTime'); - expect(shader.fragmentShader).toContain('float waterGlimmer = 0.0'); + expect(shader.vertexShader).not.toContain('uniform float uWaterTime'); + expect(shader.vertexShader).toContain('return 0.0'); + expect(shader.fragmentShader).toContain('float waterGlimmer = abs(vWarpkeepWaterWave)'); expect(shader.fragmentShader).toContain('vWarpkeepWaterFogMix'); expect(shader.fragmentShader.indexOf('waterGlimmer')) .toBeLessThan(shader.fragmentShader.indexOf('#include ')); @@ -172,6 +277,228 @@ describe('Realm canonical water layer', () => { layer.dispose(); }); + it('maps real direct and angled ray hits analytically and excludes full fog', () => { + const layer = createRealmWaterLayer({ + cells: GENESIS_WATER_REVISION_ENABLED_CELLS_V1, + quality: REALM_QUALITY_SPECS.reduced, + reducedMotion: true, + hexSize: 1, + heightAtWorld: canonicalHeightAtWorld + }); + const river = activeRiverCells[0]!; + const riverWorld = axialToWorld(river, 1); + const raycaster = new THREE.Raycaster( + new THREE.Vector3(riverWorld.x, 10, riverWorld.z), + new THREE.Vector3(0, -1, 0) + ); + const meshRaycast = vi.spyOn(raycaster, 'intersectObject'); + expect(layer.raycast(raycaster)).toMatchObject({ + cellKey: river.cellKey, + bodyId: river.bodyId, + regime: 'river', + coord: { q: river.q, r: river.r } + }); + expect(meshRaycast).not.toHaveBeenCalled(); + const broadCellRaycaster = new THREE.Raycaster( + new THREE.Vector3(riverWorld.x + 0.65, 10, riverWorld.z), + new THREE.Vector3(0, -1, 0) + ); + expect(layer.raycast(broadCellRaycaster)).toMatchObject({ + cellKey: river.cellKey, + bodyId: river.bodyId, + regime: 'river' + }); + const angledOrigin = new THREE.Vector3(riverWorld.x + 2, 6, riverWorld.z + 1); + const angledTarget = new THREE.Vector3( + riverWorld.x, + waterSurfaceLevelToWorldY(river.surfaceLevelMilli) + 0.035, + riverWorld.z + ); + const angledRaycaster = new THREE.Raycaster( + angledOrigin, + angledTarget.clone().sub(angledOrigin).normalize(), + 0, + 20 + ); + expect(layer.raycast(angledRaycaster)).toMatchObject({ + cellKey: river.cellKey, + bodyId: river.bodyId, + regime: 'river' + }); + angledRaycaster.far = 1; + expect(layer.raycast(angledRaycaster)).toBeNull(); + const visibleOcean = GENESIS_WATER_REVISION_ENABLED_CELLS_V1.find( + (cell) => cell.regime === 'ocean' && cell.fogBand !== 'full' + ); + expect(visibleOcean).toBeDefined(); + const visibleOceanWorld = axialToWorld(visibleOcean!, 1); + const oceanRaycaster = new THREE.Raycaster( + new THREE.Vector3(visibleOceanWorld.x, 10, visibleOceanWorld.z), + new THREE.Vector3(0, -1, 0) + ); + const oceanMeshRaycast = vi.spyOn(oceanRaycaster, 'intersectObject'); + expect(layer.raycast(oceanRaycaster)).toMatchObject({ + cellKey: visibleOcean!.cellKey, + bodyId: visibleOcean!.bodyId, + regime: 'ocean' + }); + expect(oceanMeshRaycast).not.toHaveBeenCalled(); + const fullFog = GENESIS_WATER_REVISION_ENABLED_CELLS_V1.find( + (cell) => cell.regime === 'ocean' && cell.fogBand === 'full' + ); + expect(fullFog).toBeDefined(); + const fogWorld = axialToWorld(fullFog!, 1); + const fogRaycaster = new THREE.Raycaster( + new THREE.Vector3(fogWorld.x, 10, fogWorld.z), + new THREE.Vector3(0, -1, 0) + ); + expect(layer.getCellPresentation(fullFog!.cellKey)?.fogBand).toBe('full'); + expect(layer.raycast(fogRaycaster)).toBeNull(); + layer.dispose(); + }); + + it('matches rendered river triangles at angled edges, transitions, misses, and clips', () => { + const layer = createRealmWaterLayer({ + cells: GENESIS_WATER_REVISION_ENABLED_CELLS_V1, + quality: REALM_QUALITY_SPECS.reduced, + reducedMotion: true, + hexSize: 1, + heightAtWorld: canonicalHeightAtWorld + }); + const riverMesh = layer.group.getObjectByName('canonical-river-ribbons') as THREE.Mesh< + THREE.BufferGeometry, + THREE.Material + >; + const fixedCases = [ + { + label: 'raised near-edge triangle', + raycaster: angledRiverRay('26,8', [0.82, 0], [3, 7, -2]), + expectedCellKey: '26,8' + }, + { + label: 'different-level adjacent triangle', + raycaster: angledRiverRay('38,17', [-0.42, -0.72], [-3, 8, -2]), + expectedCellKey: '38,17' + }, + { + label: 'sloped-geometry miss outside the rendered edge', + raycaster: angledRiverRay('26,7', [-0.82, 0], [-3, 8, -2]), + expectedCellKey: null + } + ] as const; + fixedCases.forEach(({ label, raycaster, expectedCellKey }) => { + const renderedHit = renderedRiverTriangleHit(riverMesh, raycaster); + expect(renderedHit?.cellKey ?? null, label).toBe(expectedCellKey); + const layerHit = layer.raycast(raycaster); + expect(layerHit?.cellKey ?? null, label).toBe(renderedHit?.cellKey ?? null); + if (renderedHit && layerHit) { + expect(layerHit.distance, label).toBeCloseTo(renderedHit.distance, 8); + } + }); + + const riverCellsByKey = new Map(activeRiverCells.map( + (cell) => [cell.cellKey, cell] as const + )); + for (const [upstreamKey, downstreamKey] of [ + ['26,7', '26,8'], + ['38,16', '38,17'] + ] as const) { + const upstream = riverCellsByKey.get(upstreamKey)!; + const downstream = riverCellsByKey.get(downstreamKey)!; + if (upstreamKey === '26,7') { + expect(upstream.surfaceLevelMilli).toBe(downstream.surfaceLevelMilli); + } else { + expect(upstream.surfaceLevelMilli).not.toBe(downstream.surfaceLevelMilli); + } + const upstreamWorld = axialToWorld(upstream, 1); + const downstreamWorld = axialToWorld(downstream, 1); + const towardDownstream = new THREE.Vector2( + downstreamWorld.x - upstreamWorld.x, + downstreamWorld.z - upstreamWorld.z + ).normalize(); + const target = new THREE.Vector3( + (upstreamWorld.x + downstreamWorld.x) * 0.5 + towardDownstream.x * 0.03, + 0, + (upstreamWorld.z + downstreamWorld.z) * 0.5 + towardDownstream.y * 0.03 + ); + const origin = target.clone().add(new THREE.Vector3(2, 6, 1)); + const adjacencyRaycaster = new THREE.Raycaster( + origin, + target.clone().sub(origin).normalize(), + 0, + 30 + ); + const renderedHit = renderedRiverTriangleHit(riverMesh, adjacencyRaycaster); + expect(renderedHit?.cellKey).toBe(downstreamKey); + expect(layer.raycast(adjacencyRaycaster)?.cellKey).toBe(renderedHit?.cellKey); + } + + const unclippedRaycaster = angledRiverRay('26,8', [0.82, 0], [3, 7, -2]); + const unclippedHit = renderedRiverTriangleHit(riverMesh, unclippedRaycaster)!; + const acceptedWindow = new THREE.Raycaster( + unclippedRaycaster.ray.origin.clone(), + unclippedRaycaster.ray.direction.clone(), + unclippedHit.distance - 0.000_1, + unclippedHit.distance + 0.000_1 + ); + expect(layer.raycast(acceptedWindow)?.cellKey) + .toBe(renderedRiverTriangleHit(riverMesh, acceptedWindow)?.cellKey); + for (const clippedRaycaster of [ + new THREE.Raycaster( + unclippedRaycaster.ray.origin.clone(), + unclippedRaycaster.ray.direction.clone(), + 0, + unclippedHit.distance - 0.000_1 + ), + new THREE.Raycaster( + unclippedRaycaster.ray.origin.clone(), + unclippedRaycaster.ray.direction.clone(), + unclippedHit.distance + 0.000_1, + unclippedHit.distance + 0.5 + ) + ]) { + expect(renderedRiverTriangleHit(riverMesh, clippedRaycaster)).toBeNull(); + expect(layer.raycast(clippedRaycaster)).toBeNull(); + } + layer.dispose(); + }); + + it('selects the highest and lowest river surfaces at both supported camera pitch bounds', () => { + const layer = createRealmWaterLayer({ + cells: GENESIS_WATER_REVISION_ENABLED_CELLS_V1, + quality: REALM_QUALITY_SPECS.reduced, + reducedMotion: true, + hexSize: 1, + heightAtWorld: canonicalHeightAtWorld + }); + const riverMesh = layer.group.getObjectByName('canonical-river-ribbons') as THREE.Mesh< + THREE.BufferGeometry, + THREE.Material + >; + const orderedBySurface = [...activeRiverCells].sort((left, right) => ( + left.surfaceLevelMilli - right.surfaceLevelMilli + || left.cellKey.localeCompare(right.cellKey) + )); + const lowest = orderedBySurface[0]!; + const highest = orderedBySurface.at(-1)!; + expect(highest.surfaceLevelMilli).toBeGreaterThan(lowest.surfaceLevelMilli); + + for (const cell of [lowest, highest]) { + for (const pitchDegrees of [ + DEFAULT_REALM_CAMERA_SPEC.closePitchDegrees, + DEFAULT_REALM_CAMERA_SPEC.overviewPitchDegrees + ]) { + const raycaster = cameraPitchRiverRay(riverMesh, cell.cellKey, pitchDegrees); + const renderedHit = renderedRiverTriangleHit(riverMesh, raycaster); + expect(renderedHit?.cellKey, `${cell.cellKey} at ${pitchDegrees}°`) + .toBe(cell.cellKey); + expect(layer.raycast(raycaster)?.cellKey, `${cell.cellKey} at ${pitchDegrees}°`) + .toBe(cell.cellKey); + } + } + layer.dispose(); + }); + it('keeps every canonical river surface clear and every shared edge continuous', () => { const layer = createRealmWaterLayer({ cells: GENESIS_WATER_REVISION_ENABLED_CELLS_V1, @@ -302,11 +629,13 @@ describe('Realm canonical water layer', () => { expect(ocean.material.userData.waterWaveComponents) .toBe(REALM_WATER_RENDER_BUDGETS.high.waveComponents); - expect(shader.fragmentShader.match(/sin\(/g)).toHaveLength( - REALM_WATER_RENDER_BUDGETS.high.waveComponents + expect(shader.vertexShader.match(/sin\(/g)).toHaveLength( + REALM_WATER_RENDER_BUDGETS.high.waveComponents + 1 ); - expect(shader.fragmentShader).toContain('uniform float uWaterTime'); - expect(shader.fragmentShader).not.toContain('uWaterWaveComponents'); + expect(shader.vertexShader).toContain('uniform float uWaterTime'); + expect(shader.vertexShader).not.toContain('uWaterWaveComponents'); + expect(shader.vertexShader).toContain('(modelMatrix * vec4(position, 1.0)).xz'); + expect(shader.vertexShader).not.toContain('vViewPosition.xz'); expect(shader.fragmentShader).toContain('outgoingLight +='); expect(shader.uniforms).toHaveProperty('uWaterTime'); expect(layer.updateEnvironment(1)).toBe(true); @@ -316,6 +645,101 @@ describe('Realm canonical water layer', () => { layer.dispose(); }); + it('aligns first animated samples to the same canonical environment boundary', () => { + const options = { + cells: GENESIS_WATER_CELLS_V1, + quality: REALM_QUALITY_SPECS.balanced, + reducedMotion: false, + hexSize: 1, + heightAtWorld: canonicalHeightAtWorld, + environment: { + ...GENESIS_WATER_ENVIRONMENT_V1, + updatedAtMicros: 1_000_000_000n + }, + waterBodies: GENESIS_WATER_BODIES_V1, + nowMicros: () => 1_014_000_000n + } as const; + const first = createRealmWaterLayer(options); + const second = createRealmWaterLayer(options); + expect(first.updateEnvironment(1)).toBe(true); + expect(second.updateEnvironment(40)).toBe(true); + const firstOcean = first.group.getObjectByName('canonical-ocean-surface') as THREE.Mesh< + THREE.BufferGeometry, + THREE.MeshStandardMaterial + >; + const secondOcean = second.group.getObjectByName('canonical-ocean-surface') as THREE.Mesh< + THREE.BufferGeometry, + THREE.MeshStandardMaterial + >; + const firstTime = firstOcean.material.userData.waterUniforms.uWaterTime.value as number; + const secondTime = secondOcean.material.userData.waterUniforms.uWaterTime.value as number; + expect(firstTime).toBe(secondTime); + expect(firstTime).toBeGreaterThanOrEqual(0); + expect(firstTime).toBeLessThan(97); + first.dispose(); + second.dispose(); + }); + + it('does not treat the local wall clock as a synchronized Water clock', () => { + const dateNow = vi.spyOn(Date, 'now'); + const options = { + cells: GENESIS_WATER_CELLS_V1, + quality: REALM_QUALITY_SPECS.balanced, + reducedMotion: false, + hexSize: 1, + heightAtWorld: canonicalHeightAtWorld, + environment: { + ...GENESIS_WATER_ENVIRONMENT_V1, + updatedAtMicros: 1_000_000_000n + }, + waterBodies: GENESIS_WATER_BODIES_V1 + } as const; + const first = createRealmWaterLayer(options); + const second = createRealmWaterLayer(options); + expect(first.updateEnvironment(3)).toBe(true); + expect(second.updateEnvironment(3)).toBe(true); + expect(dateNow).not.toHaveBeenCalled(); + const firstOcean = first.group.getObjectByName('canonical-ocean-surface') as THREE.Mesh< + THREE.BufferGeometry, + THREE.MeshStandardMaterial + >; + const secondOcean = second.group.getObjectByName('canonical-ocean-surface') as THREE.Mesh< + THREE.BufferGeometry, + THREE.MeshStandardMaterial + >; + expect(firstOcean.material.userData.waterUniforms.uWaterTime.value) + .toBe(secondOcean.material.userData.waterUniforms.uWaterTime.value); + first.dispose(); + second.dispose(); + }); + + it('reuses fixed overlay buffers while selection and hover move between cells', () => { + const layer = createRealmWaterLayer({ + cells: GENESIS_WATER_REVISION_ENABLED_CELLS_V1, + quality: REALM_QUALITY_SPECS.reduced, + reducedMotion: true, + hexSize: 1, + heightAtWorld: canonicalHeightAtWorld + }); + const selected = layer.group.getObjectByName('selected-water-cell-outline') as THREE.LineLoop; + const hovered = layer.group.getObjectByName('hovered-water-cell-outline') as THREE.LineLoop; + const selectedPositions = selected.geometry.getAttribute('position'); + const hoveredPositions = hovered.geometry.getAttribute('position'); + + layer.setSelectedCellKey(activeRiverCells[0]!.cellKey); + layer.setSelectedCellKey(activeRiverCells[1]!.cellKey); + layer.setHoveredCellKey(activeRiverCells[0]!.cellKey); + layer.setHoveredCellKey(activeRiverCells[1]!.cellKey); + + expect(selected.geometry.getAttribute('position')).toBe(selectedPositions); + expect(hovered.geometry.getAttribute('position')).toBe(hoveredPositions); + expect(selectedPositions.count).toBe(6); + expect(hoveredPositions.count).toBe(6); + expect(selected.visible).toBe(true); + expect(hovered.visible).toBe(true); + layer.dispose(); + }); + it('disposes every owned GPU resource once and becomes inert', () => { const layer = createLayer('balanced', true); const meshes = layer.group.children as THREE.Mesh[]; @@ -341,8 +765,8 @@ describe('Realm canonical water layer', () => { hexSize: 1, heightAtWorld: canonicalHeightAtWorld })).toThrow('REALM_WATER_RENDER_BUDGET_EXCEEDED'); - expect(geometryDispose).toHaveBeenCalledTimes(4); - expect(materialDispose).toHaveBeenCalledTimes(4); + expect(geometryDispose).toHaveBeenCalledTimes(6); + expect(materialDispose).toHaveBeenCalledTimes(6); } finally { geometryDispose.mockRestore(); materialDispose.mockRestore(); diff --git a/tests/realmWaterNavigation.test.ts b/tests/realmWaterNavigation.test.ts index a6a7b275..041addd3 100644 --- a/tests/realmWaterNavigation.test.ts +++ b/tests/realmWaterNavigation.test.ts @@ -70,7 +70,7 @@ describe('persistent Water camera envelope', () => { expect(surface.renderMap.cells).toHaveLength(10_981); }); - it('wires the validated envelope into camera navigation without rewriting the water array', () => { + it('wires the validated envelope only while Water is visible without rewriting its rows', () => { const source = readFileSync(resolve( process.cwd(), 'src/components/realm/createRealmScene.ts' @@ -78,10 +78,11 @@ describe('persistent Water camera envelope', () => { expect(source).toContain('cells: options.waterCells'); expect(source).not.toContain("filter((cell) => cell.regime !== 'lake')"); - expect(source).toContain('bounds: waterNavigationEnvelope?.bounds ?? terrainData.bounds'); - expect(source).toContain('maximumCenterHexRadius: waterNavigationEnvelope.maximumCenterHexRadius'); - expect(source).toContain('hexSize: waterNavigationEnvelope.hexSize'); - expect(source).toContain('blockedCenterCellKeys: waterNavigationEnvelope.blockedCenterCellKeys'); + expect(source).toContain('const activeWaterNavigationEnvelope = waterLayer'); + expect(source).toContain('bounds: activeWaterNavigationEnvelope?.bounds ?? terrainData.bounds'); + expect(source).toContain('maximumCenterHexRadius: activeWaterNavigationEnvelope.maximumCenterHexRadius'); + expect(source).toContain('hexSize: activeWaterNavigationEnvelope.hexSize'); + expect(source).toContain('blockedCenterCellKeys: activeWaterNavigationEnvelope.blockedCenterCellKeys'); expect(source).toContain('overviewHull: createTerrainOverviewHull(strategicOverviewMap, HEX_SIZE)'); }); diff --git a/tests/realmWaterPhase.test.ts b/tests/realmWaterPhase.test.ts new file mode 100644 index 00000000..00da9ac7 --- /dev/null +++ b/tests/realmWaterPhase.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { + REALM_WATER_MAX_DRIFT_CORRECTION_RATE, + REALM_WATER_MAX_LOCAL_STEP_SECONDS, + REALM_WATER_PHASE_PERIOD_SECONDS, + resolveRealmWaterPhase +} from '../src/components/realm/realmWaterPhase'; + +describe('shared Water phase', () => { + it('freezes reduced motion at one deterministic epoch and body phase', () => { + const first = resolveRealmWaterPhase({ + environmentEpoch: 1n, + localMonotonicSeconds: 0, + reducedMotion: true, + bodySeed: 44, + wavePreset: 'ocean' + }); + const later = resolveRealmWaterPhase({ + environmentEpoch: 1n, + localMonotonicSeconds: 999, + reducedMotion: true, + bodySeed: 44, + wavePreset: 'ocean' + }); + + expect(later.phaseSeconds).toBe(first.phaseSeconds); + expect(later.source).toBe('deterministic-freeze'); + }); + + it('gives clients the same wrapped phase from the same canonical boundary and clock sample', () => { + const shared = { + environmentEpoch: 2n, + environmentUpdatedAtMicros: 1_000_000_000n, + synchronizedServerTimeMicros: 1_014_000_000n, + bodySeed: 91, + wavePreset: 'broad-swell' + } as const; + const first = resolveRealmWaterPhase({ ...shared, localMonotonicSeconds: 0 }); + const second = resolveRealmWaterPhase({ ...shared, localMonotonicSeconds: 42 }); + + expect(first.phaseSeconds).toBe(second.phaseSeconds); + expect(first.source).toBe('server-estimate'); + }); + + it('remains monotonic internally while wrapping the finite shader phase', () => { + const previous = REALM_WATER_PHASE_PERIOD_SECONDS - 0.05; + const phase = resolveRealmWaterPhase({ + environmentEpoch: 1n, + localMonotonicSeconds: 1.2, + previousLocalMonotonicSeconds: 1, + previousUnwrappedPhaseSeconds: previous + }); + + expect(phase.unwrappedPhaseSeconds).toBeGreaterThan(previous); + expect(phase.phaseSeconds).toBeGreaterThanOrEqual(0); + expect(phase.phaseSeconds).toBeLessThan(1); + }); + + it('bounds external-clock correction and never reverses on a backward sample', () => { + const phase = resolveRealmWaterPhase({ + environmentEpoch: 1n, + environmentUpdatedAtMicros: 1_000_000_000n, + synchronizedServerTimeMicros: 1_000_000_000n, + localMonotonicSeconds: 10.1, + previousLocalMonotonicSeconds: 10, + previousUnwrappedPhaseSeconds: 80 + }); + + expect(Math.abs(phase.correctionSeconds)).toBeLessThanOrEqual( + 0.1 * REALM_WATER_MAX_DRIFT_CORRECTION_RATE + Number.EPSILON + ); + expect(phase.unwrappedPhaseSeconds).toBeGreaterThan(80); + }); + + it('clamps a resumed local clock instead of fast-forwarding a sleeping tab', () => { + const phase = resolveRealmWaterPhase({ + environmentEpoch: 1n, + localMonotonicSeconds: 5_000, + previousLocalMonotonicSeconds: 1, + previousUnwrappedPhaseSeconds: 20 + }); + + expect(phase.unwrappedPhaseSeconds - 20).toBeLessThanOrEqual( + REALM_WATER_MAX_LOCAL_STEP_SECONDS + ); + }); +}); From 50482aba7117520080a7609c226134e2e405753d Mon Sep 17 00:00:00 2001 From: Ael <183124839+ael-dev3@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:05:59 +0200 Subject: [PATCH 10/10] Alpha 0.3.14 signed source (10/10) GitHub-signed reconstruction of the exact reviewed Alpha 0.3.14 tree. --- tests/realmWorkerLayer.test.ts | 206 +++++++++++++++ tests/realmWorkerPresentation.test.ts | 333 ++++++++++++++++++++++++ tests/renderedWebglBrowserProbe.test.ts | 105 ++++++-- tests/repositoryCommandSecurity.test.ts | 31 ++- tests/terrainColor.test.ts | 18 +- tests/uiSurfaceCssContract.test.ts | 14 + tests/warpkeepConnection.test.ts | 266 +++++++++++++++++++ tests/waterInspectionPanel.test.tsx | 98 +++++++ tests/workerCommandIdempotency.test.ts | 141 ++++++++++ tests/workerRecordArt.test.ts | 143 ++++++++++ tests/workflowSecurity.test.ts | 4 +- vite.config.ts | 14 +- 12 files changed, 1343 insertions(+), 30 deletions(-) create mode 100644 tests/realmWorkerLayer.test.ts create mode 100644 tests/realmWorkerPresentation.test.ts create mode 100644 tests/waterInspectionPanel.test.tsx create mode 100644 tests/workerCommandIdempotency.test.ts create mode 100644 tests/workerRecordArt.test.ts diff --git a/tests/realmWorkerLayer.test.ts b/tests/realmWorkerLayer.test.ts new file mode 100644 index 00000000..64897842 --- /dev/null +++ b/tests/realmWorkerLayer.test.ts @@ -0,0 +1,206 @@ +import * as THREE from 'three'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createRealmWorkerLayer, + resolveRealmWorkerWorldPosition, + type RealmWorkerSceneRecord +} from '../src/components/realm/realmWorkerLayer'; + +const idleWorker = Object.freeze({ + workerId: 'genesis-001-castle-7-worker-01', + ordinal: 1 as const, + originCastleId: 7, + originCastleName: 'Hegemony Keep 007', + status: 'idle' as const, + timelineRevision: 0, + revision: 0n, + ownedByViewer: true, + originCoord: Object.freeze({ q: 0, r: 0 }) +}) satisfies RealmWorkerSceneRecord; + +const outboundWorker = Object.freeze({ + ...idleWorker, + status: 'outbound' as const, + resourceKind: 'wood' as const, + siteId: 'genesis-001:wood:0001', + startedAtMicros: 100n, + arrivesAtMicros: 300n, + gatheringEndsAtMicros: 600n, + returnsAtMicros: 800n, + routeSteps: 2, + timelineRevision: 1, + revision: 1n, + destinationCoord: Object.freeze({ q: 2, r: -1 }) +}) satisfies RealmWorkerSceneRecord; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('realm worker scene layer', () => { + it('places idle workers around their keep and interpolates outbound motion', () => { + const idle = resolveRealmWorkerWorldPosition(idleWorker, 0n, 1); + const start = resolveRealmWorkerWorldPosition(outboundWorker, 100n, 1); + const midpoint = resolveRealmWorkerWorldPosition(outboundWorker, 200n, 1); + const end = resolveRealmWorkerWorldPosition(outboundWorker, 300n, 1); + + expect(start).toEqual(idle); + expect(midpoint.x).toBeCloseTo((start.x + end.x) * 0.5, 8); + expect(midpoint.z).toBeCloseTo((start.z + end.z) * 0.5, 8); + expect(end).not.toEqual(start); + }); + + it('starts an early return from its persisted outbound progress basis', () => { + const returning = Object.freeze({ + ...outboundWorker, + status: 'returning' as const, + returnStartedAtMicros: 250n, + returnsAtMicros: 325n, + returnStartProgressBasisPoints: 7_500, + timelineRevision: 2, + revision: 2n + }) satisfies RealmWorkerSceneRecord; + const origin = resolveRealmWorkerWorldPosition(idleWorker, 0n, 1); + const destination = resolveRealmWorkerWorldPosition( + Object.freeze({ ...outboundWorker, status: 'gathering' as const }), + 300n, + 1 + ); + const returnStart = resolveRealmWorkerWorldPosition(returning, 250n, 1); + const returned = resolveRealmWorkerWorldPosition(returning, 325n, 1); + + expect(returnStart.x).toBeCloseTo(origin.x + (destination.x - origin.x) * 0.75, 8); + expect(returnStart.z).toBeCloseTo(origin.z + (destination.z - origin.z) * 0.75, 8); + expect(returned).toEqual(origin); + }); + + it('renders one bounded selectable identity and accepts only the same static catalog', () => { + const layer = createRealmWorkerLayer({ + workers: [idleWorker], + hexSize: 1, + heightAtWorld: () => 0 + }); + const world = resolveRealmWorkerWorldPosition(idleWorker, 0n, 1); + const hit = layer.raycast(new THREE.Raycaster( + new THREE.Vector3(world.x, 5, world.z), + new THREE.Vector3(0, -1, 0), + 0, + 10 + )); + expect(hit).toMatchObject({ + workerId: idleWorker.workerId, + workerOrdinal: 1, + originCastleId: 7 + }); + expect(layer.canReconcile([Object.freeze({ ...idleWorker, revision: 2n })])).toBe(true); + expect(layer.canReconcile([Object.freeze({ + ...idleWorker, + originCoord: Object.freeze({ q: 1, r: 0 }) + })])).toBe(false); + const marker = layer.group.getObjectByName('realm-worker-markers') as THREE.InstancedMesh; + const pick = layer.group.getObjectByName('realm-worker-pick-volumes') as THREE.InstancedMesh; + const markerDispose = vi.spyOn(marker, 'dispose'); + const pickDispose = vi.spyOn(pick, 'dispose'); + layer.reconcile([Object.freeze({ ...idleWorker, revision: 2n })]); + layer.setHoveredWorkerId(idleWorker.workerId); + layer.setSelectedWorkerId(idleWorker.workerId); + layer.dispose(); + expect(markerDispose).toHaveBeenCalledOnce(); + expect(pickDispose).toHaveBeenCalledOnce(); + expect(layer.raycast(new THREE.Raycaster())).toBeNull(); + }); + + it('refuses duplicate or non-canonical scene identities', () => { + expect(() => createRealmWorkerLayer({ + workers: [idleWorker, idleWorker], + hexSize: 1, + heightAtWorld: () => 0 + })).toThrow('REALM_WORKER_CATALOG_INVALID'); + expect(() => createRealmWorkerLayer({ + workers: [Object.freeze({ ...idleWorker, ordinal: 5 as never })], + hexSize: 1, + heightAtWorld: () => 0 + })).toThrow('REALM_WORKER_CATALOG_INVALID'); + }); + + it('releases every allocated resource when initial terrain sampling fails', () => { + const instanceDispose = vi.spyOn(THREE.InstancedMesh.prototype, 'dispose'); + const geometryDispose = vi.spyOn(THREE.BufferGeometry.prototype, 'dispose'); + const materialDispose = vi.spyOn(THREE.Material.prototype, 'dispose'); + + expect(() => createRealmWorkerLayer({ + workers: [idleWorker], + hexSize: 1, + heightAtWorld: () => Number.NaN + })).toThrow('REALM_WORKER_GROUND_INVALID'); + expect(instanceDispose).toHaveBeenCalledTimes(2); + expect(geometryDispose).toHaveBeenCalledTimes(2); + expect(materialDispose).toHaveBeenCalledTimes(2); + }); + + it('continues GPU cleanup when one disposal step throws', () => { + const layer = createRealmWorkerLayer({ + workers: [idleWorker], + hexSize: 1, + heightAtWorld: () => 0 + }); + const marker = layer.group.getObjectByName('realm-worker-markers') as THREE.InstancedMesh; + const pick = layer.group.getObjectByName('realm-worker-pick-volumes') as THREE.InstancedMesh; + const markerGeometryDispose = vi.spyOn(marker.geometry, 'dispose'); + const markerMaterialDispose = vi.spyOn(marker.material as THREE.Material, 'dispose'); + const pickDispose = vi.spyOn(pick, 'dispose'); + vi.spyOn(marker, 'dispose').mockImplementationOnce(() => { + throw new Error('synthetic marker disposal failure'); + }); + + expect(() => layer.dispose()).not.toThrow(); + expect(pickDispose).toHaveBeenCalledOnce(); + expect(markerGeometryDispose).toHaveBeenCalledOnce(); + expect(markerMaterialDispose).toHaveBeenCalledOnce(); + }); + + it('leaves idle instance buffers and terrain sampling untouched on unchanged frames', () => { + const heightAtWorld = vi.fn(() => 0); + const layer = createRealmWorkerLayer({ + workers: [idleWorker], + hexSize: 1, + heightAtWorld + }); + const marker = layer.group.getObjectByName('realm-worker-markers') as THREE.InstancedMesh; + const pick = layer.group.getObjectByName('realm-worker-pick-volumes') as THREE.InstancedMesh; + const markerMatrixVersion = marker.instanceMatrix.version; + const markerColorVersion = marker.instanceColor?.version; + const pickMatrixVersion = pick.instanceMatrix.version; + + expect(heightAtWorld).toHaveBeenCalledOnce(); + expect(layer.update(0n)).toBe(false); + expect(layer.update(50n)).toBe(false); + expect(heightAtWorld).toHaveBeenCalledOnce(); + expect(marker.instanceMatrix.version).toBe(markerMatrixVersion); + expect(marker.instanceColor?.version).toBe(markerColorVersion); + expect(pick.instanceMatrix.version).toBe(pickMatrixVersion); + layer.setHoveredWorkerId(idleWorker.workerId); + expect(heightAtWorld).toHaveBeenCalledOnce(); + expect(marker.instanceMatrix.version).toBeGreaterThan(markerMatrixVersion); + expect(marker.instanceColor?.version).toBeGreaterThan(markerColorVersion ?? -1); + expect(pick.instanceMatrix.version).toBe(pickMatrixVersion); + layer.dispose(); + }); + + it('stops reporting movement once an interpolated worker reaches its endpoint', () => { + const layer = createRealmWorkerLayer({ + workers: [outboundWorker], + hexSize: 1, + heightAtWorld: () => 0 + }); + + expect(layer.hasMovingWorkers()).toBe(true); + expect(layer.update(200n)).toBe(true); + expect(layer.hasMovingWorkers()).toBe(true); + expect(layer.update(300n)).toBe(true); + expect(layer.hasMovingWorkers()).toBe(false); + expect(layer.update(400n)).toBe(false); + layer.dispose(); + }); +}); diff --git a/tests/realmWorkerPresentation.test.ts b/tests/realmWorkerPresentation.test.ts new file mode 100644 index 00000000..6812737f --- /dev/null +++ b/tests/realmWorkerPresentation.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, it } from 'vitest'; + +import { + CASTLE_WORKER_POLICY_VERSION, + CASTLE_WORKER_REALM_ID, + decodeRealmWorkerOccupations, + decodeRealmWorkerPublicRows, + decodeRealmWorkerSystem, + decodeWorkerResourceState, + decodeWorkerRoster, + resolveReadyWorkerProjection, + workerAvailabilityCount, + workerRosterDigestForCastleIds +} from '../src/components/realm/realmWorkerPresentation'; +import { REALM_RESOURCE_POLICY_VERSION } from '../src/components/realm/realmResourcePresentation'; +import { + CASTLE_WORKER_POLICY_VERSION as SERVER_CASTLE_WORKER_POLICY_VERSION, + rosterDigestForCastleIds as serverRosterDigestForCastleIds +} from '../spacetimedb/src/castleWorkerPolicy'; + +function workerId(castleId: number, ordinal: number) { + return `genesis-001-castle-${castleId}-worker-${String(ordinal).padStart(2, '0')}`; +} + +function publicWorker(castleId: number, ordinal: number, assigned = false) { + return { + workerId: workerId(castleId, ordinal), + originCastleId: castleId, + ordinal, + status: assigned ? 'gathering' : 'idle', + assignmentId: assigned ? `opaque-${castleId}-${ordinal}` : undefined, + resourceKind: assigned ? 'stone' : undefined, + siteId: assigned ? 'genesis-001:stone:0001' : undefined, + startedAtMicros: assigned ? 10n : undefined, + arrivesAtMicros: assigned ? 20n : undefined, + gatheringEndsAtMicros: assigned ? 100n : undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: assigned ? 120n : undefined, + routeSteps: assigned ? 1 : undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: assigned ? 2 : 0, + revision: assigned ? 3n : 0n + }; +} + +function publicRows() { + return [7, 8].flatMap((castleId) => [1, 2, 3, 4].map((ordinal) => ( + publicWorker(castleId, ordinal, castleId === 7 && ordinal === 1) + ))); +} + +function privateRoster() { + return { + fid: 42n, + castleId: 7n, + observedAtMicros: 1000n, + workers: [1, 2, 3, 4].map((ordinal) => ({ + workerId: workerId(7, ordinal), + ordinal, + status: ordinal === 1 ? 'gathering' : 'idle', + resourceKind: ordinal === 1 ? 'stone' : undefined, + siteId: ordinal === 1 ? 'genesis-001:stone:0001' : undefined, + accruedAmount: ordinal === 1 ? 5n : 0n, + materializedAmount: ordinal === 1 ? 2n : 0n, + availableAmount: ordinal === 1 ? 3n : 0n, + observedAtMicros: 1000n, + revision: ordinal === 1 ? 3n : 0n + })) + }; +} + +function privateResources(pendingStone = 3n, workerPolicyVersion = CASTLE_WORKER_POLICY_VERSION) { + return { + fid: 42n, + food: 1n, + wood: 2n, + stone: 3n, + gold: 4n, + workerPendingFood: 0n, + workerPendingWood: 0n, + workerPendingStone: pendingStone, + workerPendingGold: 0n, + observedAtMicros: 1000n, + settledThroughMicros: 900n, + revision: 1n, + resourcePolicyVersion: REALM_RESOURCE_POLICY_VERSION, + workerPolicyVersion, + workerSystemMode: 'active' + }; +} + +function occupation(overrides: Readonly> = {}) { + return { + nodeKey: 'stone:genesis-001:stone:0001', + resourceKind: 'stone', + siteId: 'genesis-001:stone:0001', + workerId: workerId(7, 1), + workerOrdinal: 1, + originCastleId: 7, + assignmentId: 'opaque-server-id', + phase: 'gathering', + startedAtMicros: 10n, + arrivesAtMicros: 20n, + gatheringEndsAtMicros: 100n, + timelineRevision: 2, + ...overrides + }; +} + +function readyInputs() { + const castleIds = [7, 8]; + const system = decodeRealmWorkerSystem({ + realmId: CASTLE_WORKER_REALM_ID, + policyVersion: CASTLE_WORKER_POLICY_VERSION, + workersPerCastle: 4, + expectedCastleCount: 2, + expectedWorkerCount: 8, + rosterDigest: workerRosterDigestForCastleIds(castleIds), + mode: 'active', + legacyDrainRequired: false + })!; + const workers = decodeRealmWorkerPublicRows( + publicRows(), + new Map([[7, 'Own Keep'], [8, 'Peer Keep']]), + 7 + )!; + const occupations = decodeRealmWorkerOccupations([occupation()])!; + const roster = decodeWorkerRoster(privateRoster(), 42n)!; + const resourceState = decodeWorkerResourceState(privateResources(), 42n)!; + return { castleIds, system, workers, occupations, roster, resourceState }; +} + +function returningPublicRows(overrides: Readonly> = {}) { + const rows: Record[] = publicRows().map((row) => ({ ...row })); + rows[0] = { + ...publicWorker(7, 1, true), + status: 'returning', + returnStartedAtMicros: 15n, + returnsAtMicros: 20n, + returnStartProgressBasisPoints: 5_000, + ...overrides + }; + return rows; +} + +function returningReadyInputs(overrides: Readonly> = {}) { + const inputs = readyInputs(); + const workers = decodeRealmWorkerPublicRows( + returningPublicRows(overrides), + new Map([[7, 'Own Keep'], [8, 'Peer Keep']]), + 7 + ); + const rosterValue = privateRoster(); + const roster = decodeWorkerRoster({ + ...rosterValue, + workers: rosterValue.workers.map((worker, index) => ( + index === 0 ? { ...worker, status: 'returning' } : worker + )) + }, 42n); + if (workers === undefined || roster === undefined) throw new Error('returning fixture is invalid'); + return { ...inputs, workers, occupations: Object.freeze([]), roster }; +} + +describe('generic worker presentation boundary', () => { + it('requires the exact four-worker system contract and canonical digest encoding', () => { + const digest = workerRosterDigestForCastleIds([8, 7]); + expect(CASTLE_WORKER_POLICY_VERSION).toBe(SERVER_CASTLE_WORKER_POLICY_VERSION); + expect(digest).toBe(workerRosterDigestForCastleIds([7, 8])); + expect(digest).toBe(serverRosterDigestForCastleIds([7n, 8n])); + expect(decodeRealmWorkerSystem({ + realmId: CASTLE_WORKER_REALM_ID, + policyVersion: CASTLE_WORKER_POLICY_VERSION, + workersPerCastle: 4, + expectedCastleCount: 2, + expectedWorkerCount: 8, + rosterDigest: digest, + mode: 'active', + legacyDrainRequired: false + })?.mode).toBe('active'); + expect(decodeRealmWorkerSystem({ workersPerCastle: 3 })).toBeUndefined(); + expect(decodeRealmWorkerSystem({ + realmId: CASTLE_WORKER_REALM_ID, + policyVersion: CASTLE_WORKER_POLICY_VERSION, + workersPerCastle: 4, + expectedCastleCount: 0, + expectedWorkerCount: 0, + rosterDigest: 'not-a-digest', + mode: 'staged', + legacyDrainRequired: false + })).toBeUndefined(); + }); + + it('decodes canonical public rows without assignment identifiers', () => { + const workers = decodeRealmWorkerPublicRows( + publicRows(), + new Map([[7, 'Own Keep'], [8, 'Peer Keep']]), + 7 + ); + expect(workers).toHaveLength(8); + expect(workers?.filter((worker) => worker.ownedByViewer)).toHaveLength(4); + expect(Object.keys(workers?.[0] ?? {})).not.toContain('assignmentId'); + expect(workerAvailabilityCount(workers?.filter((worker) => worker.ownedByViewer) ?? [])).toBe(3); + const duplicateOrdinal = publicRows(); + duplicateOrdinal[1] = { ...duplicateOrdinal[1]!, ordinal: 1 }; + expect(decodeRealmWorkerPublicRows( + duplicateOrdinal, + new Map([[7, 'Own Keep'], [8, 'Peer Keep']]), + 7 + )).toBeUndefined(); + }); + + it('never reads legacy assignment identifiers from public rows or occupations', () => { + const rows = publicRows(); + rows[0] = { + ...rows[0]!, + get assignmentId(): never { + throw new Error('legacy assignment id must remain outside the browser domain'); + } + }; + const lease = { + ...occupation(), + get assignmentId(): never { + throw new Error('legacy assignment id must remain outside the browser domain'); + } + }; + expect(decodeRealmWorkerPublicRows( + rows, + new Map([[7, 'Own Keep'], [8, 'Peer Keep']]), + 7 + )).toHaveLength(8); + expect(decodeRealmWorkerOccupations([lease])).toHaveLength(1); + }); + + it('keeps early and zero-distance recalls active through decoding and projection', () => { + for (const boundary of [ + { returnStartedAtMicros: 15n, returnsAtMicros: 20n, returnStartProgressBasisPoints: 5_000 }, + { returnStartedAtMicros: 10n, returnsAtMicros: 10n, returnStartProgressBasisPoints: 0 } + ]) { + const inputs = returningReadyInputs(boundary); + expect(inputs.workers[0]?.status).toBe('returning'); + expect(resolveReadyWorkerProjection({ + realmId: CASTLE_WORKER_REALM_ID, + ownCastleId: 7, + ...inputs + })?.ownedWorkers[0]?.status).toBe('returning'); + } + }); + + it('rejects impossible returning chronology in decoders and resolved projections', () => { + for (const impossible of [ + { returnStartedAtMicros: 9n, returnsAtMicros: 10n }, + { returnStartedAtMicros: 101n, returnsAtMicros: 101n }, + { returnStartedAtMicros: 15n, returnsAtMicros: 14n }, + { returnStartedAtMicros: undefined }, + { returnStartProgressBasisPoints: undefined }, + { returnStartProgressBasisPoints: 10_001 }, + { returnStartProgressBasisPoints: 4_999 }, + { returnsAtMicros: 21n } + ]) { + expect(decodeRealmWorkerPublicRows( + returningPublicRows(impossible), + new Map([[7, 'Own Keep'], [8, 'Peer Keep']]), + 7 + )).toBeUndefined(); + } + + const inputs = returningReadyInputs(); + expect(resolveReadyWorkerProjection({ + realmId: CASTLE_WORKER_REALM_ID, + ownCastleId: 7, + ...inputs, + workers: inputs.workers.map((worker, index) => ( + index === 0 ? { ...worker, returnsAtMicros: 14n } : worker + )) + })).toBeUndefined(); + }); + + it('keeps occupation and caller-only projections strict and privacy bounded', () => { + const occupations = decodeRealmWorkerOccupations([occupation()]); + expect(occupations?.[0].resourceKind).toBe('stone'); + expect(Object.keys(occupations?.[0] ?? {})).not.toContain('assignmentId'); + expect(decodeRealmWorkerOccupations([ + occupation(), + occupation({ nodeKey: 'gold:site', resourceKind: 'gold', siteId: 'site' }) + ])).toBeUndefined(); + + const roster = decodeWorkerRoster(privateRoster(), 42n); + expect(roster?.workers[0].availableAmount).toBe(3n); + expect(decodeWorkerRoster({ + ...privateRoster(), + workers: privateRoster().workers.map((worker, index) => ( + index === 1 ? { ...worker, ordinal: 1 } : worker + )) + }, 42n)).toBeUndefined(); + expect(decodeWorkerResourceState(privateResources(), 42n)?.available.stone).toBe(3n); + }); + + it('activates only a complete graph and exposes exactly the viewer-owned four', () => { + const inputs = readyInputs(); + const projection = resolveReadyWorkerProjection({ + realmId: CASTLE_WORKER_REALM_ID, + ownCastleId: 7, + ...inputs + }); + expect(projection?.workers).toHaveLength(8); + expect(projection?.ownedWorkers.map((worker) => worker.originCastleId)).toEqual([7, 7, 7, 7]); + expect(projection?.ownedWorkers.map((worker) => worker.ordinal)).toEqual([1, 2, 3, 4]); + }); + + it('fails closed on castle, digest, roster, resource, or occupation disagreement', () => { + const inputs = readyInputs(); + const resolve = (overrides: Readonly>) => resolveReadyWorkerProjection({ + realmId: CASTLE_WORKER_REALM_ID, + ownCastleId: 7, + ...inputs, + ...overrides + }); + expect(resolve({ castleIds: [7] })).toBeUndefined(); + expect(resolve({ system: { ...inputs.system, rosterDigest: '0000000000000000' } })).toBeUndefined(); + expect(resolve({ + roster: { + ...inputs.roster, + workers: inputs.roster.workers.map((worker, index) => ( + index === 0 ? { ...worker, revision: 4n } : worker + )) + } + })).toBeUndefined(); + expect(resolve({ resourceState: decodeWorkerResourceState(privateResources(2n), 42n) })).toBeUndefined(); + expect(resolve({ + occupations: decodeRealmWorkerOccupations([occupation({ phase: 'outbound' })]) + })).toBeUndefined(); + }); +}); diff --git a/tests/renderedWebglBrowserProbe.test.ts b/tests/renderedWebglBrowserProbe.test.ts index 8d806483..b816317c 100644 --- a/tests/renderedWebglBrowserProbe.test.ts +++ b/tests/renderedWebglBrowserProbe.test.ts @@ -24,6 +24,7 @@ import { RENDERED_WEBGL_QA_CASE_COUNT, RENDERED_WEBGL_QA_CHROME_TEAM_ID, RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_CELL_COUNT, + RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_KIND_COUNT, RENDERED_WEBGL_QA_VITE_FS_DENY, renderedWebglLabelAnchorDistanceTelemetry, renderedWebglLabelDisplacementClassificationValid, @@ -33,6 +34,31 @@ import { terminateHeadlessChromeProcessGroup } from '../scripts/qa-observer/rendered-webgl-browser-probe.mjs'; +const EXPECTED_LOCAL_VITE_FS_DENY = Object.freeze([ + '.env', + '.env.*', + '.dev.vars*', + '.envrc', + '.npmrc', + 'credentials.json', + 'admin-secret*', + 'secret.json', + 'secrets.json', + 'id_rsa*', + 'id_ed25519*', + '*.{crt,pem}', + '*.{cer,key,p12,pfx,jks,keystore,jwk,token}', + '*.local', + '*.{log,har,trace}', + '*.{bak,backup,tmp}', + '*.{sqlite,sqlite3,db,dump}', + '*.{zip,tar,tar.gz,tgz,7z}', + '**/.git/**', + '**/.cache/**', + '**/.wrangler/**', + '**/.secrets/**' +]); + function cdpPipeFrame(value: unknown) { return Buffer.from(`${JSON.stringify(value)}\0`, 'utf8'); } @@ -192,7 +218,7 @@ describe('rendered WebGL headless browser probe contract', () => { }); it('resolves the complete Vite deny contract instead of replacing its defaults', async () => { - const { resolveConfig } = await import('vite'); + const { isFileServingAllowed, resolveConfig } = await import('vite'); const resolved = await resolveConfig({ configFile: false, envFile: false, @@ -207,13 +233,39 @@ describe('rendered WebGL headless browser probe contract', () => { } }, 'serve', 'development', 'development'); - expect(resolved.server.fs.deny).toEqual([ - '.env', - '.env.*', - '*.{crt,pem}', - '**/.git/**', - '**/.cache/**' - ]); + expect(resolved.server.fs.deny).toEqual(EXPECTED_LOCAL_VITE_FS_DENY); + for (const relativePath of [ + 'services/auth-bridge/.dev.vars.production', + 'services/auth-bridge/signing-key.jwk', + 'services/auth-bridge/admin-secret.txt', + 'services/auth-bridge/credentials.json', + 'services/auth-bridge/.wrangler/state.sqlite', + '.secrets/qa-observer.key' + ]) { + expect( + isFileServingAllowed(resolved, resolve(process.cwd(), relativePath)), + relativePath + ).toBe(false); + } + expect( + isFileServingAllowed(resolved, resolve(process.cwd(), 'src/main.tsx')) + ).toBe(true); + + const manualDevelopment = await resolveConfig({ + configFile: resolve(process.cwd(), 'vite.config.ts'), + logLevel: 'silent' + }, 'serve', 'development', 'development'); + expect(manualDevelopment.server.fs.deny).toEqual(resolved.server.fs.deny); + for (const relativePath of [ + 'services/auth-bridge/.dev.vars.production', + 'services/auth-bridge/signing-key.jwk', + 'services/auth-bridge/.wrangler/state.sqlite' + ]) { + expect( + isFileServingAllowed(manualDevelopment, resolve(process.cwd(), relativePath)), + `manual:${relativePath}` + ).toBe(false); + } }); it('uses an inline fail-closed Vite configuration and disposable cache', () => { @@ -223,7 +275,9 @@ describe('rendered WebGL headless browser probe contract', () => { ), 'utf8'); expect(source).toContain('configFile: false'); expect(source).toContain('envFile: false'); - expect(source).toContain('plugins: [reactPlugin(), ...localQaPlugins]'); + expect(source).toContain( + 'plugins: [warpkeepLocalPublicBoundaryPlugin(), reactPlugin(), ...localQaPlugins]' + ); expect(source).toContain('castleLodVisualEvidenceSourceVitePlugin(castleLodVisualSource)'); expect(source).toContain('runCastleLodVisualEvidenceBrowserCase(devtools'); expect(source).toContain('onCastleLodVisualEvidence?.(castleLodVisualEvidence)'); @@ -240,13 +294,11 @@ describe('rendered WebGL headless browser probe contract', () => { ); expect(source).toContain('await attempt(() => options.removeProfile?.());'); expect(source).toContain('onCastleLodVisualBoundary?.(castleLodVisualBoundary)'); - expect(RENDERED_WEBGL_QA_VITE_FS_DENY).toEqual([ - '.env', - '.env.*', - '*.{crt,pem}', - '**/.git/**', - '**/.cache/**' - ]); + expect(source).toContain( + '.realm-cell-navigator__castles[aria-label="Founded castles"] > li > button' + ); + expect(source).not.toContain("'.realm-cell-navigator__castles button'"); + expect(RENDERED_WEBGL_QA_VITE_FS_DENY).toEqual(EXPECTED_LOCAL_VITE_FS_DENY); expect(source).toContain('attestStableHeadlessChromeExecutable(reviewedChromeIdentity)'); expect(source).toContain('readReviewedChromeExecutableIdentity()'); expect(source).toContain("'--remote-debugging-pipe'"); @@ -917,7 +969,7 @@ describe('rendered WebGL headless browser probe contract', () => { readyAfterMilliseconds: 2_412, environmentLighting: 'procedural', semanticTerrainCellCount: RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_CELL_COUNT, - semanticTerrainKindCount: 7, + semanticTerrainKindCount: RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_KIND_COUNT, semanticTerrainFeatureCount: 700, semanticTerrainFeatureDrawCalls: 5, totalTerrainDetailInstanceCount: 5_000, @@ -998,7 +1050,7 @@ describe('rendered WebGL headless browser probe contract', () => { readyAfterMilliseconds: 2_412, environmentLighting: 'procedural', semanticTerrainCellCount: RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_CELL_COUNT, - semanticTerrainKindCount: 7, + semanticTerrainKindCount: RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_KIND_COUNT, semanticTerrainFeatureCount: 700, semanticTerrainFeatureDrawCalls: 5, totalTerrainDetailInstanceCount: 5_000, @@ -1034,17 +1086,19 @@ describe('rendered WebGL headless browser probe contract', () => { semanticTerrainCellCount: staleOrMixedTerrainCellCount }, expected)).toThrow(/semantic-terrain-cell-count/i); } - expect(() => parseRenderedWebglBrowserDom({ - ...ready, - semanticTerrainKindCount: 6 - }, expected)).toThrow(/semantic-terrain-kind-count/i); + for (const staleKindCount of [5, 7]) { + expect(() => parseRenderedWebglBrowserDom({ + ...ready, + semanticTerrainKindCount: staleKindCount + }, expected)).toThrow(/semantic-terrain-kind-count/i); + } expect(() => parseRenderedWebglBrowserDom({ ...ready, semanticTerrainFeatureCount: 0 }, expected)).toThrow(/semantic-terrain-feature-budget/i); expect(() => parseRenderedWebglBrowserDom({ ...ready, - semanticTerrainFeatureCount: 1_011 + semanticTerrainFeatureCount: 1_101 }, expected)).toThrow(/semantic-terrain-feature-budget/i); expect(() => parseRenderedWebglBrowserDom({ ...ready, @@ -1056,7 +1110,7 @@ describe('rendered WebGL headless browser probe contract', () => { }, expected)).toThrow(/semantic-terrain-feature-draw-calls/i); expect(() => parseRenderedWebglBrowserDom({ ...ready, - totalTerrainDetailInstanceCount: 5_711 + totalTerrainDetailInstanceCount: 5_801 }, expected)).toThrow(/total-terrain-detail-budget/i); expect(() => parseRenderedWebglBrowserDom({ ...ready, @@ -1211,7 +1265,8 @@ describe('rendered WebGL headless browser probe contract', () => { }, expected)).toThrow(/observation/i); for (const [caseId, quality, semanticFeatureCount, totalDetailInstanceCount] of [ - ['desktop-high', 'high', 1_310, 7_210], + ['desktop-high', 'high', 1_550, 7_450], + ['desktop-balanced', 'balanced', 1_100, 5_800], ['desktop-reduced', 'reduced', 610, 3_210] ] as const) { const qualityCase = renderedWebglBrowserProbeCases(41_733) diff --git a/tests/repositoryCommandSecurity.test.ts b/tests/repositoryCommandSecurity.test.ts index 6f122b91..39e48eb0 100644 --- a/tests/repositoryCommandSecurity.test.ts +++ b/tests/repositoryCommandSecurity.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; @@ -28,7 +29,8 @@ describe('repository command security policy', () => { }); it('defensively ignores common local credential and recovery artifacts', () => { - const ignore = readFileSync(resolve(import.meta.dirname, '../.gitignore'), 'utf8'); + const repositoryRoot = resolve(import.meta.dirname, '..'); + const ignore = readFileSync(resolve(repositoryRoot, '.gitignore'), 'utf8'); for (const pattern of [ 'credentials.json', @@ -36,6 +38,16 @@ describe('repository command security policy', () => { '.npmrc', '*.pem', '*.key', + '*.crt', + '*.cer', + '*.jwk', + '*.token', + 'id_rsa*', + 'id_ed25519*', + 'admin-secret*', + 'secret.json', + 'secrets.json', + '.secrets/', '*.log', '*.har', '*.trace', @@ -47,5 +59,22 @@ describe('repository command security policy', () => { ]) { expect(ignore.split('\n')).toContain(pattern); } + + const plausibleSecretPaths = [ + 'services/auth-bridge/signing-key.jwk', + 'services/auth-bridge/admin-secret.txt', + 'services/auth-bridge/id_ed25519', + 'services/auth-bridge/client.crt', + 'services/auth-bridge/.secrets/device.key' + ]; + expect(execFileSync( + 'git', + ['check-ignore', '--no-index', '--stdin'], + { + cwd: repositoryRoot, + encoding: 'utf8', + input: `${plausibleSecretPaths.join('\n')}\n` + } + ).trim().split('\n')).toEqual(plausibleSecretPaths); }); }); diff --git a/tests/terrainColor.test.ts b/tests/terrainColor.test.ts index 7917db63..fce42630 100644 --- a/tests/terrainColor.test.ts +++ b/tests/terrainColor.test.ts @@ -4,10 +4,26 @@ import { generateRealmTerrainMap, terrainCellByCoord } from '../src/game/map/gen import { axialToWorld } from '../src/game/map/hexCoordinates'; import { HEGEMONY_GENESIS_001 } from '../src/game/map/realmSeed'; import type { RealmTerrainKind } from '../src/game/map/realmTerrainSemantics'; -import { sampleLowlandsColor } from '../src/game/map/terrainColor'; +import { + REALM_TERRAIN_KIND_PALETTE, + sampleLowlandsColor, + terrainColorVisualMetrics +} from '../src/game/map/terrainColor'; import { createHegemonyKeepPlacement } from '../src/game/map/terrainPlacements'; describe('lowlands terrain color', () => { + it('keeps heath olive-green and ancient stone neutral instead of purple', () => { + const heath = REALM_TERRAIN_KIND_PALETTE.heath.color; + const ancientStone = REALM_TERRAIN_KIND_PALETTE['ancient-stone'].color; + + expect(heath.g).toBeGreaterThan(heath.r); + expect(heath.g).toBeGreaterThan(heath.b); + expect(terrainColorVisualMetrics('heath').greenLead).toBeGreaterThan(0.1); + expect(terrainColorVisualMetrics('ancient-stone').channelSpread).toBeLessThanOrEqual(0.05); + expect(ancientStone.g).toBeGreaterThanOrEqual(ancientStone.r); + expect(ancientStone.g).toBeGreaterThanOrEqual(ancientStone.b); + }); + it('is deterministic, finite, and identical from either side of a shared edge', () => { const map = generateRealmTerrainMap(HEGEMONY_GENESIS_001, 5); const first = terrainCellByCoord(map, { q: 0, r: 0 }); diff --git a/tests/uiSurfaceCssContract.test.ts b/tests/uiSurfaceCssContract.test.ts index cbbbe083..488acbab 100644 --- a/tests/uiSurfaceCssContract.test.ts +++ b/tests/uiSurfaceCssContract.test.ts @@ -100,6 +100,20 @@ describe('shared Warpkeep surface system', () => { expect(menu).toContain('overflow: auto;'); }); + it('keeps the mobile Worker inspector inside every device safe area', () => { + const source = css('src/components/realm/WorkerInspectionPanel.css'); + const dismiss = lastBlock(source, '.worker-inspection__dismiss'); + const body = lastBlock(source, '.worker-inspection__body'); + const drawer = lastBlock(source, '.worker-inspection__drawer'); + + expect(dismiss).toContain('env(safe-area-inset-top)'); + expect(dismiss).toContain('env(safe-area-inset-right)'); + expect(body).toContain('env(safe-area-inset-right)'); + expect(body).toContain('env(safe-area-inset-bottom)'); + expect(body).toContain('env(safe-area-inset-left)'); + expect(drawer).toContain('env(safe-area-inset-top)'); + }); + it('removes backdrop compositing on the performance profile and has an opaque fallback', () => { const source = css('src/components/WarpkeepExperience.css'); const performance = firstBlock( diff --git a/tests/warpkeepConnection.test.ts b/tests/warpkeepConnection.test.ts index 5a94a3c4..1c13f7f4 100644 --- a/tests/warpkeepConnection.test.ts +++ b/tests/warpkeepConnection.test.ts @@ -55,6 +55,11 @@ import { GENESIS_WATER_REVISION_ENABLED_CELLS_V1 } from '../spacetimedb/src/waterRevision'; import { resolveCanonicalWaterProjection } from '../src/components/realm/realmWaterProjection'; +import { + CASTLE_WORKER_POLICY_VERSION, + CASTLE_WORKER_REALM_ID, + workerRosterDigestForCastleIds +} from '../src/components/realm/realmWorkerPresentation'; import { CANONICAL_TEST_FID, createCanonicalGenesisCandidate @@ -219,6 +224,57 @@ function stoneSubscriptionConnection( return Object.freeze({ connection, core, pairedStone }); } +function workerSubscriptionConnection( + candidate: WarpkeepRealmSnapshotCandidate, + workerRows: readonly unknown[] +) { + const rows = rawRowsForCandidate(candidate); + const table = (values: readonly T[]) => ({ + iter: function* () { yield* values; } + }); + const core = callbackSubscriptionDouble(); + const pairedWorkers = callbackSubscriptionDouble(); + const castleId = candidate.castles[0]!.castleId; + const connection = { + db: { + worldTile: table(rows.worldTile), + worldTileMetaV1: table(rows.worldTileMetaV1), + playerV2: table(rows.playerV2), + realmProfileV1: table(rows.realmProfileV1), + realmV1: table(rows.realmV1), + castle: table(rows.castle), + realmWorkerSystemV1: table([{ + realmId: CASTLE_WORKER_REALM_ID, + policyVersion: CASTLE_WORKER_POLICY_VERSION, + workersPerCastle: 4, + expectedCastleCount: 1, + expectedWorkerCount: 4, + rosterDigest: workerRosterDigestForCastleIds([castleId]), + mode: 'active', + legacyDrainRequired: false + }]), + castleWorkerV1: table(workerRows), + workerNodeOccupationV1: table([{ + nodeKey: 'stone:genesis-001:stone:0001', + resourceKind: 'stone', + siteId: 'genesis-001:stone:0001', + workerId: `genesis-001-castle-${castleId}-worker-01`, + workerOrdinal: 1, + originCastleId: BigInt(castleId), + phase: 'gathering', + startedAtMicros: 10n, + arrivesAtMicros: 20n, + gatheringEndsAtMicros: 100n, + timelineRevision: 1 + }]) + }, + subscriptionBuilder: vi.fn() + .mockReturnValueOnce(core.builder) + .mockReturnValueOnce(pairedWorkers.builder) + } as unknown as WarpkeepConnection; + return Object.freeze({ connection, core, pairedWorkers }); +} + /** Core + Water projection using the generated SDK's exact Q15 wire spelling. */ function waterSubscriptionConnection( candidate: WarpkeepRealmSnapshotCandidate, @@ -652,6 +708,166 @@ describe('Warpkeep authenticated connection boundary', () => { composite.unsubscribe(); }); + it('publishes the paired worker graph without exposing opaque assignment ids', () => { + const candidate = createCanonicalGenesisCandidate(); + const castleId = candidate.castles[0]!.castleId; + const workerRows = [1, 2, 3, 4].map((ordinal) => ({ + workerId: `genesis-001-castle-${castleId}-worker-0${ordinal}`, + originCastleId: BigInt(castleId), + ordinal, + status: ordinal === 1 ? 'gathering' : 'idle', + resourceKind: ordinal === 1 ? 'stone' : undefined, + siteId: ordinal === 1 ? 'genesis-001:stone:0001' : undefined, + startedAtMicros: ordinal === 1 ? 10n : undefined, + arrivesAtMicros: ordinal === 1 ? 20n : undefined, + gatheringEndsAtMicros: ordinal === 1 ? 100n : undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: ordinal === 1 ? 120n : undefined, + routeSteps: ordinal === 1 ? 1 : undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: ordinal === 1 ? 1 : 0, + revision: ordinal === 1 ? 2n : 0n + })); + const { connection, core, pairedWorkers } = workerSubscriptionConnection( + candidate, + workerRows + ); + const composite = subscribeToWarpkeepRealm(connection, vi.fn(), vi.fn()); + + expect(pairedWorkers.builder.subscribe).toHaveBeenCalledWith([ + tables.realmWorkerSystemV1, + tables.castleWorkerV1, + tables.workerNodeOccupationV1 + ]); + core.apply(); + expect(readWarpkeepRealmSnapshot(connection, CANONICAL_TEST_FID)) + .not.toHaveProperty('workerWorkers'); + pairedWorkers.apply(); + const snapshot = readWarpkeepRealmSnapshot(connection, CANONICAL_TEST_FID); + expect(snapshot.workerWorkers).toHaveLength(4); + expect(snapshot.workerWorkers?.every((worker) => worker.ownedByViewer)).toBe(true); + expect(Object.keys(snapshot.workerWorkers?.[0] ?? {})).not.toContain('assignmentId'); + expect(Object.keys(snapshot.workerOccupations?.[0] ?? {})).not.toContain('assignmentId'); + expect(snapshot.workerOccupations?.[0]).toMatchObject({ + workerId: `genesis-001-castle-${castleId}-worker-01`, + phase: 'gathering' + }); + + composite.unsubscribe(); + expect(core.subscription.unsubscribe).toHaveBeenCalledOnce(); + expect(pairedWorkers.subscription.unsubscribe).toHaveBeenCalledOnce(); + }); + + it('keeps a v11 core Realm live when the additive worker subscription is rejected', () => { + const candidate = createCanonicalGenesisCandidate(); + const { connection, core, pairedWorkers } = workerSubscriptionConnection(candidate, []); + const onApplied = vi.fn(); + const onError = vi.fn(); + const composite = subscribeToWarpkeepRealm(connection, onApplied, onError); + + core.apply(); + expect(onApplied).toHaveBeenCalledTimes(1); + expect(readWarpkeepRealmSnapshot(connection, CANONICAL_TEST_FID)) + .not.toHaveProperty('workerWorkers'); + + pairedWorkers.fail(); + expect(onError).not.toHaveBeenCalled(); + expect(onApplied).toHaveBeenCalledTimes(2); + const snapshot = readWarpkeepRealmSnapshot(connection, CANONICAL_TEST_FID); + expect(snapshot).not.toHaveProperty('workerSystem'); + expect(snapshot).not.toHaveProperty('workerWorkers'); + expect(snapshot).not.toHaveProperty('workerOccupations'); + + composite.unsubscribe(); + expect(core.subscription.unsubscribe).toHaveBeenCalledOnce(); + expect(pairedWorkers.subscription.unsubscribe).toHaveBeenCalledOnce(); + }); + + it('resets worker availability before a same-connection resubscribe applies', () => { + const candidate = createCanonicalGenesisCandidate(); + const castleId = candidate.castles[0]!.castleId; + const workerRows = [1, 2, 3, 4].map((ordinal) => ({ + workerId: `genesis-001-castle-${castleId}-worker-0${ordinal}`, + originCastleId: BigInt(castleId), + ordinal, + status: 'idle', + resourceKind: undefined, + siteId: undefined, + startedAtMicros: undefined, + arrivesAtMicros: undefined, + gatheringEndsAtMicros: undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: undefined, + routeSteps: undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: 0, + revision: 0n + })); + const { connection, core, pairedWorkers } = workerSubscriptionConnection( + candidate, + workerRows + ); + const first = subscribeToWarpkeepRealm(connection, vi.fn(), vi.fn()); + core.apply(); + pairedWorkers.apply(); + expect(readWarpkeepRealmSnapshot(connection, CANONICAL_TEST_FID).workerWorkers) + .toHaveLength(4); + + const secondCore = callbackSubscriptionDouble(); + const secondWorkers = callbackSubscriptionDouble(); + vi.mocked(connection.subscriptionBuilder) + .mockReturnValueOnce(secondCore.builder as never) + .mockReturnValueOnce(secondWorkers.builder as never); + const second = subscribeToWarpkeepRealm(connection, vi.fn(), vi.fn()); + + expect(readWarpkeepRealmSnapshot(connection, CANONICAL_TEST_FID)) + .not.toHaveProperty('workerWorkers'); + secondCore.apply(); + expect(readWarpkeepRealmSnapshot(connection, CANONICAL_TEST_FID)) + .not.toHaveProperty('workerWorkers'); + secondWorkers.apply(); + expect(readWarpkeepRealmSnapshot(connection, CANONICAL_TEST_FID).workerWorkers) + .toHaveLength(4); + + second.unsubscribe(); + first.unsubscribe(); + }); + + it('omits the complete worker graph when public ordinals disagree', () => { + const candidate = createCanonicalGenesisCandidate(); + const castleId = candidate.castles[0]!.castleId; + const malformedRows = [1, 2, 3, 4].map((ordinal) => ({ + workerId: `genesis-001-castle-${castleId}-worker-0${ordinal}`, + originCastleId: BigInt(castleId), + ordinal: ordinal === 2 ? 1 : ordinal, + status: 'idle', + resourceKind: undefined, + siteId: undefined, + startedAtMicros: undefined, + arrivesAtMicros: undefined, + gatheringEndsAtMicros: undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: undefined, + routeSteps: undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: 0, + revision: 0n + })); + const { connection, core, pairedWorkers } = workerSubscriptionConnection( + candidate, + malformedRows + ); + const composite = subscribeToWarpkeepRealm(connection, vi.fn(), vi.fn()); + core.apply(); + pairedWorkers.apply(); + + const snapshot = readWarpkeepRealmSnapshot(connection, CANONICAL_TEST_FID); + expect(snapshot).not.toHaveProperty('workerSystem'); + expect(snapshot).not.toHaveProperty('workerWorkers'); + expect(snapshot).not.toHaveProperty('workerOccupations'); + composite.unsubscribe(); + }); + it('adapts generated Q15 Water fields before exact Realm attestation', () => { const candidate = createCanonicalGenesisCandidate(); const { connection, core, pairedWater } = waterSubscriptionConnection(candidate); @@ -670,6 +886,10 @@ describe('Warpkeep authenticated connection boundary', () => { core.apply(); pairedWater.apply(); const snapshot = readWarpkeepRealmSnapshot(connection, CANONICAL_TEST_FID); + expect(snapshot.realmEnvironment).toMatchObject({ + updatedAtMicros: 1_752_408_000_000_000n + }); + expect(snapshot.realmEnvironment).not.toHaveProperty('updatedAt'); expect(resolveCanonicalWaterProjection( snapshot.waterLayout, snapshot.waterBodies, @@ -1337,6 +1557,52 @@ describe('Warpkeep authenticated connection boundary', () => { } }); + it('rolls back every earlier listener when observer registration throws', () => { + const observed = observableConnectionForCandidate(createCanonicalGenesisCandidate()); + observed.worldTileMetaV1.table.onInsert.mockImplementationOnce((listener) => { + observed.worldTileMetaV1.listeners.insert = listener; + throw new Error('synthetic observer registration failure'); + }); + observed.worldTileMetaV1.table.removeOnInsert.mockImplementationOnce((listener) => { + if (observed.worldTileMetaV1.listeners.insert === listener) { + observed.worldTileMetaV1.listeners.insert = undefined; + } + }); + + expect(() => observeWarpkeepRealm( + observed.connection, + CANONICAL_TEST_FID, + vi.fn(), + vi.fn() + )).toThrow('synthetic observer registration failure'); + expect(observed.worldTile.table.removeOnInsert).toHaveBeenCalledOnce(); + expect(observed.worldTile.table.removeOnDelete).toHaveBeenCalledOnce(); + expect(observed.worldTile.table.removeOnUpdate).toHaveBeenCalledOnce(); + expect(observed.worldTileMetaV1.table.removeOnInsert).toHaveBeenCalledOnce(); + expect(observed.worldTileMetaV1.listeners.insert).toBeUndefined(); + expect(observed.playerV2.table.onInsert).not.toHaveBeenCalled(); + }); + + it('continues removing every listener when one generated remover throws', () => { + const observed = observableConnectionForCandidate(createCanonicalGenesisCandidate()); + observed.realmProfileV1.table.removeOnUpdate.mockImplementationOnce(() => { + throw new Error('synthetic observer cleanup failure'); + }); + const cleanupObserver = observeWarpkeepRealm( + observed.connection, + CANONICAL_TEST_FID, + vi.fn(), + vi.fn() + ); + + expect(() => cleanupObserver()).not.toThrow(); + expect(observed.realmProfileV1.table.removeOnDelete).toHaveBeenCalledOnce(); + expect(observed.realmProfileV1.table.removeOnInsert).toHaveBeenCalledOnce(); + expect(observed.worldTile.table.removeOnInsert).toHaveBeenCalledOnce(); + expect(observed.worldTile.table.removeOnDelete).toHaveBeenCalledOnce(); + expect(observed.worldTile.table.removeOnUpdate).toHaveBeenCalledOnce(); + }); + it('observes and releases both public forest tables with the Realm lifecycle', () => { const candidate = createCanonicalGenesisCandidate(); const observed = observableConnectionForCandidate(candidate); diff --git a/tests/waterInspectionPanel.test.tsx b/tests/waterInspectionPanel.test.tsx new file mode 100644 index 00000000..360570b3 --- /dev/null +++ b/tests/waterInspectionPanel.test.tsx @@ -0,0 +1,98 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { WaterInspectionPanel } from '../src/components/realm/WaterInspectionPanel'; +import type { RealmWaterInspectionRecord } from '../src/components/realm/realmWaterInspectionPresentation'; + +const RECORD: RealmWaterInspectionRecord = Object.freeze({ + cellKey: '12,-7', + coord: Object.freeze({ q: 12, r: -7 }), + bodyId: 'genesis-river-01', + regime: 'river', + displayType: 'river', + displayName: 'Genesis River 01', + description: 'A persistent river record.', + riverOrdinal: 1, + riverPosition: 'middle reach', + riverOrder: 4, + riverCellCount: 12, + sourceCellKey: '10,-5', + mouthCellKey: '20,-10', + sourceCoord: Object.freeze({ q: 10, r: -5 }), + mouthCoord: Object.freeze({ q: 20, r: -10 }), + downstreamWaterCellKey: '13,-8', + flowClass: 'main reach', + depthCells: 1, + fogBand: 'clear', + underlyingTileKey: '12,-7', + underlyingTerrainKind: 'forest', + underlyingTerrainLabel: 'Lowland Forest', + underlyingPassable: true, + gameplayBoundary: 'Read-only water presentation.' +}); + +afterEach(cleanup); + +describe('WaterInspectionPanel', () => { + it('focuses its close control and handles one Escape without leaking it outward', async () => { + const onRequestClose = vi.fn(); + const outerEscape = vi.fn(); + render( +
    event.key === 'Escape' && outerEscape()}> + +
    + ); + + const close = screen.getByRole('button', { name: 'CLOSE WATER RECORD' }); + await waitFor(() => expect(document.activeElement).toBe(close)); + fireEvent.keyDown(close, { key: 'Escape' }); + + expect(onRequestClose).toHaveBeenCalledOnce(); + expect(outerEscape).not.toHaveBeenCalled(); + }); + + it('closes the water inspector before viewing the underlying land cell', () => { + const onRequestClose = vi.fn(); + const onViewUnderlyingCell = vi.fn(); + render( + + ); + + expect(screen.getByText('Lowland Forest')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'VIEW UNDERLYING CELL' })); + + expect(onRequestClose).toHaveBeenCalledOnce(); + expect(onViewUnderlyingCell).toHaveBeenCalledOnce(); + expect(onRequestClose.mock.invocationCallOrder[0]) + .toBeLessThan(onViewUnderlyingCell.mock.invocationCallOrder[0]!); + }); + + it('routes source and mouth controls to their exact persisted endpoint keys', () => { + const onFocusCell = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'FOLLOW TO SOURCE' })); + fireEvent.click(screen.getByRole('button', { name: 'FOLLOW TO MOUTH' })); + + expect(onFocusCell.mock.calls).toEqual([ + [RECORD.sourceCellKey], + [RECORD.mouthCellKey] + ]); + }); +}); diff --git a/tests/workerCommandIdempotency.test.ts b/tests/workerCommandIdempotency.test.ts new file mode 100644 index 00000000..5ce8d7c2 --- /dev/null +++ b/tests/workerCommandIdempotency.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + serializeWorkerCommandFingerprint, + workerCommandAttemptFor, + workerCommandAttemptMatchesLifecycle, + type WorkerCommandLifecycleState +} from '../src/spacetime/workerCommandIdempotency'; + +const lifecycle = ( + revision: bigint, + status = 'idle' +): WorkerCommandLifecycleState => Object.freeze({ + castleId: 7, + workers: Object.freeze([ + Object.freeze({ + workerId: 'genesis-001-castle-7-worker-01', + status, + revision + }), + Object.freeze({ + workerId: 'genesis-001-castle-7-worker-02', + status: 'idle', + revision: 0n + }) + ]) +}); + +describe('worker command idempotency', () => { + it('reuses a key only for an exact command fingerprint in one connection generation', () => { + const createKey = vi.fn() + .mockReturnValueOnce('first-command-key') + .mockReturnValueOnce('second-command-key') + .mockReturnValueOnce('third-command-key'); + const dispatch = { + kind: 'dispatch' as const, + workerId: 'genesis-001-castle-7-worker-01', + resourceKind: 'stone' as const, + siteId: 'genesis-001:stone:0001' + }; + const first = workerCommandAttemptFor(undefined, 4, dispatch, lifecycle(0n), createKey); + expect(workerCommandAttemptFor(first, 4, { ...dispatch }, lifecycle(0n), createKey)).toBe(first); + expect(workerCommandAttemptFor(first, 4, { + ...dispatch, + siteId: 'genesis-001:stone:0002' + }, lifecycle(0n), createKey)?.idempotencyKey).toBe('second-command-key'); + expect(workerCommandAttemptFor(first, 5, dispatch, lifecycle(0n), createKey)?.idempotencyKey) + .toBe('third-command-key'); + expect(createKey).toHaveBeenCalledTimes(3); + }); + + it('separates dispatch, individual recall, and recall-all fingerprints', () => { + const workerId = 'genesis-001-castle-7-worker-01'; + expect(new Set([ + serializeWorkerCommandFingerprint({ + kind: 'dispatch', + workerId, + resourceKind: 'wood', + siteId: 'genesis-001:wood:0001' + }), + serializeWorkerCommandFingerprint({ kind: 'recall', workerId }), + serializeWorkerCommandFingerprint({ kind: 'recall-all', castleId: 7 }) + ]).size).toBe(3); + }); + + it('reuses an ambiguous key only while the private worker lifecycle is unchanged', () => { + const createKey = vi.fn() + .mockReturnValueOnce('ambiguous-command-key') + .mockReturnValueOnce('later-lifecycle-key'); + const command = { + kind: 'dispatch' as const, + workerId: 'genesis-001-castle-7-worker-01', + resourceKind: 'stone' as const, + siteId: 'genesis-001:stone:0001' + }; + const initial = workerCommandAttemptFor( + undefined, + 4, + command, + lifecycle(0n), + createKey + ); + expect(workerCommandAttemptFor( + initial, + 4, + command, + lifecycle(0n), + createKey + )).toBe(initial); + const later = workerCommandAttemptFor( + initial, + 4, + command, + lifecycle(2n, 'idle'), + createKey + ); + expect(later?.idempotencyKey).toBe('later-lifecycle-key'); + expect(workerCommandAttemptMatchesLifecycle(initial!, 4, lifecycle(2n, 'idle'))).toBe(false); + expect(workerCommandAttemptMatchesLifecycle(later!, 4, lifecycle(2n, 'idle'))).toBe(true); + expect(createKey).toHaveBeenCalledTimes(2); + }); + + it('invalidates recall-all after any owned worker lifecycle changes', () => { + const command = { kind: 'recall-all' as const, castleId: 7 }; + const attempt = workerCommandAttemptFor( + undefined, + 9, + command, + lifecycle(0n, 'outbound'), + () => 'recall-all-key' + ); + expect(attempt).toBeDefined(); + expect(workerCommandAttemptMatchesLifecycle( + attempt!, + 9, + lifecycle(1n, 'returning') + )).toBe(false); + expect(workerCommandAttemptMatchesLifecycle( + attempt!, + 10, + lifecycle(0n, 'outbound') + )).toBe(false); + }); + + it('refuses a lifecycle scope that does not contain the commanded worker or castle', () => { + expect(workerCommandAttemptFor( + undefined, + 1, + { kind: 'recall', workerId: 'missing-worker' }, + lifecycle(0n), + () => 'must-not-be-used' + )).toBeUndefined(); + expect(workerCommandAttemptFor( + undefined, + 1, + { kind: 'recall-all', castleId: 8 }, + lifecycle(0n), + () => 'must-not-be-used' + )).toBeUndefined(); + }); +}); diff --git a/tests/workerRecordArt.test.ts b/tests/workerRecordArt.test.ts new file mode 100644 index 00000000..f05126f6 --- /dev/null +++ b/tests/workerRecordArt.test.ts @@ -0,0 +1,143 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import sharp from 'sharp'; +import { describe, expect, it } from 'vitest'; + +const ROOT = resolve(import.meta.dirname, '..'); +const MANIFEST_PATH = resolve( + ROOT, + 'docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json' +); +const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as { + schemaVersion: number; + projectAuthorization: { + scope: string; + notGranted: string[]; + }; + sourceInputs: Array<{ sha256: string; repositoryRetained: boolean }>; + processing: { + runtimeAsset: { + path: string; + format: string; + width: number; + height: number; + bytes: number; + sha256: string; + decodedRgbaSha256: string; + alpha: { + transparentPixels: number; + partiallyTransparentPixels: number; + opaquePixels: number; + }; + visibleBoundsAlpha16: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }; + }; + }; + presentationBoundary: { + component: string; + runtimeUse: string; + forbiddenClaims: string[]; + }; + licence: { spdx: string }; +}; + +function sha256(bytes: Buffer) { + return createHash('sha256').update(bytes).digest('hex'); +} + +function alphaProfile(raw: Buffer) { + const profile = { + transparentPixels: 0, + partiallyTransparentPixels: 0, + opaquePixels: 0 + }; + for (let index = 3; index < raw.length; index += 4) { + if (raw[index] === 0) profile.transparentPixels += 1; + else if (raw[index] === 255) profile.opaquePixels += 1; + else profile.partiallyTransparentPixels += 1; + } + return profile; +} + +function visibleBounds(raw: Buffer, width: number, height: number) { + const bounds = { minX: width, minY: height, maxX: -1, maxY: -1 }; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if (raw[(y * width + x) * 4 + 3]! < 16) continue; + bounds.minX = Math.min(bounds.minX, x); + bounds.minY = Math.min(bounds.minY, y); + bounds.maxX = Math.max(bounds.maxX, x); + bounds.maxY = Math.max(bounds.maxY, y); + } + } + return bounds; +} + +describe('Hegemony Worker record art', () => { + it('pins the narrow Worker UI authorization and provenance-required licence', () => { + expect(manifest.schemaVersion).toBe(1); + expect(manifest.projectAuthorization.scope).toContain('Worker UI slice'); + expect(manifest.projectAuthorization.notGranted.join(' ')).toMatch( + /copyright ownership|public open-content licence|worker ownership/i + ); + expect(manifest.licence.spdx).toBe('LicenseRef-Warpkeep-Provenance-Required'); + expect(manifest.sourceInputs.every((source) => source.repositoryRetained === false)).toBe(true); + expect(manifest.presentationBoundary.runtimeUse).toMatch(/same-origin decorative hero art/i); + expect(manifest.presentationBoundary.forbiddenClaims).toContain( + 'browser-derived dispatch or recall authority' + ); + + const attribution = readFileSync(resolve(ROOT, 'ASSETS-LICENSE.md'), 'utf8'); + expect(attribution).toContain('Hegemony Worker inspection artwork'); + expect(attribution).toContain(manifest.processing.runtimeAsset.sha256); + }); + + it('ships the exact bounded 1024-square alpha WebP', async () => { + const asset = manifest.processing.runtimeAsset; + const assetPath = resolve(ROOT, asset.path); + const bytes = readFileSync(assetPath); + expect(bytes.byteLength).toBe(asset.bytes); + expect(sha256(bytes)).toBe(asset.sha256); + expect(manifest.sourceInputs.map((source) => source.sha256)).not.toContain(asset.sha256); + + const image = sharp(bytes, { + failOn: 'warning', + limitInputPixels: asset.width * asset.height + }); + expect(await image.metadata()).toMatchObject({ + format: asset.format, + width: asset.width, + height: asset.height, + channels: 4, + depth: 'uchar', + hasAlpha: true + }); + const raw = await image.ensureAlpha().raw().toBuffer(); + expect(sha256(raw)).toBe(asset.decodedRgbaSha256); + expect(alphaProfile(raw)).toEqual(asset.alpha); + expect(visibleBounds(raw, asset.width, asset.height)).toEqual(asset.visibleBoundsAlpha16); + }); + + it('loads only as same-origin decorative art in the Worker inspector', () => { + const component = readFileSync( + resolve(ROOT, manifest.presentationBoundary.component), + 'utf8' + ); + expect(component).toContain("publicAssetUrl('images/realm/hegemony-worker-record.webp')"); + expect(component).toContain('className="worker-inspection__art-stage"'); + expect(component).toContain('className="worker-inspection__hero-art"'); + expect(component).toContain('aria-hidden="true"'); + expect(component).toContain('alt=""'); + expect(component).toContain('decoding="async"'); + expect(component).toContain('draggable={false}'); + expect(component).toContain('height="1024"'); + expect(component).toContain('width="1024"'); + expect(component).not.toMatch(/https?:\/\//i); + }); +}); diff --git a/tests/workflowSecurity.test.ts b/tests/workflowSecurity.test.ts index 40611e11..a6292118 100644 --- a/tests/workflowSecurity.test.ts +++ b/tests/workflowSecurity.test.ts @@ -114,8 +114,8 @@ describe('GitHub workflow security policy', () => { const checkoutCount = (source.match(/actions\/checkout@/g) ?? []).length; const disabledCredentialCount = (source.match(/persist-credentials:\s*false/g) ?? []).length; expect(disabledCredentialCount).toBe(checkoutCount); - expect(source).toContain('pnpm --dir services/auth-bridge audit --audit-level high'); - expect(source).toContain('pnpm --dir spacetimedb audit --audit-level high'); + expect(source).toContain('pnpm --dir services/auth-bridge audit --audit-level low'); + expect(source).toContain('pnpm --dir spacetimedb audit --audit-level low'); expect(source).toContain('npm audit signatures'); }); diff --git a/vite.config.ts b/vite.config.ts index 392167e7..5b0a8a82 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,11 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; +import { + WARPKEEP_LOCAL_VITE_FS_DENY, + warpkeepLocalPublicBoundaryPlugin +} from './scripts/qa-observer/local-vite-fs-deny.mjs'; + type DeploymentEnvironment = Readonly>; const SEMANTIC_VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; @@ -99,11 +104,18 @@ export default defineConfig(({ command }) => ({ __WARPKEEP_LOCAL_QA__: JSON.stringify(command === 'serve'), __WARPKEEP_PRODUCT_VERSION__: JSON.stringify(productVersion) }, + // Manual QA uses the normal development server. Keep ignored Worker secrets, + // private keys, and deployment state outside its repository-wide file surface. + server: { + fs: { + deny: [...WARPKEEP_LOCAL_VITE_FS_DENY] + } + }, // Vite's React development preamble is an inline module. The public build // keeps the strict document CSP, while localhost development removes only // that explicitly marked production meta element. Dedicated QA entries // retain their own loopback-only CSPs. - plugins: [react(), stripProductionCspFromLocalServe()], + plugins: [warpkeepLocalPublicBoundaryPlugin(), react(), stripProductionCspFromLocalServe()], test: { environment: 'jsdom', globals: true,