diff --git a/.env.example b/.env.example index 0b379543..f6288928 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ # bridge value are valid. No VITE_ variable may contain a secret. VITE_WARPKEEP_SHARED_ALPHA_ENABLED=false VITE_SPACETIMEDB_URI=https://maincloud.spacetimedb.com -VITE_SPACETIMEDB_DATABASE=warpkeep-89e4u +VITE_SPACETIMEDB_DATABASE=c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e VITE_WARPKEEP_AUTH_BRIDGE_URL= VITE_WARPKEEP_OIDC_ISSUER= VITE_WARPKEEP_OIDC_AUDIENCE=warpkeep-spacetimedb diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index e3e995d1..0c8546d8 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -31,7 +31,7 @@ jobs: VITE_WARPKEEP_OIDC_ISSUER: ${{ vars.WARPKEEP_OIDC_ISSUER || '' }} VITE_WARPKEEP_OIDC_AUDIENCE: ${{ vars.WARPKEEP_OIDC_AUDIENCE || 'warpkeep-spacetimedb' }} VITE_SPACETIMEDB_URI: ${{ vars.WARPKEEP_SPACETIMEDB_URI || 'https://maincloud.spacetimedb.com' }} - VITE_SPACETIMEDB_DATABASE: ${{ vars.WARPKEEP_SPACETIMEDB_DATABASE || 'warpkeep-89e4u' }} + VITE_SPACETIMEDB_DATABASE: ${{ vars.WARPKEEP_SPACETIMEDB_DATABASE || 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e' }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index bb240426..32ffe093 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -147,6 +147,7 @@ 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 diff --git a/.gitignore b/.gitignore index 3189f5f8..7950bd5a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,20 @@ dist .DS_Store coverage *.local -.cache/warpkeep-assets/ -.cache/warpkeep-tools/ + +# Machine-local caches, deployment state, and diagnostic captures. +.cache/ +.wrangler/ +.dev.vars* +!.dev.vars.example +*.har +*.log + +# Credential-bearing files must stay in Keychain or ignored local config. +.npmrc +*.key +*.pem +*.p12 +*.pfx +*.jks +*.keystore 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/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..02ce14d8 --- /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": 50, + "maxX": 863, + "maxY": 934 + } + } + }, + "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/index.html b/index.html index 41d3e697..45280d58 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,11 @@ + { 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 +2382,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..4632b9f8 100644 --- a/scripts/spacetime-additive-migration-proof.mjs +++ b/scripts/spacetime-additive-migration-proof.mjs @@ -3,7 +3,7 @@ const RECEIPT_FIELD = 'artifact_sha256'; 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. diff --git a/scripts/validate-pages-deploy-config.mjs b/scripts/validate-pages-deploy-config.mjs index e697c132..2a543f3d 100644 --- a/scripts/validate-pages-deploy-config.mjs +++ b/scripts/validate-pages-deploy-config.mjs @@ -3,7 +3,7 @@ const EXPECTED_REPOSITORY_URL = 'https://github.com/ael-dev3/Warpkeep'; const EXPECTED_AUDIENCE = 'warpkeep-spacetimedb'; const EXPECTED_BRIDGE = 'https://auth.warpkeep.com'; const EXPECTED_SPACETIMEDB_URI = 'https://maincloud.spacetimedb.com'; -const EXPECTED_SPACETIMEDB_DATABASE = 'warpkeep-89e4u'; +const EXPECTED_SPACETIMEDB_DATABASE = 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e'; const SHA_PATTERN = /^[0-9a-f]{40}$/i; function fail(message) { diff --git a/scripts/verify-castle-worker-additive-migration.mjs b/scripts/verify-castle-worker-additive-migration.mjs new file mode 100644 index 00000000..2a7d073c --- /dev/null +++ b/scripts/verify-castle-worker-additive-migration.mjs @@ -0,0 +1,57 @@ +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); + 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); +} +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'); diff --git a/scripts/verify-production-dist-exclusions.mjs b/scripts/verify-production-dist-exclusions.mjs index 75942ebc..862a92fa 100644 --- a/scripts/verify-production-dist-exclusions.mjs +++ b/scripts/verify-production-dist-exclusions.mjs @@ -55,6 +55,27 @@ const forbiddenContent = Object.freeze([ '/v1/qa/challenge', '/v1/qa/realm-snapshot' ]); +const requiredProductionCspFragments = Object.freeze([ + 'data-warpkeep-production-csp', + "default-src 'none'", + "base-uri 'none'", + "object-src 'none'", + "frame-src 'none'", + "form-action 'none'", + "script-src 'self' 'wasm-unsafe-eval'", + "script-src-attr 'none'", + 'https://auth.warpkeep.com', + 'https://relay.farcaster.xyz', + 'https://mainnet.optimism.io', + 'https://maincloud.spacetimedb.com', + 'wss://maincloud.spacetimedb.com', + 'https://imagedelivery.net', + 'https://wrpcd.net', + 'https://res.cloudinary.com', + 'https://i.imgur.com', + 'https://lh3.googleusercontent.com', + 'https://i.seadn.io' +]); function filesUnder(directory) { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { @@ -67,6 +88,22 @@ function filesUnder(directory) { }); } +const productionIndex = readFileSync(resolve(dist, 'index.html'), 'utf8'); +for (const fragment of requiredProductionCspFragments) { + if (!productionIndex.includes(fragment)) { + throw new Error(`Production document CSP is missing ${JSON.stringify(fragment)}.`); + } +} +if (/script-src[^;]*'unsafe-eval'/.test(productionIndex)) { + throw new Error('Production document CSP permits unrestricted unsafe-eval.'); +} +if (/(?:^|[;\s])https:(?:[;\s]|$)|(?:^|[;\s])wss?:(?:[;\s]|$)/.test(productionIndex)) { + throw new Error('Production document CSP permits an unrestricted network scheme.'); +} +if (/localhost|127\.0\.0\.1|\[::1\]/.test(productionIndex)) { + throw new Error('Production document CSP contains a loopback network exception.'); +} + for (const path of filesUnder(dist)) { const relativePath = relative(dist, path).replaceAll('\\', '/'); if (forbiddenPathFragments.some((fragment) => relativePath.includes(fragment))) { diff --git a/scripts/verify-spacetime-additive-migration.mjs b/scripts/verify-spacetime-additive-migration.mjs index 29286a25..c2d9c455 100644 --- a/scripts/verify-spacetime-additive-migration.mjs +++ b/scripts/verify-spacetime-additive-migration.mjs @@ -63,6 +63,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 +247,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 +291,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 +343,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 +1216,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', 'created_at', 'updated_at', + ], + }, + 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 +2132,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 +2949,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 +3389,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 +3860,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); @@ -4548,28 +4651,130 @@ 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); + 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 +4782,9 @@ async function main() { server, owner.token, populatedWaterStoneMigrationDatabase, - deployedV11Tables, + deployedV12Tables, ), - populatedWaterStoneV11Rows, + populatedWaterStoneV12Rows, ); await verifyResolverHttpLifecycle(server, actualModuleDatabase, privateKey); const worldExpansionDurationMilliseconds = await verifyGenesisWorldExpansionLifecycle( @@ -4616,30 +4821,30 @@ 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); + 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 +4857,7 @@ async function main() { ); } - const idempotentSchemaBefore = schemaDigest(nonemptyCandidateV11); + const idempotentSchemaBefore = schemaDigest(nonemptyCandidateV12); await publishBuiltArtifact( server, owner.token, @@ -4663,10 +4868,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 +4896,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, database, table), 0n); } @@ -4721,7 +4927,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 +4949,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 +4975,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 +4988,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -4794,7 +5002,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 +5015,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -4820,7 +5029,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4832,7 +5041,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4844,10 +5053,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 +5067,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4870,7 +5079,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV11SchemaDigest, + populatedV12SchemaDigest, ); await publish( server, @@ -4882,7 +5091,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 +5104,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 +5126,7 @@ async function main() { ...additiveV9Tables, ...additiveV10Tables, ...additiveV11Tables, + ...additiveV12Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -4939,7 +5149,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,8 +5163,8 @@ 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.', artifactDigest: builtArtifactDigest, })); } finally { diff --git a/services/auth-bridge/README.md b/services/auth-bridge/README.md index 85d6b317..5c7a42c2 100644 --- a/services/auth-bridge/README.md +++ b/services/auth-bridge/README.md @@ -266,7 +266,7 @@ one verified FID being resolved. The module retains a 60-second rejection ceiling. The token has no admin role and is never returned or logged. The Worker calls the fixed documented Maincloud endpoint -`POST https://maincloud.spacetimedb.com/v1/database/warpkeep-89e4u/call/auth_resolver_get_fid_admission_v2` +`POST https://maincloud.spacetimedb.com/v1/database/c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e/call/auth_resolver_get_fid_admission_v2` with `Authorization: Bearer ` and SATS-JSON argument `[]`. The exact HTTP SATS-JSON product response is `["missing"|"disabled"|"enabled", ]`: missing/disabled require epoch `0`, diff --git a/services/auth-bridge/src/config.ts b/services/auth-bridge/src/config.ts index b4822ed0..2797ffb6 100644 --- a/services/auth-bridge/src/config.ts +++ b/services/auth-bridge/src/config.ts @@ -14,8 +14,11 @@ export const MAX_ADMIN_TOKEN_SECRET_BYTES = 512 export const MIN_SESSION_COOKIE_KEY_BYTES = 32 export const MAX_SESSION_COOKIE_KEY_BYTES = 512 export const PRODUCTION_SPACETIMEDB_URI = 'https://maincloud.spacetimedb.com' -export const PRODUCTION_SPACETIMEDB_DATABASE = 'warpkeep-89e4u' +/** Immutable public address; unlike a database alias, it cannot drift after a rename. */ +export const PRODUCTION_SPACETIMEDB_DATABASE = 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e' export const PRODUCTION_QA_OBSERVER_SPACETIMEDB_URI = 'https://maincloud.spacetimedb.com' +const SPACETIMEDB_DATABASE_IDENTITY_PATTERN = /^[a-f0-9]{64}$/ +const SPACETIMEDB_DATABASE_ALIAS_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/ export type QaObserverSpacetimeDbConfig = Readonly<{ uri: string @@ -186,7 +189,7 @@ function parseSpacetimeDbUri(value: string, production: boolean): string { } function parseSpacetimeDbDatabase(value: string): string { - if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) { + if (!SPACETIMEDB_DATABASE_IDENTITY_PATTERN.test(value) && !SPACETIMEDB_DATABASE_ALIAS_PATTERN.test(value)) { throw new ConfigurationError() } return value @@ -357,6 +360,7 @@ export function readBridgeConfig(env: WorkerEnv): BridgeConfig { qaObserverSpacetimeDb && ( (production && qaObserverSpacetimeDb.uri !== PRODUCTION_QA_OBSERVER_SPACETIMEDB_URI) + || (production && !SPACETIMEDB_DATABASE_IDENTITY_PATTERN.test(qaObserverSpacetimeDb.database)) || qaObserverSpacetimeDb.database === spacetimeDbDatabase || qaObserverSpacetimeDb.audience === audience ) diff --git a/services/auth-bridge/src/spacetimeAuthEpochResolver.ts b/services/auth-bridge/src/spacetimeAuthEpochResolver.ts index 9a83fa18..5b78896f 100644 --- a/services/auth-bridge/src/spacetimeAuthEpochResolver.ts +++ b/services/auth-bridge/src/spacetimeAuthEpochResolver.ts @@ -46,7 +46,7 @@ function resolverFailure(stage: AuthEpochResolverFailureStage): never { } const MAX_SUPPORTED_FID = BigInt(Number.MAX_SAFE_INTEGER) -const DATABASE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +const DATABASE_NAME_PATTERN = /^(?:[a-f0-9]{64}|[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)$/ const encoder = new TextEncoder() export type SpacetimeAuthEpochResolverConfig = Readonly<{ diff --git a/services/auth-bridge/test/app.test.ts b/services/auth-bridge/test/app.test.ts index 46ad98f9..f039cfde 100644 --- a/services/auth-bridge/test/app.test.ts +++ b/services/auth-bridge/test/app.test.ts @@ -7,6 +7,7 @@ import { type AuthBridgeDependencies, } from '../src/app' import { MemoryChallengeStore } from '../src/challengeStore' +import { PRODUCTION_SPACETIMEDB_DATABASE } from '../src/config' import { FarcasterVerifierUnavailableError } from '../src/farcaster' import { MemorySessionFamilyStore } from '../src/sessionFamily' import { @@ -55,7 +56,7 @@ function env(overrides: Partial = {}): WorkerEnv { OIDC_AUDIENCE: 'warpkeep-spacetimedb', OIDC_KEY_ID: 'test-es256-2026', SPACETIMEDB_URI: 'https://maincloud.spacetimedb.com', - SPACETIMEDB_DATABASE: 'warpkeep-89e4u', + SPACETIMEDB_DATABASE: PRODUCTION_SPACETIMEDB_DATABASE, PUBLIC_AUTH_ENABLED: 'true', QA_OBSERVER_ENABLED: 'false', SIGNING_KEY_JWK: JSON.stringify(privateJwk), @@ -1147,7 +1148,7 @@ describe('Warpkeep auth bridge', () => { audience: 'warpkeep-spacetimedb', keyId: 'test-es256-2026', spacetimeDbUri: 'https://maincloud.spacetimedb.com', - spacetimeDbDatabase: 'warpkeep-89e4u', + spacetimeDbDatabase: PRODUCTION_SPACETIMEDB_DATABASE, publicAuthEnabled: true, qaObserverEnabled: false, qaObserverSpacetimeDbUri: null, @@ -1204,7 +1205,9 @@ describe('Warpkeep auth bridge', () => { await expect(response.json()).resolves.toEqual({ ok: true }) expect(upstream).toHaveBeenCalledOnce() const [input, init] = upstream.mock.calls[0] as unknown as [URL, RequestInit] - expect(input.toString()).toBe('https://maincloud.spacetimedb.com/v1/database/warpkeep-89e4u/call/auth_resolver_get_fid_admission_v2') + expect(input.toString()).toBe( + `https://maincloud.spacetimedb.com/v1/database/${PRODUCTION_SPACETIMEDB_DATABASE}/call/auth_resolver_get_fid_admission_v2`, + ) expect(init.body).toBe('[9007199254740991]') expect(init.redirect).toBe('manual') expect(events).toContain('auth_epoch_probe_succeeded') @@ -1598,6 +1601,8 @@ describe('Warpkeep auth bridge', () => { expect(lookalikeUri.status).toBe(503) const lookalikeDatabase = await h.app.fetch(request('/healthz'), env({ SPACETIMEDB_DATABASE: 'lookalike-database' })) expect(lookalikeDatabase.status).toBe(503) + const mutableFormerAlias = await h.app.fetch(request('/healthz'), env({ SPACETIMEDB_DATABASE: 'warpkeep-89e4u' })) + expect(mutableFormerAlias.status).toBe(503) const development = await h.app.fetch(request('/healthz'), env({ ENVIRONMENT: 'development', diff --git a/services/auth-bridge/test/qaObserver.test.ts b/services/auth-bridge/test/qaObserver.test.ts index f00ddf00..9f74fd33 100644 --- a/services/auth-bridge/test/qaObserver.test.ts +++ b/services/auth-bridge/test/qaObserver.test.ts @@ -1,6 +1,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest' import { createAuthBridge } from '../src/app' import { + PRODUCTION_SPACETIMEDB_DATABASE, QA_OBSERVER_MAX_REGISTRATION_LIFETIME_MILLISECONDS, type PublicEcJwk, } from '../src/config' @@ -17,6 +18,7 @@ const ISSUER = 'https://auth.warpkeep.example' const ORIGIN = 'https://warpkeep.example' const ADMIN_SECRET = 'TEST_ONLY_ADMIN_SECRET_'.repeat(2) const SESSION_COOKIE_KEY = 'TEST_ONLY_SESSION_COOKIE_KEY_'.repeat(2) +const QA_DATABASE_IDENTITY = 'd2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e' const NOW = 1_800_000_000_000 let signingPrivateJwk: JsonWebKey @@ -116,9 +118,9 @@ function environment(overrides: Partial = {}): WorkerEnv { ADMIN_TOKEN_SECRET: ADMIN_SECRET, SESSION_COOKIE_KEY, SPACETIMEDB_URI: 'https://maincloud.spacetimedb.com', - SPACETIMEDB_DATABASE: 'warpkeep-89e4u', + SPACETIMEDB_DATABASE: PRODUCTION_SPACETIMEDB_DATABASE, QA_OBSERVER_SPACETIMEDB_URI: 'https://maincloud.spacetimedb.com', - QA_OBSERVER_SPACETIMEDB_DATABASE: 'warpkeep-qa-observer-test', + QA_OBSERVER_SPACETIMEDB_DATABASE: QA_DATABASE_IDENTITY, QA_OBSERVER_OIDC_AUDIENCE: 'warpkeep-qa-observer-spacetimedb', PUBLIC_AUTH_ENABLED: 'false', QA_OBSERVER_ENABLED: 'true', @@ -237,7 +239,7 @@ describe('machine-bound QA observer bridge', () => { publicAuthEnabled: false, qaObserverEnabled: true, qaObserverSpacetimeDbUri: 'https://maincloud.spacetimedb.com', - qaObserverSpacetimeDbDatabase: 'warpkeep-qa-observer-test', + qaObserverSpacetimeDbDatabase: QA_DATABASE_IDENTITY, qaObserverAudience: 'warpkeep-qa-observer-spacetimedb', qaObserverKeyFingerprint: await qaObserverKeyThumbprint(qaPublicJwk), qaObserverKeyRegisteredAt: new Date(NOW).toISOString(), @@ -257,7 +259,9 @@ describe('machine-bound QA observer bridge', () => { { QA_OBSERVER_SPACETIMEDB_DATABASE: undefined }, { QA_OBSERVER_OIDC_AUDIENCE: undefined }, { QA_OBSERVER_SPACETIMEDB_URI: undefined }, + { QA_OBSERVER_SPACETIMEDB_DATABASE: PRODUCTION_SPACETIMEDB_DATABASE }, { QA_OBSERVER_SPACETIMEDB_DATABASE: 'warpkeep-89e4u' }, + { QA_OBSERVER_SPACETIMEDB_DATABASE: 'warpkeep-qa-observer-test' }, { QA_OBSERVER_OIDC_AUDIENCE: 'warpkeep-spacetimedb' }, { QA_OBSERVER_SPACETIMEDB_URI: 'http://maincloud.spacetimedb.com' }, { QA_OBSERVER_SPACETIMEDB_URI: 'https://attacker.example' }, @@ -301,9 +305,9 @@ describe('machine-bound QA observer bridge', () => { expect(upstream).toHaveBeenCalledOnce() const [input, init] = upstream.mock.calls[0] as unknown as [URL, RequestInit] expect(input.toString()).toBe( - 'https://maincloud.spacetimedb.com/v1/database/warpkeep-qa-observer-test/call/qa_observer_get_realm_attestation_v2', + `https://maincloud.spacetimedb.com/v1/database/${QA_DATABASE_IDENTITY}/call/qa_observer_get_realm_attestation_v2`, ) - expect(input.toString()).not.toContain('/database/warpkeep-89e4u/') + expect(input.toString()).not.toContain(`/database/${PRODUCTION_SPACETIMEDB_DATABASE}/`) const authorization = new Headers(init.headers).get('authorization') expect(authorization).toMatch(/^Bearer [^.]+\.[^.]+\.[^.]+$/) const payloadSegment = authorization!.slice('Bearer '.length).split('.')[1] diff --git a/services/auth-bridge/wrangler.toml b/services/auth-bridge/wrangler.toml index e0c600e5..0d39c08d 100644 --- a/services/auth-bridge/wrangler.toml +++ b/services/auth-bridge/wrangler.toml @@ -17,7 +17,7 @@ FARCASTER_SIWE_URI = "https://warpkeep.com/" OIDC_AUDIENCE = "warpkeep-spacetimedb" OIDC_KEY_ID = "warpkeep-alpha-2026-07-01" SPACETIMEDB_URI = "https://maincloud.spacetimedb.com" -SPACETIMEDB_DATABASE = "warpkeep-89e4u" +SPACETIMEDB_DATABASE = "c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e" PUBLIC_AUTH_ENABLED = "false" QA_OBSERVER_ENABLED = "false" 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..fd747802 --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts @@ -0,0 +1,609 @@ +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(), + createdAt: t.timestamp(), updatedAt: t.timestamp(), +}); +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, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + } + 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..dc668f69 --- /dev/null +++ b/spacetimedb/src/castleWorkerAuthority.ts @@ -0,0 +1,1088 @@ +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 { + 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 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 castleIds = [...ctx.db.castle.iter()].map(castle => castle.castleId); + const expectedWorkerCount = BigInt(castleIds.length * CASTLE_WORKERS_PER_CASTLE); + if ( + BigInt(row.expectedCastleCount) !== BigInt(castleIds.length) + || BigInt(row.expectedWorkerCount) !== expectedWorkerCount + || ctx.db.castleWorkerV1.count() !== expectedWorkerCount + || row.rosterDigest !== rosterDigestForCastleIds(castleIds) + ) fail('WORKER_ROSTER_NOT_READY'); + for (const castleId of castleIds) assertCastleWorkerRoster(ctx, castleId); + for (const worker of ctx.db.castleWorkerV1.iter()) { + if (ctx.db.castle.castleId.find(worker.originCastleId) === null) { + fail('WORKER_ROSTER_ORPHAN'); + } + } + const graph = inspectCastleWorkerGraph(ctx); + if ( + !graph.systemConfigValid + || !graph.expectedCountsMatch + || !graph.rosterDigestMatches + || graph.castlesMissingWorkers !== 0n + || graph.castlesWithExtraWorkers !== 0n + || graph.duplicateOrdinals !== 0n + || graph.malformedWorkerIds !== 0n + || graph.invalidWorkerStates !== 0n + || graph.orphanWorkers !== 0n + || graph.orphanAssignments !== 0n + || graph.assignmentsMissingOccupation !== 0n + || graph.assignmentsWithoutSingleSchedule !== 0n + || graph.orphanOccupations !== 0n + || graph.orphanSchedules !== 0n + || graph.invalidSchedules !== 0n + || graph.assignmentPublicMismatches !== 0n + || graph.occupationSiteMismatches !== 0n + || graph.invalidAssignments !== 0n + || graph.invalidIdempotencyReceipts !== 0n + || graph.idempotencyOverflowFids !== 0n + ) fail('WORKER_SYSTEM_INTEGRITY'); + 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 castle !== null + && castle.ownerFid === fid + && account !== null + && account.castleId === 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 { + if ( + !receipt.requestKey.startsWith(`${receipt.fid.toString()}:`) + || receipt.resultRevision < 0n + ) return false; + if (receipt.commandKind === 'dispatch' || receipt.commandKind === 'recall') { + return receipt.workerId !== undefined + && receipt.resourceKind !== undefined + && ['gold', 'food', 'wood', 'stone'].includes(receipt.resourceKind) + && receipt.siteId !== undefined + && receipt.siteId.length > 0 + && receipt.assignmentId !== undefined + && receipt.assignmentId.length > 0; + } + return receipt.commandKind === 'recall-all' + && receipt.workerId === undefined + && receipt.resourceKind === undefined + && receipt.siteId === undefined + && receipt.assignmentId !== undefined + && receipt.assignmentId.length > 0; +} + +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 { + const expectedStage = assignment.phase === 'outbound' + ? WORKER_SCHEDULE_STAGE_ARRIVAL + : assignment.phase === 'gathering' + ? WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY + : WORKER_SCHEDULE_STAGE_RETURN_COMPLETE; + const expectedAtMicros = expectedStage === WORKER_SCHEDULE_STAGE_ARRIVAL + ? assignment.arrivesAtMicros + : expectedStage === WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY + ? assignment.gatheringEndsAtMicros + : assignment.returnsAtMicros; + return schedule.stage === expectedStage + && schedule.workerId === assignment.workerId + && schedule.timelineRevision === assignment.timelineRevision + && schedule.scheduledAt.tag === 'Time' + && schedule.scheduledAt.value.microsSinceUnixEpoch === expectedAtMicros; +} + +function pruneWorkerIdempotencyReceipts(ctx: WarpkeepReducerContext, fid: bigint): void { + const receipts = [...ctx.db.workerCommandIdempotencyV1.byFid.filter(fid)] + .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 = [...ctx.db.workerAssignmentV1.byFid.filter(fid)]; + 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 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 = [...assertCastleWorkerRoster(ctx, resource.castle.castleId)] + .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'); + settleAllWorkerAssignmentsForFid(ctx, input.fid); + const roster = assertCastleWorkerRoster(ctx, input.castle.castleId); + 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'), + updatedAt: ctx.timestamp, + }); + 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'), + updatedAt: ctx.timestamp, + }); + 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'), + updatedAt: ctx.timestamp, + }); +} + +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'), + updatedAt: ctx.timestamp, + }); + 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 ( + !workerReceiptShapeIsValid(prior) + || !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'); + settleAllWorkerAssignmentsForFid(ctx, input.fid); + const worker = ctx.db.castleWorkerV1.workerId.find(input.workerId); + if (worker === null || worker.originCastleId !== input.castle.castleId) fail('WORKER_NOT_OWNED'); + assertCastleWorkerRoster(ctx, input.castle.castleId); + const assignment = ctx.db.workerAssignmentV1.workerId.find(worker.workerId); + if (assignment === null) { + if (worker.status !== 'idle') fail('WORKER_ASSIGNMENT_MISSING'); + return; + } + if (assignment.fid !== input.fid) fail('WORKER_ASSIGNMENT_MISSING'); + assertAssignmentState(assignment); + if (assignment.phase === 'returning') return; + const now = ctx.timestamp.microsSinceUnixEpoch; + 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(worker.workerId); + if (updatedWorker === null || !publicWorkerMatchesAssignment(updatedWorker, returning)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + pruneWorkerIdempotencyReceipts(ctx, input.fid); + ctx.db.workerCommandIdempotencyV1.insert({ requestKey, fid: input.fid, workerId: worker.workerId, commandKind: 'recall', resourceKind: assignment.resourceKind, siteId: assignment.siteId, assignmentId: assignment.assignmentId, resultRevision: updatedWorker.revision, 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 ( + !workerReceiptShapeIsValid(prior) + || !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 roster = assertCastleWorkerRoster(ctx, input.castle.castleId); + settleAllWorkerAssignmentsForFid(ctx, input.fid); + const now = ctx.timestamp.microsSinceUnixEpoch; + let lastAssignmentId: string | undefined; + let resultRevision = 0n; + for (const worker of [...roster].sort((left, right) => left.ordinal - right.ordinal)) { + 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; + } + if (lastAssignmentId === undefined) return; + pruneWorkerIdempotencyReceipts(ctx, input.fid); + ctx.db.workerCommandIdempotencyV1.insert({ requestKey, fid: input.fid, workerId: undefined, commandKind: 'recall-all', resourceKind: undefined, siteId: undefined, assignmentId: lastAssignmentId, resultRevision, 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/castleWorkerPolicy.ts b/spacetimedb/src/castleWorkerPolicy.ts new file mode 100644 index 00000000..13dd6414 --- /dev/null +++ b/spacetimedb/src/castleWorkerPolicy.ts @@ -0,0 +1,383 @@ +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) { + for (const workerId of Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, i) => workerIdForCastle(castleId, i + 1))) { + for (const byte of new TextEncoder().encode(workerId)) { + hash ^= BigInt(byte); + hash = (hash * 0x100000001b3n) & CASTLE_WORKER_U64_MAX; + } + } + } + return hash.toString(16).padStart(16, '0'); +} diff --git a/spacetimedb/src/castleWorkerRoster.ts b/spacetimedb/src/castleWorkerRoster.ts new file mode 100644 index 00000000..d909035d --- /dev/null +++ b/spacetimedb/src/castleWorkerRoster.ts @@ -0,0 +1,195 @@ +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; + +import { + CASTLE_WORKER_POLICY_VERSION, + CASTLE_WORKERS_PER_CASTLE, + rosterDigestForCastleIds, + 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, + timestamp: WarpkeepReducerContext['timestamp'], +): 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, + createdAt: timestamp, + updatedAt: timestamp, + }); + })); +} + +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 = [...ctx.db.castleWorkerV1.byOriginCastle.filter(castleId)] + .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 = [...ctx.db.castleWorkerV1.byOriginCastle.filter(castle.castleId)]; + const castleIds = [...ctx.db.castle.iter()].map(row => row.castleId); + const priorCastleIds = existing.length === 0 + ? castleIds.filter(castleId => castleId !== castle.castleId) + : castleIds; + if ( + system.expectedCastleCount !== priorCastleIds.length + || system.expectedWorkerCount !== priorCastleIds.length * CASTLE_WORKERS_PER_CASTLE + || system.rosterDigest !== rosterDigestForCastleIds(priorCastleIds) + ) fail('WORKER_SYSTEM_INTEGRITY'); + if (existing.length > 0) { + assertCastleWorkerRoster(ctx, castle.castleId); + } else { + for (const row of expectedWorkerRowsForCastle(castle, ctx.timestamp)) { + ctx.db.castleWorkerV1.insert(row); + } + assertCastleWorkerRoster(ctx, castle.castleId); + } + if (castleIds.length > MAX_U32 || castleIds.length > Math.floor(MAX_U32 / CASTLE_WORKERS_PER_CASTLE)) { + fail('WORKER_ROSTER_CAPACITY'); + } + for (const castleId of castleIds) assertCastleWorkerRoster(ctx, castleId); + if (ctx.db.castleWorkerV1.count() !== BigInt(castleIds.length * CASTLE_WORKERS_PER_CASTLE)) { + fail('WORKER_ROSTER_INTEGRITY'); + } + for (const worker of ctx.db.castleWorkerV1.iter()) { + if (ctx.db.castle.castleId.find(worker.originCastleId) === null) fail('WORKER_ROSTER_ORPHAN'); + } + ctx.db.realmWorkerSystemV1.realmId.update({ + ...system, + expectedCastleCount: castleIds.length, + expectedWorkerCount: castleIds.length * CASTLE_WORKERS_PER_CASTLE, + rosterDigest: rosterDigestForCastleIds(castleIds), + }); +} + +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..01282fd6 100644 --- a/spacetimedb/src/resourceExpeditionReservationAuthority.ts +++ b/spacetimedb/src/resourceExpeditionReservationAuthority.ts @@ -1,9 +1,17 @@ import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; +import { + 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 +49,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 +80,28 @@ 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; + for (const assignment of ctx.db.workerAssignmentV1.byFid.filter(fid)) { + 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..5ebe4abd 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,172 @@ 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(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + }, +); + +/** 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 +1221,12 @@ const warpkeep = schema({ stoneExpeditionIdempotencyV1, stoneExpeditionScheduleV1, realmWaterRevisionV1, + realmWorkerSystemV1, + castleWorkerV1, + workerAssignmentV1, + workerNodeOccupationV1, + workerCommandIdempotencyV1, + workerAssignmentScheduleV1, }); /** @@ -1124,6 +1300,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 +1359,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) { 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..dcd64bb9 --- /dev/null +++ b/spacetimedb/tests/castleWorkerAuthority.test.ts @@ -0,0 +1,140 @@ +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, deletes stale schedules, and stores no no-op receipt', () => { + 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, /if \(assignment === null\)[\s\S]*return;/); + assert.match(recall, /if \(assignment\.phase === 'returning'\) return;/); + assert.match(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: castleIds\.length,[\s\S]*expectedWorkerCount: castleIds\.length \* CASTLE_WORKERS_PER_CASTLE,[\s\S]*rosterDigest: rosterDigestForCastleIds\(castleIds\)/); + 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 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.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\)/); + for (const replay of [dispatch, recall, recallAll]) { + assert.match(replay, /workerReceiptShapeIsValid\(prior\)/); + 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/castleWorkerMigrationTooling.test.ts b/spacetimedb/tests/castleWorkerMigrationTooling.test.ts new file mode 100644 index 00000000..9305b9c0 --- /dev/null +++ b/spacetimedb/tests/castleWorkerMigrationTooling.test.ts @@ -0,0 +1,78 @@ +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); + 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\(\)/); +}); + +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/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..c4676134 100644 --- a/src/components/realm/RealmAccessibilityControls.tsx +++ b/src/components/realm/RealmAccessibilityControls.tsx @@ -25,6 +25,15 @@ export type RealmNavigatorCastle = Readonly<{ r: number; }>; +export type RealmNavigatorWaterBody = Readonly<{ + bodyId: string; + label: string; + sourceCellKey: string; + mouthCellKey: string; + sourceCoord: HexCoord; + mouthCoord: HexCoord; +}>; + export type RealmNavigatorCloseReason = 'escape' | 'close-button' | 'camera-preset'; export type RealmNavigatorCoordinateJump = Readonly<{ @@ -43,11 +52,13 @@ export type RealmAccessibilityControlsProps = Readonly<{ id: string; open: boolean; castles: readonly RealmNavigatorCastle[]; + waterBodies?: readonly RealmNavigatorWaterBody[]; ownCastleId?: number; selectedCastleId?: number; onRequestOpen: () => void; onRequestClose: (reason: RealmNavigatorCloseReason) => void; onActivateCastle: (castle: RealmNavigatorCastle) => void; + onActivateWaterCell?: (cellKey: string) => void; coordinateJump?: RealmNavigatorCoordinateJump; cameraPresets?: readonly RealmNavigatorCameraPreset[]; /** Player chrome may provide its own PFP launcher while reusing this dialog. */ @@ -75,11 +86,13 @@ export function RealmAccessibilityControls({ id, open, castles, + waterBodies = [], ownCastleId, selectedCastleId, onRequestOpen, onRequestClose, onActivateCastle, + onActivateWaterCell, coordinateJump, cameraPresets = [], triggerVisible = true, @@ -126,6 +139,16 @@ 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 handleDialogKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Escape') return; @@ -164,7 +187,7 @@ export function RealmAccessibilityControls({ + + + + + ))} + + + ) : null} + {coordinateJump ? (
diff --git a/src/components/realm/RealmHud.tsx b/src/components/realm/RealmHud.tsx index 5e9eab42..03c60a78 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,7 @@ const RESOURCE_ICON_PATHS: Readonly< }) }); -type RealmMenuSurface = 'closed' | 'menu' | 'settings'; +type RealmMenuSurface = 'closed' | 'menu' | 'settings' | 'workers' | 'worker-inspection'; function publicAssetUrl(path: string) { const base = import.meta.env.BASE_URL || '/'; @@ -142,8 +160,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 +204,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 +240,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 ? (
    ('closed'); + const [selectedWorkerId, setSelectedWorkerId] = useState(undefined); const [collecting, setCollecting] = useState(false); const triggerRef = useRef(null); const wasOpenRef = useRef(false); @@ -477,6 +519,20 @@ 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 genericWorkersActive = 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 +548,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(); @@ -514,7 +583,7 @@ export function RealmHud({
    - {resources ? : null} + {resources ? : null}

    setSurface('closed')} onCollect={() => void collect()} onExplore={() => closeThen(() => onRequestExplore?.())} @@ -556,6 +626,32 @@ 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} diff --git a/src/components/realm/RealmMapScreen.css b/src/components/realm/RealmMapScreen.css index 1071cb6c..f48b09ea 100644 --- a/src/components/realm/RealmMapScreen.css +++ b/src/components/realm/RealmMapScreen.css @@ -196,6 +196,9 @@ z-index: 2; right: max(0.8rem, env(safe-area-inset-right)); bottom: max(4.55rem, calc(env(safe-area-inset-bottom) + 4rem)); + display: grid; + justify-items: end; + gap: 0.5rem; width: min(28rem, calc(100vw - 1.6rem)); margin: 0; color: #f8f0d6; @@ -205,6 +208,24 @@ text-shadow: 0 1px 8px #293018; } +.realm-map-screen__fallback-copy button { + min-height: 2.5rem; + padding: 0 0.85rem; + border: 1px solid rgb(255 241 184 / 62%); + border-radius: 0.4rem; + background: rgb(20 27 17 / 72%); + color: #fff1b8; + font: 700 0.75rem/1 Inter, ui-sans-serif, system-ui, sans-serif; + cursor: pointer; +} + +.realm-map-screen__fallback-copy button:hover, +.realm-map-screen__fallback-copy button:focus-visible { + border-color: #fff1b8; + outline: 2px solid #fff1b8; + outline-offset: 2px; +} + .realm-hud { position: absolute; z-index: 5; @@ -499,6 +520,39 @@ font-weight: 800; } +.realm-cell-navigator__water { + display: grid; + gap: 0.35rem; + margin-top: 0.8rem; +} + +.realm-cell-navigator__water > 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..4ac9a304 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -32,12 +32,16 @@ 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 { WaterInspectionPanel } from './WaterInspectionPanel'; import { RealmAccessibilityControls } from './RealmAccessibilityControls'; import { RealmCastleLabels, @@ -54,6 +58,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 +119,7 @@ import type { RealmCastleProjectionFrame } from './realmTypes'; import { CASTLE_LABEL_LAYOUT_MAX_CASTLES, castleProfileLabel, + fallbackCastleIsInViewBox, fallbackCastleProjection, publicProfileForCastle, resolvePersistentCastleLabels, @@ -143,8 +153,23 @@ 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 type { + ReadyWorkerProjection, + ReadyWorkerResourceState, + RealmWorkerDestinationPresentation, + WorkerRosterPresentation +} from './realmWorkerPresentation'; export { BLOCKED_SHARED_FOREST_PROJECTION_SIGNATURE, @@ -183,6 +208,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 +308,12 @@ function CanonicalRealmMapScreen({ stoneExpedition, onDispatchStoneExpedition, onClaimStoneExpedition, + workerProjection, + workerRoster, + workerResourceState, + onDispatchWorker, + onRecallWorker, + onRecallAllWorkers, graphicsPreference, resolvedGraphicsQuality, audioMuted, @@ -322,6 +363,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( @@ -456,6 +511,32 @@ 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 activeWagons = useMemo(() => { if (observerMode) return Object.freeze([]); const items: RealmActiveWagonMenuItem[] = []; @@ -593,9 +674,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 +720,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 +765,15 @@ function CanonicalRealmMapScreen({ && 'stoneSiteId' in selectedInspectorTarget ? stoneNodesBySiteId.get(selectedInspectorTarget.stoneSiteId) : undefined; + const inspectorWater = selectedInspectorTarget !== null + && 'cellKey' in selectedInspectorTarget + ? waterRecordsByKey.get(selectedInspectorTarget.cellKey) + : 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 = waterRecords.find((record) => sameCoord(record.coord, selectedCoord)); const ownProfile = profileRecords.get(ownCastle.castleId)?.profile; const focusedCastleId = interaction.cameraTarget.kind === 'castle' ? interaction.cameraTarget.castleId @@ -731,6 +844,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 +948,19 @@ 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 openActiveWagon = useCallback((wagon: RealmActiveWagonMenuItem) => { if (!activeWagons.some((candidate) => ( candidate.resource === wagon.resource && candidate.siteId === wagon.siteId @@ -866,9 +994,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 +1086,14 @@ 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') { + updateHoveredCastleId(undefined); + hoveredCoordRef.current = null; + sceneRef.current?.setHoveredWaterCellKey?.(target.cellKey); + sceneRef.current?.setHovered(null); + return; + } + sceneRef.current?.setHoveredWaterCellKey?.(null); updateHoveredCoord(target?.coord ?? null); updateHoveredCastleId(target?.kind === 'castle' ? target.castleId : undefined); }, [updateHoveredCastleId, updateHoveredCoord]); @@ -920,8 +1125,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]); const updateCastleProjection = useCallback((frame: RealmCastleProjectionFrame) => { latestProjectionRef.current = frame; @@ -1077,6 +1287,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 +1300,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 +1352,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 +1411,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 +1443,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 +1460,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 +1499,6 @@ function CanonicalRealmMapScreen({ rootRef.current.dataset.stoneMarkerOnlySiteCount = String(stoneNodeCatalog.length); } setVisibleCastleLabels([]); - setCameraMode('realm'); scene = createRealmScene({ canvas, surface, @@ -1271,7 +1512,10 @@ function CanonicalRealmMapScreen({ 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 +1529,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 +1576,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 +1626,50 @@ function CanonicalRealmMapScreen({ ? interactionRef.current.inspectorTarget.stoneSiteId : 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 === '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]); useEffect(() => { sceneRef.current?.reconcileLiveGatheringState?.(liveGatheringState); @@ -1381,6 +1699,10 @@ function CanonicalRealmMapScreen({ sceneRef.current?.setSelectedStoneSiteId?.(inspectorStoneNode?.siteId ?? null); }, [inspectorStoneNode?.siteId]); + 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 +1785,8 @@ function CanonicalRealmMapScreen({ selectWoodNode(woodNodeAtSelectedCell); } else if (stoneNodeAtSelectedCell) { selectStoneNode(stoneNodeAtSelectedCell); + } else if (waterAtSelectedCell) { + selectWaterCell(waterAtSelectedCell); } else { sceneRef.current?.focusCell(selectedCoord); dispatchInteraction({ @@ -1479,16 +1803,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 +2161,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,10 +2250,27 @@ function CanonicalRealmMapScreen({ /> ) : 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); }} + 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..2b1a5c84 --- /dev/null +++ b/src/components/realm/WorkerCommandCenter.tsx @@ -0,0 +1,158 @@ +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; + +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..e4bd5d92 --- /dev/null +++ b/src/components/realm/WorkerInspectionPanel.css @@ -0,0 +1,2 @@ +.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} 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..2f926723 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; @@ -291,6 +343,8 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re 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 +367,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; @@ -356,11 +412,16 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re 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..4dcd9b3e 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 { @@ -135,7 +141,8 @@ import { import { arbitrateRealmPick, type RealmInteractionTarget, - type RealmResourcePickHit + type RealmResourcePickHit, + type RealmWaterPickHit } from './realmPickArbitration'; import { REALM_LIGHTING_SPECS, @@ -148,6 +155,10 @@ import type { RealmCastleProjectionFrame, RealmCastleScreenBounds } from './realmTypes'; +import { + classifyRealmRendererFailure, + type RealmRendererFailure +} from './realmRendererRecovery'; const HEX_SIZE = 1; const OVERLAY_LIFT = 0.026; @@ -392,6 +403,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 +417,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 +435,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 +451,8 @@ export type RealmSceneHandle = Readonly<{ setSelectedFoodSiteId: (siteId: string | null) => void; setSelectedWoodSiteId: (siteId: string | null) => void; setSelectedStoneSiteId: (siteId: string | null) => void; + setSelectedWaterCellKey: (cellKey: string | null) => void; + setHoveredWaterCellKey: (cellKey: string | null) => void; setComposition: (composition: RealmCameraComposition) => void; showRealm: () => void; }>; @@ -451,6 +474,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; @@ -521,8 +545,14 @@ export type CreateRealmSceneOptions = Readonly<{ 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 +572,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 +584,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 +982,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 +1013,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 +1033,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 +1279,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 +1289,8 @@ function initializeRealmScene( quality: runtimeQuality, reducedMotion: options.reducedMotion, hexSize: HEX_SIZE, + environment: options.waterEnvironment, + waterBodies: options.waterBodies, heightAtWorld: (world) => terrainHeightAtWorld( options.surface.renderMap, world, @@ -1270,6 +1316,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 +1476,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 +1507,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 +1524,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 +1550,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 +1570,7 @@ function initializeRealmScene( const signature = [ telemetry.semanticFeatureCount, telemetry.semanticFeatureDrawCalls, + Object.values(telemetry.semanticFeatureCounts).join(','), telemetry.totalDetailInstanceCount, telemetry.totalDetailDrawCalls, telemetry.forestPlacementSource, @@ -1497,7 +1580,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 +1694,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 +1706,7 @@ function initializeRealmScene( && !foodNodeCoordinateKeys.has(hexKey(coord)) && !woodNodeCoordinateKeys.has(hexKey(coord)) && !stoneNodeCoordinateKeys.has(hexKey(coord)) + && !waterCellCoordinateKeys.has(hexKey(coord)) ? coord : null ); @@ -1680,9 +1776,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 @@ -1800,6 +1902,7 @@ function initializeRealmScene( }; const render = () => { if (cleanup.isDisposed()) return; + if (contextLost) return; if (document.hidden) { renderPendingWhileHidden = true; return; @@ -1924,11 +2027,21 @@ function initializeRealmScene( try { renderer.render(scene, cameraController.camera); } catch (error) { - if (!isGrassShaderContractFailure(error) || !disableGrassPresentation()) throw error; + if (!isGrassShaderContractFailure(error) || !disableGrassPresentation()) { + options.onRendererFailure?.({ + code: 'scene-build-failed', + retryable: true, + phase: 'ready', + message: error instanceof Error ? error.message : String(error) + }); + disposeScene(); + 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); } + options.canvas.dataset.realmLastSuccessfulRenderedGeneration = String(rendererGeneration); projectCastleLabels(); if (pendingCastlesReadyCount !== null) { const castleCount = pendingCastlesReadyCount; @@ -1937,13 +2050,31 @@ 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.' + }; + options.onRendererFailure?.(failure); + options.onRendererUnavailable(); + 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.' + }; + options.onRendererFailure?.(failure); + options.onRendererUnavailable(); + return; } pendingCastlesReadyCount = null; options.onCastlesReady?.(castleCount); @@ -1964,12 +2095,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 @@ -2098,7 +2229,10 @@ function initializeRealmScene( 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; @@ -2198,7 +2332,8 @@ function initializeRealmScene( const stoneNodeHit = stoneNodeLayer?.raycast(raycaster); if (stoneNodeHit) resourceHits.push({ kind: 'stone-site', ...stoneNodeHit }); const castleHit = castleLayer?.raycast(raycaster); - const foregroundHit = arbitrateRealmPick({ resourceHits, castleHit }); + const waterHit: RealmWaterPickHit | null = waterLayer?.raycast(raycaster) ?? null; + const foregroundHit = arbitrateRealmPick({ resourceHits, castleHit, waterHit }); if (foregroundHit) return foregroundHit; const intersections = raycaster.intersectObject(terrain, false); for (const intersection of intersections) { @@ -2355,8 +2490,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 +2522,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 +2575,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 +2604,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 +2622,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 +2648,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 +2667,7 @@ function initializeRealmScene( }; const handlePointerLeave = () => { + if (contextLost) return; if (pointerGestures.snapshot().pointerCount === 0) { cancelPendingHover(); dispatchHover(null); @@ -2501,6 +2675,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 +2719,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 +2781,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 +2828,7 @@ function initializeRealmScene( resize(); const usedCastleLods = castleLodsForQuality(runtimeQuality); + const requestedCastleLodPolicy = castleLodPolicy; const prefabRepository = createHegemonyKeepPrefabRepository({ baseUrl: options.baseUrl, maxAnisotropy: renderer.capabilities.getMaxAnisotropy() @@ -2643,45 +2846,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 +2917,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,21 +2951,162 @@ 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); + } + } + } + }; + + // Start the complete family together. Compact alone gates first playable + // presentation; optional LODs upgrade progressively as a contiguous chain + // and can never impose their network timeout on Realm readiness. + 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(); + }); + }); + + 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; + } + 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.onRendererFailure?.(classifyRealmRendererFailure( + error, + 'loading', + 'castle-compact-load-failed' + )); options.onRendererUnavailable(); + disposeScene(); }); function disposeScene() { @@ -2855,6 +3190,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 +3206,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 +3245,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 +3280,7 @@ function initializeRealmScene( recenterKeep: cameraController.recenterKeep, setHovered: (coord) => { if (cleanup.isDisposed()) return; + waterLayer?.setHoveredCellKey(null); hoveredTerrainCoord = coord; grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); // A terrain hex runs through the wider authored landscape-base mesh. @@ -2945,6 +3308,7 @@ function initializeRealmScene( }, setSelected: (coord) => { if (cleanup.isDisposed()) return; + waterLayer?.setSelectedCellKey(null); selectedTerrainCoord = coord; grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); selectedCastleId = coord @@ -2964,6 +3328,7 @@ function initializeRealmScene( if (cleanup.isDisposed()) return; selectedCastleId = castleId === null ? undefined : castleId; if (castleId !== null) { + waterLayer?.setSelectedCellKey(null); selectedTerrainCoord = null; grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); setOverlay(selectedOverlay, options.surface, null, terrainPlacements); @@ -2974,28 +3339,68 @@ 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); + 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); + 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); + 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); + 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; + goldNodeLayer?.setSelectedSiteId(null); + foodNodeLayer?.setSelectedSiteId(null); + woodNodeLayer?.setSelectedSiteId(null); + stoneNodeLayer?.setSelectedSiteId(null); + grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } + render(); + }, + setHoveredWaterCellKey: (cellKey) => { + if (cleanup.isDisposed()) return; + waterLayer?.setHoveredCellKey(cellKey); + if (cellKey !== 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..3e54286c --- /dev/null +++ b/src/components/realm/realmWaterInspectionPresentation.ts @@ -0,0 +1,207 @@ +import { axialToWorld, 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; + worldPosition: Readonly<{ x: number; z: number }>; + 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, + worldPosition: axialToWorld(coord, 1), + 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, + worldPosition: axialToWorld(coord, 1), + 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..39d4ee76 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,10 @@ 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; /** Convert the persisted +1000 fixed-point datum into the terrain's world-Y space. */ export function waterSurfaceLevelToWorldY(surfaceLevelMilli: number): number { @@ -46,6 +58,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 +76,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 +102,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 +165,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 +189,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 +200,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 +219,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 +264,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 +336,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 +352,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 +369,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 +388,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 +398,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 +422,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 +440,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 +609,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 +619,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 +646,164 @@ 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 pickHeightByCellKey = new Map(); + ocean.forEach((cell) => { + pickHeightByCellKey.set( + cell.cellKey, + waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + ); + }); + rivers.forEach((cell, index) => { + const renderedCenterY = riverGeometryData + .getAttribute('position') + .getY(index * 7); + pickHeightByCellKey.set( + cell.cellKey, + Number.isFinite(renderedCenterY) + ? renderedCenterY + : waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + WATER_Y_LIFT + ); + }); + const visiblePickHeights = [...visiblePickCellsByKey.keys()] + .flatMap((cellKey) => { + const height = pickHeightByCellKey.get(cellKey); + return height === undefined || !Number.isFinite(height) ? [] : [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 analyticRaycast = (raycaster: THREE.Raycaster) => { + if (visiblePickCellsByKey.size === 0) return null; + const candidateKeys = new Set(); + const samplePoint = new THREE.Vector3(); + 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, samplePoint); + if (!point) continue; + const nearestCoord = worldToNearestAxial({ x: point.x, z: point.z }, options.hexSize); + hexDisc(nearestCoord, ANALYTIC_PICK_NEIGHBORHOOD_RADIUS).forEach((coord) => { + candidateKeys.add(hexKey(coord)); + }); + } + let nearest: RealmWaterCellHit | null = null; + const hitPoint = new THREE.Vector3(); + for (const cellKey of candidateKeys) { + const cell = visiblePickCellsByKey.get(cellKey); + const surfaceY = pickHeightByCellKey.get(cellKey); + if (!cell || surfaceY === undefined) continue; + const point = rayPointAtSurfaceY(raycaster.ray, surfaceY, hitPoint); + if (!point) continue; + const center = axialToWorld(cell, options.hexSize); + if (!pointInsidePointyHex({ x: point.x, z: point.z }, center, options.hexSize)) continue; + const distance = raycaster.ray.origin.distanceTo(point); + if ( + !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 +812,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 +823,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 +866,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 +902,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/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'); +} 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 ae8dac82..9a55783b 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,27 @@ 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, + type WorkerCommandAttempt, + type WorkerCommandFingerprint +} from './workerCommandIdempotency'; /** * The generation-three Realm replicates 20,000 immutable world rows before @@ -68,6 +88,7 @@ import { createExpeditionIdempotencyKey } from './expeditionIdempotencyKey'; export const CANONICAL_REALM_READINESS_TIMEOUT_MILLISECONDS = 60_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 ResourceOperationDeadlineError extends Error { constructor() { @@ -119,6 +140,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; }>; /** @@ -135,6 +166,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. */ @@ -171,6 +207,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, @@ -275,6 +316,23 @@ function resourceProjectionIsAtLeastAsNew( ); } +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 + }); +} + /** * 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 @@ -338,6 +396,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; @@ -367,6 +429,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(); @@ -446,6 +512,119 @@ 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 + ); + 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.'); + } + 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 + ) 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; @@ -1342,6 +1521,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 @@ -1358,6 +1543,11 @@ export function WarpkeepSpacetimeProvider({ readinessTimeout = undefined; } canonicalRealmSourceRef.current = canonicalRealmSource; + const workerProjection = activeWorkerProjection( + realm, + workerRoster, + workerResourceState + ); setState({ phase: 'ready', identity, @@ -1367,7 +1557,10 @@ 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 }) }); } catch { fail(); @@ -1427,6 +1620,16 @@ export function WarpkeepSpacetimeProvider({ : withResourceOperationDeadline( runtime.readStoneExpeditionState(activeConnection) ).catch(() => undefined); + const initialWorkerRosterPromise = runtime.readWorkerRoster === undefined + ? Promise.resolve(undefined) + : withResourceOperationDeadline( + runtime.readWorkerRoster(activeConnection, bridgeFid!) + ).catch(() => undefined); + const initialWorkerResourceStatePromise = runtime.readResourceStateV2 === undefined + ? Promise.resolve(undefined) + : withResourceOperationDeadline( + runtime.readResourceStateV2(activeConnection, bridgeFid!) + ).catch(() => undefined); // A synchronous observer/subscription exception can terminate this // generation before the await below. Pre-handle the same promise so // its eventual rejection cannot escape as an unhandled rejection; @@ -1457,13 +1660,17 @@ export function WarpkeepSpacetimeProvider({ initialGoldExpedition, initialFoodExpedition, initialWoodExpedition, - initialStoneExpedition + initialStoneExpedition, + initialWorkerRoster, + initialWorkerResourceState ] = await Promise.all([ initialResourcePromise, initialGoldExpeditionPromise, initialFoodExpeditionPromise, initialWoodExpeditionPromise, - initialStoneExpeditionPromise + initialStoneExpeditionPromise, + initialWorkerRosterPromise, + initialWorkerResourceStatePromise ]); if (!current()) return; resourceStateRef.current = Object.freeze({ @@ -1486,6 +1693,8 @@ export function WarpkeepSpacetimeProvider({ generation, value: initialStoneExpedition }); + workerRosterStateRef.current = Object.freeze({ generation, value: initialWorkerRoster }); + workerResourceStateRef.current = Object.freeze({ generation, value: initialWorkerResourceState }); realmActivated = true; const refreshResources = async () => { if (!current() || !realmActivated || resourceRefreshInFlight) return; @@ -1511,14 +1720,22 @@ export function WarpkeepSpacetimeProvider({ : withResourceOperationDeadline( runtime.readStoneExpeditionState(activeConnection) ).catch(() => undefined); - const [refreshed, refreshedGoldExpedition, refreshedFoodExpedition, refreshedWoodExpedition, refreshedStoneExpedition] = await Promise.all([ + const workerRosterRefresh = runtime.readWorkerRoster === undefined + ? Promise.resolve(undefined) + : withResourceOperationDeadline(runtime.readWorkerRoster(activeConnection, bridgeFid!)).catch(() => undefined); + const workerResourceRefresh = runtime.readResourceStateV2 === undefined + ? Promise.resolve(undefined) + : withResourceOperationDeadline(runtime.readResourceStateV2(activeConnection, bridgeFid!)).catch(() => undefined); + const [refreshed, refreshedGoldExpedition, refreshedFoodExpedition, refreshedWoodExpedition, refreshedStoneExpedition, refreshedWorkerRoster, refreshedWorkerResourceState] = await Promise.all([ withResourceOperationDeadline( runtime.readResourceState(activeConnection, bridgeFid!) ), goldRefresh, foodRefresh, woodRefresh, - stoneRefresh + stoneRefresh, + workerRosterRefresh, + workerResourceRefresh ]); if (!current()) return; if (refreshed.fid !== BigInt(bridgeFid!)) { @@ -1545,6 +1762,8 @@ export function WarpkeepSpacetimeProvider({ generation, value: refreshedStoneExpedition }); + workerRosterStateRef.current = Object.freeze({ generation, value: refreshedWorkerRoster }); + workerResourceStateRef.current = Object.freeze({ generation, value: refreshedWorkerResourceState }); setState((latest) => { const latestRetained = resourceStateRef.current?.generation === generation ? resourceStateRef.current.value @@ -1566,7 +1785,14 @@ export function WarpkeepSpacetimeProvider({ goldExpedition: refreshedGoldExpedition, foodExpedition: refreshedFoodExpedition, woodExpedition: refreshedWoodExpedition, - stoneExpedition: refreshedStoneExpedition + stoneExpedition: refreshedStoneExpedition, + workerRoster: refreshedWorkerRoster, + workerResourceState: refreshedWorkerResourceState, + workerProjection: activeWorkerProjection( + latest.realm, + refreshedWorkerRoster, + refreshedWorkerResourceState + ) }; }); } catch { @@ -1627,7 +1853,10 @@ export function WarpkeepSpacetimeProvider({ dispatchWoodExpedition, claimWoodExpedition, dispatchStoneExpedition, - claimStoneExpedition + claimStoneExpedition, + dispatchWorker, + recallWorker, + recallAllWorkers }), [ beginAlphaTermsAcceptance, cancelAlphaTermsAcceptance, @@ -1642,6 +1871,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..6265b8fc --- /dev/null +++ b/src/spacetime/module_bindings/castle_worker_v_1_table.ts @@ -0,0 +1,31 @@ +// 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(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); 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..92e50da1 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,27 @@ 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(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type CastleWorkerV1 = __Infer; + export const FidWalletAttributionV1 = __t.object("FidWalletAttributionV1", { snapshotAttributionKey: __t.string(), attributionKey: __t.string(), @@ -491,6 +589,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 +646,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 +925,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 +1150,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..ecce8cfc 100644 --- a/src/spacetime/playerModuleBindings.ts +++ b/src/spacetime/playerModuleBindings.ts @@ -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({ diff --git a/src/spacetime/warpkeepConfig.ts b/src/spacetime/warpkeepConfig.ts index 87babcca..78dc480a 100644 --- a/src/spacetime/warpkeepConfig.ts +++ b/src/spacetime/warpkeepConfig.ts @@ -3,7 +3,9 @@ * SpacetimeDB login credentials must never be exposed through Vite. */ export const DEFAULT_SPACETIMEDB_URI = 'https://maincloud.spacetimedb.com'; -export const DEFAULT_SPACETIMEDB_DATABASE = 'warpkeep-89e4u'; +/** Immutable public address; unlike a database alias, it cannot drift after a rename. */ +export const DEFAULT_SPACETIMEDB_DATABASE = + 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e'; export const DEFAULT_WARPKEEP_OIDC_AUDIENCE = 'warpkeep-spacetimedb'; export const CANONICAL_WARPKEEP_AUTH_ORIGIN = 'https://auth.warpkeep.com'; export const WARPKEEP_SHARED_ALPHA_UNAVAILABLE_MESSAGE = @@ -89,7 +91,9 @@ function normalizeDatabaseName(value: string | undefined) { if (value === undefined) return DEFAULT_SPACETIMEDB_DATABASE; const candidate = cleanOptionalString(value); if (!candidate) return undefined; - return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(candidate) ? candidate : undefined; + const databaseIdentity = /^[a-f0-9]{64}$/; + const databaseAlias = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; + return databaseIdentity.test(candidate) || databaseAlias.test(candidate) ? candidate : undefined; } function normalizeAudience(value: string | undefined) { diff --git a/src/spacetime/warpkeepConnection.ts b/src/spacetime/warpkeepConnection.ts index cfde15e2..8a9870fd 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, @@ -165,6 +174,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; @@ -173,6 +187,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([ @@ -362,6 +378,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, @@ -788,6 +874,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 @@ -858,6 +971,7 @@ export function subscribeToWarpkeepRealm( foodProjectionAvailability.delete(connection); woodProjectionAvailability.delete(connection); stoneProjectionAvailability.delete(connection); + workerProjectionAvailability.delete(connection); forestProjectionAvailability.delete(connection); waterProjectionAvailability.delete(connection); try { @@ -871,7 +985,11 @@ export function subscribeToWarpkeepRealm( try { woodSubscription?.unsubscribe(); } finally { - stoneSubscription?.unsubscribe(); + try { + stoneSubscription?.unsubscribe(); + } finally { + workerSubscription?.unsubscribe(); + } } } } @@ -1252,7 +1370,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; } @@ -1385,6 +1504,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; @@ -1770,6 +1984,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), @@ -1806,6 +2023,11 @@ export function readWarpkeepRealmSnapshot( waterRevision: publicWater.waterRevision }) }), + ...(publicWorkers === undefined ? {} : { + workerSystem: publicWorkers.system, + workerWorkers: publicWorkers.workers, + workerOccupations: publicWorkers.occupations + }), ...(ownCastle ? { ownCastle } : {}) }; return validateCanonicalGenesisSnapshot(candidate, { @@ -1891,6 +2113,16 @@ export function observeWarpkeepRealm( stoneTables?.stoneNodeOccupationV1?.onInsert?.(sync); stoneTables?.stoneNodeOccupationV1?.onDelete?.(sync); stoneTables?.stoneNodeOccupationV1?.onUpdate?.(sync); + const workerTables = publicWorkerTables(connection); + workerTables?.realmWorkerSystemV1?.onInsert?.(sync); + workerTables?.realmWorkerSystemV1?.onDelete?.(sync); + workerTables?.realmWorkerSystemV1?.onUpdate?.(sync); + workerTables?.castleWorkerV1?.onInsert?.(sync); + workerTables?.castleWorkerV1?.onDelete?.(sync); + workerTables?.castleWorkerV1?.onUpdate?.(sync); + workerTables?.workerNodeOccupationV1?.onInsert?.(sync); + workerTables?.workerNodeOccupationV1?.onDelete?.(sync); + workerTables?.workerNodeOccupationV1?.onUpdate?.(sync); const forestTables = publicForestTables(connection); forestTables?.realmForestLayoutV1?.onInsert?.(sync); forestTables?.realmForestLayoutV1?.onDelete?.(sync); @@ -1959,6 +2191,15 @@ export function observeWarpkeepRealm( stoneTables?.stoneNodeOccupationV1?.removeOnInsert?.(sync); stoneTables?.stoneNodeOccupationV1?.removeOnDelete?.(sync); stoneTables?.stoneNodeOccupationV1?.removeOnUpdate?.(sync); + workerTables?.realmWorkerSystemV1?.removeOnInsert?.(sync); + workerTables?.realmWorkerSystemV1?.removeOnDelete?.(sync); + workerTables?.realmWorkerSystemV1?.removeOnUpdate?.(sync); + workerTables?.castleWorkerV1?.removeOnInsert?.(sync); + workerTables?.castleWorkerV1?.removeOnDelete?.(sync); + workerTables?.castleWorkerV1?.removeOnUpdate?.(sync); + workerTables?.workerNodeOccupationV1?.removeOnInsert?.(sync); + workerTables?.workerNodeOccupationV1?.removeOnDelete?.(sync); + workerTables?.workerNodeOccupationV1?.removeOnUpdate?.(sync); forestTables?.realmForestLayoutV1?.removeOnInsert?.(sync); forestTables?.realmForestLayoutV1?.removeOnDelete?.(sync); forestTables?.realmForestLayoutV1?.removeOnUpdate?.(sync); diff --git a/src/spacetime/workerCommandIdempotency.ts b/src/spacetime/workerCommandIdempotency.ts new file mode 100644 index 00000000..9b5811e4 --- /dev/null +++ b/src/spacetime/workerCommandIdempotency.ts @@ -0,0 +1,47 @@ +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; + idempotencyKey: string; +}>; + +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}`; +} + +/** + * 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, + createKey: () => string | undefined = createExpeditionIdempotencyKey +): WorkerCommandAttempt | undefined { + const fingerprint = serializeWorkerCommandFingerprint(command); + if (retained?.generation === generation && retained.fingerprint === fingerprint) { + return retained; + } + const idempotencyKey = createKey(); + return idempotencyKey === undefined + ? undefined + : Object.freeze({ generation, fingerprint, idempotencyKey }); +} diff --git a/tests/WarpkeepExperienceRealm.test.tsx b/tests/WarpkeepExperienceRealm.test.tsx index f41e3ec3..53efa729 100644 --- a/tests/WarpkeepExperienceRealm.test.tsx +++ b/tests/WarpkeepExperienceRealm.test.tsx @@ -38,6 +38,7 @@ import type { WarpkeepRealmSnapshot } from '../src/spacetime/warpkeepBackendTypes'; import { + DEFAULT_SPACETIMEDB_DATABASE, WARPKEEP_SHARED_ALPHA_UNAVAILABLE_MESSAGE, type WarpkeepRuntimeConfig } from '../src/spacetime/warpkeepConfig'; @@ -51,7 +52,7 @@ const TEST_BINDING_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; const TEST_BINDING_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; const TEST_CONFIG: WarpkeepRuntimeConfig = Object.freeze({ spacetimeUri: 'https://maincloud.spacetimedb.com', - spacetimeDatabase: 'warpkeep-89e4u', + spacetimeDatabase: DEFAULT_SPACETIMEDB_DATABASE, bridgeUrl: TEST_ISSUER, issuer: TEST_ISSUER, audience: TEST_AUDIENCE, diff --git a/tests/WarpkeepSpacetimeCanonicalReadiness.test.tsx b/tests/WarpkeepSpacetimeCanonicalReadiness.test.tsx index c5c0a6b3..552302d2 100644 --- a/tests/WarpkeepSpacetimeCanonicalReadiness.test.tsx +++ b/tests/WarpkeepSpacetimeCanonicalReadiness.test.tsx @@ -16,7 +16,10 @@ import { type WarpkeepBackendRuntime } from '../src/spacetime/WarpkeepSpacetimeProvider'; import type { WarpkeepRealmSnapshot } from '../src/spacetime/warpkeepBackendTypes'; -import type { WarpkeepRuntimeConfig } from '../src/spacetime/warpkeepConfig'; +import { + DEFAULT_SPACETIMEDB_DATABASE, + type WarpkeepRuntimeConfig +} from '../src/spacetime/warpkeepConfig'; import { createCanonicalGenesisCandidate, createCanonicalGenesisSnapshot @@ -25,7 +28,7 @@ import { createReadyResourceState } from './fixtures/resourceState'; const CONFIG: WarpkeepRuntimeConfig = Object.freeze({ spacetimeUri: 'https://maincloud.spacetimedb.com', - spacetimeDatabase: 'warpkeep-89e4u', + spacetimeDatabase: DEFAULT_SPACETIMEDB_DATABASE, bridgeUrl: 'https://auth.warpkeep.com', issuer: 'https://auth.warpkeep.com', audience: 'warpkeep-spacetimedb', diff --git a/tests/WarpkeepSpacetimeResources.test.tsx b/tests/WarpkeepSpacetimeResources.test.tsx index e8ec580c..b333d2a0 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, @@ -49,13 +56,16 @@ import { type WarpkeepBackendControllerValue, type WarpkeepBackendRuntime } from '../src/spacetime/WarpkeepSpacetimeProvider'; -import type { WarpkeepRuntimeConfig } from '../src/spacetime/warpkeepConfig'; +import { + DEFAULT_SPACETIMEDB_DATABASE, + type WarpkeepRuntimeConfig +} from '../src/spacetime/warpkeepConfig'; import { createCanonicalGenesisSnapshot } from './fixtures/canonicalGenesisSnapshot'; import { createReadyResourceState } from './fixtures/resourceState'; const CONFIG: WarpkeepRuntimeConfig = Object.freeze({ spacetimeUri: 'https://maincloud.spacetimedb.com', - spacetimeDatabase: 'warpkeep-89e4u', + spacetimeDatabase: DEFAULT_SPACETIMEDB_DATABASE, bridgeUrl: 'https://auth.warpkeep.com', issuer: 'https://auth.warpkeep.com', audience: 'warpkeep-spacetimedb', @@ -268,6 +278,71 @@ 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) { + 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 createRuntimeHarness() { const disconnect = vi.fn((connection: { disconnect?: () => void } | undefined) => { connection?.disconnect?.(); @@ -336,6 +411,9 @@ function Probe() { ? '' : String(backend.state.stoneExpedition.active)} + + {backend.state.workerProjection?.mode ?? ''} + + ); @@ -480,6 +568,80 @@ describe('Warpkeep private resource lifecycle', () => { await waitFor(() => expect(screen.getByTestId('phase').textContent).toBe('ready')); }); + 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('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('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/WarpkeepSpacetimeTermsGate.test.tsx b/tests/WarpkeepSpacetimeTermsGate.test.tsx index 7271e252..fc9107c6 100644 --- a/tests/WarpkeepSpacetimeTermsGate.test.tsx +++ b/tests/WarpkeepSpacetimeTermsGate.test.tsx @@ -14,13 +14,16 @@ import { useWarpkeepBackend, type WarpkeepBackendRuntime } from '../src/spacetime/WarpkeepSpacetimeProvider'; -import type { WarpkeepRuntimeConfig } from '../src/spacetime/warpkeepConfig'; +import { + DEFAULT_SPACETIMEDB_DATABASE, + type WarpkeepRuntimeConfig +} from '../src/spacetime/warpkeepConfig'; import { createCanonicalGenesisSnapshot } from './fixtures/canonicalGenesisSnapshot'; import { createReadyResourceState } from './fixtures/resourceState'; const CONFIG: WarpkeepRuntimeConfig = Object.freeze({ spacetimeUri: 'https://maincloud.spacetimedb.com', - spacetimeDatabase: 'warpkeep-89e4u', + spacetimeDatabase: DEFAULT_SPACETIMEDB_DATABASE, bridgeUrl: 'https://auth.warpkeep.com', issuer: 'https://auth.warpkeep.com', audience: 'warpkeep-spacetimedb', 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/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/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/pagesDeployConfig.test.ts b/tests/pagesDeployConfig.test.ts index cf7eb338..ea2b32c8 100644 --- a/tests/pagesDeployConfig.test.ts +++ b/tests/pagesDeployConfig.test.ts @@ -17,7 +17,7 @@ function deploymentEnvironment(overrides: Record = {}) { VITE_WARPKEEP_OIDC_ISSUER: '', VITE_WARPKEEP_OIDC_AUDIENCE: 'warpkeep-spacetimedb', VITE_SPACETIMEDB_URI: 'https://maincloud.spacetimedb.com', - VITE_SPACETIMEDB_DATABASE: 'warpkeep-89e4u', + VITE_SPACETIMEDB_DATABASE: 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e', ...overrides }; } @@ -71,6 +71,6 @@ describe('Pages deployment configuration validation', () => { VITE_SPACETIMEDB_DATABASE: 'lookalike-database' }); expect(result.status).not.toBe(0); - expect(result.stderr).toContain('warpkeep-89e4u'); + expect(result.stderr).toContain('c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e'); }); }); diff --git a/tests/playerModuleBindings.test.ts b/tests/playerModuleBindings.test.ts index 3c041514..f037ac17 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 @@ -80,6 +83,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 +111,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_assignment_idempotency_v_1') }) it('exposes only the player reducer and procedure accessors at runtime', () => { @@ -134,6 +144,9 @@ describe('player SpacetimeDB bindings', () => { 'dispatchGoldExpeditionV1', 'dispatchStoneExpeditionV1', 'dispatchWoodExpeditionV1', + 'dispatchWorkerV1', + 'recallAllWorkersV1', + 'recallWorkerV1', ]) expect(Object.keys(connection.procedures).sort()).toEqual([ 'getAlphaBackendInfo', @@ -141,8 +154,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..acb9ed56 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, 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..1ee72855 100644 --- a/tests/realmAccessibilityControls.test.tsx +++ b/tests/realmAccessibilityControls.test.tsx @@ -119,7 +119,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, resources, and water' }) )); fireEvent.click(within(dialog).getByRole('button', { name: 'CLOSE EXPLORE' })); @@ -154,7 +154,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, resources, and water' }); await waitFor(() => expect(document.activeElement).toBe(search)); const list = screen.getByRole('list', { name: 'Founded castles' }); @@ -295,7 +295,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, 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..e6673084 100644 --- a/tests/realmGrassGenesisBounds.test.ts +++ b/tests/realmGrassGenesisBounds.test.ts @@ -125,7 +125,7 @@ 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'); diff --git a/tests/realmGrassLayer.test.ts b/tests/realmGrassLayer.test.ts index eaec52ac..e0cb5028 100644 --- a/tests/realmGrassLayer.test.ts +++ b/tests/realmGrassLayer.test.ts @@ -50,6 +50,23 @@ 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)).toBeLessThanOrEqual( + telemetry.activeCellCount + ); + 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..bff3861e 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' @@ -221,6 +353,99 @@ 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' }); + const roster = within(commandCenter).getByRole('list', { name: 'Your four workers' }); + expect(within(roster).getAllByRole('listitem')).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' }); + 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('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' }); + 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..54e53bf5 100644 --- a/tests/realmInteractionState.test.ts +++ b/tests/realmInteractionState.test.ts @@ -86,6 +86,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/realmObserverProductionExclusion.test.ts b/tests/realmObserverProductionExclusion.test.ts index 54326150..bf34e14e 100644 --- a/tests/realmObserverProductionExclusion.test.ts +++ b/tests/realmObserverProductionExclusion.test.ts @@ -159,6 +159,8 @@ describe('local QA production exclusion', () => { expect(runtimeGate).toContain("new Set(['localhost', '127.0.0.1', '::1', '[::1]'])"); expect(viteConfig).toContain("input: resolve(process.cwd(), 'index.html')"); expect(viteConfig).toContain("__WARPKEEP_LOCAL_QA__: JSON.stringify(command === 'serve')"); + expect(viteConfig).toContain('warpkeep-local-serve-csp-boundary'); + expect(viteConfig).toContain('data-warpkeep-production-csp'); expect(existsSync(resolve(root, 'dev/realm-qa.html'))).toBe(false); expect(existsSync(resolve(root, 'src/dev/RealmQaHarness.tsx'))).toBe(false); expect(packageJson.scripts.build).toContain('verify-production-dist-exclusions.mjs'); 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..1018bdb8 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; @@ -399,6 +402,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); 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..c2bd95d1 100644 --- a/tests/realmSceneCleanup.test.ts +++ b/tests/realmSceneCleanup.test.ts @@ -17,6 +17,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<{ @@ -88,6 +89,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 +150,7 @@ 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'; type ListenerSpy = ReturnType; @@ -164,6 +180,7 @@ function dispatchPointer( buttons: { value: input.buttons ?? (type === 'pointerup' ? 0 : 1) } }); target.dispatchEvent(event); + return event; } function createOptions( @@ -256,6 +273,7 @@ describe('realm scene setup cleanup', () => { 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 +350,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 +404,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 +423,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 +441,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 +487,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); @@ -921,6 +984,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 +1161,120 @@ describe('realm scene setup cleanup', () => { expect(materialDispose).toHaveBeenCalledTimes(1); }); + it('starts optional castle LODs concurrently without gating Compact 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).toHaveBeenCalledTimes(3)); + expect(new Set(keepLoadState.load.mock.calls.map(([input]) => ( + (input as { quality: { id: string } }).quality.id + )))).toEqual(new Set(['reduced', 'balanced', 'high'])); + 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)); + 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).toHaveBeenCalledTimes(3)); + resolvers.get('high')?.(loadedCastleAssembly(highRoot, 'high')); + await Promise.resolve(); + rejecters.get('balanced')?.(new Error('synthetic Balanced transport failure')); + resolvers.get('reduced')?.(loadedCastleAssembly(compactRoot, 'compact')); + + await vi.waitFor(() => expect(onCastlesReady).toHaveBeenCalledWith(1)); + 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) => { 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..1e418fb6 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 { @@ -66,7 +68,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( @@ -140,8 +146,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 +179,69 @@ 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 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.raycast(fogRaycaster)).toBeNull(); + 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 +372,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 +388,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 +508,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 + ); + }); +}); 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..515602ff 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, @@ -240,6 +241,10 @@ describe('rendered WebGL headless browser probe contract', () => { ); expect(source).toContain('await attempt(() => options.removeProfile?.());'); expect(source).toContain('onCastleLodVisualBoundary?.(castleLodVisualBoundary)'); + 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([ '.env', '.env.*', @@ -917,7 +922,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 +1003,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 +1039,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 +1063,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 +1218,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/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/warpkeepConfig.test.ts b/tests/warpkeepConfig.test.ts index cca146c7..585cc6a8 100644 --- a/tests/warpkeepConfig.test.ts +++ b/tests/warpkeepConfig.test.ts @@ -61,6 +61,10 @@ describe('Warpkeep runtime configuration', () => { ...complete, VITE_SPACETIMEDB_DATABASE: 'lookalike-database' }))).toBe(false); + expect(hasUsableWarpkeepBridge(readWarpkeepRuntimeConfig({ + ...complete, + VITE_SPACETIMEDB_DATABASE: 'warpkeep-89e4u' + }))).toBe(false); expect(hasUsableWarpkeepBridge(readWarpkeepRuntimeConfig({ ...complete, VITE_SPACETIMEDB_URI: 'https://lookalike.example' diff --git a/tests/warpkeepConnection.test.ts b/tests/warpkeepConnection.test.ts index b8976227..01a79675 100644 --- a/tests/warpkeepConnection.test.ts +++ b/tests/warpkeepConnection.test.ts @@ -54,6 +54,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 @@ -61,7 +66,7 @@ import { const config: WarpkeepRuntimeConfig = Object.freeze({ spacetimeUri: 'https://maincloud.spacetimedb.com', - spacetimeDatabase: 'warpkeep-89e4u', + spacetimeDatabase: 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e', bridgeUrl: 'https://auth.warpkeep.example', issuer: 'https://auth.warpkeep.example', audience: 'warpkeep-spacetimedb', @@ -218,6 +223,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, @@ -362,7 +418,9 @@ describe('Warpkeep authenticated connection boundary', () => { createWarpkeepConnectionBuilder(config, 'header.payload.signature'); expect(builder.withUri).toHaveBeenCalledWith('https://maincloud.spacetimedb.com'); - expect(builder.withDatabaseName).toHaveBeenCalledWith('warpkeep-89e4u'); + expect(builder.withDatabaseName).toHaveBeenCalledWith( + 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e' + ); expect(builder.withToken).toHaveBeenCalledWith('header.payload.signature'); expect(window.localStorage.length).toBe(0); }); @@ -605,6 +663,91 @@ 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('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); @@ -623,6 +766,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, diff --git a/tests/warpkeepRecovery.test.tsx b/tests/warpkeepRecovery.test.tsx index 288d0f9e..448727f7 100644 --- a/tests/warpkeepRecovery.test.tsx +++ b/tests/warpkeepRecovery.test.tsx @@ -127,6 +127,9 @@ describe('Warpkeep document fallback', () => { const parsed = new DOMParser().parseFromString(indexHtml, 'text/html'); const root = parsed.querySelector('#root'); const status = root?.querySelector('[role="status"]'); + const contentSecurityPolicy = parsed.querySelector( + 'meta[http-equiv="Content-Security-Policy"]' + )?.getAttribute('content'); expect(root?.querySelector('.warpkeep-boot')).not.toBeNull(); expect(root?.querySelector('#warpkeep-boot-title')).toBeNull(); @@ -138,6 +141,22 @@ describe('Warpkeep document fallback', () => { expect(indexHtml).toContain(''); expect(indexHtml).toContain('href="/favicon.svg"'); expect(mainSource).toContain('WARPKEEP_ROOT_ERROR_HANDLERS'); + expect(contentSecurityPolicy).toContain("default-src 'none'"); + expect(contentSecurityPolicy).toContain("script-src 'self' 'wasm-unsafe-eval'"); + expect(contentSecurityPolicy).toContain("script-src-attr 'none'"); + expect(contentSecurityPolicy).not.toContain("'unsafe-eval'"); + expect(contentSecurityPolicy).not.toMatch(/(?:^|[;\s])https:(?:[;\s]|$)/); + expect(contentSecurityPolicy).not.toMatch(/(?:^|[;\s])wss?:(?:[;\s]|$)/); + expect(contentSecurityPolicy).not.toMatch(/localhost|127\.0\.0\.1|\[::1\]/); + expect(contentSecurityPolicy).toContain("object-src 'none'"); + expect(contentSecurityPolicy).toContain("frame-src 'none'"); + expect(contentSecurityPolicy).toContain("form-action 'none'"); + expect(contentSecurityPolicy).toContain('https://auth.warpkeep.com'); + expect(contentSecurityPolicy).toContain('https://relay.farcaster.xyz'); + expect(contentSecurityPolicy).toContain('https://mainnet.optimism.io'); + expect(contentSecurityPolicy).toContain('https://maincloud.spacetimedb.com'); + expect(contentSecurityPolicy).toContain('wss://maincloud.spacetimedb.com'); + expect(parsed.querySelector('[data-warpkeep-production-csp]')).not.toBeNull(); }); it('provides actionable no-JavaScript guidance and motion-safe styling', () => { diff --git a/tests/waterInspectionPanel.test.tsx b/tests/waterInspectionPanel.test.tsx new file mode 100644 index 00000000..ee9cdb5b --- /dev/null +++ b/tests/waterInspectionPanel.test.tsx @@ -0,0 +1,99 @@ +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, + worldPosition: Object.freeze({ x: 1, z: 2 }), + 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..7dd9bd11 --- /dev/null +++ b/tests/workerCommandIdempotency.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + serializeWorkerCommandFingerprint, + workerCommandAttemptFor +} from '../src/spacetime/workerCommandIdempotency'; + +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, createKey); + expect(workerCommandAttemptFor(first, 4, { ...dispatch }, createKey)).toBe(first); + expect(workerCommandAttemptFor(first, 4, { + ...dispatch, + siteId: 'genesis-001:stone:0002' + }, createKey)?.idempotencyKey).toBe('second-command-key'); + expect(workerCommandAttemptFor(first, 5, dispatch, 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); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index b6534008..392167e7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -68,6 +68,19 @@ export function resolveDeploymentBase(environment: DeploymentEnvironment = proce const deploymentBase = resolveDeploymentBase(); const productVersion = readWarpkeepPackageVersion(); +function stripProductionCspFromLocalServe() { + return { + name: 'warpkeep-local-serve-csp-boundary', + apply: 'serve' as const, + transformIndexHtml(html: string) { + return html.replace( + /\s*/, + '' + ); + } + }; +} + export default defineConfig(({ command }) => ({ base: deploymentBase, build: { @@ -86,7 +99,11 @@ export default defineConfig(({ command }) => ({ __WARPKEEP_LOCAL_QA__: JSON.stringify(command === 'serve'), __WARPKEEP_PRODUCT_VERSION__: JSON.stringify(productVersion) }, - plugins: [react()], + // 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()], test: { environment: 'jsdom', globals: true,