From 84a93ce2fa30cd275b09d1fc9b4b8185c1c99518 Mon Sep 17 00:00:00 2001 From: Ael Date: Mon, 3 Aug 2026 15:59:24 +0200 Subject: [PATCH 1/5] feat: add inactive Inner Keep construction --- ASSETS-LICENSE.md | 26 + CHANGELOG.md | 14 + dev/inner-keep-qa.html | 17 + docs/design/inner-keep-construction.md | 203 ++ docs/design/roadmap.md | 26 +- docs/operations/inner-keep-activation.md | 206 ++ .../reconstruction/asset-pipeline.md | 29 + .../README.md | 85 + .../manifest.json | 2243 +++++++++++++++++ docs/security/threat-model.md | 15 + docs/technical-architecture.md | 28 +- package.json | 17 +- scripts/inner-keep-affordability-report.ts | 212 ++ scripts/inner-keep-operator-core.ts | 896 +++++++ scripts/inner-keep-operator.ts | 641 +++++ .../inner-keep-runtime-asset-contract.d.mts | 36 + scripts/inner-keep-runtime-asset-contract.mjs | 550 ++++ .../install-inner-keep-runtime-assets.d.mts | 6 + scripts/install-inner-keep-runtime-assets.mjs | 281 +++ scripts/publish-spacetime-dev.d.mts | 38 + scripts/publish-spacetime-dev.mjs | 1133 ++++++++- .../qa-observer/inner-keep-browser-probe.mjs | 491 ++++ .../qa-observer/inner-keep-qa-contract.d.mts | 61 + .../qa-observer/inner-keep-qa-contract.mjs | 258 ++ .../local-fullstack-browser-probe.mjs | 718 ++++++ .../qa-observer/local-fullstack-spacetime.mjs | 592 ++++- scripts/qa/agent.mjs | 2 +- .../spacetime-additive-migration-proof.mjs | 12 +- ...rify-access-request-additive-migration.mjs | 18 +- scripts/verify-inner-keep-runtime-assets.mjs | 73 + scripts/verify-production-dist-exclusions.mjs | 7 + .../verify-spacetime-additive-migration.mjs | 482 +++- spacetimedb/README.md | 59 +- .../additive-v14-schema/src/index.ts | 23 + .../additive-v15-schema/package.json | 13 + .../additive-v15-schema/src/index.ts | 805 ++++++ .../additive-v15-schema/tsconfig.json | 13 + spacetimedb/pnpm-lock.yaml | 10 + spacetimedb/src/foundingAuthority.ts | 6 + spacetimedb/src/index.ts | 13 + spacetimedb/src/innerKeepAuthority.ts | 1176 +++++++++ spacetimedb/src/innerKeepBuilderAuthority.ts | 124 + spacetimedb/src/innerKeepLayoutPolicy.ts | 192 ++ spacetimedb/src/innerKeepPolicy.ts | 454 ++++ spacetimedb/src/reducers/innerKeep.ts | 415 +++ spacetimedb/src/schema.ts | 191 ++ .../accessRequestMigrationTooling.test.ts | 26 +- .../tests/accessRequestReducers.test.ts | 2 +- .../tests/castleWorkerAuthority.test.ts | 5 +- .../castleWorkerLifecycleStateful.test.ts | 75 + spacetimedb/tests/dailyMarksReducers.test.ts | 2 +- spacetimedb/tests/innerKeepAuthority.test.ts | 221 ++ ...erKeepEntrySynchronizationStateful.test.ts | 427 ++++ .../tests/innerKeepMigrationTooling.test.ts | 98 + spacetimedb/tests/innerKeepPolicy.test.ts | 249 ++ .../innerKeepProjectLifecycleStateful.test.ts | 703 ++++++ .../tests/playerIdentityPrivacy.test.ts | 8 + spacetimedb/tests/resourceReducers.test.ts | 8 + .../tests/waterRevisionAuthority.test.ts | 2 +- .../waterRevisionMigrationTooling.test.ts | 10 +- src/components/WarpkeepExperience.tsx | 18 +- .../audio/WarpkeepHapticsDirector.tsx | 14 +- src/components/audio/proceduralSfxEngine.ts | 38 + src/components/audio/sfxEvents.ts | 9 + src/components/inner-keep/InnerKeepScreen.css | 1191 +++++++++ src/components/inner-keep/InnerKeepScreen.tsx | 923 +++++++ .../inner-keep/createInnerKeepSceneLayer.ts | 1244 +++++++++ .../inner-keep/innerKeepLayoutV1.ts | 44 + .../inner-keep/innerKeepPresentation.ts | 307 +++ .../innerKeepPresentationLayoutPolicy.ts | 632 +++++ .../realm/CastleInspectionPanel.tsx | 19 +- .../realm/RealmCastlePresentation.css | 39 + src/components/realm/RealmHud.tsx | 43 +- src/components/realm/RealmMapScreen.css | 33 + src/components/realm/RealmMapScreen.tsx | 286 ++- src/components/realm/createRealmScene.ts | 235 +- .../realm/realmSurfaceNavigation.ts | 19 +- src/dev/FullstackLocalQaApp.tsx | 45 + src/dev/InnerKeepQaHarness.tsx | 339 +++ src/dev/innerKeepQa.css | 100 + src/dev/innerKeepQaFixture.ts | 253 ++ src/dev/innerKeepQaInstrumentation.ts | 121 + src/dev/innerKeepQaMain.tsx | 36 + src/dev/innerKeepQaScenarioManifest.d.mts | 47 + src/dev/innerKeepQaScenarioManifest.mjs | 145 ++ src/spacetime/WarpkeepSpacetimeProvider.tsx | 620 ++++- src/spacetime/innerKeepCommandIdempotency.ts | 285 +++ src/spacetime/innerKeepProjection.ts | 698 +++++ .../admin_activate_inner_keep_v_1_reducer.ts | 23 + ...ackfill_inner_keep_builders_v_1_reducer.ts | 21 + ...admin_deactivate_inner_keep_v_1_reducer.ts | 17 + ...min_get_inner_keep_status_v_1_procedure.ts | 19 + ..._plan_inner_keep_builders_v_1_procedure.ts | 19 + ...n_plan_inner_keep_catalog_v_1_procedure.ts | 19 + ...min_seed_inner_keep_catalog_v_1_reducer.ts | 22 + .../castle_inner_keep_building_v_1_table.ts | 26 + ...inner_keep_request_status_v_1_procedure.ts | 20 + .../get_my_inner_keep_state_v_1_procedure.ts | 19 + src/spacetime/module_bindings/index.ts | 93 + .../inner_keep_build_level_v_1_table.ts | 24 + .../inner_keep_building_catalog_v_1_table.ts | 27 + .../inner_keep_layout_v_1_table.ts | 25 + .../inner_keep_slot_v_1_table.ts | 22 + .../inner_keep_start_project_v_1_reducer.ts | 17 + src/spacetime/module_bindings/types.ts | 202 ++ .../module_bindings/types/procedures.ts | 15 + .../module_bindings/types/reducers.ts | 10 + src/spacetime/playerModuleBindings.ts | 74 +- src/spacetime/warpkeepBackendTypes.ts | 3 + src/spacetime/warpkeepConnection.ts | 270 +- tests/WarpkeepSpacetimeInnerKeep.test.tsx | 452 ++++ tests/activationToolingSecurity.test.ts | 822 ++++++ tests/castleInspectionPanel.test.tsx | 43 + tests/fixtures/innerKeepPresentation.ts | 155 ++ tests/fullstackLocalQaSecurity.test.ts | 151 +- tests/innerKeepAffordabilityReport.test.ts | 32 + tests/innerKeepCommandIdempotency.test.ts | 122 + tests/innerKeepConnection.test.ts | 228 ++ tests/innerKeepInputCssContract.test.ts | 59 + tests/innerKeepOperator.test.ts | 571 +++++ tests/innerKeepPresentation.test.ts | 127 + ...nnerKeepPresentationLayoutManifest.test.ts | 174 ++ tests/innerKeepProjection.test.ts | 214 ++ tests/innerKeepQa.test.ts | 308 +++ tests/innerKeepRuntimeAssetSelection.test.ts | 192 ++ tests/innerKeepSceneLayer.test.ts | 255 ++ tests/innerKeepScreen.test.tsx | 439 ++++ tests/miniAppHostProvider.test.tsx | 51 + tests/playerModuleBindings.test.ts | 23 + tests/realmHud.test.tsx | 64 + .../realmObserverProductionExclusion.test.ts | 1 + tests/realmQualityRecreation.test.tsx | 113 +- tests/realmSceneCleanup.test.ts | 166 ++ tests/realmSurfaceNavigation.test.ts | 26 + 134 files changed, 28151 insertions(+), 153 deletions(-) create mode 100644 dev/inner-keep-qa.html create mode 100644 docs/design/inner-keep-construction.md create mode 100644 docs/operations/inner-keep-activation.md create mode 100644 docs/reference/assets/2026-08-02-inner-keep-3d-library/README.md create mode 100644 docs/reference/assets/2026-08-02-inner-keep-3d-library/manifest.json create mode 100644 scripts/inner-keep-affordability-report.ts create mode 100644 scripts/inner-keep-operator-core.ts create mode 100644 scripts/inner-keep-operator.ts create mode 100644 scripts/inner-keep-runtime-asset-contract.d.mts create mode 100644 scripts/inner-keep-runtime-asset-contract.mjs create mode 100644 scripts/install-inner-keep-runtime-assets.d.mts create mode 100644 scripts/install-inner-keep-runtime-assets.mjs create mode 100644 scripts/qa-observer/inner-keep-browser-probe.mjs create mode 100644 scripts/qa-observer/inner-keep-qa-contract.d.mts create mode 100644 scripts/qa-observer/inner-keep-qa-contract.mjs create mode 100644 scripts/verify-inner-keep-runtime-assets.mjs create mode 100644 spacetimedb/migration-fixtures/additive-v15-schema/package.json create mode 100644 spacetimedb/migration-fixtures/additive-v15-schema/src/index.ts create mode 100644 spacetimedb/migration-fixtures/additive-v15-schema/tsconfig.json create mode 100644 spacetimedb/src/innerKeepAuthority.ts create mode 100644 spacetimedb/src/innerKeepBuilderAuthority.ts create mode 100644 spacetimedb/src/innerKeepLayoutPolicy.ts create mode 100644 spacetimedb/src/innerKeepPolicy.ts create mode 100644 spacetimedb/src/reducers/innerKeep.ts create mode 100644 spacetimedb/tests/innerKeepAuthority.test.ts create mode 100644 spacetimedb/tests/innerKeepEntrySynchronizationStateful.test.ts create mode 100644 spacetimedb/tests/innerKeepMigrationTooling.test.ts create mode 100644 spacetimedb/tests/innerKeepPolicy.test.ts create mode 100644 spacetimedb/tests/innerKeepProjectLifecycleStateful.test.ts create mode 100644 src/components/inner-keep/InnerKeepScreen.css create mode 100644 src/components/inner-keep/InnerKeepScreen.tsx create mode 100644 src/components/inner-keep/createInnerKeepSceneLayer.ts create mode 100644 src/components/inner-keep/innerKeepLayoutV1.ts create mode 100644 src/components/inner-keep/innerKeepPresentation.ts create mode 100644 src/components/inner-keep/innerKeepPresentationLayoutPolicy.ts create mode 100644 src/dev/InnerKeepQaHarness.tsx create mode 100644 src/dev/innerKeepQa.css create mode 100644 src/dev/innerKeepQaFixture.ts create mode 100644 src/dev/innerKeepQaInstrumentation.ts create mode 100644 src/dev/innerKeepQaMain.tsx create mode 100644 src/dev/innerKeepQaScenarioManifest.d.mts create mode 100644 src/dev/innerKeepQaScenarioManifest.mjs create mode 100644 src/spacetime/innerKeepCommandIdempotency.ts create mode 100644 src/spacetime/innerKeepProjection.ts create mode 100644 src/spacetime/module_bindings/admin_activate_inner_keep_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/admin_backfill_inner_keep_builders_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/admin_deactivate_inner_keep_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/admin_get_inner_keep_status_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/admin_plan_inner_keep_builders_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/admin_plan_inner_keep_catalog_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/admin_seed_inner_keep_catalog_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/castle_inner_keep_building_v_1_table.ts create mode 100644 src/spacetime/module_bindings/get_my_inner_keep_request_status_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/get_my_inner_keep_state_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/inner_keep_build_level_v_1_table.ts create mode 100644 src/spacetime/module_bindings/inner_keep_building_catalog_v_1_table.ts create mode 100644 src/spacetime/module_bindings/inner_keep_layout_v_1_table.ts create mode 100644 src/spacetime/module_bindings/inner_keep_slot_v_1_table.ts create mode 100644 src/spacetime/module_bindings/inner_keep_start_project_v_1_reducer.ts create mode 100644 tests/WarpkeepSpacetimeInnerKeep.test.tsx create mode 100644 tests/fixtures/innerKeepPresentation.ts create mode 100644 tests/innerKeepAffordabilityReport.test.ts create mode 100644 tests/innerKeepCommandIdempotency.test.ts create mode 100644 tests/innerKeepConnection.test.ts create mode 100644 tests/innerKeepInputCssContract.test.ts create mode 100644 tests/innerKeepOperator.test.ts create mode 100644 tests/innerKeepPresentation.test.ts create mode 100644 tests/innerKeepPresentationLayoutManifest.test.ts create mode 100644 tests/innerKeepProjection.test.ts create mode 100644 tests/innerKeepQa.test.ts create mode 100644 tests/innerKeepRuntimeAssetSelection.test.ts create mode 100644 tests/innerKeepSceneLayer.test.ts create mode 100644 tests/innerKeepScreen.test.tsx diff --git a/ASSETS-LICENSE.md b/ASSETS-LICENSE.md index 2c1b59d3..b952ff67 100644 --- a/ASSETS-LICENSE.md +++ b/ASSETS-LICENSE.md @@ -461,6 +461,32 @@ placement, ownership, resources, rewards, pathing, or other game authority. Legacy Regular Tree variants have no authorized runtime collision; species records retain only their supplied trunk-only guidance. +## Pending Inner Keep 3D archive selection + +The public Warpkeep-Assets release +[`inner-keep-3d-asset-library-2026-08-02`](https://github.com/ael-dev3/Warpkeep-Assets/releases/tag/inner-keep-3d-asset-library-2026-08-02) +contains a 234,962,670-byte archive with SHA-256 +`f13bc9e7b8e32a6767b1959307a202d894123f384e11fd19550d75e0dfe5f6c9`. +Ael authorized its public archival and GitHub Release distribution. The release +does not grant one blanket open-content license, and runtime designation or +archive presence does not itself authorize live-game integration. + +Warpkeep records a proposed exact subset of 108 GLBs and four PNG previews at +selection digest +`6763aeb1755d800b817a0d5174182474d3836a928c59beb4b4fdf65f5d1f6ec3`. +The subset remains `LicenseRef-Warpkeep-Provenance-Required`. None of those +archive-only binaries is committed or served by this repository while exact +official repository/runtime-use authorization is pending. The selection does +not establish ownership, relicensing, general derivative or redistribution +rights, trademark or canonical-identity rights, merge, deployment, activation, +or same-named-file substitution. + +Three separately authorized existing tree families may be reused under their +existing dated record; that permission does not extend to the three newly +selected archive tree families. The complete allowlist, hashes, exclusions, +audit procedure, and exact owner decision still required are in the dated +[Inner Keep asset selection record](docs/reference/assets/2026-08-02-inner-keep-3d-library/). + ## Historical Hegemony Frontier Keep provenance The Hegemony Frontier Keep and its former runtime derivatives were project-provided media supplied for this repository. The 63 MB source remains byte-for-byte identifiable through its technical record but is not present in the current tree or a public release while redistribution authority is unresolved. The three former runtime derivatives are also retired and absent; their future license follows the source-rights determination, not the fact of conversion. diff --git a/CHANGELOG.md b/CHANGELOG.md index 303cef82..3cf4b276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ full engineering record. ## [Unreleased] +- Prepared the inactive Inner Keep V1 foundation as one server-authoritative + construction loop: twelve fixed slots, four five-level economy buildings, + one internal Builder, exact resource recipes, offline completion, and a + functional portrait-first fallback. +- Added an additive v15 schema with fail-closed rollout controls. A merge to + protected `main` triggers the existing verified Pages deployment, but the + compatible client remains dormant. Module publication, catalog seeding, + Builder backfill, asset authorization, and activation still require separate + owner review. The guarded publisher accepts only an explicitly selected + active-v14-to-inactive-v15 lane with exact preflight and postflight checks; + that source path does not itself authorize publication. +- Pinned a curated Inner Keep art allowlist to one exact asset release and kept + every archive-only GLB and preview out of the runtime until explicit owner + authorization is recorded. - Prepared an optional **Enable Admission Alerts** step after a confirmed access request. It remains fail-closed behind a default-off release gate until the owner-controlled signed notification canary is complete, and never affects diff --git a/dev/inner-keep-qa.html b/dev/inner-keep-qa.html new file mode 100644 index 00000000..4e9e8c4e --- /dev/null +++ b/dev/inner-keep-qa.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Warpkeep Local Inner Keep QA + + +
+ + + diff --git a/docs/design/inner-keep-construction.md b/docs/design/inner-keep-construction.md new file mode 100644 index 00000000..4eb7036f --- /dev/null +++ b/docs/design/inner-keep-construction.md @@ -0,0 +1,203 @@ +# Inner Keep construction V1 + +The Inner Keep turns gathered resources into visible, persistent growth inside +the player's own castle. This document fixes the Alpha V1 product and authority +contract. The compatible client may ship through the existing protected-main +Pages workflow, but the implementation remains inactive until separately +reviewed publication, catalog seed, Builder backfill, asset authorization, and +activation steps are complete. + +## Player loop + +1. Enter **Inner Keep** from the Realm menu or the player's own castle record. +2. Select one of eight medium construction slots. +3. Review one of four economy buildings, its exact cost, effect, and duration. +4. Confirm one construction or upgrade project. +5. The server settles stored resources, deducts the exact recipe, and occupies + the castle's one internal Builder. +6. A scaffolded worksite and construction shroud remain visible until the + authoritative completion is observed. +7. The completed building persists and discounts the matching resource in + later Inner Keep projects. + +The four external gathering Workers remain independent. V1 has no queue, +second Builder, speedup, cancellation, demolition, relocation, refund, wallet, +token, payment, or Community Mark spending. + +## Compound and slots + +The canonical layout contains one central keep, a gate and perimeter, a road +and plaza spine, eight medium economy-capable slots, and four large reserved +future slots. All twelve slots are visible from the first release. The browser +renders deterministic decorative scenery, but the server accepts only a fixed +slot ID and footprint class; it never accepts coordinates or transforms. + +The four large slots are visible, reserved, and non-actionable in V1. A castle +can contain at most one instance of each actionable building kind. + +The deterministic presentation manifest lives in +`src/components/inner-keep/innerKeepPresentationLayoutPolicy.ts`. It records the layout +ID and version, all twelve slot transforms, all 36 selected source asset IDs, +their exact content-addressed High/Balanced/Compact runtime paths, fixed and +slot-relative transforms, scale, footprint, picking and clearance roles, +quality availability, road/slot/wall clearances, and camera presets. Its +SHA-256 digest is +`96c20cac900e02234e53b36a15069d4e7c12057e5f1737c183f6c31cdb38b8b6`. + +The server's compact slot-policy digest includes that presentation digest, so +the client and activation tooling pin the combined layout digest +`dc314255b0046f5b43be836b52ab4b7af94a2d25992031f75aee89b1a81490c7`. +Decorative transforms remain client presentation data rather than database +rows, and the browser never sends a transform to construction authority. + +## Buildings + +| Building | Level-1 recipe (Food / Wood / Stone / Gold) | Completed effect | +| --- | ---: | --- | +| City Mill | 300 / 900 / 600 / 0 | Food construction cost −5% per level | +| Lumber Camp | 500 / 700 / 650 / 0 | Wood construction cost −5% per level | +| City Stoneworks | 500 / 900 / 450 / 0 | Stone construction cost −5% per level | +| City Goldworks | 700 / 1,200 / 1,000 / 500 | Gold construction cost −5% per level | + +Each building has five completed levels. Only a completed level grants an +effect. The matching discount is 500 basis points per completed level and is +capped at 2,500 basis points. A building's completed level applies to the +matching resource portion of its own next upgrade. Effects do not alter +passive production, Worker gathering, expeditions, existing balances, or Marks. + +## Costs and time + +Target-level multipliers are exact integers: + +| Target level | Multiplier | Duration | +| ---: | ---: | ---: | +| 1 | 10,000 bps | 24 hours | +| 2 | 22,400 bps | 48 hours | +| 3 | 37,632 bps | 78 hours | +| 4 | 56,197 bps | 112 hours | +| 5 | 78,676 bps | 151 hours | + +The documented, inactive continuation is 196, 247, 305, 370, and 444 hours +for Levels 6–10. V1 neither exposes nor accepts those levels. + +For each resource, the server multiplies the Level-1 base by the target-level +multiplier, divides by 10,000, and rounds upward to the next ten whole +resources. It then applies the matching completed-building discount and rounds +upward to ten again. All authority uses checked integer arithmetic. The exact +effective deduction is recorded in a caller-private receipt. + +The server starts the timer at the reducer timestamp. Construction continues +offline and finishes through a scheduled reducer. A later authoritative Inner +Keep entry read can reconcile exactly one caller-owned, canonical overdue +project when the component is active and the Worker backend is ready. Before +the deadline, a missing schedule fails closed without mutation. Deactivated +entry reads are also mutation-free. Browser clocks only present time remaining +and cannot grant completion. + +## Affordability audit + +`npm run report:inner-keep-affordability` checks all 28 combinations of the +four Level-1 buildings and seven current terrain-rate profiles. Starting from +zero stored resources, each recipe fits within one day of passive production +plus concurrent deliberate Worker gathering at the current one-resource-per- +minute rate. Travel is deliberately excluded from the minute estimate and must +remain a UI caveat; only resources already settled into the authoritative +account can enable Build. + +All four first choices remain reachable on every terrain without a completed +Inner Keep discount, so none is a mathematical prerequisite. Goldworks always +requires an explicit Gold assignment. The largest raw Level-5 resource +component is 9,450, below one percent of the 1,000,000 account cap. + +## Authority and privacy + +Public state contains only the fixed layout and catalog plus each castle's +building kind, slot, completed and target levels, phase, public timestamps, and +revision. The absence of a building row means the slot is empty. + +Caller-private state contains Builder ownership, the active project pointer, +exact receipts and request keys, deducted costs, and resource balances. A +player command supplies only a canonical slot ID, building kind, and bounded +idempotency key. SpacetimeDB derives the FID, castle, level, costs, discounts, +time, and outcome. + +Starting a project first materializes authoritative Worker/resource settlement +while preserving active expedition reservation capacity. It then validates the +single Builder, slot, footprint, uniqueness, level cap, and stored balances. +Resource deduction, project state, Builder state, schedule, and receipt commit +as one transaction or not at all. Replaying the same accepted request returns +the same result and cannot deduct twice. + +## Presentation contract + +Portrait Mini App presentation is primary: header and resources at the top, +the compound in the main area, one reachable Builder bar at the bottom, and a +contextual sheet for slot, build, and upgrade details. Desktop uses the same +controller with a persistent right-side panel. + +Every slot and building has a native accessible control independent of WebGL +picking. If 3D initialization or a required asset fails, a functional 12-slot +schematic retains the same catalog, resources, Builder timer, and guarded +actions. Reduced motion uses a static construction shroud. + +Accepted slot and build-menu routes use one bounded light host cue. A newly +observed authoritative project uses one restrained confirmation cue, and a +constructing-to-complete transition uses one success cue while the player is +viewing. Repeated gestures are suppressed, the effects mute setting also mutes +these haptics, and visible plus screen-reader feedback never depends on them. + +The world and Inner Keep share the existing Realm canvas, renderer, quality +preference, recovery path, and animation scheduler. Entering the Inner Keep +must not create another WebGL context or animation loop. The finished model is +not instantiated visibly while the public project is constructing. If the +model is not ready at completion, the worksite remains until it is safe to +reveal without an empty or white frame. + +## Asset boundary + +The selected source is the exact +`inner-keep-3d-asset-library-2026-08-02-v1.zip` release attachment, 234,962,670 +bytes with SHA-256 +`f13bc9e7b8e32a6767b1959307a202d894123f384e11fd19550d75e0dfe5f6c9`. +The reviewed selection contains 36 selected assets across five families, 108 +High/Balanced/Compact GLBs, and four previews. Its language-neutral parsed-JSON digest is +`6763aeb1755d800b817a0d5174182474d3836a928c59beb4b4fdf65f5d1f6ec3`. +The bounded installer verifies the attachment and extracts only that allowlist +into content-addressed runtime paths. + +The presentation manifest records those paths as planned data for review; it +does not install, fetch, or authorize an asset. The current procedural scene +remains the fail-closed visual fallback while runtime use is pending. An +authorized asset integration must implement the same pinned transforms and +combined digest rather than inventing an unreviewed placement at load time. + +The release record does not itself authorize copying archive-only files into +the public Warpkeep runtime. No selected GLB or preview may be committed until +an exact owner authorization record covers the public repository and official +runtime. This is an integration-use gate, not a relicensing request. + +## Release gates + +`npm run qa:inner-keep` runs the dedicated local-only synthetic presentation +matrix. Its 16 cases cover an empty compound, completed Levels 1–5, +construction at 1/50/99 percent, the authoritative completion reveal, occupied +Builder and insufficient-resource states, compact quality, reduced motion, +missing optional art, and the functional 2D fallback. The browser probe reuses +the reviewed signed-Chrome, private DevTools-pipe, and exact-loopback guards. +It captures only synthetic screenshots in memory and reports aggregate counts +and booleans. WebGL cases require one renderer, one context, one animation +owner, twelve scene pads, and twelve native controls; the 2D case requires no +WebGL allocation and the same twelve controls. + +The page at `/dev/inner-keep-qa.html` is a Vite-development entry guarded by +`assertLocalQaRuntime`. It is not a production build input, and the production +output verifier rejects its path, source markers, and scenario manifest. + +Merging source code to protected `main` triggers the existing verified Pages +deployment, with the Inner Keep entry still hidden. It does not activate +construction. Publication, catalog seed, Builder backfill, activation, smoke +testing, and any asset-use authorization remain separate owner-reviewed +actions. The rollback is forward-safe: deactivate new starts, hide entry, +retain every row and deducted resource, and allow valid active schedules to +finish or be repaired through a separately reviewed path. There is no +destructive schema rollback. diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md index a9530693..bbf9a18d 100644 --- a/docs/design/roadmap.md +++ b/docs/design/roadmap.md @@ -26,20 +26,28 @@ world foundation and visual representation of what is being built. ## Now building -The next useful slice rolls out the prepared four-worker controls and gives -gathered resources a small, understandable purpose: +The next useful slice gives gathered resources a small, understandable purpose +inside each player's own castle: -1. make expedition progress and return clearer; -2. add one durable construction or keep-improvement choice; -3. let its result remain visible in the shared world; -4. test whether that loop is enjoyable before adding breadth. +1. enter a twelve-slot Inner Keep compound without leaving the Realm session; +2. spend stored resources on one of four server-priced economy buildings; +3. let one internal Builder complete construction while the player is away; +4. keep the completed level visible and use its matching resource discount; +5. test whether that loop is enjoyable before adding breadth. -Buildings, units, and other map systems remain design work until they are +This work is prepared behind an inactive component gate. A merge to protected +`main` triggers the existing verified Pages deployment of the compatible, +dormant client. It does not publish the schema, seed the catalog, backfill +Builders, authorize archive assets, activate construction, or make the loop +playable. Units and other map systems remain design work until they are intentionally released. -## After the resource loop +The exact inactive V1 policy and release gates are documented in +[Inner Keep construction V1](inner-keep-construction.md). -- construction and upgrades with server-owned costs and queues; +## After the Inner Keep loop + +- more construction choices and deliberately bounded queue options; - unit training, scouting, travel, and map visibility; - defenses, raids, and bounded combat; - alliances, diplomacy, trading, chat, and seasons; diff --git a/docs/operations/inner-keep-activation.md b/docs/operations/inner-keep-activation.md new file mode 100644 index 00000000..a70467d7 --- /dev/null +++ b/docs/operations/inner-keep-activation.md @@ -0,0 +1,206 @@ +# Inner Keep activation runbook + +This runbook describes a future owner-reviewed rollout. It does not authorize +publication, seeding, backfill, deployment, activation, production gameplay, +or asset use. Every mutating command is a dry run unless `--confirm` is supplied +after reviewing the exact preflight. + +The repository's guarded publisher exposes one protocol-v15 lane: the exact +active-v14 predecessor to an inactive v15 append. Selecting that source lane +does not authorize its use. It still requires owner review, the exact +production expectations and private credential, a successful network-read-only +dry run, and a separately confirmed publication operation. + +## Required approvals and inputs + +- reviewed Warpkeep source commit and module artifact digest; +- current production database identity and the exact v14 predecessor proof; +- additive v15 migration proof with refs 0–55 unchanged; +- compatible client artifact and Inner Keep catalog/layout digest; +- exact selected-asset runtime authorization and registry digest; +- owner/legal accuracy review of the then-current Alpha Terms and Privacy + Notice, without treating this runbook as acceptance of a new legal bundle; +- owner approval for each publication, seed, backfill, deploy, and activation; +- short-lived Hermes administrator credential through the private operator + input path. + +Never place credentials, FIDs, balances, receipts, request keys, private rows, +or raw production output in a public record. + +This inactive source PR does not change the accepted legal bundle. The current +Privacy Notice already describes public gameplay projections and private game +authority records. The current Alpha Terms also say the larger gameplay loop is +not implemented, so their factual accuracy must be reviewed before construction +is ever made live. Any legal-text update and fresh acceptance remain separate +owner/legal decisions. + +## Review sequence + +1. Run the complete repository, module, migration, bindings, asset, Mini App, + rendered, accessibility, and local full-stack checks. +2. When separately approved, merge the exact source to protected `main`. The + existing workflow then deploys the verified compatible client to Pages with + the Inner Keep entry still hidden. +3. Verify the deployed client attestation, runtime registry, functional 2D + fallback, and disabled command behavior. +4. Inspect production with the counts-only Inner Keep command. Require no v15 + rows before the first additive publication. +5. After separate owner review, use the dedicated v15 publication lane to + publish the exact proven module with data deletion disabled. Do not seed, + backfill, or activate as a side effect. +6. Reinspect. Require the v14 prefix and all historical counts unchanged and + all v15 tables empty. +7. Seed the exact inactive layout, twelve slots, four building catalog rows, + and twenty level-policy rows. Require the reviewed digests and no duplicates. +8. Backfill exactly one idle private Builder for each existing founded castle. + Require no resource, building, schedule, receipt, Worker, Mark, castle, or + Terms mutation. +9. Activate the component in a separate confirmed operation only when the + catalog, Builder graph, schedules, runtime registry, and client attestation + are exact. +10. Run one disposable local full-stack founder journey. Any production smoke + remains a separate owner decision. + +## Mutating command contract + +Each future seed, backfill, activation, or deactivation command requires: + +- the exact database identity and module protocol; +- exact current and expected aggregate counts; +- a fresh preflight and postflight; +- `--confirm` for mutation; +- deletion disabled; +- a counts-only local result plus the corresponding private admin audit row; +- fail-closed behavior on ambiguity or timeout. + +Activation additionally binds the expected source commit, client release, and +client/module artifact digests. The other commands bind their command-specific +policy or layout digests where applicable. + +Do not blindly retry an ambiguous mutation. Reinspect the exact aggregate state +and reconcile the previous result first. + +## Guarded schema publication lane + +The only accepted v15 selection is explicit on both boundaries: + +```sh +npm run stdb:publish:dev -- \ + --dry-run \ + --resource-rollout-stage=ready \ + --genesis-world-stage=expanded \ + --worker-rollout-stage=active \ + --worker-module-predecessor=exact-v14-active \ + --worker-forward-repair=none \ + --inner-keep-module-predecessor=exact-v14-active \ + --inner-keep-publication-stage=append-inactive +``` + +The dry run verifies the pinned CLI and migration artifact, canonical issuer +and database identity, exact v14 schema and ABI, and the protected historical +aggregates. Its network operations are reads; it never calls `spacetime +publish`. A real publication additionally requires the existing explicit +database confirmation and repeats every preflight before invoking the sole +publisher with `--delete-data=never`. + +After a successful publish response, the lane requires all refs 0–55 and their +signatures to remain exact, refs 56–63 to match the reviewed public/private v15 +contracts, the Worker and Inner Keep ABIs to be complete, every historical +aggregate to equal its preflight value, and every new Inner Keep table to be +empty with the component inactive. Historical comparison covers the combined +protocol/resource/Worker envelope, active Daily Marks status, and counts-only +private access-request totals. An ambiguity, timeout, changed count, ABI drift, +non-empty v15 table, or active component stops the lane. It never seeds the +catalog, backfills Builders, deploys a client, or activates Inner Keep. + +## Source-only operator + +The checked-in operator exposes the review sequence as separate commands: + +```sh +npm run stdb:inner-keep:inspect +npm run stdb:inner-keep:plan-catalog +npm run stdb:inner-keep:seed-catalog -- \ + --expected-missing-layout 1 \ + --expected-missing-slots 12 \ + --expected-missing-buildings 4 \ + --expected-missing-levels 20 +npm run stdb:inner-keep:plan-builders +npm run stdb:inner-keep:backfill-builders -- \ + --expected-castles \ + --expected-existing-builders \ + --expected-missing-builders +``` + +The mutation commands above only print a counts-and-digests dry-run record. +They do not request a credential or contact SpacetimeDB until `--confirm` is +added. Copy expected counts from a fresh plan. Supply the short-lived Hermes +secret only through the existing private stdin contract. Never place it in an +argument, log, shell history, or public evidence. + +### Exact protected-state evidence + +Every confirmed seed, Builder backfill, activation, and deactivation uses the +exact reviewed SpacetimeDB CLI to take private fixed-query snapshots immediately +before and after the reducer or confirmed no-op boundary. The evidence covers +all castle and claim rows +(including castle level and state), resource accounts, Alpha Terms acceptance, +the generic four-Worker system, roster, assignments and their reservation +inputs, occupations, schedules, command receipts, and every Marks account, +projection, credit, grant, and schedule row. All fifteen table outputs must be +byte-for-byte identical. + +Raw rows and reversible snapshot bytes stay in operator memory. They are never +written to stdout, stderr, receipts, or audit notes. The printable result carries +only a structured `inner-keep-protected-state-proof-v1` record: the table count, +one verified boolean for each protected surface, and confirmation that no private +rows were emitted. The CLI receives its token only through a temporary +mode-`0600` config inside a mode-`0700` directory, and the operator removes that +directory on exit. + +These are sequential operator-side reads, not one atomic database snapshot. Run +confirmed mutations only during a reviewed quiet window. A concurrent Worker or +Marks schedule can therefore make the evidence ambiguous even when the Inner +Keep reducer behaved correctly. Any changed table, unsafe or incomplete read, +timeout, or oversized output fails closed; stop and inspect before any retry. +Dry runs still return before credential access, network access, or snapshotting. + +Future activation also requires `--expected-castles`, `--client-release`, +`--client-artifact-digest`, `--module-artifact-digest`, and `--source-commit`. +Even with `--confirm`, it fails before reading a credential or using the network +until the exact runtime-use authorization is recorded, all selected files form +the exact runtime registry, and the local artifacts come from clean protected +`main`. Deactivation similarly binds `--expected-castles` and +`--expected-active-projects` to a fresh preflight. The deactivation reducer +rechecks both counts and the active state in its own transaction, immediately +before changing the layout. A project or castle committed after inspection +therefore makes the request fail without mutation. This PR does not satisfy or +exercise any of those production gates. + +## Activation invariants + +- one active layout with the exact catalog and layout digests; +- twelve exact slots: eight medium and four reserved large; +- four exact active building kinds and five level policies per kind; +- one private idle Builder for every founded castle; +- zero duplicate castle/slot or castle/building assignments; +- zero orphan or mismatched schedules; +- zero active projects at first activation; +- the current generic four-Worker system active and legacy expeditions drained; +- exact compatible client and runtime asset registry attestations; +- no asset with unresolved runtime-use authorization. + +## Deactivation and recovery + +Deactivation stops new starts and hides player entry. It does not delete rows, +refund resources, rewrite levels, cancel projects, alter Workers, change Marks, +or roll back the schema. Existing valid schedules may complete. A missing exact +schedule before its deadline, or while construction is deactivated, remains a +forward-fix blocker. No repair operator exists in this release. Once an exact +caller-owned project is overdue, an active and Worker-ready Inner Keep entry +read may complete it transactionally after proving the project and Builder +correlation; corrupt schedule state always fails closed. + +If publication or an operator call times out, stop. Read the anonymous schema +and counts-only status, compare them with the private admin audit and local +result, and choose a forward action only after the state is unambiguous. diff --git a/docs/operations/reconstruction/asset-pipeline.md b/docs/operations/reconstruction/asset-pipeline.md index 1f07c70f..5d61955f 100644 --- a/docs/operations/reconstruction/asset-pipeline.md +++ b/docs/operations/reconstruction/asset-pipeline.md @@ -58,6 +58,35 @@ Then verify checksums, manifest entry parity, path safety, and every GLB: npx --yes @gltf-transform/cli@4.4.1 validate ``` +## Pending Inner Keep archive selection + +The [`inner-keep-3d-asset-library-2026-08-02`](https://github.com/ael-dev3/Warpkeep-Assets/releases/tag/inner-keep-3d-asset-library-2026-08-02) +release remains an archive source, not a browser CDN. Warpkeep records an exact +allowlist of 36 assets: 108 High/Balanced/Compact GLBs and four building-card +PNGs. The complete 234,962,670-byte archive, unselected media, editable sources, +and inspection models remain outside this repository. + +Audit an exact trusted local copy without writing runtime files: + +```sh +WARPKEEP_INNER_KEEP_ARCHIVE=/trusted/offline/inner-keep-3d-asset-library-2026-08-02-v1.zip \ +WARPKEEP_INNER_KEEP_RELEASE_MANIFEST=/trusted/offline/manifest.json \ + npm run assets:audit:inner-keep +npm run verify:inner-keep-assets +``` + +The audit pins the release and selection digests, validates all 1,347 ZIP +members against the trusted release manifest, rejects unsafe or non-regular +members, and verifies every selected source manifest, GLB, and PNG. It uses no +network and extracts selected bytes only into a temporary private directory. + +`npm run prepare:inner-keep-assets` is the intentionally fail-closed future +installation path. It cannot install while the provenance record says official +repository/runtime use is unauthorized. An explicit owner decision must be +recorded and the selection contract and runtime verifier reviewed together +before that gate may change. The exact selection and required decision are in +the [dated Inner Keep asset record](../../reference/assets/2026-08-02-inner-keep-3d-library/). + ## Active runtime models | File | Bytes | SHA-256 | diff --git a/docs/reference/assets/2026-08-02-inner-keep-3d-library/README.md b/docs/reference/assets/2026-08-02-inner-keep-3d-library/README.md new file mode 100644 index 00000000..2dbba6c6 --- /dev/null +++ b/docs/reference/assets/2026-08-02-inner-keep-3d-library/README.md @@ -0,0 +1,85 @@ +# Inner Keep 3D asset selection record + +This directory records the exact, reviewed subset of the Warpkeep Inner-Keep +3D Asset Library proposed for the Inner Keep. It is an allowlist and provenance +record, not a runtime-use grant. No archive model or preview is committed to +Warpkeep by this record. + +## Pinned source + +| Coordinate | Pinned value | +| --- | --- | +| Repository | [`ael-dev3/Warpkeep-Assets`](https://github.com/ael-dev3/Warpkeep-Assets) | +| Repository main commit | `b074ffb6317ff9a581f5b7fc7f0a0760e721a9b6` | +| Release commit | `74033ebffb7f0a3ec371ccdabac10974bbe413b9` | +| Release | [`inner-keep-3d-asset-library-2026-08-02`](https://github.com/ael-dev3/Warpkeep-Assets/releases/tag/inner-keep-3d-asset-library-2026-08-02) | +| Attachment | `inner-keep-3d-asset-library-2026-08-02-v1.zip` | +| Attachment size | 234,962,670 bytes | +| Attachment SHA-256 | `f13bc9e7b8e32a6767b1959307a202d894123f384e11fd19550d75e0dfe5f6c9` | +| Trusted release manifest SHA-256 | `67a31bee8c63718143a9071e9cb906f2229b9776c55d9d6fce3fd87bf2f032ae` | +| Selection digest | `6763aeb1755d800b817a0d5174182474d3836a928c59beb4b4fdf65f5d1f6ec3` | + +The [machine-readable selection](manifest.json) pins every selected source +member, byte count, SHA-256 digest, triangle count, bounds, and proposed +content-addressed destination. + +The selection digest hashes the parsed JSON projection `{ selectionId, +sourceRelease, profiles, assets, existingRuntimeReuse }`. Object keys are +sorted recursively, array order is retained, and ECMAScript JSON serialization +defines string and number encoding. This avoids generator-specific distinctions +such as `9` versus `9.0`, which are the same JSON number after parsing. + +## Reviewed subset + +| Family | Assets | +| --- | ---: | +| Economy buildings | 4 | +| Palisade pieces | 6 | +| Town items | 19 | +| Stone and ruin pieces | 4 | +| New tree assets | 3 | +| Existing authorized tree families reused | 3 | + +The new subset contains 108 GLBs: High, Balanced, and Compact for each of 36 +assets. It also contains four 320×320 building-card previews. The selected +models total 11,316,348 bytes across all three profiles. The selected previews +total 634,685 bytes. `LOD3_Map`, Barracks, Cathedral, inspection/catalogue +models, editable sources, tools, and every unlisted asset remain excluded. + +## Authorization gate + +The source release authorizes public archival and GitHub Release distribution. +It does not separately authorize copying archive-only media into the public +Warpkeep repository or official runtime. The supplied product prompt expressly +is not a relicensing grant, so installation remains blocked. + +An owner decision must name this exact use before the record can change. A +sufficient narrowly scoped record would be: + +> I authorize the 108 GLBs and four PNG previews identified by Inner Keep +> selection digest `6763aeb1755d800b817a0d5174182474d3836a928c59beb4b4fdf65f5d1f6ec3` +> from Warpkeep-Assets release `inner-keep-3d-asset-library-2026-08-02` to be +> copied into the public `ael-dev3/Warpkeep` repository and served by the +> official `warpkeep.com` runtime at their recorded content-addressed paths. +> This is a runtime-use authorization only. It does not approve merge, +> deployment, activation, broader redistribution, relicensing, or substitution. + +After that decision is preserved in reviewable project history, update the +authorization fields in `manifest.json` and the corresponding verifier +expectations together. Never bypass only the installer check. + +## Local audit + +Place the exact archive and trusted release manifest in a private local cache, +or point the audit at trusted offline files: + +```sh +WARPKEEP_INNER_KEEP_ARCHIVE=/trusted/offline/inner-keep-3d-asset-library-2026-08-02-v1.zip \ +WARPKEEP_INNER_KEEP_RELEASE_MANIFEST=/trusted/offline/manifest.json \ + npm run assets:audit:inner-keep +``` + +The audit validates the full ZIP member set and extracts only the allowlisted +members into a temporary private directory for verification. It writes no +runtime output. `npm run prepare:inner-keep-assets` intentionally fails before +reading the archive while runtime-use authorization remains pending. diff --git a/docs/reference/assets/2026-08-02-inner-keep-3d-library/manifest.json b/docs/reference/assets/2026-08-02-inner-keep-3d-library/manifest.json new file mode 100644 index 00000000..e267bb3d --- /dev/null +++ b/docs/reference/assets/2026-08-02-inner-keep-3d-library/manifest.json @@ -0,0 +1,2243 @@ +{ + "schema": "warpkeep.inner-keep-asset-selection.v1", + "selectionId": "inner-keep-v1", + "selectionDigestSha256": "6763aeb1755d800b817a0d5174182474d3836a928c59beb4b4fdf65f5d1f6ec3", + "selectionDigestAlgorithm": "sha256-ecmascript-canonical-json-v1", + "recordedAt": "2026-08-03", + "sourceRelease": { + "repository": "ael-dev3/Warpkeep-Assets", + "repositoryMainCommit": "b074ffb6317ff9a581f5b7fc7f0a0760e721a9b6", + "releaseCommit": "74033ebffb7f0a3ec371ccdabac10974bbe413b9", + "tag": "inner-keep-3d-asset-library-2026-08-02", + "attachment": { + "name": "inner-keep-3d-asset-library-2026-08-02-v1.zip", + "bytes": 234962670, + "sha256": "f13bc9e7b8e32a6767b1959307a202d894123f384e11fd19550d75e0dfe5f6c9", + "entries": 1347, + "packageRoot": "inner-keep-3d-asset-library-2026-08-02-v1" + }, + "trustedReleaseManifest": { + "repositoryPath": "releases/inner-keep-3d-asset-library-2026-08-02/manifest.json", + "bytes": 552321, + "sha256": "67a31bee8c63718143a9071e9cb906f2229b9776c55d9d6fce3fd87bf2f032ae" + } + }, + "authorization": { + "archiveDistributionAuthorized": true, + "officialRepositoryRuntimeUseAuthorized": false, + "status": "pending-owner-runtime-use-authorization", + "scopeBoundary": "Archive deposit and release distribution are recorded. Copying selected archive-only models or previews into the public Warpkeep repository or official runtime remains blocked until an explicit owner authorization record names that use." + }, + "selectionPolicy": { + "profiles": [ + "high", + "balanced", + "compact" + ], + "sourceAssetCount": 36, + "sourceModelCount": 108, + "sourcePreviewCount": 4, + "excludedProfiles": [ + "LOD3_Map" + ], + "excludedPackages": [ + "Warpkeep_CityBarracks_GameReady", + "Warpkeep_GrandCovenantCathedral_GameReady" + ], + "inspectionCataloguesAllowed": false, + "editableSourcesAllowed": false, + "ordinaryBuildMayReadArchive": false, + "ordinaryBuildMayUseNetwork": false + }, + "qualityTotals": { + "high": { + "bytes": 5891104, + "triangles": 63034 + }, + "balanced": { + "bytes": 3634852, + "triangles": 37156 + }, + "compact": { + "bytes": 1790392, + "triangles": 15445 + } + }, + "selectedModelBytesAllProfiles": 11316348, + "selectedPreviewBytes": 634685, + "existingRuntimeReuse": [ + { + "assetId": "warpkeep.tree.oak.spring-broad", + "sourceRecord": "docs/reference/assets/2026-07-18-hegemony-environment-trees/manifest.json", + "intendedUse": "Existing authorized broad perimeter tree" + }, + { + "assetId": "warpkeep.tree.spruce.deep-narrow", + "sourceRecord": "docs/reference/assets/2026-07-18-hegemony-environment-trees/manifest.json", + "intendedUse": "Existing authorized dense edge evergreen" + }, + { + "assetId": "warpkeep.tree.cypress.ancient-dark", + "sourceRecord": "docs/reference/assets/2026-07-18-hegemony-environment-trees/manifest.json", + "intendedUse": "Existing authorized narrow inner/perimeter tree" + } + ], + "assets": [ + { + "id": "city-mill", + "family": "buildings", + "sourcePackage": "Warpkeep_CityMill_GameReady", + "sourceAssetId": "warpkeep.city-buildings.city-mill", + "displayName": "Hegemony Crownwheel Mill", + "intendedUse": "Food economy building", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityMill_GameReady/Runtime/runtime-manifest.json", + "bytes": 3075, + "sha256": "bd787111bd3f813eef3b4d957d32e14935c8e06bd12fcd01ad3d38098352c52d" + }, + "boundsMeters": [ + 9.3, + 7.1229, + 7.5 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityMill_GameReady/Runtime/Warpkeep_CityMill_LOD0_High_Runtime.glb", + "bytes": 478660, + "sha256": "8613faf8ac5a61f50cb6464cc9c2c8391f9f12fc087c2419cc5ac5827e905a8c", + "triangles": 4900, + "boundsMeters": [ + 9.3, + 7.1229, + 7.5 + ], + "destinationPath": "public/models/hegemony/inner-keep/buildings/inner-keep-city-mill-high-8613faf8ac5a61f5.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityMill_GameReady/Runtime/Warpkeep_CityMill_LOD1_Balanced_Runtime.glb", + "bytes": 293168, + "sha256": "0963ee142eb0d8ea711aac7594610eade84eebd2eb3540868bb7ca7c07474cd7", + "triangles": 2792, + "boundsMeters": [ + 9.3, + 7.1229, + 7.5 + ], + "destinationPath": "public/models/hegemony/inner-keep/buildings/inner-keep-city-mill-balanced-0963ee142eb0d8ea.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityMill_GameReady/Runtime/Warpkeep_CityMill_LOD2_Compact_Runtime.glb", + "bytes": 121080, + "sha256": "80419c9566ea5ab5053f8d8f411655fe1b9ee26a040333bb380ff7d4d6d3a322", + "triangles": 1224, + "boundsMeters": [ + 9.3, + 7.1229, + 7.5 + ], + "destinationPath": "public/models/hegemony/inner-keep/buildings/inner-keep-city-mill-compact-80419c9566ea5ab5.glb" + } + ], + "preview": { + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityMill_GameReady/Previews/Warpkeep_CityMill_MobilePreview_320.png", + "bytes": 160025, + "sha256": "717cf59d3bba14858b49dc7fb692066285d7ab9b27f6dfeae1b2238d542cdb02", + "width": 320, + "height": 320, + "destinationPath": "public/images/inner-keep/catalog/city-mill-717cf59d3bba1485.png" + } + }, + { + "id": "lumber-camp", + "family": "buildings", + "sourcePackage": "Warpkeep_LumberCamp_GameReady", + "sourceAssetId": "warpkeep.city-buildings.lumber-camp", + "displayName": "Hegemony Timberwright Yard", + "intendedUse": "Wood economy building", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_LumberCamp_GameReady/Runtime/runtime-manifest.json", + "bytes": 2526, + "sha256": "59f82b995f815f69bba8958fa8afa329b97686c451f227f6378f6e0869fa4c6e" + }, + "boundsMeters": [ + 8.6, + 6.05, + 6.8 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_LumberCamp_GameReady/Runtime/Warpkeep_LumberCamp_LOD0_High_Runtime.glb", + "bytes": 532928, + "sha256": "a4f6831dd6ddcb0956b6338b6d206985928f6d67443122ec7766390778b84191", + "triangles": 5712, + "boundsMeters": [ + 8.6, + 6.05, + 6.8 + ], + "destinationPath": "public/models/hegemony/inner-keep/buildings/inner-keep-lumber-camp-high-a4f6831dd6ddcb09.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_LumberCamp_GameReady/Runtime/Warpkeep_LumberCamp_LOD1_Balanced_Runtime.glb", + "bytes": 314132, + "sha256": "d8f33e487545f44afc28a770466c3503957e6f4658e39034540f50ce67f52c7a", + "triangles": 2954, + "boundsMeters": [ + 8.6, + 6.05, + 6.8 + ], + "destinationPath": "public/models/hegemony/inner-keep/buildings/inner-keep-lumber-camp-balanced-d8f33e487545f44a.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_LumberCamp_GameReady/Runtime/Warpkeep_LumberCamp_LOD2_Compact_Runtime.glb", + "bytes": 116200, + "sha256": "1be188b3bb6a90b5362b2fd1a30d6ce292de333d0a1f6e688743ced43174b6bf", + "triangles": 1082, + "boundsMeters": [ + 8.6, + 6.05, + 6.8 + ], + "destinationPath": "public/models/hegemony/inner-keep/buildings/inner-keep-lumber-camp-compact-1be188b3bb6a90b5.glb" + } + ], + "preview": { + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_LumberCamp_GameReady/Previews/Warpkeep_LumberCamp_MobilePreview_320.png", + "bytes": 159403, + "sha256": "d180ce53c9573f7f7e7969dbd46fbacc1c4bca1c5f8aa090a1804f86d98bcfea", + "width": 320, + "height": 320, + "destinationPath": "public/images/inner-keep/catalog/lumber-camp-d180ce53c9573f7f.png" + } + }, + { + "id": "city-stoneworks", + "family": "buildings", + "sourcePackage": "Warpkeep_CityStoneworks_GameReady", + "sourceAssetId": "warpkeep.city-buildings.city-stoneworks", + "displayName": "Hegemony Royal Mason's Yard", + "intendedUse": "Stone economy building", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityStoneworks_GameReady/Runtime/runtime-manifest.json", + "bytes": 3237, + "sha256": "e53e15683d40f1d346b14df054cd3b09463f8c00c5e5fdef3f15057cf8d3b03f" + }, + "boundsMeters": [ + 9.0, + 5.39, + 7.2 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityStoneworks_GameReady/Runtime/Warpkeep_CityStoneworks_LOD0_High_Runtime.glb", + "bytes": 508540, + "sha256": "01df9557bccaed14b44bcc0efa4971283f08632572aa892c1e6fb6261f22a913", + "triangles": 4900, + "boundsMeters": [ + 9.0, + 5.39, + 7.2 + ], + "destinationPath": "public/models/hegemony/inner-keep/buildings/inner-keep-city-stoneworks-high-01df9557bccaed14.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityStoneworks_GameReady/Runtime/Warpkeep_CityStoneworks_LOD1_Balanced_Runtime.glb", + "bytes": 305380, + "sha256": "81f8818339900c2f50329e1a9f593600afe916d32b89eee2295121b308005b73", + "triangles": 2792, + "boundsMeters": [ + 9.0, + 5.39, + 7.2 + ], + "destinationPath": "public/models/hegemony/inner-keep/buildings/inner-keep-city-stoneworks-balanced-81f8818339900c2f.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityStoneworks_GameReady/Runtime/Warpkeep_CityStoneworks_LOD2_Compact_Runtime.glb", + "bytes": 134400, + "sha256": "174fd2f984065937d5a4a22800512047c064b528ce0b1e3a448f461574be61cc", + "triangles": 1224, + "boundsMeters": [ + 9.0, + 5.39, + 7.2 + ], + "destinationPath": "public/models/hegemony/inner-keep/buildings/inner-keep-city-stoneworks-compact-174fd2f984065937.glb" + } + ], + "preview": { + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityStoneworks_GameReady/Previews/Warpkeep_CityStoneworks_MobilePreview_320.png", + "bytes": 157575, + "sha256": "e4c562fc94705fc71b1e77f7b3911ce88854464e24a72f45a5bfa07c88260f07", + "width": 320, + "height": 320, + "destinationPath": "public/images/inner-keep/catalog/city-stoneworks-e4c562fc94705fc7.png" + } + }, + { + "id": "city-goldworks", + "family": "buildings", + "sourcePackage": "Warpkeep_CityGoldworks_GameReady", + "sourceAssetId": "warpkeep.city-buildings.city-goldworks", + "displayName": "Hegemony Royal Goldworks", + "intendedUse": "Gold economy building", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityGoldworks_GameReady/Runtime/runtime-manifest.json", + "bytes": 3287, + "sha256": "5beca683daac80bb28e9574e0c444a76012b2cbb6a01c23d8212af7649b7ad5d" + }, + "boundsMeters": [ + 9.0, + 5.39, + 7.2 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityGoldworks_GameReady/Runtime/Warpkeep_CityGoldworks_LOD0_High_Runtime.glb", + "bytes": 528552, + "sha256": "9cd4a9851ce291a95d01451f005eec0981bfc5921691a6c24184b6039fbad589", + "triangles": 4900, + "boundsMeters": [ + 9.0, + 5.39, + 7.2 + ], + "destinationPath": "public/models/hegemony/inner-keep/buildings/inner-keep-city-goldworks-high-9cd4a9851ce291a9.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityGoldworks_GameReady/Runtime/Warpkeep_CityGoldworks_LOD1_Balanced_Runtime.glb", + "bytes": 304152, + "sha256": "662fa7a5983818cf6f191c69c74e1987e15bdbf65c0c56c8603c6131f1a93248", + "triangles": 2792, + "boundsMeters": [ + 9.0, + 5.39, + 7.2 + ], + "destinationPath": "public/models/hegemony/inner-keep/buildings/inner-keep-city-goldworks-balanced-662fa7a5983818cf.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityGoldworks_GameReady/Runtime/Warpkeep_CityGoldworks_LOD2_Compact_Runtime.glb", + "bytes": 135864, + "sha256": "ed47ade5517e78da64d2cecb7b93ae90af52fc47869deee1621d73fdf1854c5d", + "triangles": 1224, + "boundsMeters": [ + 9.0, + 5.39, + 7.2 + ], + "destinationPath": "public/models/hegemony/inner-keep/buildings/inner-keep-city-goldworks-compact-ed47ade5517e78da.glb" + } + ], + "preview": { + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityGoldworks_GameReady/Previews/Warpkeep_CityGoldworks_MobilePreview_320.png", + "bytes": 157682, + "sha256": "4b0caa06c4ffb1e8871ec8c8044ce108faa6b345bf73d6365eb5cb8fa52aff79", + "width": 320, + "height": 320, + "destinationPath": "public/images/inner-keep/catalog/city-goldworks-4b0caa06c4ffb1e8.png" + } + }, + { + "id": "palisade-wall-straight-4m", + "family": "palisade", + "sourcePackage": "Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady", + "sourceAssetId": "warpkeep.inner_keep.wood_palisade.W02", + "displayName": "Palisade Wall Straight 4 m", + "intendedUse": "Perimeter wall infill", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeWall_Straight4m/runtime-manifest.json", + "bytes": 10387, + "sha256": "06b7f76f7ba369116774e6144a2c3c6f7a5302c5ac351455989bad42dc5a4839" + }, + "boundsMeters": [ + 4.0, + 3.2, + 0.65 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeWall_Straight4m/Warpkeep_W02_LOD0_High_Runtime.glb", + "bytes": 70956, + "sha256": "00cdf8624e12b594303c749d777e248e031effe3831bae88641e8c90852c9263", + "triangles": 608, + "boundsMeters": [ + 4.0, + 3.2, + 0.65 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-4m-high-00cdf8624e12b594.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeWall_Straight4m/Warpkeep_W02_LOD1_Balanced_Runtime.glb", + "bytes": 39960, + "sha256": "c16ecc1c5ea8adba8a987256ef37964f426ecf026148ebeccd0a021a01ceb8fa", + "triangles": 336, + "boundsMeters": [ + 4.0, + 3.2, + 0.65 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-4m-balanced-c16ecc1c5ea8adba.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeWall_Straight4m/Warpkeep_W02_LOD2_Compact_Runtime.glb", + "bytes": 27636, + "sha256": "d0d33e6da38ed1f73e60903a81bd52da99e4d093a672bfe801bbb54d13349156", + "triangles": 228, + "boundsMeters": [ + 4.0, + 3.2, + 0.65 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-4m-compact-d0d33e6da38ed1f7.glb" + } + ] + }, + { + "id": "palisade-wall-straight-8m", + "family": "palisade", + "sourcePackage": "Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady", + "sourceAssetId": "warpkeep.inner_keep.wood_palisade.W03", + "displayName": "Palisade Wall Straight 8 m", + "intendedUse": "Long perimeter wall run", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeWall_Straight8m/runtime-manifest.json", + "bytes": 10392, + "sha256": "30936d9fb359c20f2a750fe6b4eeea7c63f601ab9fab0b8b809bb642dd10a358" + }, + "boundsMeters": [ + 8.0, + 3.2, + 0.65 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeWall_Straight8m/Warpkeep_W03_LOD0_High_Runtime.glb", + "bytes": 132520, + "sha256": "84d5d73d4b56baa9a93d86e1541bebd0c4bd38556f4ae7256bdabb962fd22107", + "triangles": 1148, + "boundsMeters": [ + 8.0, + 3.2, + 0.65 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-8m-high-84d5d73d4b56baa9.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeWall_Straight8m/Warpkeep_W03_LOD1_Balanced_Runtime.glb", + "bytes": 71424, + "sha256": "d3f7ad3299cfc9327b51d383a64528006d464713f68824a3fb919273d21bcfb4", + "triangles": 612, + "boundsMeters": [ + 8.0, + 3.2, + 0.65 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-8m-balanced-d3f7ad3299cfc932.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeWall_Straight8m/Warpkeep_W03_LOD2_Compact_Runtime.glb", + "bytes": 48620, + "sha256": "70aa97418582b41ba0b149ac9d87eceb198cba47cf4862ac05ba25898f567abc", + "triangles": 412, + "boundsMeters": [ + 8.0, + 3.2, + 0.65 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-8m-compact-70aa97418582b41b.glb" + } + ] + }, + { + "id": "palisade-wall-corner-90", + "family": "palisade", + "sourcePackage": "Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady", + "sourceAssetId": "warpkeep.inner_keep.wood_palisade.W10", + "displayName": "Palisade Wall Corner 90 4 m", + "intendedUse": "Perimeter corner", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeWall_Corner90_4m/runtime-manifest.json", + "bytes": 10543, + "sha256": "649ec963be1fea2f35fb4e89177cd6fa92fcf5c708aced961a0c67661f1a7c20" + }, + "boundsMeters": [ + 4.0, + 3.2, + 4.0 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeWall_Corner90_4m/Warpkeep_W10_LOD0_High_Runtime.glb", + "bytes": 99648, + "sha256": "6641c3b823412e2f9fe949005a7b9d75090e65e8dcda2bc962a5cb39a621ae59", + "triangles": 860, + "boundsMeters": [ + 4.0, + 3.2, + 4.0 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-corner-90-high-6641c3b823412e2f.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeWall_Corner90_4m/Warpkeep_W10_LOD1_Balanced_Runtime.glb", + "bytes": 65916, + "sha256": "c138e93283e0daf61684e5b300c4e481a96b149c5af1e0cd548bd9b796b74415", + "triangles": 564, + "boundsMeters": [ + 4.0, + 3.2, + 4.0 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-corner-90-balanced-c138e93283e0daf6.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeWall_Corner90_4m/Warpkeep_W10_LOD2_Compact_Runtime.glb", + "bytes": 47216, + "sha256": "7a9f51fa1355fc6cb126abd44d8b12008af9b794f2cfb6a5588eaf23f9d598f7", + "triangles": 400, + "boundsMeters": [ + 4.0, + 3.2, + 4.0 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-corner-90-compact-7a9f51fa1355fc6c.glb" + } + ] + }, + { + "id": "palisade-gate-frame-6m", + "family": "palisade", + "sourcePackage": "Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady", + "sourceAssetId": "warpkeep.palisade.inner_keep.W15", + "displayName": "Palisade Gate Frame 6m", + "intendedUse": "South gate frame", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeGate_Frame6m/runtime-manifest.json", + "bytes": 8350, + "sha256": "b96276f1fbfa1596283d0eeebf437a7b815a8694b2a420482315c6692fe30f05" + }, + "boundsMeters": [ + 6.0, + 5.0, + 1.3 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeGate_Frame6m/Warpkeep_W15_LOD0_High_Runtime.glb", + "bytes": 46112, + "sha256": "d4f580ae04a3b94ebcd18050d6affaa8d85858502f7308759d27ded3439d718f", + "triangles": 542, + "boundsMeters": [ + 6.0, + 5.0, + 1.3 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-frame-6m-high-d4f580ae04a3b94e.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeGate_Frame6m/Warpkeep_W15_LOD1_Balanced_Runtime.glb", + "bytes": 40448, + "sha256": "af67ba3dc8085d8095a8c63d2d6b61f701c796ccb041241ea767b8eacd0e252e", + "triangles": 462, + "boundsMeters": [ + 6.0, + 5.0, + 1.3 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-frame-6m-balanced-af67ba3dc8085d80.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeGate_Frame6m/Warpkeep_W15_LOD2_Compact_Runtime.glb", + "bytes": 25560, + "sha256": "c144a11a4f496e4ba1ec147d23c2b8df769ba33b56abe8a0d6f723b471f6c5f7", + "triangles": 286, + "boundsMeters": [ + 6.0, + 5.0, + 1.3 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-frame-6m-compact-c144a11a4f496e4b.glb" + } + ] + }, + { + "id": "palisade-gate-leaf-left", + "family": "palisade", + "sourcePackage": "Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady", + "sourceAssetId": "warpkeep.palisade.inner_keep.W16", + "displayName": "Palisade Gate Leaf Left", + "intendedUse": "South gate left leaf", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeGate_LeafLeft/runtime-manifest.json", + "bytes": 7766, + "sha256": "2a25460c7be00ff3ed11bcaacd780769d087728abe44b762afdecc217ccefedc" + }, + "boundsMeters": [ + 2.1, + 3.45, + 0.28 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeGate_LeafLeft/Warpkeep_W16_LOD0_High_Runtime.glb", + "bytes": 22860, + "sha256": "a3e3e5eb7b13568eb63c4b8e5cfdef6bad516502b87d5729a8125fc1ed373861", + "triangles": 276, + "boundsMeters": [ + 2.1, + 3.45, + 0.28 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-left-high-a3e3e5eb7b13568e.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeGate_LeafLeft/Warpkeep_W16_LOD1_Balanced_Runtime.glb", + "bytes": 14604, + "sha256": "8b7009dccabeb38bad03b490dee1f273837f314ce414ad1e4efc6c50a857cb2b", + "triangles": 172, + "boundsMeters": [ + 2.1, + 3.45, + 0.28 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-left-balanced-8b7009dccabeb38b.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeGate_LeafLeft/Warpkeep_W16_LOD2_Compact_Runtime.glb", + "bytes": 9896, + "sha256": "6653afb68006acb2faeac7b2bf76d544a633f064a8167e9ab23fb79369a91ce8", + "triangles": 108, + "boundsMeters": [ + 2.1, + 3.45, + 0.28 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-left-compact-6653afb68006acb2.glb" + } + ] + }, + { + "id": "palisade-gate-leaf-right", + "family": "palisade", + "sourcePackage": "Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady", + "sourceAssetId": "warpkeep.palisade.inner_keep.W17", + "displayName": "Palisade Gate Leaf Right", + "intendedUse": "South gate right leaf", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeGate_LeafRight/runtime-manifest.json", + "bytes": 7781, + "sha256": "543fde76a295caa4f9896a4800baab52fb5bea5ea191876bb68cbc314a323e37" + }, + "boundsMeters": [ + 2.1, + 3.45, + 0.28 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeGate_LeafRight/Warpkeep_W17_LOD0_High_Runtime.glb", + "bytes": 22936, + "sha256": "0b397bb750ba886ecdb02e3ae53d4b4fef8459b07bc579347b599a3483716fee", + "triangles": 276, + "boundsMeters": [ + 2.1, + 3.45, + 0.28 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-right-high-0b397bb750ba886e.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeGate_LeafRight/Warpkeep_W17_LOD1_Balanced_Runtime.glb", + "bytes": 14604, + "sha256": "e59ec83f925c9741c4dcf0824b82bc735948e28abdebd91bac34d515a23a5734", + "triangles": 172, + "boundsMeters": [ + 2.1, + 3.45, + 0.28 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-right-balanced-e59ec83f925c9741.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_WoodenPalisadeKit_GameReady/Runtime/Props/PalisadeGate_LeafRight/Warpkeep_W17_LOD2_Compact_Runtime.glb", + "bytes": 9900, + "sha256": "5b7e4895ecf8f2d5d4bed541fa7886243a7e3395df6f55812c7e657d01b2cbad", + "triangles": 108, + "boundsMeters": [ + 2.1, + 3.45, + 0.28 + ], + "destinationPath": "public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-right-compact-5b7e4895ecf8f2d5.glb" + } + ] + }, + { + "id": "courtyard-linden-teardrop", + "family": "trees", + "sourcePackage": "Warpkeep_InnerKeepTrees_GameReady", + "sourceAssetId": "warpkeep.tree.ornamental.courtyard-linden-teardrop", + "displayName": "Courtyard Linden — Teardrop Crown", + "intendedUse": "Sparse inner-courtyard tree", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeepTrees_GameReady/Runtime/Trees/CourtyardLinden_Teardrop/runtime-manifest.json", + "bytes": 3225, + "sha256": "87d30d271fd4dcc118b70a1bff33dc3efbeada30bfcbfad3b28ef7e5f423843f" + }, + "boundsMeters": [ + 2.876495, + 5.2, + 2.040444 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeepTrees_GameReady/Runtime/Trees/CourtyardLinden_Teardrop/Warpkeep_Tree_CourtyardLinden_Teardrop_LOD0_High_Runtime.glb", + "bytes": 164384, + "sha256": "e1f548983c08130897d6a30b0500a1534429d97e7412c92c26a339af9346b660", + "triangles": 1204, + "boundsMeters": [ + 2.876495, + 5.2, + 2.040444 + ], + "destinationPath": "public/models/hegemony/inner-keep/trees/inner-keep-courtyard-linden-teardrop-high-e1f548983c081308.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeepTrees_GameReady/Runtime/Trees/CourtyardLinden_Teardrop/Warpkeep_Tree_CourtyardLinden_Teardrop_LOD1_Balanced_Runtime.glb", + "bytes": 74720, + "sha256": "70e50e8f3cc5de23351a599c89be9250917f33ea61460b9331e3ef43ac4220a7", + "triangles": 536, + "boundsMeters": [ + 2.901637, + 5.2, + 2.063829 + ], + "destinationPath": "public/models/hegemony/inner-keep/trees/inner-keep-courtyard-linden-teardrop-balanced-70e50e8f3cc5de23.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeepTrees_GameReady/Runtime/Trees/CourtyardLinden_Teardrop/Warpkeep_Tree_CourtyardLinden_Teardrop_LOD2_Compact_Runtime.glb", + "bytes": 25840, + "sha256": "556a89c1ad3f6a3110ce83423b47d610b00f3ae30bcc573829f7e32dabeb0f8b", + "triangles": 180, + "boundsMeters": [ + 2.764695, + 5.2, + 1.377189 + ], + "destinationPath": "public/models/hegemony/inner-keep/trees/inner-keep-courtyard-linden-teardrop-compact-556a89c1ad3f6a31.glb" + } + ] + }, + { + "id": "pruned-ornamental-three-tier", + "family": "trees", + "sourcePackage": "Warpkeep_InnerKeepTrees_GameReady", + "sourceAssetId": "warpkeep.tree.ornamental.pruned-ornamental-tiered", + "displayName": "Pruned Ornamental — Three Tier", + "intendedUse": "Formal level dressing and topiary", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeepTrees_GameReady/Runtime/Trees/PrunedOrnamental_ThreeTier/runtime-manifest.json", + "bytes": 3228, + "sha256": "0da02c7804459745c5bdf3347ee096f97245fcc48e3fea587ee05d2ba8fe288d" + }, + "boundsMeters": [ + 2.788351, + 4.7, + 2.738395 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeepTrees_GameReady/Runtime/Trees/PrunedOrnamental_ThreeTier/Warpkeep_Tree_PrunedOrnamental_ThreeTier_LOD0_High_Runtime.glb", + "bytes": 175604, + "sha256": "8bbe6ec99f51822bd9790b021b68d4f372ccf6a4d8aa9498d614d7924a0f06ad", + "triangles": 1284, + "boundsMeters": [ + 2.788351, + 4.7, + 2.738395 + ], + "destinationPath": "public/models/hegemony/inner-keep/trees/inner-keep-pruned-ornamental-three-tier-high-8bbe6ec99f51822b.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeepTrees_GameReady/Runtime/Trees/PrunedOrnamental_ThreeTier/Warpkeep_Tree_PrunedOrnamental_ThreeTier_LOD1_Balanced_Runtime.glb", + "bytes": 74636, + "sha256": "72bdcd76ef4bf9f4cd84f76c094212ce4433c53b19164769dfd7b05dd285cef4", + "triangles": 536, + "boundsMeters": [ + 2.745673, + 4.7, + 2.685407 + ], + "destinationPath": "public/models/hegemony/inner-keep/trees/inner-keep-pruned-ornamental-three-tier-balanced-72bdcd76ef4bf9f4.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeepTrees_GameReady/Runtime/Trees/PrunedOrnamental_ThreeTier/Warpkeep_Tree_PrunedOrnamental_ThreeTier_LOD2_Compact_Runtime.glb", + "bytes": 23084, + "sha256": "6995d4e430b3b1babfe866dc0fc80ef617e502ce3fe0ef9a3b5adae96821a627", + "triangles": 160, + "boundsMeters": [ + 2.349507, + 4.7, + 1.901195 + ], + "destinationPath": "public/models/hegemony/inner-keep/trees/inner-keep-pruned-ornamental-three-tier-compact-6995d4e430b3b1ba.glb" + } + ] + }, + { + "id": "giant-ancient-cedar", + "family": "trees", + "sourcePackage": "Warpkeep_InnerKeepTrees_FantasyExpansion_GameReady", + "sourceAssetId": "warpkeep.tree.fantasy.giant-ancient-cedar", + "displayName": "Giant Ancient Cedar", + "intendedUse": "Rare north-edge landmark tree", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeepTrees_FantasyExpansion_GameReady/Runtime/Trees/GiantAncientCedar/runtime-manifest.json", + "bytes": 4953, + "sha256": "3c746e186743e49e24cff69d9ff0a4d6d46610b5ad47d8fdd775a5975f74dbdf" + }, + "boundsMeters": [ + 13.105342, + 11.311282, + 9.922385 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeepTrees_FantasyExpansion_GameReady/Runtime/Trees/GiantAncientCedar/Warpkeep_FantasyTree_GiantAncientCedar_LOD0_High_Runtime.glb", + "bytes": 96164, + "sha256": "a27d4410172bfba24cee062b448e416ec5174c4bcf3fc600fa70d1ae12471fdd", + "triangles": 896, + "boundsMeters": [ + 13.105342, + 11.311282, + 9.922385 + ], + "destinationPath": "public/models/hegemony/inner-keep/trees/inner-keep-giant-ancient-cedar-high-a27d4410172bfba2.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeepTrees_FantasyExpansion_GameReady/Runtime/Trees/GiantAncientCedar/Warpkeep_FantasyTree_GiantAncientCedar_LOD1_Balanced_Runtime.glb", + "bytes": 56864, + "sha256": "6563723d5f37cb00c7bac2ab8fc631b5dbd4dffbbc2bf3ef6477cddb845ed7f1", + "triangles": 488, + "boundsMeters": [ + 13.105342, + 11.311282, + 9.922385 + ], + "destinationPath": "public/models/hegemony/inner-keep/trees/inner-keep-giant-ancient-cedar-balanced-6563723d5f37cb00.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeepTrees_FantasyExpansion_GameReady/Runtime/Trees/GiantAncientCedar/Warpkeep_FantasyTree_GiantAncientCedar_LOD2_Compact_Runtime.glb", + "bytes": 29584, + "sha256": "48d82b31a418db32438e06b67b4c56f7c154471655acba2ed193cbf4393e6d12", + "triangles": 245, + "boundsMeters": [ + 13.105342, + 11.314669, + 9.992666 + ], + "destinationPath": "public/models/hegemony/inner-keep/trees/inner-keep-giant-ancient-cedar-compact-48d82b31a418db32.glb" + } + ] + }, + { + "id": "compact-processional-standard", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "compact-processional-standard", + "displayName": "Compact Street and Processional Standard", + "intendedUse": "Hegemony banner dressing", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Banners/BannerStands/CompactProcessionalStandard/runtime-manifest.json", + "bytes": 3651, + "sha256": "a1c5f470fe079c291e0e01717a6c894e7335240aa7ddaa014d9c78494df48f9f" + }, + "boundsMeters": [ + 1.6341, + 3.5823, + 1.0083 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Banners/BannerStands/CompactProcessionalStandard/Warpkeep_CompactProcessionalStandard_LOD0_High.glb", + "bytes": 775984, + "sha256": "2b8b1a6b5c7353748893bbd3cdf89055fddd406eb1fb7c8e641466f1811fe6ad", + "triangles": 6906, + "boundsMeters": [ + 1.6341, + 3.5823, + 1.0083 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-compact-processional-standard-high-2b8b1a6b5c735374.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Banners/BannerStands/CompactProcessionalStandard/Warpkeep_CompactProcessionalStandard_LOD1_Balanced.glb", + "bytes": 588400, + "sha256": "9cee7a39ec36ffb021d6bf838996cf5411bc4c1b97d3c3830492cadc1012f5c8", + "triangles": 4366, + "boundsMeters": [ + 1.6347, + 3.5823, + 1.0094 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-compact-processional-standard-balanced-9cee7a39ec36ffb0.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Banners/BannerStands/CompactProcessionalStandard/Warpkeep_CompactProcessionalStandard_LOD2_Compact.glb", + "bytes": 373368, + "sha256": "10ddd768469d4f4ff65af8557cc2ad5eb818c828e863f3c7438bc4a87644d2d3", + "triangles": 1078, + "boundsMeters": [ + 1.6315, + 3.5823, + 1.003 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-compact-processional-standard-compact-10ddd768469d4f4f.glb" + } + ] + }, + { + "id": "roofed-noticeboard", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "roofed-noticeboard", + "displayName": "Roofed Noticeboard", + "intendedUse": "Builder noticeboard", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/RoofedNoticeboard/runtime-manifest.json", + "bytes": 1985, + "sha256": "77d0db97018ee5839d3377a6816c4b81c23a3454b0c6fb06590c96494b5ff201" + }, + "boundsMeters": [ + 2.92, + 3.3079, + 0.98 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/RoofedNoticeboard/Warpkeep_RoofedNoticeboard_LOD0_High.glb", + "bytes": 304108, + "sha256": "8d45890e63829407533065e01e277ec788734c5b5212216a5ca4329511e8e2a8", + "triangles": 4156, + "boundsMeters": [ + 2.92, + 3.3079, + 0.98 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-roofed-noticeboard-high-8d45890e63829407.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/RoofedNoticeboard/Warpkeep_RoofedNoticeboard_LOD1_Balanced.glb", + "bytes": 149576, + "sha256": "91976a7e7e6ec54f9e75e41b1ffbd37212b3864e0bad65825b590c09064b375e", + "triangles": 2068, + "boundsMeters": [ + 2.92, + 3.3059, + 0.98 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-roofed-noticeboard-balanced-91976a7e7e6ec54f.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/RoofedNoticeboard/Warpkeep_RoofedNoticeboard_LOD2_Compact.glb", + "bytes": 46428, + "sha256": "dfd35c9c36f2f4dad94744c0a429be592526f65e3485436893174b1930317bc4", + "triangles": 532, + "boundsMeters": [ + 2.92, + 3.3053, + 0.98 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-roofed-noticeboard-compact-dfd35c9c36f2f4da.glb" + } + ] + }, + { + "id": "directional-signpost", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "directional-signpost", + "displayName": "Directional Signpost", + "intendedUse": "Road junction signpost", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/DirectionalSignpost/runtime-manifest.json", + "bytes": 2006, + "sha256": "9768aa93f7641c14d992e67c7796c96e2740affd4b8a0163a1faaf2ce37e7c45" + }, + "boundsMeters": [ + 1.9564, + 3.37, + 0.9476 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/DirectionalSignpost/Warpkeep_DirectionalSignpost_LOD0_High.glb", + "bytes": 165248, + "sha256": "8ab4ca08af77c67b1c6b5c7256e698ccb04a83338e13d732a76df8bfbda968bc", + "triangles": 2298, + "boundsMeters": [ + 1.9564, + 3.37, + 0.9476 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-directional-signpost-high-8ab4ca08af77c67b.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/DirectionalSignpost/Warpkeep_DirectionalSignpost_LOD1_Balanced.glb", + "bytes": 97376, + "sha256": "1e41b951b18a6395483776d851b1bfc95c40c80a626dcf2121f38e99a3dce10c", + "triangles": 1354, + "boundsMeters": [ + 1.9564, + 3.37, + 0.9493 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-directional-signpost-balanced-1e41b951b18a6395.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/DirectionalSignpost/Warpkeep_DirectionalSignpost_LOD2_Compact.glb", + "bytes": 40756, + "sha256": "8afe0112a0de6af81bc7e4d121a445aec55f0975e5178746865378ed5f0eaf18", + "triangles": 498, + "boundsMeters": [ + 1.9564, + 3.37, + 0.9343 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-directional-signpost-compact-8afe0112a0de6af8.glb" + } + ] + }, + { + "id": "timber-bench", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "timber-bench", + "displayName": "Timber Civic Bench", + "intendedUse": "Civic and level dressing bench", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/TimberBench/runtime-manifest.json", + "bytes": 1949, + "sha256": "e4f9b99b8e5b65c20655d2077c037f74b33ce2f058250b43a406425d536926cd" + }, + "boundsMeters": [ + 3.08, + 2.1482, + 0.9575 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/TimberBench/Warpkeep_TimberBench_LOD0_High.glb", + "bytes": 255436, + "sha256": "e96e0add043678ad3ab14a7f0bbae1b57f1ba714da731f77b20fcddb8685617f", + "triangles": 3588, + "boundsMeters": [ + 3.08, + 2.1482, + 0.9575 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-timber-bench-high-e96e0add043678ad.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/TimberBench/Warpkeep_TimberBench_LOD1_Balanced.glb", + "bytes": 146084, + "sha256": "625b24648f9fbdae18675b5b48cd9f5f7dcf33209a6c1dd7f96195a8b3ec0c72", + "triangles": 2068, + "boundsMeters": [ + 3.08, + 2.1482, + 0.9575 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-timber-bench-balanced-625b24648f9fbdae.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/TimberBench/Warpkeep_TimberBench_LOD2_Compact.glb", + "bytes": 39596, + "sha256": "b77c20806cdf61e25b6619bb432ec4bbf7f862c017f0b54bf2dec28564b14628", + "triangles": 484, + "boundsMeters": [ + 3.08, + 2.1482, + 0.9396 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-timber-bench-compact-b77c20806cdf61e2.glb" + } + ] + }, + { + "id": "timber-post-lamp", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "timber-post-lamp", + "displayName": "Timber Single Post Lamp", + "intendedUse": "Road and level dressing lamp", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/TimberPostLamp/runtime-manifest.json", + "bytes": 1975, + "sha256": "7481daed9639679a8f9135c093658a071a125a05b41a6454a1c4aa35191f4dc5" + }, + "boundsMeters": [ + 1.3204, + 3.435, + 0.78 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/TimberPostLamp/Warpkeep_TimberPostLamp_LOD0_High.glb", + "bytes": 267500, + "sha256": "bf0d7f23315858a81f43f5738b4268324728619580b0b7348cc216cad5b48b7b", + "triangles": 3784, + "boundsMeters": [ + 1.3204, + 3.435, + 0.78 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-timber-post-lamp-high-bf0d7f23315858a8.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/TimberPostLamp/Warpkeep_TimberPostLamp_LOD1_Balanced.glb", + "bytes": 169416, + "sha256": "381b847971a6cc547c2a9e60a0b61b6e17830db7be867ad31ce2989ea2a087ab", + "triangles": 2408, + "boundsMeters": [ + 1.3204, + 3.4345, + 0.78 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-timber-post-lamp-balanced-381b847971a6cc54.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/TimberPostLamp/Warpkeep_TimberPostLamp_LOD2_Compact.glb", + "bytes": 69320, + "sha256": "5f35fa874a95c04bbd8c2abef9f31e48c988c7ad7d8a149edf36f12ca49a053e", + "triangles": 868, + "boundsMeters": [ + 1.3204, + 3.4317, + 0.78 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-timber-post-lamp-compact-5f35fa874a95c04b.glb" + } + ] + }, + { + "id": "stone-pedestal-brazier", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "stone-pedestal-brazier", + "displayName": "Stone Pedestal Brazier", + "intendedUse": "Plaza and level dressing brazier", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/StonePedestalBrazier/runtime-manifest.json", + "bytes": 2008, + "sha256": "13f27989a7ae5ddf5ba9cebcbca20e10f38d50e7748034c0bca04f33b21825c8" + }, + "boundsMeters": [ + 1.25, + 2.6396, + 1.25 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/StonePedestalBrazier/Warpkeep_StonePedestalBrazier_LOD0_High.glb", + "bytes": 168348, + "sha256": "c6d2485ca58328e1863c8c9179f909bec28fdc7aaaf4601720a3de36ca71daf8", + "triangles": 2296, + "boundsMeters": [ + 1.25, + 2.6396, + 1.25 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-stone-pedestal-brazier-high-c6d2485ca58328e1.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/StonePedestalBrazier/Warpkeep_StonePedestalBrazier_LOD1_Balanced.glb", + "bytes": 98792, + "sha256": "6e9b6f425e0812773ae4b7688a871b949ab794612d99a3f5ff74faa86afdc4a2", + "triangles": 1352, + "boundsMeters": [ + 1.25, + 2.6396, + 1.25 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-stone-pedestal-brazier-balanced-6e9b6f425e081277.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/StonePedestalBrazier/Warpkeep_StonePedestalBrazier_LOD2_Compact.glb", + "bytes": 35728, + "sha256": "35a276eee41bbb72e80a2229fc53965f6226c7305167d3a4f633f796920de3aa", + "triangles": 404, + "boundsMeters": [ + 1.25, + 2.6396, + 1.25 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-stone-pedestal-brazier-compact-35a276eee41bbb72.glb" + } + ] + }, + { + "id": "timber-water-trough", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "timber-water-trough", + "displayName": "Timber Water Trough", + "intendedUse": "Work-yard level dressing", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/TimberWaterTrough/runtime-manifest.json", + "bytes": 1985, + "sha256": "ef2cb22321936012f6608c2ed1ad42bd966498e0a92d63137150b368cdff5dec" + }, + "boundsMeters": [ + 3.43, + 1.48, + 1.2638 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/TimberWaterTrough/Warpkeep_TimberWaterTrough_LOD0_High.glb", + "bytes": 309984, + "sha256": "66a56aae3d4a762ceeba8f996eec0942a3af88e5866c991f46d76113e5d1f0a1", + "triangles": 4260, + "boundsMeters": [ + 3.43, + 1.48, + 1.2638 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-timber-water-trough-high-66a56aae3d4a762c.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/TimberWaterTrough/Warpkeep_TimberWaterTrough_LOD1_Balanced.glb", + "bytes": 174120, + "sha256": "6425e366ed4276e8c79638ac2656b6d807303e705284555ff2c53ecc27cbe819", + "triangles": 2444, + "boundsMeters": [ + 3.43, + 1.48, + 1.2638 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-timber-water-trough-balanced-6425e366ed4276e8.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Fixtures/Fixtures/TimberWaterTrough/Warpkeep_TimberWaterTrough_LOD2_Compact.glb", + "bytes": 57996, + "sha256": "76408969d1a42d952efb3ed420441894fc23e78f0e2be5c9645b849c3b064a13", + "triangles": 684, + "boundsMeters": [ + 3.43, + 1.48, + 1.2638 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-timber-water-trough-compact-76408969d1a42d95.glb" + } + ] + }, + { + "id": "formal-hedge-straight", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "warpkeep.town.flora.formal-hedge-straight", + "displayName": "Formal Hedge Straight Module", + "intendedUse": "Road-edge hedge", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Flora/Flora/FormalHedgeStraight/runtime-manifest.json", + "bytes": 3123, + "sha256": "6d0b419d88edb7ddd67333cadebe8f63d450fcafacc48d983c72d96003673a9e" + }, + "boundsMeters": [ + 4.02824, + 1.341619, + 1.366458 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Flora/Flora/FormalHedgeStraight/Warpkeep_TownFlora_FormalHedgeStraight_LOD0_High_Runtime.glb", + "bytes": 68556, + "sha256": "ba22ccdbef21b42c63084f9536ea1722ecd84a4b834e5efbe9e78582093377cf", + "triangles": 648, + "boundsMeters": [ + 4.02824, + 1.341619, + 1.366458 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-straight-high-ba22ccdbef21b42c.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Flora/Flora/FormalHedgeStraight/Warpkeep_TownFlora_FormalHedgeStraight_LOD1_Balanced_Runtime.glb", + "bytes": 19592, + "sha256": "d619e7c26a496ba566ba04c4d7bc85169ddf8b954329cb4a920aff673be9c096", + "triangles": 168, + "boundsMeters": [ + 4.009702, + 1.341619, + 1.190962 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-straight-balanced-d619e7c26a496ba5.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Flora/Flora/FormalHedgeStraight/Warpkeep_TownFlora_FormalHedgeStraight_LOD2_Compact_Runtime.glb", + "bytes": 8992, + "sha256": "3724b124e4504f98aee923745e89e9f29fb426a07a62f029c766d069cf377357", + "triangles": 88, + "boundsMeters": [ + 3.92, + 1.28, + 0.9 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-straight-compact-3724b124e4504f98.glb" + } + ] + }, + { + "id": "formal-hedge-corner", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "warpkeep.town.flora.formal-hedge-corner", + "displayName": "Formal Hedge Corner Module", + "intendedUse": "Road-edge hedge corner", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Flora/Flora/FormalHedgeCorner/runtime-manifest.json", + "bytes": 3183, + "sha256": "5c4d79557e1e43c9905ff71af3d42fe621b655006f52c22640de0abef6d0804a" + }, + "boundsMeters": [ + 4.141878, + 1.353266, + 4.025798 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Flora/Flora/FormalHedgeCorner/Warpkeep_TownFlora_FormalHedgeCorner_LOD0_High_Runtime.glb", + "bytes": 58904, + "sha256": "e87710f79704b1174db6dc4f21ee5c600b78ad876c3a6bb823d18a3c755414d6", + "triangles": 576, + "boundsMeters": [ + 4.141878, + 1.353266, + 4.025798 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-corner-high-e87710f79704b117.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Flora/Flora/FormalHedgeCorner/Warpkeep_TownFlora_FormalHedgeCorner_LOD1_Balanced_Runtime.glb", + "bytes": 18452, + "sha256": "088946fbca5f5b86e551e3727a2d79229dea7ff4be987f84a0317457ee495476", + "triangles": 196, + "boundsMeters": [ + 3.955084, + 1.353266, + 3.950876 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-corner-balanced-088946fbca5f5b86.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Flora/Flora/FormalHedgeCorner/Warpkeep_TownFlora_FormalHedgeCorner_LOD2_Compact_Runtime.glb", + "bytes": 15692, + "sha256": "0074e6825468a2c8c502f8d2d80ca2be0f1c88d3f68e76e59c53e109173b403e", + "triangles": 140, + "boundsMeters": [ + 3.955084, + 1.353266, + 3.950876 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-corner-compact-0074e6825468a2c8.glb" + } + ] + }, + { + "id": "clipped-boxwood-mound", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "warpkeep.town.flora.clipped-boxwood-mound", + "displayName": "Clipped Boxwood Mound", + "intendedUse": "Courtyard shrub", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Flora/Flora/ClippedBoxwoodMound/runtime-manifest.json", + "bytes": 3119, + "sha256": "9f27c847af8da62828330792f5ad56707695010c8da33626f13ba0341d2ef7c9" + }, + "boundsMeters": [ + 1.967486, + 1.515793, + 1.94 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Flora/Flora/ClippedBoxwoodMound/Warpkeep_TownFlora_ClippedBoxwoodMound_LOD0_High_Runtime.glb", + "bytes": 53212, + "sha256": "71bd32a43e6f81dd312e41f4396b7a5db5cbbeedf56133afc601c7d18a3a52b6", + "triangles": 540, + "boundsMeters": [ + 1.967486, + 1.515793, + 1.94 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-clipped-boxwood-mound-high-71bd32a43e6f81dd.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Flora/Flora/ClippedBoxwoodMound/Warpkeep_TownFlora_ClippedBoxwoodMound_LOD1_Balanced_Runtime.glb", + "bytes": 23248, + "sha256": "6e447757d3a205d94684b3634f9e39e2fc1c35bc68517919c4ad12322e7625d8", + "triangles": 220, + "boundsMeters": [ + 1.941436, + 1.521177, + 1.94 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-clipped-boxwood-mound-balanced-6e447757d3a205d9.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Flora/Flora/ClippedBoxwoodMound/Warpkeep_TownFlora_ClippedBoxwoodMound_LOD2_Compact_Runtime.glb", + "bytes": 10844, + "sha256": "c01e8db707adf31ba0c5546b26800c5b0d573a9acfdb1a4e7dec711b0725cb98", + "triangles": 96, + "boundsMeters": [ + 1.94, + 1.50644, + 1.94 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-clipped-boxwood-mound-compact-c01e8db707adf31b.glb" + } + ] + }, + { + "id": "picket-fence-4m", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "warpkeep.town-hardscape.th02", + "displayName": "Picket Fence 4 m", + "intendedUse": "Building level dressing fence", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Fence_Picket4m/runtime-manifest.json", + "bytes": 5757, + "sha256": "c5bfd9fa385b9ec43bc346914f70aede07df655c8fc3453393319a23574be39f" + }, + "boundsMeters": [ + 4.0, + 1.25, + 0.3 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Fence_Picket4m/Warpkeep_TH02_LOD0_High_Runtime.glb", + "bytes": 43968, + "sha256": "a3a5044e875691516c47e230ce221c4dee70ea22ec86bb4c8fec07a6f66176e8", + "triangles": 540, + "boundsMeters": [ + 4.0, + 1.25, + 0.3 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-picket-fence-4m-high-a3a5044e87569151.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Fence_Picket4m/Warpkeep_TH02_LOD1_Balanced_Runtime.glb", + "bytes": 39716, + "sha256": "63f09802eac8c246525a14da9c65cd568c2bde636cc7fca09d62e5d7f4335981", + "triangles": 480, + "boundsMeters": [ + 4.0, + 1.25, + 0.3 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-picket-fence-4m-balanced-63f09802eac8c246.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Fence_Picket4m/Warpkeep_TH02_LOD2_Compact_Runtime.glb", + "bytes": 36884, + "sha256": "8d62f006b21fd7212d6c21e60098627d43740da324a0e334071546ae0868e1ac", + "triangles": 440, + "boundsMeters": [ + 4.0, + 1.25, + 0.3 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-picket-fence-4m-compact-8d62f006b21fd721.glb" + } + ] + }, + { + "id": "dirt-road-straight-4m", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "warpkeep.town-hardscape.th07", + "displayName": "Dirt Road Straight 4 m", + "intendedUse": "Primary dirt road", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_DirtStraight4m/runtime-manifest.json", + "bytes": 5772, + "sha256": "7df8c848f7645bd1beb5de2c6776419fe84d81b762c91a0291dbbec7ff1caeaf" + }, + "boundsMeters": [ + 4.0, + 0.12, + 2.4 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_DirtStraight4m/Warpkeep_TH07_LOD0_High_Runtime.glb", + "bytes": 15560, + "sha256": "d572f8bc4e9d38d4439b81175267052b7dd25d185cc6281c9f0b4b55d0b1bc1b", + "triangles": 160, + "boundsMeters": [ + 4.0, + 0.12, + 2.4 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-straight-4m-high-d572f8bc4e9d38d4.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_DirtStraight4m/Warpkeep_TH07_LOD1_Balanced_Runtime.glb", + "bytes": 13280, + "sha256": "cec2db4515a24d8f67ec985ac2c8cfb6cdb14126d958fc0048fbc8f133234ffe", + "triangles": 140, + "boundsMeters": [ + 4.0, + 0.12, + 2.4 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-straight-4m-balanced-cec2db4515a24d8f.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_DirtStraight4m/Warpkeep_TH07_LOD2_Compact_Runtime.glb", + "bytes": 10064, + "sha256": "88065d72da8b6ceb9777e3e7af1425ee16eddcde90f2e213d91e741587bff21b", + "triangles": 108, + "boundsMeters": [ + 4.0, + 0.12, + 2.4 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-straight-4m-compact-88065d72da8b6ceb.glb" + } + ] + }, + { + "id": "dirt-road-curve-90-4m", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "warpkeep.town-hardscape.th08", + "displayName": "Dirt Road Curve 90°", + "intendedUse": "Curved dirt road", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_DirtCurve90_4m/runtime-manifest.json", + "bytes": 5766, + "sha256": "19f757f214997d89ce044ee708102bee6fe4f19ef7ec5d24e323c6f89594192b" + }, + "boundsMeters": [ + 4.0, + 0.12, + 4.0 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_DirtCurve90_4m/Warpkeep_TH08_LOD0_High_Runtime.glb", + "bytes": 15060, + "sha256": "b93f180794c8de5c15d0fcaa4c999ba2d35465051e3fb7e83af4b4d4d9f90cde", + "triangles": 152, + "boundsMeters": [ + 4.0, + 0.12, + 4.0 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-curve-90-4m-high-b93f180794c8de5c.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_DirtCurve90_4m/Warpkeep_TH08_LOD1_Balanced_Runtime.glb", + "bytes": 12780, + "sha256": "e3c0221f0b8a5338f8609d0ad774f80257ccb1196bd8eef6348ea203b47a44eb", + "triangles": 132, + "boundsMeters": [ + 4.0, + 0.12, + 4.0 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-curve-90-4m-balanced-e3c0221f0b8a5338.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_DirtCurve90_4m/Warpkeep_TH08_LOD2_Compact_Runtime.glb", + "bytes": 9560, + "sha256": "8434a330607f99930427ed88b5f86ab614a2b0d5413f44cfd41208cf314328ab", + "triangles": 100, + "boundsMeters": [ + 4.0, + 0.12, + 4.0 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-curve-90-4m-compact-8434a330607f9993.glb" + } + ] + }, + { + "id": "dirt-road-t-junction-4m", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "warpkeep.town-hardscape.th09", + "displayName": "Dirt Road T-Junction 4 m", + "intendedUse": "Dirt road junction", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_DirtTJunction4m/runtime-manifest.json", + "bytes": 5840, + "sha256": "4686e79eb847190397097c3c0f6c0764744e6787f57b0fb4d3a2fb877c4ccbdd" + }, + "boundsMeters": [ + 4.0, + 0.12, + 4.0 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_DirtTJunction4m/Warpkeep_TH09_LOD0_High_Runtime.glb", + "bytes": 15928, + "sha256": "8df463a83ecdf544af9e5069a0c02cb6ac93056a070bd2b40afe632eb5b6cf54", + "triangles": 156, + "boundsMeters": [ + 4.0, + 0.12, + 4.0 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-t-junction-4m-high-8df463a83ecdf544.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_DirtTJunction4m/Warpkeep_TH09_LOD1_Balanced_Runtime.glb", + "bytes": 13648, + "sha256": "0278db0236891174005901c8e08c7f69f7e6aba7a9d6e49dc2fe8b287f466512", + "triangles": 136, + "boundsMeters": [ + 4.0, + 0.12, + 4.0 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-t-junction-4m-balanced-0278db0236891174.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_DirtTJunction4m/Warpkeep_TH09_LOD2_Compact_Runtime.glb", + "bytes": 10432, + "sha256": "07c1f95ab8841bb0fc28a24ffcf20dd45f4e1ff2c50356576ecc7177b35ebcfd", + "triangles": 104, + "boundsMeters": [ + 4.0, + 0.12, + 4.0 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-t-junction-4m-compact-07c1f95ab8841bb0.glb" + } + ] + }, + { + "id": "cobble-road-straight-4m", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "warpkeep.town-hardscape.th11", + "displayName": "Cobble Road Straight 4 m", + "intendedUse": "Improved road and frontage", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_CobbleStraight4m/runtime-manifest.json", + "bytes": 5792, + "sha256": "573acaa139973ac71fb7a63587e79013251618b23e364117ebd61ac05f43ba49" + }, + "boundsMeters": [ + 4.0, + 0.14, + 2.4 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_CobbleStraight4m/Warpkeep_TH11_LOD0_High_Runtime.glb", + "bytes": 87600, + "sha256": "2066fd90088760b630a14c17412e47b2b28afa846e70ea56dd4ce1db13d2fad2", + "triangles": 992, + "boundsMeters": [ + 4.0, + 0.14, + 2.4 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-cobble-road-straight-4m-high-2066fd90088760b6.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_CobbleStraight4m/Warpkeep_TH11_LOD1_Balanced_Runtime.glb", + "bytes": 66360, + "sha256": "54b23ca15974c825b7c99dd25603672a984836703f8872967d5148fd3ac67c98", + "triangles": 752, + "boundsMeters": [ + 4.0, + 0.14, + 2.4 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-cobble-road-straight-4m-balanced-54b23ca15974c825.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_CobbleStraight4m/Warpkeep_TH11_LOD2_Compact_Runtime.glb", + "bytes": 45048, + "sha256": "6d838a7ea65cf3132b499d10a4bff8709d6e8203ec84473aef44bc0a322135e0", + "triangles": 512, + "boundsMeters": [ + 4.0, + 0.14, + 2.4 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-cobble-road-straight-4m-compact-6d838a7ea65cf313.glb" + } + ] + }, + { + "id": "cobble-plaza-6m", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "warpkeep.town-hardscape.th13", + "displayName": "Cobble Plaza 6 m", + "intendedUse": "Central civic plaza", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_CobblePlaza6m/runtime-manifest.json", + "bytes": 5918, + "sha256": "27609bdbd84afa1fe94ae7116b78e85f8e11fe2d47576d5c7db4efb21a49ba78" + }, + "boundsMeters": [ + 6.0, + 0.1625, + 6.0 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_CobblePlaza6m/Warpkeep_TH13_LOD0_High_Runtime.glb", + "bytes": 197828, + "sha256": "85662cda60bedecf3d0d9ff88c2c5e3fa5756db051bcae38beff010996927fb4", + "triangles": 2292, + "boundsMeters": [ + 6.0, + 0.1625, + 6.0 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-cobble-plaza-6m-high-85662cda60bedecf.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_CobblePlaza6m/Warpkeep_TH13_LOD1_Balanced_Runtime.glb", + "bytes": 149684, + "sha256": "965d83a7e208b70466b6cbec00a2ec647982a180087dbcb579c0ccb97a0d31e9", + "triangles": 1744, + "boundsMeters": [ + 6.0, + 0.1625, + 6.0 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-cobble-plaza-6m-balanced-965d83a7e208b704.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Road_CobblePlaza6m/Warpkeep_TH13_LOD2_Compact_Runtime.glb", + "bytes": 100384, + "sha256": "767384a66fae79389a105143a9b2585c9fb6cf3ea49123347602fe3fcec923cd", + "triangles": 1172, + "boundsMeters": [ + 6.0, + 0.1625, + 6.0 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-cobble-plaza-6m-compact-767384a66fae7938.glb" + } + ] + }, + { + "id": "stone-curb-2m", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "warpkeep.town-hardscape.th18", + "displayName": "Modular Stone Curb 2 m", + "intendedUse": "Road and terrace curb", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Stone_Curb2m/runtime-manifest.json", + "bytes": 5751, + "sha256": "141af680b334f602796ebf68a733112f0303d61b43e0ad6cac935eea7e96c506" + }, + "boundsMeters": [ + 2.0, + 0.3, + 0.45 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Stone_Curb2m/Warpkeep_TH18_LOD0_High_Runtime.glb", + "bytes": 26536, + "sha256": "46cbb80d3b92d3f336fbc6a7234d7c18933757cb3f40a82afc12cd867bbd7532", + "triangles": 284, + "boundsMeters": [ + 2.0, + 0.3, + 0.45 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-stone-curb-2m-high-46cbb80d3b92d3f3.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Stone_Curb2m/Warpkeep_TH18_LOD1_Balanced_Runtime.glb", + "bytes": 23968, + "sha256": "6a34e355098ba968c26831b6e97c232d1420b5976259b7ed378679c10d76f44a", + "triangles": 264, + "boundsMeters": [ + 2.0, + 0.3, + 0.45 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-stone-curb-2m-balanced-6a34e355098ba968.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Stone_Curb2m/Warpkeep_TH18_LOD2_Compact_Runtime.glb", + "bytes": 20248, + "sha256": "ad9a56588710e3e9e3cf29c31666e4a187c310329db6b82e44a1e1055f128b5e", + "triangles": 220, + "boundsMeters": [ + 2.0, + 0.3, + 0.45 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-stone-curb-2m-compact-ad9a56588710e3e9.glb" + } + ] + }, + { + "id": "boulder-cluster-3m", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "warpkeep.town-hardscape.th15", + "displayName": "Natural Boulder Cluster", + "intendedUse": "Terrain-edge rock dressing", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Stone_BoulderCluster3m/runtime-manifest.json", + "bytes": 5718, + "sha256": "2b05f20358a71dff6198b3e6a5aa7e4c0550aeb1ffc0f7cd3a6717e516ad0c87" + }, + "boundsMeters": [ + 3.0, + 1.5, + 2.4 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Stone_BoulderCluster3m/Warpkeep_TH15_LOD0_High_Runtime.glb", + "bytes": 19884, + "sha256": "a1859faf45ecf03e8bda8ff28a6c4440ec64bb3331d62b1f836806f3672140e5", + "triangles": 160, + "boundsMeters": [ + 3.0, + 1.5, + 2.4 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-boulder-cluster-3m-high-a1859faf45ecf03e.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Stone_BoulderCluster3m/Warpkeep_TH15_LOD1_Balanced_Runtime.glb", + "bytes": 17604, + "sha256": "90235d94748ba8f91223a2bc68712a6036782a27cdd150bb7f2de81152a5092b", + "triangles": 140, + "boundsMeters": [ + 3.0, + 1.5, + 2.4 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-boulder-cluster-3m-balanced-90235d94748ba8f9.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Stone_BoulderCluster3m/Warpkeep_TH15_LOD2_Compact_Runtime.glb", + "bytes": 15324, + "sha256": "06849c7703cc1e171511d35387d0fb96a8756005a4c4a469d3ef5931455652b9", + "triangles": 120, + "boundsMeters": [ + 3.0, + 1.5, + 2.4 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-boulder-cluster-3m-compact-06849c7703cc1e17.glb" + } + ] + }, + { + "id": "masonry-rubble-2m", + "family": "town-items", + "sourcePackage": "Warpkeep_TownItems_EnvironmentKit_GameReady", + "sourceAssetId": "warpkeep.town-hardscape.th16", + "displayName": "Masonry Rubble Cluster", + "intendedUse": "Small masonry dressing", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Stone_MasonryRubble2m/runtime-manifest.json", + "bytes": 5715, + "sha256": "00024e0062f3317426541bd870cf381cbe8c338d28fec08ff198e77de2f61e15" + }, + "boundsMeters": [ + 2.0, + 0.8, + 1.6 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Stone_MasonryRubble2m/Warpkeep_TH16_LOD0_High_Runtime.glb", + "bytes": 36120, + "sha256": "ff69c805c0321fbff395c59c71fb33d6bd45d6be809fd097a1e2dc45a22fa916", + "triangles": 392, + "boundsMeters": [ + 2.0, + 0.8, + 1.6 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-masonry-rubble-2m-high-ff69c805c0321fbf.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Stone_MasonryRubble2m/Warpkeep_TH16_LOD1_Balanced_Runtime.glb", + "bytes": 33836, + "sha256": "02f591b8bf2362c4adb3f87a18cc114ce8f523a5589031708510bcd731627650", + "triangles": 372, + "boundsMeters": [ + 2.0, + 0.8, + 1.6 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-masonry-rubble-2m-balanced-02f591b8bf2362c4.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_TownItems_EnvironmentKit_GameReady/Runtime/Collections/Hardscape/Props/Stone_MasonryRubble2m/Warpkeep_TH16_LOD2_Compact_Runtime.glb", + "bytes": 31700, + "sha256": "06a6458760fcc83f43765670fa838b0e5596fd81d7c485a1c0b953d5834943ff", + "triangles": 352, + "boundsMeters": [ + 2.0, + 0.8, + 1.6 + ], + "destinationPath": "public/models/hegemony/inner-keep/town-items/inner-keep-masonry-rubble-2m-compact-06a6458760fcc83f.glb" + } + ] + }, + { + "id": "canopied-keep-well", + "family": "stone", + "sourcePackage": "Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady", + "sourceAssetId": "CivicWell_Canopied", + "displayName": "Canopied Keep Well", + "intendedUse": "Central keep well", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/CivicWell_Canopied/runtime-manifest.json", + "bytes": 5078, + "sha256": "aa8b630f74a327422e71215b3e03b1cf32fabedcfb53064a8b940602b41d9487" + }, + "boundsMeters": [ + 3.4, + 3.6, + 3.4 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/CivicWell_Canopied/Warpkeep_CivicWell_Canopied_LOD0_High_Runtime.glb", + "bytes": 40056, + "sha256": "fa7b15b156fc13cbf656d3e8b74d537e02d218b24bc9c90fb472c0cc94eb7cc8", + "triangles": 504, + "boundsMeters": [ + 3.4, + 3.6, + 3.4 + ], + "destinationPath": "public/models/hegemony/inner-keep/stone/inner-keep-canopied-keep-well-high-fa7b15b156fc13cb.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/CivicWell_Canopied/Warpkeep_CivicWell_Canopied_LOD1_Balanced_Runtime.glb", + "bytes": 30176, + "sha256": "750ea19f085dd660f533878df932aaa66ae47e52224623434ce8c60539282a99", + "triangles": 368, + "boundsMeters": [ + 3.4, + 3.6, + 3.4 + ], + "destinationPath": "public/models/hegemony/inner-keep/stone/inner-keep-canopied-keep-well-balanced-750ea19f085dd660.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/CivicWell_Canopied/Warpkeep_CivicWell_Canopied_LOD2_Compact_Runtime.glb", + "bytes": 14736, + "sha256": "8628434c2c8c3fde21d33511c5c8726b4a6f9406e99421c74efd4b598efe0bd4", + "triangles": 172, + "boundsMeters": [ + 3.4, + 3.6, + 3.4 + ], + "destinationPath": "public/models/hegemony/inner-keep/stone/inner-keep-canopied-keep-well-compact-8628434c2c8c3fde.glb" + } + ] + }, + { + "id": "collapsed-courtyard-arch", + "family": "stone", + "sourcePackage": "Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady", + "sourceAssetId": "warpkeep.architecture.inner_keep.RuinArch_Collapsed4m", + "displayName": "Collapsed Courtyard Arch", + "intendedUse": "North-edge ruin", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/RuinArch_Collapsed4m/runtime-manifest.json", + "bytes": 8159, + "sha256": "a75471dd48090d22e043671dbd68f3a22612179b99a2cb67278da11845ddf1ec" + }, + "boundsMeters": [ + 4.4, + 4.0, + 1.6 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/RuinArch_Collapsed4m/Warpkeep_A09_RuinArch_Collapsed4m_LOD0_High_Runtime.glb", + "bytes": 25504, + "sha256": "087f2557b6595d60e9b4d22182dfc9e3a62ef6f0d2bd152fb42ae57292e25b55", + "triangles": 252, + "boundsMeters": [ + 4.4, + 4.0, + 1.6 + ], + "destinationPath": "public/models/hegemony/inner-keep/stone/inner-keep-collapsed-courtyard-arch-high-087f2557b6595d60.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/RuinArch_Collapsed4m/Warpkeep_A09_RuinArch_Collapsed4m_LOD1_Balanced_Runtime.glb", + "bytes": 24780, + "sha256": "db433981ff318419d2e6ce3cf8c7a71bf6dc4c209e3a523496584bb374d82783", + "triangles": 240, + "boundsMeters": [ + 4.4, + 4.0, + 1.6 + ], + "destinationPath": "public/models/hegemony/inner-keep/stone/inner-keep-collapsed-courtyard-arch-balanced-db433981ff318419.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/RuinArch_Collapsed4m/Warpkeep_A09_RuinArch_Collapsed4m_LOD2_Compact_Runtime.glb", + "bytes": 16208, + "sha256": "bb8b41dbe991314cc150e75499d4f81acf4161adbea8ad7648a2581156d60c8d", + "triangles": 156, + "boundsMeters": [ + 4.4, + 4.0, + 1.6 + ], + "destinationPath": "public/models/hegemony/inner-keep/stone/inner-keep-collapsed-courtyard-arch-compact-bb8b41dbe991314c.glb" + } + ] + }, + { + "id": "masonry-rubble-cluster", + "family": "stone", + "sourcePackage": "Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady", + "sourceAssetId": "warpkeep.architecture.inner_keep.RuinRubble_Masonry3m", + "displayName": "Masonry Rubble Cluster", + "intendedUse": "Large ruin rubble", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/RuinRubble_Masonry3m/runtime-manifest.json", + "bytes": 10310, + "sha256": "05e7217a5e04843d27e452c4ef814b05a0cc9a24f3bb5d0345ea19c8c55e35c0" + }, + "boundsMeters": [ + 3.2, + 1.1, + 2.6 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/RuinRubble_Masonry3m/Warpkeep_A12_RuinRubble_Masonry3m_LOD0_High_Runtime.glb", + "bytes": 26768, + "sha256": "9bed961f4df79a584d2f43b3fbcadf896ecf0d5b61c290bc203d321c4e995c65", + "triangles": 264, + "boundsMeters": [ + 3.2, + 1.1, + 2.6 + ], + "destinationPath": "public/models/hegemony/inner-keep/stone/inner-keep-masonry-rubble-cluster-high-9bed961f4df79a58.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/RuinRubble_Masonry3m/Warpkeep_A12_RuinRubble_Masonry3m_LOD1_Balanced_Runtime.glb", + "bytes": 22612, + "sha256": "33f632b03246785611834444499b0d4678bd75a12b21c0b03c86b07fb5b2e037", + "triangles": 220, + "boundsMeters": [ + 3.2, + 1.1, + 2.6 + ], + "destinationPath": "public/models/hegemony/inner-keep/stone/inner-keep-masonry-rubble-cluster-balanced-33f632b032467856.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/RuinRubble_Masonry3m/Warpkeep_A12_RuinRubble_Masonry3m_LOD2_Compact_Runtime.glb", + "bytes": 14304, + "sha256": "fbcc413bfd0f0ceeefc893ac71c6aaa004b79669adedc41a0dd47e18a4f3e0f3", + "triangles": 136, + "boundsMeters": [ + 3.2, + 1.1, + 2.6 + ], + "destinationPath": "public/models/hegemony/inner-keep/stone/inner-keep-masonry-rubble-cluster-compact-fbcc413bfd0f0cee.glb" + } + ] + }, + { + "id": "breached-keep-wall", + "family": "stone", + "sourcePackage": "Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady", + "sourceAssetId": "warpkeep.architecture.inner_keep.RuinWall_Breached4m", + "displayName": "Breached Keep Wall", + "intendedUse": "North-edge breached ruin wall", + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/RuinWall_Breached4m/runtime-manifest.json", + "bytes": 8148, + "sha256": "1e5f5861f9a6cef68d5d9662838f43ea6ae7476872494e2fcdf18c2c0d2e897a" + }, + "boundsMeters": [ + 4.0, + 2.8, + 1.1 + ], + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/RuinWall_Breached4m/Warpkeep_A08_RuinWall_Breached4m_LOD0_High_Runtime.glb", + "bytes": 33148, + "sha256": "802c1017e987e03b5fe13fe98f520dcf91cf222681086d6ceafac6e4cf9d7b47", + "triangles": 328, + "boundsMeters": [ + 4.0, + 2.8, + 1.1 + ], + "destinationPath": "public/models/hegemony/inner-keep/stone/inner-keep-breached-keep-wall-high-802c1017e987e03b.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/RuinWall_Breached4m/Warpkeep_A08_RuinWall_Breached4m_LOD1_Balanced_Runtime.glb", + "bytes": 31344, + "sha256": "914de93f1e8928c8ca8074e11cae85279896d12106a4ef1c191da606d515359d", + "triangles": 316, + "boundsMeters": [ + 4.0, + 2.8, + 1.1 + ], + "destinationPath": "public/models/hegemony/inner-keep/stone/inner-keep-breached-keep-wall-balanced-914de93f1e8928c8.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_InnerKeep_StoneRuinsMonuments_GameReady/Runtime/Assets/RuinWall_Breached4m/Warpkeep_A08_RuinWall_Breached4m_LOD2_Compact_Runtime.glb", + "bytes": 11900, + "sha256": "95131380fca529e6ddd376d969dab523a67ff911c505ab744bd7fbbc2b0667c7", + "triangles": 100, + "boundsMeters": [ + 4.0, + 2.8, + 1.1 + ], + "destinationPath": "public/models/hegemony/inner-keep/stone/inner-keep-breached-keep-wall-compact-95131380fca529e6.glb" + } + ] + } + ] +} diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 7121cdbd..38bb1562 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -57,6 +57,7 @@ operation. Anonymous visitors do not connect to the game database. | Terms acceptance | Private versioned evidence; never contains proof, token, cookie, or QR material. | | Player resources and Marks | Private balances and accounting; only the authenticated caller may read permitted projections. | | World and castle state | Transactional integrity and server-enforced ownership. | +| Inner Keep projects and Builder state | Server-derived costs and timers, atomic resource deduction, private receipts, and one Builder per castle. | | Deployment authority | Least privilege, reviewed changes, protected branches, and reproducible artifacts. | | Player privacy | Minimum collection, bounded presentation fields, redacted diagnostics, and private operational records. | @@ -137,6 +138,12 @@ operation. Anonymous visitors do not connect to the game database. closed rather than being repaired from browser data. - Resource reads and collection are caller-scoped. Peer balances do not enter the public Realm subscription, and the browser applies no optimistic credit. +- Inner Keep commands accept only the requested slot, building kind, and an + idempotency key. The server derives cost and duration from a versioned policy, + verifies ownership and slot state, settles resources, deducts them atomically, + and permits only one active project for the castle's single Builder. Public + building progress is a projection; request receipts and Builder state remain + private to caller-scoped procedures. - Terms acceptance is recorded only after authenticated, explicit acceptance of the current version. The pre-authentication checkbox is local to one dialog attempt and is discarded on cancellation or failure. @@ -183,6 +190,10 @@ operation. Anonymous visitors do not connect to the game database. `main` checks. Deployment authority is isolated from ordinary verification. - Release verification binds the deployed site to a reviewed commit SHA. Passing local tests alone does not authorize a deployment or data change. +- Inner Keep runtime assets must match the reviewed release identifier, + attachment checksum, content allowlist, and per-file hashes. Packaging an + archive is not authorization to publish it. The installer fails closed until + an explicit runtime-use authorization record covers the selected files. ## Principal risks and treatment @@ -193,6 +204,10 @@ operation. Anonymous visitors do not connect to the game database. | Access token stolen by script or extension | Memory-only storage and short lifetime limit exposure, but a compromised origin or device can use the token until expiry. | | Resolver token stolen while fresh | One-FID binding and least-privilege guards limit access; a fresh token may still expose that FID's admission status and public subscriptions until disconnect. | | Private data exposed through schema drift | Private tables, generated-binding checks, aggregate preflight, and the empty legacy table requirement block known paths; every schema change needs renewed review. | +| Parallel construction double-spends resources | One server transaction settles balances, checks the Builder and slot, deducts resources, records the receipt, and schedules completion; database or transaction-semantics regressions remain a review trigger. | +| Client forges a construction cost, timer, or slot | The server accepts identifiers only and resolves fixed layouts and versioned policy values itself; catalog or policy changes still require additive-migration and economy review. | +| Construction receipts leak player activity | Receipts and Builder state are private and returned only by caller-scoped procedures; diagnostics and future analytics must preserve that boundary. | +| Unapproved archive material enters a release | A checksum-pinned allowlist and authorization-gated installer block unknown or unauthorized files; owner authorization and license provenance remain human approval steps. | | Spoofed profile or wallet data | Browser fields are presentation-only; trusted updates use separate operator paths. External source data may still be stale or incorrect. | | External avatar tracking | No-referrer, credential-free requests reduce data sent, but the image host still observes connection metadata. | | Resource exhaustion | Size limits, deadlines, early challenge claim, cleanup, and rate control reduce cost; aggregate monitoring and alerting remain limited. | diff --git a/docs/technical-architecture.md b/docs/technical-architecture.md index a9031417..92318fac 100644 --- a/docs/technical-architecture.md +++ b/docs/technical-architecture.md @@ -38,8 +38,10 @@ Implementation and local setup are documented in SpacetimeDB owns admission, player and castle bindings, the persistent world, Terms acceptance, resource accounts, Community Marks, and server-time -settlement. Public tables expose only what the shared map needs; ownership, -administration, and player balances remain private. +settlement. The prepared Inner Keep suffix also keeps construction policy, +Builder state, deductions, receipts, and completion under the same authority. +Public tables expose only what shared presentation needs; ownership, +administration, balances, Builder state, and receipts remain private. Reducers derive identity and authority from the authenticated caller. The browser cannot choose an FID, castle owner, balance, timer, or outcome through @@ -67,6 +69,17 @@ to its caller and settled by server time. Public occupation rows show only the site, phase, timeline, and origin castle. Construction, upgrades, units, combat, alliances, trading, chat, seasons, and governance are not playable yet. +The inactive Inner Keep V1 foundation defines twelve fixed castle-compound +slots, four unique economy buildings with five levels, and one internal Builder +per founded castle. It is separate from the four external gathering Workers. +The client submits a fixed slot, building kind, and idempotency key; the server +derives level, cost, discount, duration, settlement, deduction, and completion. +Merging to protected `main` triggers the existing verified Pages deployment of +the compatible, dormant client. Publication, catalog seed, Builder backfill, +asset authorization, and activation remain separate owner-reviewed operations. +See the [Inner Keep V1 authority contract](design/inner-keep-construction.md) +and [future activation runbook](operations/inner-keep-activation.md). + ## Realm presentation The client receives a validated public Realm snapshot and the authenticated @@ -84,9 +97,18 @@ The coast and twelve rivers are rendered from an activated canonical water layout held by SpacetimeDB. Graphics quality may change environmental detail, but never world membership, resource placement, or authority. +The Realm and Inner Keep use one canvas, WebGL renderer, quality policy, +recovery path, and animation scheduler. Switching to the Inner Keep suspends +world interaction instead of creating a second graphics context. A functional +twelve-slot HTML/CSS view preserves navigation, costs, Builder status, and +guarded actions when 3D or an optional model is unavailable. + Runtime assets use immutable filenames and integrity checks. Source packages, reference masters, and provenance records stay separate from public runtime -files. See [`ASSETS-LICENSE.md`](../ASSETS-LICENSE.md) before changing media. +files. An archive release is not runtime-use authorization: the Inner Keep +installer remains fail-closed until an exact owner authorization record covers +the selected content-addressed files. See +[`ASSETS-LICENSE.md`](../ASSETS-LICENSE.md) before changing media. ## Security and privacy diff --git a/package.json b/package.json index c0d24252..52a24475 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && node scripts/verify-runtime-assets.mjs && node scripts/verify-hegemony-gold-mine-candidates.mjs && node scripts/verify-hegemony-gold-mine-runtime.mjs && node scripts/verify-hegemony-wheat-farm-runtime-assets.mjs && node scripts/verify-hegemony-logging-camp-runtime-assets.mjs && node scripts/verify-hegemony-stone-quarry-runtime-assets.mjs && node scripts/verify-hegemony-supply-wagon-assets.mjs && node scripts/verify-hegemony-tree-runtime-assets.mjs && vite build && node scripts/verify-production-dist-exclusions.mjs && node scripts/verify-farcaster-miniapp.mjs", + "build": "tsc -b && node scripts/verify-runtime-assets.mjs && node scripts/verify-inner-keep-runtime-assets.mjs && node scripts/verify-hegemony-gold-mine-candidates.mjs && node scripts/verify-hegemony-gold-mine-runtime.mjs && node scripts/verify-hegemony-wheat-farm-runtime-assets.mjs && node scripts/verify-hegemony-logging-camp-runtime-assets.mjs && node scripts/verify-hegemony-stone-quarry-runtime-assets.mjs && node scripts/verify-hegemony-supply-wagon-assets.mjs && node scripts/verify-hegemony-tree-runtime-assets.mjs && vite build && node scripts/verify-production-dist-exclusions.mjs && node scripts/verify-farcaster-miniapp.mjs", "preview": "vite preview", "check": "npm run verify:licenses && npm run verify:runtime-assets && npm run verify:file-sizes && npm test && npm run typecheck && npm run build", "validate:pages-config": "node scripts/validate-pages-deploy-config.mjs", @@ -49,15 +49,20 @@ "prepare:hegemony-trees": "node scripts/install-hegemony-tree-runtime.mjs", "prepare:hegemony-castle:source-0.3.4": "node scripts/prepare-hegemony-main-castle.mjs", "prepare:hegemony-mark": "node scripts/prepare-hegemony-mark.mjs", + "assets:audit:inner-keep": "node scripts/install-inner-keep-runtime-assets.mjs --audit-only", + "prepare:inner-keep-assets": "node scripts/install-inner-keep-runtime-assets.mjs --install", "profiles:plan": "tsx scripts/profiles/profiles-operator.ts plan", "profiles:refresh": "tsx scripts/profiles/profiles-operator.ts refresh", "profiles:apply": "tsx scripts/profiles/profiles-operator.ts apply", "profiles:inspect": "tsx scripts/profiles/profiles-operator.ts inspect", + "report:inner-keep-affordability": "tsx scripts/inner-keep-affordability-report.ts", "qa:agent": "node scripts/qa/agent.mjs", "qa:fullstack:local": "node scripts/qa-observer/local-fullstack-browser-probe.mjs", + "qa:inner-keep": "node scripts/qa-observer/inner-keep-browser-probe.mjs", "qa:rendered-webgl": "node scripts/qa-observer/rendered-webgl-browser-probe.mjs", "qa:title-transition": "npm run build && node scripts/qa-observer/title-transition-frame-browser-probe.mjs", - "verify:runtime-assets": "node scripts/verify-runtime-assets.mjs", + "verify:runtime-assets": "node scripts/verify-runtime-assets.mjs && node scripts/verify-inner-keep-runtime-assets.mjs", + "verify:inner-keep-assets": "node scripts/verify-inner-keep-runtime-assets.mjs", "verify:gold-mine-candidates": "node scripts/verify-hegemony-gold-mine-candidates.mjs", "verify:hegemony-gold-mine-runtime": "node scripts/verify-hegemony-gold-mine-runtime.mjs", "verify:hegemony-wheat-farm": "node scripts/verify-hegemony-wheat-farm-runtime-assets.mjs", @@ -73,6 +78,7 @@ "stdb:generate": "node scripts/generate-spacetime-bindings.mjs", "stdb:verify-bindings": "node scripts/verify-spacetime-bindings.mjs", "stdb:build-v14-migration-fixture": "spacetime build --module-path spacetimedb/migration-fixtures/additive-v14-schema", + "stdb:build-v15-migration-fixture": "spacetime build --module-path spacetimedb/migration-fixtures/additive-v15-schema", "stdb:verify-additive-migration": "node scripts/verify-spacetime-additive-migration.mjs", "stdb:verify-worker-migration": "node scripts/verify-castle-worker-additive-migration.mjs", "stdb:verify-access-request-migration": "node scripts/verify-access-request-additive-migration.mjs", @@ -101,6 +107,13 @@ "stdb:inspect-water-revision": "tsx scripts/water-revision-operator.ts inspect", "stdb:seed-water-revision": "tsx scripts/water-revision-operator.ts seed", "stdb:activate-water-revision": "tsx scripts/water-revision-operator.ts activate", + "stdb:inner-keep:inspect": "tsx scripts/inner-keep-operator.ts inspect-inner-keep", + "stdb:inner-keep:plan-catalog": "tsx scripts/inner-keep-operator.ts plan-inner-keep-catalog", + "stdb:inner-keep:seed-catalog": "tsx scripts/inner-keep-operator.ts seed-inner-keep-catalog", + "stdb:inner-keep:plan-builders": "tsx scripts/inner-keep-operator.ts plan-inner-keep-builders", + "stdb:inner-keep:backfill-builders": "tsx scripts/inner-keep-operator.ts backfill-inner-keep-builders", + "stdb:inner-keep:activate": "tsx scripts/inner-keep-operator.ts activate-inner-keep", + "stdb:inner-keep:deactivate": "tsx scripts/inner-keep-operator.ts deactivate-inner-keep", "stdb:worker-rollout": "tsx scripts/worker-rollout-operator.ts", "stdb:worker-rollout:inspect": "tsx scripts/worker-rollout-operator.ts inspect", "stdb:worker-rollout:stage": "tsx scripts/worker-rollout-operator.ts stage", diff --git a/scripts/inner-keep-affordability-report.ts b/scripts/inner-keep-affordability-report.ts new file mode 100644 index 00000000..a3e9e7e0 --- /dev/null +++ b/scripts/inner-keep-affordability-report.ts @@ -0,0 +1,212 @@ +import { pathToFileURL } from 'node:url'; + +import { + CANONICAL_INNER_KEEP_BUILDING_CATALOG, + INNER_KEEP_MAXIMUM_LEVEL, + INNER_KEEP_RESOURCE_BALANCE_CAP, + canonicalInnerKeepCost, + type InnerKeepResourceKind, +} from '../spacetimedb/src/innerKeepPolicy'; +import { + CASTLE_WORKERS_PER_CASTLE, + workerResourcePolicy, +} from '../spacetimedb/src/castleWorkerPolicy'; +import { + REALM_RESOURCE_QUANTUM_MICROS, + REALM_RESOURCE_TERRAIN_RATES, +} from '../spacetimedb/src/resourceAuthorityPolicy'; + +const DAY_MICROS = 24n * 60n * 60n * 1_000_000n; +const RESOURCE_ORDER = Object.freeze([ + 'food', + 'wood', + 'stone', + 'gold', +] as const satisfies readonly InnerKeepResourceKind[]); + +type TerrainKind = keyof typeof REALM_RESOURCE_TERRAIN_RATES; + +export type InnerKeepAffordabilityRow = Readonly<{ + terrain: TerrainKind; + buildingKind: string; + buildingLabel: string; + passiveDay: Readonly>; + deficits: Readonly>; + assignedResourceCount: number; + longestGatheringMinutes: bigint; + totalGatheringMinutes: bigint; + reachableWithFourWorkersInOneGatheringDay: boolean; + requiresGoldGathering: boolean; +}>; + +export type InnerKeepAffordabilityReport = Readonly<{ + rows: readonly InnerKeepAffordabilityRow[]; + maximumLevelCost: bigint; + allLevelOneProjectsReachable: boolean; + allTerrainsProgressionCapable: boolean; + noMandatoryFirstChoice: boolean; + goldworksRequiresGathering: boolean; + noCostApproachesAccountCap: boolean; + activeReservationSafety: 'enforced-by-authoritative-settlement'; +}>; + +function ceilDiv(value: bigint, divisor: bigint) { + return value === 0n ? 0n : (value + divisor - 1n) / divisor; +} + +function passiveDayFor(terrain: TerrainKind) { + const quanta = DAY_MICROS / REALM_RESOURCE_QUANTUM_MICROS; + const rates = REALM_RESOURCE_TERRAIN_RATES[terrain]; + return Object.freeze(Object.fromEntries(RESOURCE_ORDER.map(resource => [ + resource, + rates[resource] * quanta, + ])) as Record); +} + +function gatheringMinutesFor(resource: InnerKeepResourceKind, deficit: bigint) { + const policy = workerResourcePolicy(resource); + const quanta = ceilDiv(deficit, policy.ratePerQuantum); + return ceilDiv(quanta * policy.quantumMicros, 60_000_000n); +} + +export function buildInnerKeepAffordabilityReport(): InnerKeepAffordabilityReport { + const rows: InnerKeepAffordabilityRow[] = []; + let maximumLevelCost = 0n; + + for (const building of CANONICAL_INNER_KEEP_BUILDING_CATALOG) { + const levelFive = canonicalInnerKeepCost( + building.buildingKind, + INNER_KEEP_MAXIMUM_LEVEL, + ); + for (const resource of RESOURCE_ORDER) { + if (levelFive.rawCost[resource] > maximumLevelCost) { + maximumLevelCost = levelFive.rawCost[resource]; + } + } + + const levelOne = canonicalInnerKeepCost(building.buildingKind, 1); + for (const terrain of Object.keys(REALM_RESOURCE_TERRAIN_RATES) as TerrainKind[]) { + const passiveDay = passiveDayFor(terrain); + const deficits = Object.freeze(Object.fromEntries(RESOURCE_ORDER.map(resource => [ + resource, + levelOne.effectiveCost[resource] > passiveDay[resource] + ? levelOne.effectiveCost[resource] - passiveDay[resource] + : 0n, + ])) as Record); + const gatheringMinutes = RESOURCE_ORDER.map(resource => ( + gatheringMinutesFor(resource, deficits[resource]) + )); + const assignedResourceCount = gatheringMinutes.filter(minutes => minutes > 0n).length; + const longestGatheringMinutes = gatheringMinutes.reduce( + (maximum, minutes) => minutes > maximum ? minutes : maximum, + 0n, + ); + const totalGatheringMinutes = gatheringMinutes.reduce( + (total, minutes) => total + minutes, + 0n, + ); + rows.push(Object.freeze({ + terrain, + buildingKind: building.buildingKind, + buildingLabel: building.publicLabel, + passiveDay, + deficits, + assignedResourceCount, + longestGatheringMinutes, + totalGatheringMinutes, + // One worker can cover each nonzero resource bucket in parallel. This + // deliberately leaves travel out of the exact number; UI estimates + // must label return travel separately and stored authority still wins. + reachableWithFourWorkersInOneGatheringDay: + assignedResourceCount <= CASTLE_WORKERS_PER_CASTLE + && longestGatheringMinutes <= 24n * 60n, + requiresGoldGathering: deficits.gold > 0n, + })); + } + } + + const allLevelOneProjectsReachable = rows.every(row => ( + row.reachableWithFourWorkersInOneGatheringDay + )); + const allTerrainsProgressionCapable = ( + Object.keys(REALM_RESOURCE_TERRAIN_RATES) as TerrainKind[] + ).every(terrain => rows + .filter(row => row.terrain === terrain) + .every(row => row.reachableWithFourWorkersInOneGatheringDay)); + // Every terrain can reach every Level-1 recipe from the same zero-balance + // starting assumption. No completed Inner Keep discount is required, so the + // simulation does not make one building a mathematical prerequisite. + const noMandatoryFirstChoice = CANONICAL_INNER_KEEP_BUILDING_CATALOG.every( + building => rows + .filter(row => row.buildingKind === building.buildingKind) + .every(row => row.reachableWithFourWorkersInOneGatheringDay), + ); + const goldworksRows = rows.filter(row => row.buildingKind === 'city-goldworks'); + const goldworksRequiresGathering = goldworksRows.length > 0 + && goldworksRows.every(row => row.requiresGoldGathering); + // "Approaches" is pinned here to a conservative one-percent warning line. + // The current maximum is far below it; a future recipe drift fails loudly. + const noCostApproachesAccountCap = maximumLevelCost + < INNER_KEEP_RESOURCE_BALANCE_CAP / 100n; + + if ( + !allLevelOneProjectsReachable + || !allTerrainsProgressionCapable + || !noMandatoryFirstChoice + || !goldworksRequiresGathering + || !noCostApproachesAccountCap + ) { + throw new Error('INNER_KEEP_AFFORDABILITY_CONTRACT_FAILED'); + } + + return Object.freeze({ + rows: Object.freeze(rows), + maximumLevelCost, + allLevelOneProjectsReachable, + allTerrainsProgressionCapable, + noMandatoryFirstChoice, + goldworksRequiresGathering, + noCostApproachesAccountCap, + activeReservationSafety: 'enforced-by-authoritative-settlement', + }); +} + +function formatCost(cost: Readonly>) { + return RESOURCE_ORDER.map(resource => `${resource}=${cost[resource]}`).join(', '); +} + +export function formatInnerKeepAffordabilityReport( + report: InnerKeepAffordabilityReport, +) { + const lines = [ + '# Inner Keep V1 affordability report', + '', + 'Assumption: zero stored resources, one exact day of passive terrain output,', + 'with one external Worker assigned alongside it for each remaining resource', + 'bucket at the current rate. All listed Worker minutes fit inside that day.', + 'Travel is deliberately excluded from the minute number and must remain a', + 'presentation caveat. Only stored server-authoritative balances are spendable.', + '', + '| Terrain | Building | Remaining after passive day | Longest Worker assignment | Reachable |', + '| --- | --- | --- | ---: | --- |', + ...report.rows.map(row => ( + `| ${row.terrain} | ${row.buildingLabel} | ${formatCost(row.deficits)} | ` + + `${row.longestGatheringMinutes} min | ` + + `${row.reachableWithFourWorkersInOneGatheringDay ? 'yes' : 'no'} |` + )), + '', + `Maximum raw Level-5 resource component: ${report.maximumLevelCost.toString()}.`, + `All Level-1 terrain/building pairs reachable: ${report.allLevelOneProjectsReachable}.`, + `No Level-1 building is a mandatory first choice: ${report.noMandatoryFirstChoice}.`, + `Goldworks always requires Gold gathering: ${report.goldworksRequiresGathering}.`, + `Maximum cost remains below 1% of account cap: ${report.noCostApproachesAccountCap}.`, + 'Active expedition reservations are protected by the existing authoritative settlement path.', + ]; + return `${lines.join('\n')}\n`; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.stdout.write(formatInnerKeepAffordabilityReport( + buildInnerKeepAffordabilityReport(), + )); +} diff --git a/scripts/inner-keep-operator-core.ts b/scripts/inner-keep-operator-core.ts new file mode 100644 index 00000000..467a12e9 --- /dev/null +++ b/scripts/inner-keep-operator-core.ts @@ -0,0 +1,896 @@ +import { Buffer } from 'node:buffer'; + +import { + INNER_KEEP_ASSET_CATALOG_DIGEST, + INNER_KEEP_LAYOUT_DIGEST, + INNER_KEEP_LAYOUT_POLICY_VERSION, +} from '../spacetimedb/src/innerKeepLayoutPolicy'; +import { + INNER_KEEP_POLICY_DIGEST, + INNER_KEEP_POLICY_VERSION, + INNER_KEEP_PROTOCOL_CAPABILITY, +} from '../spacetimedb/src/innerKeepPolicy'; + +export const INNER_KEEP_CANONICAL_TARGET = Object.freeze({ + uri: 'https://maincloud.spacetimedb.com', + database: 'c2001f161d44e50c0a75356d79a4d10fa4a9d77ea4eddd56cda7ac6af50b570e', + bridge: 'https://auth.warpkeep.com', +}); + +export const INNER_KEEP_PROTECTED_STATE_PROOF_PROTOCOL = + 'inner-keep-protected-state-proof-v1'; + +export type InnerKeepProtectedStateSurface = + | 'castleState' + | 'resources' + | 'termsAcceptance' + | 'genericWorkers' + | 'marks'; + +export type InnerKeepProtectedStateQuery = Readonly<{ + surface: InnerKeepProtectedStateSurface; + table: string; + sql: string; +}>; + +/** + * Exact rows that an Inner Keep catalog, Builder, or lifecycle mutation is not + * allowed to alter. These are private owner-SQL reads; their bytes stay in + * memory and only the comparison booleans may enter operator output. + */ +export const INNER_KEEP_PROTECTED_STATE_QUERIES: readonly InnerKeepProtectedStateQuery[] = + Object.freeze([ + Object.freeze({ surface: 'castleState', table: 'castle', sql: 'SELECT * FROM castle' }), + Object.freeze({ surface: 'castleState', table: 'castle_slot_claim_v1', sql: 'SELECT * FROM castle_slot_claim_v1' }), + Object.freeze({ surface: 'resources', table: 'resource_account_v1', sql: 'SELECT * FROM resource_account_v1' }), + Object.freeze({ surface: 'termsAcceptance', table: 'alpha_terms_acceptance_v1', sql: 'SELECT * FROM alpha_terms_acceptance_v1' }), + Object.freeze({ surface: 'genericWorkers', table: 'realm_worker_system_v1', sql: 'SELECT * FROM realm_worker_system_v1' }), + Object.freeze({ surface: 'genericWorkers', table: 'castle_worker_v1', sql: 'SELECT * FROM castle_worker_v1' }), + Object.freeze({ surface: 'genericWorkers', table: 'worker_assignment_v1', sql: 'SELECT * FROM worker_assignment_v1' }), + Object.freeze({ surface: 'genericWorkers', table: 'worker_node_occupation_v1', sql: 'SELECT * FROM worker_node_occupation_v1' }), + Object.freeze({ surface: 'genericWorkers', table: 'worker_assignment_schedule_v_1', sql: 'SELECT * FROM worker_assignment_schedule_v_1' }), + Object.freeze({ surface: 'genericWorkers', table: 'worker_command_idempotency_v1', sql: 'SELECT * FROM worker_command_idempotency_v1' }), + Object.freeze({ surface: 'marks', table: 'realm_profile_v1', sql: 'SELECT * FROM realm_profile_v1' }), + Object.freeze({ surface: 'marks', table: 'mark_account_v1', sql: 'SELECT * FROM mark_account_v1' }), + Object.freeze({ surface: 'marks', table: 'snap_burn_credit_v1', sql: 'SELECT * FROM snap_burn_credit_v1' }), + Object.freeze({ surface: 'marks', table: 'daily_mark_grant_v1', sql: 'SELECT * FROM daily_mark_grant_v1' }), + Object.freeze({ surface: 'marks', table: 'daily_mark_schedule_v_1', sql: 'SELECT * FROM daily_mark_schedule_v_1' }), + ]); + +const MAXIMUM_PROTECTED_TABLE_OUTPUT_BYTES = 1_000_000; +const MAXIMUM_PROTECTED_SNAPSHOT_OUTPUT_BYTES = 8_000_000; + +type InnerKeepProtectedStateEntry = Readonly<{ + surface: InnerKeepProtectedStateSurface; + table: string; + /** Reversible private bytes. Never pass this snapshot to printable operator output. */ + exactSqlOutputBase64: string; +}>; + +export type InnerKeepProtectedStateSnapshot = Readonly<{ + protocol: typeof INNER_KEEP_PROTECTED_STATE_PROOF_PROTOCOL; + entries: readonly InnerKeepProtectedStateEntry[]; +}>; + +export type InnerKeepProtectedStateProof = Readonly<{ + protocol: typeof INNER_KEEP_PROTECTED_STATE_PROOF_PROTOCOL; + comparison: 'exact-private-sql-bytes'; + tablesCompared: number; + surfaces: Readonly>; + privateRowsEmitted: false; + verified: true; +}>; + +const MAX_CASTLES = 100n; +const MAX_BUILDINGS = MAX_CASTLES * 4n; +const MAX_RECEIPTS = MAX_CASTLES * 20n; +const SHA256_HEX = /^[0-9a-f]{64}$/u; +const GIT_COMMIT_HEX = /^[0-9a-f]{40}$/u; +const CLIENT_RELEASE = /^(?:alpha-)?0\.3\.[0-9]+(?:[-+][a-z0-9.-]+)?$/u; + +export type InnerKeepOperatorCommand = + | 'inspect-inner-keep' + | 'plan-inner-keep-catalog' + | 'seed-inner-keep-catalog' + | 'plan-inner-keep-builders' + | 'backfill-inner-keep-builders' + | 'activate-inner-keep' + | 'deactivate-inner-keep'; + +export type InnerKeepOperatorArguments = Readonly<{ + command: InnerKeepOperatorCommand; + confirmed: boolean; + expectedMissingLayout?: number; + expectedMissingSlots?: number; + expectedMissingBuildings?: number; + expectedMissingLevels?: number; + expectedCastles?: number; + expectedExistingBuilders?: number; + expectedMissingBuilders?: number; + expectedActiveProjects?: number; + clientRelease?: string; + clientArtifactDigest?: string; + moduleArtifactDigest?: string; + sourceCommit?: string; +}>; + +export type InnerKeepStatus = Readonly<{ + layoutRows: bigint; + slotRows: bigint; + buildingCatalogRows: bigint; + levelPolicyRows: bigint; + castleRows: bigint; + builderRows: bigint; + buildingRows: bigint; + activeProjects: bigint; + receiptRows: bigint; + scheduleRows: bigint; + missingBuilders: bigint; + orphanBuilders: bigint; + invalidBuilders: bigint; + invalidBuildings: bigint; + invalidSchedules: bigint; + builderProjectMismatches: bigint; + staticCatalogExact: boolean; + workerSystemReady: boolean; + readyForCatalogSeed: boolean; + readyForBuilderBackfill: boolean; + readyForActivation: boolean; + active: boolean; + policyVersion: string; + policyDigest: string; + layoutPolicyVersion: string; + layoutDigest: string; + assetCatalogDigest: string; +}>; + +export type InnerKeepCatalogPlan = Readonly<{ + missingLayout: number; + missingSlots: number; + missingBuildings: number; + missingLevels: number; + ready: boolean; +}>; + +export type InnerKeepBuilderPlan = Readonly<{ + expectedCastles: number; + existingBuilders: number; + missingBuilders: number; + ready: boolean; +}>; + +export class InnerKeepOperatorError extends Error { + constructor(message: string) { + super(message); + this.name = 'InnerKeepOperatorError'; + } +} + +function fail(message: string): never { + throw new InnerKeepOperatorError(message); +} + +const INNER_KEEP_PROTECTED_STATE_SURFACES = Object.freeze([ + 'castleState', + 'resources', + 'termsAcceptance', + 'genericWorkers', + 'marks', +] as const satisfies readonly InnerKeepProtectedStateSurface[]); + +function assertProtectedStateQueryContract(): void { + const tables = new Set(); + for (const query of INNER_KEEP_PROTECTED_STATE_QUERIES) { + if ( + !INNER_KEEP_PROTECTED_STATE_SURFACES.includes(query.surface) + || !/^[a-z][a-z0-9_]{0,63}$/u.test(query.table) + || query.sql !== `SELECT * FROM ${query.table}` + || tables.has(query.table) + ) fail('Inner Keep protected-state query contract is invalid.'); + tables.add(query.table); + } + if ( + tables.size !== INNER_KEEP_PROTECTED_STATE_QUERIES.length + || INNER_KEEP_PROTECTED_STATE_SURFACES.some((surface) => ( + !INNER_KEEP_PROTECTED_STATE_QUERIES.some((query) => query.surface === surface) + )) + ) fail('Inner Keep protected-state query contract is incomplete.'); +} + +assertProtectedStateQueryContract(); + +function privateProtectedSqlBytes(value: unknown): Buffer { + if (typeof value !== 'string' && !ArrayBuffer.isView(value)) { + fail('Inner Keep protected state could not be read safely.'); + } + const bytes = typeof value === 'string' + ? Buffer.from(value, 'utf8') + : Buffer.from(value.buffer, value.byteOffset, value.byteLength); + if ( + bytes.byteLength < 1 + || bytes.byteLength > MAXIMUM_PROTECTED_TABLE_OUTPUT_BYTES + || bytes.includes(0) + ) fail('Inner Keep protected state could not be read safely.'); + return bytes; +} + +/** + * Capture every protected table with fixed read-only SQL. The returned object + * contains private row bytes and is deliberately unsuitable for serialization. + */ +export async function captureInnerKeepProtectedState( + read: (query: InnerKeepProtectedStateQuery) => Promise, +): Promise { + if (typeof read !== 'function') fail('Inner Keep protected-state reader is unavailable.'); + const entries: InnerKeepProtectedStateEntry[] = []; + let totalBytes = 0; + for (const query of INNER_KEEP_PROTECTED_STATE_QUERIES) { + let raw: string | Uint8Array; + try { + raw = await read(query); + } catch { + fail('Inner Keep protected state could not be read safely.'); + } + const exactBytes = privateProtectedSqlBytes(raw); + let exactSqlOutputBase64: string; + try { + totalBytes += exactBytes.byteLength; + if (totalBytes > MAXIMUM_PROTECTED_SNAPSHOT_OUTPUT_BYTES) { + fail('Inner Keep protected state exceeded its fixed evidence bound.'); + } + exactSqlOutputBase64 = exactBytes.toString('base64'); + } finally { + exactBytes.fill(0); + if (Buffer.isBuffer(raw)) raw.fill(0); + } + entries.push(Object.freeze({ + surface: query.surface, + table: query.table, + exactSqlOutputBase64, + })); + } + return Object.freeze({ + protocol: INNER_KEEP_PROTECTED_STATE_PROOF_PROTOCOL, + entries: Object.freeze(entries), + }); +} + +function assertProtectedStateSnapshot(snapshot: InnerKeepProtectedStateSnapshot): void { + if ( + snapshot?.protocol !== INNER_KEEP_PROTECTED_STATE_PROOF_PROTOCOL + || !Array.isArray(snapshot.entries) + || snapshot.entries.length !== INNER_KEEP_PROTECTED_STATE_QUERIES.length + ) fail('Inner Keep protected-state evidence is invalid.'); + for (let index = 0; index < INNER_KEEP_PROTECTED_STATE_QUERIES.length; index += 1) { + const expected = INNER_KEEP_PROTECTED_STATE_QUERIES[index]!; + const actual = snapshot.entries[index]; + if ( + actual?.surface !== expected.surface + || actual.table !== expected.table + || typeof actual.exactSqlOutputBase64 !== 'string' + ) fail('Inner Keep protected-state evidence is invalid.'); + let exactBytes: Buffer | undefined; + try { + exactBytes = Buffer.from(actual.exactSqlOutputBase64, 'base64'); + if ( + exactBytes.byteLength < 1 + || exactBytes.byteLength > MAXIMUM_PROTECTED_TABLE_OUTPUT_BYTES + || exactBytes.includes(0) + || exactBytes.toString('base64') !== actual.exactSqlOutputBase64 + ) fail('Inner Keep protected-state evidence is invalid.'); + } finally { + exactBytes?.fill(0); + } + } +} + +/** Compare private pre/post bytes exactly and return only non-sensitive proof. */ +export function verifyInnerKeepProtectedStatePreserved( + before: InnerKeepProtectedStateSnapshot, + after: InnerKeepProtectedStateSnapshot, +): InnerKeepProtectedStateProof { + assertProtectedStateSnapshot(before); + assertProtectedStateSnapshot(after); + for (let index = 0; index < before.entries.length; index += 1) { + if ( + before.entries[index]!.exactSqlOutputBase64 + !== after.entries[index]!.exactSqlOutputBase64 + ) { + fail( + `Inner Keep protected ${before.entries[index]!.surface} state changed during mutation. ` + + 'Stop and inspect before any retry.', + ); + } + } + return Object.freeze({ + protocol: INNER_KEEP_PROTECTED_STATE_PROOF_PROTOCOL, + comparison: 'exact-private-sql-bytes', + tablesCompared: INNER_KEEP_PROTECTED_STATE_QUERIES.length, + surfaces: Object.freeze({ + castleState: true, + resources: true, + termsAcceptance: true, + genericWorkers: true, + marks: true, + }), + privateRowsEmitted: false, + verified: true, + }); +} + +function asRecord(value: unknown): Readonly> | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Readonly> + : undefined; +} + +function exactKeys( + row: Readonly>, + expected: ReadonlySet, + label: string, +): void { + const keys = Object.keys(row); + if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) { + fail(`${label} returned unexpected fields.`); + } +} + +const STATUS_COUNT_FIELDS = Object.freeze([ + 'layoutRows', + 'slotRows', + 'buildingCatalogRows', + 'levelPolicyRows', + 'castleRows', + 'builderRows', + 'buildingRows', + 'activeProjects', + 'receiptRows', + 'scheduleRows', + 'missingBuilders', + 'orphanBuilders', + 'invalidBuilders', + 'invalidBuildings', + 'invalidSchedules', + 'builderProjectMismatches', +] as const satisfies readonly (keyof InnerKeepStatus)[]); + +const STATUS_BOOLEAN_FIELDS = Object.freeze([ + 'staticCatalogExact', + 'workerSystemReady', + 'readyForCatalogSeed', + 'readyForBuilderBackfill', + 'readyForActivation', + 'active', +] as const satisfies readonly (keyof InnerKeepStatus)[]); + +const STATUS_STRING_FIELDS = Object.freeze([ + 'policyVersion', + 'policyDigest', + 'layoutPolicyVersion', + 'layoutDigest', + 'assetCatalogDigest', +] as const satisfies readonly (keyof InnerKeepStatus)[]); + +const STATUS_FIELDS = new Set([ + ...STATUS_COUNT_FIELDS, + ...STATUS_BOOLEAN_FIELDS, + ...STATUS_STRING_FIELDS, +]); + +export function projectInnerKeepStatus(value: unknown): InnerKeepStatus { + const row = asRecord(value); + if (row === undefined) fail('Inner Keep inspection returned an invalid aggregate.'); + exactKeys(row, STATUS_FIELDS, 'Inner Keep inspection'); + if ( + STATUS_COUNT_FIELDS.some((field) => typeof row[field] !== 'bigint' || row[field] < 0n) + || STATUS_BOOLEAN_FIELDS.some((field) => typeof row[field] !== 'boolean') + || row.policyVersion !== INNER_KEEP_POLICY_VERSION + || row.policyDigest !== INNER_KEEP_POLICY_DIGEST + || row.layoutPolicyVersion !== INNER_KEEP_LAYOUT_POLICY_VERSION + || row.layoutDigest !== INNER_KEEP_LAYOUT_DIGEST + || row.assetCatalogDigest !== INNER_KEEP_ASSET_CATALOG_DIGEST + ) fail('Inner Keep inspection did not match protocol v15.'); + + const status = row as unknown as InnerKeepStatus; + if ( + status.layoutRows > 1n + || status.slotRows > 12n + || status.buildingCatalogRows > 4n + || status.levelPolicyRows > 20n + || status.castleRows > MAX_CASTLES + || status.builderRows > MAX_CASTLES + || status.buildingRows > MAX_BUILDINGS + || status.activeProjects > status.buildingRows + || status.receiptRows > MAX_RECEIPTS + || status.scheduleRows > status.buildingRows + || status.missingBuilders > status.castleRows + || status.staticCatalogExact && ( + status.layoutRows !== 1n + || status.slotRows !== 12n + || status.buildingCatalogRows !== 4n + || status.levelPolicyRows !== 20n + ) + || status.readyForCatalogSeed && ( + status.layoutRows !== 0n + || status.slotRows !== 0n + || status.buildingCatalogRows !== 0n + || status.levelPolicyRows !== 0n + || status.builderRows !== 0n + || status.buildingRows !== 0n + || status.receiptRows !== 0n + || status.scheduleRows !== 0n + ) + || status.readyForBuilderBackfill && ( + !status.staticCatalogExact + || status.active + || status.buildingRows !== 0n + || status.receiptRows !== 0n + || status.scheduleRows !== 0n + ) + || status.readyForActivation && ( + !status.staticCatalogExact + || status.active + || !status.workerSystemReady + || status.builderRows !== status.castleRows + || status.missingBuilders !== 0n + || status.orphanBuilders !== 0n + || status.invalidBuilders !== 0n + || status.invalidBuildings !== 0n + || status.invalidSchedules !== 0n + || status.builderProjectMismatches !== 0n + ) + || status.active && ( + !status.staticCatalogExact + || !status.workerSystemReady + || status.builderRows !== status.castleRows + || status.missingBuilders !== 0n + || status.orphanBuilders !== 0n + || status.invalidBuilders !== 0n + || status.invalidBuildings !== 0n + || status.invalidSchedules !== 0n + || status.builderProjectMismatches !== 0n + ) + ) fail('Inner Keep inspection returned an inconsistent aggregate.'); + + return Object.freeze({ + layoutRows: status.layoutRows, + slotRows: status.slotRows, + buildingCatalogRows: status.buildingCatalogRows, + levelPolicyRows: status.levelPolicyRows, + castleRows: status.castleRows, + builderRows: status.builderRows, + buildingRows: status.buildingRows, + activeProjects: status.activeProjects, + receiptRows: status.receiptRows, + scheduleRows: status.scheduleRows, + missingBuilders: status.missingBuilders, + orphanBuilders: status.orphanBuilders, + invalidBuilders: status.invalidBuilders, + invalidBuildings: status.invalidBuildings, + invalidSchedules: status.invalidSchedules, + builderProjectMismatches: status.builderProjectMismatches, + staticCatalogExact: status.staticCatalogExact, + workerSystemReady: status.workerSystemReady, + readyForCatalogSeed: status.readyForCatalogSeed, + readyForBuilderBackfill: status.readyForBuilderBackfill, + readyForActivation: status.readyForActivation, + active: status.active, + policyVersion: status.policyVersion, + policyDigest: status.policyDigest, + layoutPolicyVersion: status.layoutPolicyVersion, + layoutDigest: status.layoutDigest, + assetCatalogDigest: status.assetCatalogDigest, + }); +} + +const CATALOG_PLAN_FIELDS = new Set([ + 'missingLayout', + 'missingSlots', + 'missingBuildings', + 'missingLevels', + 'ready', +]); + +export function projectInnerKeepCatalogPlan(value: unknown): InnerKeepCatalogPlan { + const row = asRecord(value); + if (row === undefined) fail('Inner Keep catalog plan returned an invalid aggregate.'); + exactKeys(row, CATALOG_PLAN_FIELDS, 'Inner Keep catalog plan'); + if ( + !Number.isInteger(row.missingLayout) + || (row.missingLayout !== 0 && row.missingLayout !== 1) + || !Number.isInteger(row.missingSlots) + || Number(row.missingSlots) < 0 + || Number(row.missingSlots) > 12 + || !Number.isInteger(row.missingBuildings) + || Number(row.missingBuildings) < 0 + || Number(row.missingBuildings) > 4 + || !Number.isInteger(row.missingLevels) + || Number(row.missingLevels) < 0 + || Number(row.missingLevels) > 20 + || typeof row.ready !== 'boolean' + || row.ready !== ( + row.missingLayout === 0 + && row.missingSlots === 0 + && row.missingBuildings === 0 + && row.missingLevels === 0 + ) + ) fail('Inner Keep catalog plan returned inconsistent counts.'); + return Object.freeze({ + missingLayout: row.missingLayout as number, + missingSlots: row.missingSlots as number, + missingBuildings: row.missingBuildings as number, + missingLevels: row.missingLevels as number, + ready: row.ready, + }); +} + +const BUILDER_PLAN_FIELDS = new Set([ + 'expectedCastles', + 'existingBuilders', + 'missingBuilders', + 'ready', +]); + +export function projectInnerKeepBuilderPlan(value: unknown): InnerKeepBuilderPlan { + const row = asRecord(value); + if (row === undefined) fail('Inner Keep Builder plan returned an invalid aggregate.'); + exactKeys(row, BUILDER_PLAN_FIELDS, 'Inner Keep Builder plan'); + if ( + !Number.isInteger(row.expectedCastles) + || Number(row.expectedCastles) < 0 + || Number(row.expectedCastles) > Number(MAX_CASTLES) + || !Number.isInteger(row.existingBuilders) + || Number(row.existingBuilders) < 0 + || Number(row.existingBuilders) > Number(MAX_CASTLES) + || !Number.isInteger(row.missingBuilders) + || Number(row.missingBuilders) < 0 + || Number(row.missingBuilders) > Number(row.expectedCastles) + || Number(row.existingBuilders) + Number(row.missingBuilders) !== Number(row.expectedCastles) + || typeof row.ready !== 'boolean' + || row.ready !== (row.missingBuilders === 0 && row.existingBuilders === row.expectedCastles) + ) fail('Inner Keep Builder plan returned inconsistent counts.'); + return Object.freeze({ + expectedCastles: row.expectedCastles as number, + existingBuilders: row.existingBuilders as number, + missingBuilders: row.missingBuilders as number, + ready: row.ready, + }); +} + +function readBoundedDecimal( + value: string | undefined, + label: string, + maximum: number, +): number { + if (value === undefined || !/^(?:0|[1-9][0-9]*)$/u.test(value)) { + fail(`${label} must be a decimal count from 0 to ${maximum}.`); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed > maximum) { + fail(`${label} must be a decimal count from 0 to ${maximum}.`); + } + return parsed; +} + +function readExactHex(value: string | undefined, label: string, pattern: RegExp): string { + if (value === undefined || !pattern.test(value)) fail(`${label} is invalid.`); + return value; +} + +function readClientRelease(value: string | undefined): string { + if (value === undefined || !CLIENT_RELEASE.test(value)) fail('--client-release is invalid.'); + return value; +} + +const MUTATING_COMMANDS = new Set([ + 'seed-inner-keep-catalog', + 'backfill-inner-keep-builders', + 'activate-inner-keep', + 'deactivate-inner-keep', +]); + +const KNOWN_COMMANDS = new Set([ + 'inspect-inner-keep', + 'plan-inner-keep-catalog', + 'seed-inner-keep-catalog', + 'plan-inner-keep-builders', + 'backfill-inner-keep-builders', + 'activate-inner-keep', + 'deactivate-inner-keep', +]); + +const VALUE_FLAGS = new Set([ + '--expected-missing-layout', + '--expected-missing-slots', + '--expected-missing-buildings', + '--expected-missing-levels', + '--expected-castles', + '--expected-existing-builders', + '--expected-missing-builders', + '--expected-active-projects', + '--client-release', + '--client-artifact-digest', + '--module-artifact-digest', + '--source-commit', +]); + +export function parseInnerKeepOperatorArguments( + argv = process.argv.slice(2), +): InnerKeepOperatorArguments { + const command = argv[0] as InnerKeepOperatorCommand | undefined; + if (command === undefined || !KNOWN_COMMANDS.has(command)) { + fail( + 'Usage: inner-keep-operator.ts ' + + ' [exact expected-state arguments] [--confirm].', + ); + } + + const values = new Map(); + let confirmed = false; + let explicitDryRun = false; + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index]!; + if (argument === '--confirm' || argument === '--dry-run') { + if (argument === '--confirm' ? confirmed : explicitDryRun) { + fail('Inner Keep operator arguments are duplicated.'); + } + if (argument === '--confirm') confirmed = true; + else explicitDryRun = true; + continue; + } + if (!VALUE_FLAGS.has(argument) || values.has(argument)) { + fail('Inner Keep operator arguments are invalid.'); + } + const next = argv[index + 1]; + if (next === undefined || next.startsWith('--')) { + fail('Inner Keep operator arguments are invalid.'); + } + values.set(argument, next); + index += 1; + } + if (confirmed && explicitDryRun) fail('--confirm and --dry-run are mutually exclusive.'); + if (!MUTATING_COMMANDS.has(command) && (confirmed || explicitDryRun || values.size > 0)) { + fail('Read-only Inner Keep commands do not accept mutation arguments.'); + } + + const result: { + command: InnerKeepOperatorCommand; + confirmed: boolean; + expectedMissingLayout?: number; + expectedMissingSlots?: number; + expectedMissingBuildings?: number; + expectedMissingLevels?: number; + expectedCastles?: number; + expectedExistingBuilders?: number; + expectedMissingBuilders?: number; + expectedActiveProjects?: number; + clientRelease?: string; + clientArtifactDigest?: string; + moduleArtifactDigest?: string; + sourceCommit?: string; + } = { command, confirmed }; + + if (command === 'seed-inner-keep-catalog') { + result.expectedMissingLayout = readBoundedDecimal( + values.get('--expected-missing-layout'), '--expected-missing-layout', 1, + ); + result.expectedMissingSlots = readBoundedDecimal( + values.get('--expected-missing-slots'), '--expected-missing-slots', 12, + ); + result.expectedMissingBuildings = readBoundedDecimal( + values.get('--expected-missing-buildings'), '--expected-missing-buildings', 4, + ); + result.expectedMissingLevels = readBoundedDecimal( + values.get('--expected-missing-levels'), '--expected-missing-levels', 20, + ); + if (values.size !== 4) fail('Inner Keep catalog seed arguments are invalid.'); + } else if (command === 'backfill-inner-keep-builders') { + result.expectedCastles = readBoundedDecimal( + values.get('--expected-castles'), '--expected-castles', Number(MAX_CASTLES), + ); + result.expectedExistingBuilders = readBoundedDecimal( + values.get('--expected-existing-builders'), '--expected-existing-builders', Number(MAX_CASTLES), + ); + result.expectedMissingBuilders = readBoundedDecimal( + values.get('--expected-missing-builders'), '--expected-missing-builders', Number(MAX_CASTLES), + ); + if ( + result.expectedExistingBuilders + result.expectedMissingBuilders !== result.expectedCastles + || values.size !== 3 + ) fail('Inner Keep Builder backfill arguments are inconsistent.'); + } else if (command === 'activate-inner-keep') { + result.expectedCastles = readBoundedDecimal( + values.get('--expected-castles'), '--expected-castles', Number(MAX_CASTLES), + ); + result.clientRelease = readClientRelease(values.get('--client-release')); + result.clientArtifactDigest = readExactHex( + values.get('--client-artifact-digest'), '--client-artifact-digest', SHA256_HEX, + ); + result.moduleArtifactDigest = readExactHex( + values.get('--module-artifact-digest'), '--module-artifact-digest', SHA256_HEX, + ); + result.sourceCommit = readExactHex( + values.get('--source-commit'), '--source-commit', GIT_COMMIT_HEX, + ); + if (values.size !== 5) fail('Inner Keep activation arguments are invalid.'); + } else if (command === 'deactivate-inner-keep') { + result.expectedCastles = readBoundedDecimal( + values.get('--expected-castles'), '--expected-castles', Number(MAX_CASTLES), + ); + result.expectedActiveProjects = readBoundedDecimal( + values.get('--expected-active-projects'), '--expected-active-projects', Number(MAX_BUILDINGS), + ); + if (values.size !== 2) fail('Inner Keep deactivation arguments are invalid.'); + } + + return Object.freeze(result); +} + +export function assertCanonicalInnerKeepTarget( + environment: Readonly> = process.env, +): typeof INNER_KEEP_CANONICAL_TARGET { + const uri = environment.WARPKEEP_SPACETIMEDB_URI ?? INNER_KEEP_CANONICAL_TARGET.uri; + const database = environment.WARPKEEP_SPACETIMEDB_DATABASE + ?? INNER_KEEP_CANONICAL_TARGET.database; + const bridge = environment.WARPKEEP_AUTH_BRIDGE_URL ?? INNER_KEEP_CANONICAL_TARGET.bridge; + if ( + uri !== INNER_KEEP_CANONICAL_TARGET.uri + || database !== INNER_KEEP_CANONICAL_TARGET.database + || bridge !== INNER_KEEP_CANONICAL_TARGET.bridge + ) fail('Inner Keep operations require the immutable Warpkeep production target.'); + return INNER_KEEP_CANONICAL_TARGET; +} + +export function innerKeepStaticAttestation() { + return Object.freeze({ + capability: INNER_KEEP_PROTOCOL_CAPABILITY, + policyVersion: INNER_KEEP_POLICY_VERSION, + policyDigest: INNER_KEEP_POLICY_DIGEST, + layoutPolicyVersion: INNER_KEEP_LAYOUT_POLICY_VERSION, + layoutDigest: INNER_KEEP_LAYOUT_DIGEST, + assetCatalogDigest: INNER_KEEP_ASSET_CATALOG_DIGEST, + }); +} + +export function innerKeepDeactivationReducerArguments( + args: InnerKeepOperatorArguments, +): Readonly<{ + capability: string; + expectedCastleCount: number; + expectedActiveProjects: number; +}> { + if ( + args.command !== 'deactivate-inner-keep' + || !Number.isSafeInteger(args.expectedCastles) + || args.expectedCastles! < 0 + || args.expectedCastles! > Number(MAX_CASTLES) + || !Number.isSafeInteger(args.expectedActiveProjects) + || args.expectedActiveProjects! < 0 + || args.expectedActiveProjects! > Number(MAX_BUILDINGS) + ) fail('Inner Keep deactivation reducer arguments are invalid.'); + return Object.freeze({ + capability: INNER_KEEP_PROTOCOL_CAPABILITY, + expectedCastleCount: args.expectedCastles!, + expectedActiveProjects: args.expectedActiveProjects!, + }); +} + +export function printableInnerKeepRecord>>( + value: T, +): Readonly> { + return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, entry]) => [ + key, + typeof entry === 'bigint' ? entry.toString() : entry, + ]))); +} + +export function createInnerKeepDryRunRecord(args: InnerKeepOperatorArguments) { + if (!MUTATING_COMMANDS.has(args.command)) { + fail('Only mutating Inner Keep commands have dry-run records.'); + } + const { confirmed: _confirmed, command: _command, ...expected } = args; + return Object.freeze({ + command: args.command, + mode: 'dry-run', + targetDatabase: INNER_KEEP_CANONICAL_TARGET.database, + ...innerKeepStaticAttestation(), + ...expected, + dataDeletion: false, + secretsInOutput: false, + publicIdentifiersInOutput: false, + blindRetryAllowed: false, + }); +} + +export function assertCatalogPlanMatchesArguments( + plan: InnerKeepCatalogPlan, + args: InnerKeepOperatorArguments, +): void { + if ( + args.command !== 'seed-inner-keep-catalog' + || plan.missingLayout !== args.expectedMissingLayout + || plan.missingSlots !== args.expectedMissingSlots + || plan.missingBuildings !== args.expectedMissingBuildings + || plan.missingLevels !== args.expectedMissingLevels + ) fail('Inner Keep catalog counts changed before mutation.'); +} + +export function assertBuilderPlanMatchesArguments( + plan: InnerKeepBuilderPlan, + args: InnerKeepOperatorArguments, +): void { + if ( + args.command !== 'backfill-inner-keep-builders' + || plan.expectedCastles !== args.expectedCastles + || plan.existingBuilders !== args.expectedExistingBuilders + || plan.missingBuilders !== args.expectedMissingBuilders + ) fail('Inner Keep Builder counts changed before mutation.'); +} + +export function assertStatusMatchesMutationArguments( + status: InnerKeepStatus, + args: InnerKeepOperatorArguments, +): void { + if ( + (args.command === 'activate-inner-keep' || args.command === 'deactivate-inner-keep') + && status.castleRows !== BigInt(args.expectedCastles!) + ) fail('Inner Keep castle count changed before mutation.'); + if ( + args.command === 'deactivate-inner-keep' + && status.activeProjects !== BigInt(args.expectedActiveProjects!) + ) fail('Inner Keep active-project count changed before deactivation.'); +} + +export function verifyInnerKeepMutationPostcondition( + command: Extract, + before: InnerKeepStatus, + after: InnerKeepStatus, +): void { + if (command === 'seed-inner-keep-catalog') { + if ( + !after.staticCatalogExact + || !after.readyForBuilderBackfill + || after.active + || after.castleRows !== before.castleRows + || after.builderRows !== before.builderRows + || after.buildingRows !== before.buildingRows + || after.receiptRows !== before.receiptRows + || after.scheduleRows !== before.scheduleRows + ) fail('Inner Keep catalog seed postcondition failed. Inspect state before retrying.'); + } else if (command === 'backfill-inner-keep-builders') { + if ( + after.builderRows !== after.castleRows + || after.missingBuilders !== 0n + || after.orphanBuilders !== 0n + || after.invalidBuilders !== 0n + || after.active + || after.buildingRows !== before.buildingRows + || after.receiptRows !== before.receiptRows + || after.scheduleRows !== before.scheduleRows + ) fail('Inner Keep Builder backfill postcondition failed. Inspect state before retrying.'); + } else if (command === 'activate-inner-keep') { + if ( + !before.readyForActivation + || !after.active + || after.castleRows !== before.castleRows + || after.builderRows !== before.builderRows + || after.buildingRows !== before.buildingRows + || after.receiptRows !== before.receiptRows + || after.scheduleRows !== before.scheduleRows + ) fail('Inner Keep activation postcondition failed. Inspect state before retrying.'); + } else if ( + !before.active + || after.active + || after.castleRows !== before.castleRows + || after.builderRows !== before.builderRows + || after.buildingRows !== before.buildingRows + || after.receiptRows !== before.receiptRows + || after.scheduleRows !== before.scheduleRows + ) fail('Inner Keep deactivation postcondition failed. Inspect state before retrying.'); +} diff --git a/scripts/inner-keep-operator.ts b/scripts/inner-keep-operator.ts new file mode 100644 index 00000000..81ddb0d9 --- /dev/null +++ b/scripts/inner-keep-operator.ts @@ -0,0 +1,641 @@ +import { spawn } from 'node:child_process'; +import { + chmodSync, + closeSync, + constants, + fchmodSync, + fstatSync, + fsyncSync, + lstatSync, + mkdtempSync, + openSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { setGlobalLogLevel } from 'spacetimedb'; + +import { DbConnection } from '../src/spacetime/module_bindings'; +import { + INNER_KEEP_ASSET_SELECTION, + INNER_KEEP_PLANNED_RUNTIME_PATHS, + INNER_KEEP_SELECTED_MODELS, + INNER_KEEP_SELECTED_PREVIEWS, + assertInnerKeepRuntimeUseAuthorized, + sha256, +} from './inner-keep-runtime-asset-contract.mjs'; +import { + connect, + privacySafeHermesErrorMessage, + readAdminSecret, + requestAdminToken, + withOperationTimeout, +} from './hermes-admin'; +import { + INNER_KEEP_CANONICAL_TARGET, + INNER_KEEP_PROTECTED_STATE_QUERIES, + InnerKeepOperatorError, + assertBuilderPlanMatchesArguments, + assertCanonicalInnerKeepTarget, + assertCatalogPlanMatchesArguments, + assertStatusMatchesMutationArguments, + captureInnerKeepProtectedState, + createInnerKeepDryRunRecord, + innerKeepDeactivationReducerArguments, + innerKeepStaticAttestation, + parseInnerKeepOperatorArguments, + printableInnerKeepRecord, + projectInnerKeepBuilderPlan, + projectInnerKeepCatalogPlan, + projectInnerKeepStatus, + verifyInnerKeepMutationPostcondition, + verifyInnerKeepProtectedStatePreserved, + type InnerKeepOperatorArguments, + type InnerKeepProtectedStateProof, + type InnerKeepProtectedStateQuery, + type InnerKeepProtectedStateSnapshot, + type InnerKeepStatus, +} from './inner-keep-operator-core'; +import { attestPinnedSpacetimeCli } from './publish-spacetime-dev.mjs'; +import { + attestCanonicalClientArtifactDirectory, + digestExactArtifactFile, + readPackageRelease, +} from './worker-rollout-operator-core'; +import { attestExactProtectedWorkerRolloutMain } from './worker-rollout-operator'; + +const REPOSITORY_ROOT = resolve(import.meta.dirname, '..'); +const INNER_KEEP_RUNTIME_ROOTS = Object.freeze([ + 'public/models/hegemony/inner-keep', + 'public/images/inner-keep/catalog', +]); +const PRIVATE_SQL_TIMEOUT_MILLISECONDS = 20_000; +const PRIVATE_SQL_MAXIMUM_STDOUT_BYTES = 1_100_000; +const PRIVATE_SQL_MAXIMUM_STDERR_BYTES = 64_000; +const PRIVATE_CREDENTIAL_DIRECTORY_MODE = 0o700; +const PRIVATE_CREDENTIAL_FILE_MODE = 0o600; +const PRIVATE_SQL_CHILD_ENVIRONMENT_KEYS = Object.freeze([ + 'PATH', + 'HOME', + 'USER', + 'LOGNAME', + 'TMPDIR', + 'TMP', + 'TEMP', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'USERPROFILE', + 'APPDATA', + 'LOCALAPPDATA', + 'SYSTEMROOT', + 'COMSPEC', + 'PATHEXT', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'NODE_EXTRA_CA_CERTS', + 'HTTPS_PROXY', + 'HTTP_PROXY', + 'NO_PROXY', +] as const); + +type MutationCommand = Extract; + +function fail(message: string): never { + throw new InnerKeepOperatorError(message); +} + +type PrivateSqlCredential = Readonly<{ + configPath: string; + cleanup: () => void; +}>; + +function privateSqlChildEnvironment( + source: Readonly> = process.env, +): Readonly> { + return Object.freeze(Object.fromEntries( + PRIVATE_SQL_CHILD_ENVIRONMENT_KEYS + .filter((key) => typeof source[key] === 'string' && source[key]!.length > 0) + .map((key) => [key, source[key]!] as const), + )); +} + +function createPrivateSqlCredential(token: string): PrivateSqlCredential { + if ( + typeof token !== 'string' + || token.length < 32 + || token.length > 16_384 + || token.includes('\0') + ) fail('Inner Keep protected-state credential is invalid.'); + let directory: string | undefined; + let descriptor: number | undefined; + let credentialBytes: Buffer | undefined; + try { + directory = mkdtempSync(join(tmpdir(), 'warpkeep-inner-keep-evidence-')); + chmodSync(directory, PRIVATE_CREDENTIAL_DIRECTORY_MODE); + const directoryStatus = statSync(directory); + if ( + !directoryStatus.isDirectory() + || (directoryStatus.mode & 0o777) !== PRIVATE_CREDENTIAL_DIRECTORY_MODE + ) fail('Inner Keep protected-state credential directory is unsafe.'); + const configPath = join(directory, 'spacetime-cli.toml'); + descriptor = openSync( + configPath, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, + PRIVATE_CREDENTIAL_FILE_MODE, + ); + credentialBytes = Buffer.from( + `spacetimedb_token = ${JSON.stringify(token)}\n`, + 'utf8', + ); + writeFileSync(descriptor, credentialBytes); + fchmodSync(descriptor, PRIVATE_CREDENTIAL_FILE_MODE); + fsyncSync(descriptor); + const status = fstatSync(descriptor); + if ( + !status.isFile() + || status.size !== credentialBytes.byteLength + || (status.mode & 0o777) !== PRIVATE_CREDENTIAL_FILE_MODE + ) fail('Inner Keep protected-state credential file is unsafe.'); + closeSync(descriptor); + descriptor = undefined; + credentialBytes.fill(0); + credentialBytes = undefined; + let cleaned = false; + return Object.freeze({ + configPath, + cleanup: () => { + if (cleaned) return; + try { + rmSync(directory!, { recursive: true, force: true }); + cleaned = true; + } catch { + fail('Inner Keep protected-state credential cleanup failed.'); + } + }, + }); + } catch (error) { + credentialBytes?.fill(0); + if (descriptor !== undefined) { + try { closeSync(descriptor); } catch { /* Continue to private cleanup. */ } + } + if (directory !== undefined) { + try { rmSync(directory, { recursive: true, force: true }); } catch { /* Fail generically below. */ } + } + if (error instanceof InnerKeepOperatorError) throw error; + fail('Inner Keep protected-state credential could not be created safely.'); + } +} + +function collectBoundedPrivateSqlOutput( + stream: NodeJS.ReadableStream, + maximumBytes: number, + onOverflow: () => void, +): () => Buffer { + const chunks: Buffer[] = []; + let bytes = 0; + stream.on('data', (chunk: string | Buffer) => { + const byteChunk = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk; + bytes += byteChunk.byteLength; + if (bytes > maximumBytes) { + onOverflow(); + return; + } + chunks.push(Buffer.from(byteChunk)); + }); + return () => Buffer.concat(chunks); +} + +async function readPrivateProtectedTable( + executable: string, + credential: PrivateSqlCredential, + token: string, + target: typeof INNER_KEEP_CANONICAL_TARGET, + query: InnerKeepProtectedStateQuery, +): Promise { + if ( + !INNER_KEEP_PROTECTED_STATE_QUERIES.includes(query) + || query.sql !== `SELECT * FROM ${query.table}` + ) fail('Inner Keep protected-state SQL contract is invalid.'); + return new Promise((resolvePromise, rejectPromise) => { + let settled = false; + let overflow = false; + let timedOut = false; + let forcedSettlement: ReturnType | undefined; + let deadline: ReturnType | undefined; + const child = spawn(executable, [ + `--config-path=${credential.configPath}`, + 'sql', + '--server', target.uri, + '--confirmed', 'true', + '--no-config', + target.database, + query.sql, + ], { + cwd: REPOSITORY_ROOT, + env: privateSqlChildEnvironment(), + stdio: ['ignore', 'pipe', 'pipe'], + }); + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + if (deadline !== undefined) clearTimeout(deadline); + if (forcedSettlement !== undefined) clearTimeout(forcedSettlement); + callback(); + }; + const stopForOverflow = () => { + overflow = true; + try { child.kill('SIGKILL'); } catch { /* The bounded failure remains generic. */ } + }; + const readStdout = collectBoundedPrivateSqlOutput( + child.stdout, + PRIVATE_SQL_MAXIMUM_STDOUT_BYTES, + stopForOverflow, + ); + const readStderr = collectBoundedPrivateSqlOutput( + child.stderr, + PRIVATE_SQL_MAXIMUM_STDERR_BYTES, + stopForOverflow, + ); + child.once('error', () => finish(() => rejectPromise( + new InnerKeepOperatorError('Inner Keep protected state could not be read safely.'), + ))); + child.once('close', (code) => finish(() => { + const stdout = readStdout(); + const stderr = readStderr(); + if ( + timedOut + || overflow + || code !== 0 + || stdout.includes(token, 'utf8') + || stderr.includes(token, 'utf8') + || stdout.includes(credential.configPath, 'utf8') + || stderr.includes(credential.configPath, 'utf8') + ) { + stdout.fill(0); + stderr.fill(0); + rejectPromise(new InnerKeepOperatorError( + 'Inner Keep protected state could not be read safely.', + )); + return; + } + stderr.fill(0); + resolvePromise(stdout); + })); + deadline = setTimeout(() => { + timedOut = true; + try { child.kill('SIGKILL'); } catch { /* The deadline remains authoritative. */ } + forcedSettlement = setTimeout(() => finish(() => rejectPromise( + new InnerKeepOperatorError('Inner Keep protected-state read exceeded its deadline.'), + )), 2_000); + }, PRIVATE_SQL_TIMEOUT_MILLISECONDS); + }); +} + +function protectedStateReader( + executable: string, + credential: PrivateSqlCredential, + token: string, + target: typeof INNER_KEEP_CANONICAL_TARGET, +): () => Promise { + return () => captureInnerKeepProtectedState((query) => ( + readPrivateProtectedTable(executable, credential, token, target, query) + )); +} + +function presentRuntimeFiles(relativeRoot: string): readonly string[] { + const absoluteRoot = resolve(REPOSITORY_ROOT, relativeRoot); + let status; + try { + status = lstatSync(absoluteRoot); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return Object.freeze([]); + fail('Inner Keep runtime registry cannot be inspected safely.'); + } + if (!status.isDirectory() || status.isSymbolicLink()) { + fail('Inner Keep runtime registry root is invalid.'); + } + const files: string[] = []; + const visit = (absoluteDirectory: string, relativeDirectory: string): void => { + for (const entry of readdirSync(absoluteDirectory, { withFileTypes: true })) { + if ( + entry.name === '.' + || entry.name === '..' + || entry.name.includes('\0') + || entry.name.normalize('NFC') !== entry.name + ) fail('Inner Keep runtime registry contains an unsafe path.'); + const childAbsolute = resolve(absoluteDirectory, entry.name); + const childRelative = `${relativeDirectory}/${entry.name}`; + const childStatus = lstatSync(childAbsolute); + if (childStatus.isSymbolicLink()) fail('Inner Keep runtime registry contains a symbolic link.'); + if (childStatus.isDirectory()) visit(childAbsolute, childRelative); + else if (childStatus.isFile()) files.push(childRelative); + else fail('Inner Keep runtime registry contains an unsupported entry.'); + } + }; + visit(absoluteRoot, relativeRoot); + return Object.freeze(files.sort()); +} + +function verifyExactRuntimeFile( + relativePath: string, + expectedBytes: number, + expectedDigest: string, +): void { + const absolutePath = resolve(REPOSITORY_ROOT, relativePath); + if (!absolutePath.startsWith(`${REPOSITORY_ROOT}${sep}`)) { + fail('Inner Keep runtime registry path escaped the repository.'); + } + let before; + let canonical; + let bytes; + let after; + try { + before = lstatSync(absolutePath); + canonical = realpathSync(absolutePath); + bytes = readFileSync(absolutePath); + after = lstatSync(absolutePath); + } catch { + fail('Inner Keep runtime registry is incomplete.'); + } + if ( + !before.isFile() + || before.isSymbolicLink() + || canonical !== absolutePath + || before.dev !== after.dev + || before.ino !== after.ino + || before.size !== after.size + || bytes.byteLength !== expectedBytes + || before.size !== expectedBytes + || sha256(bytes) !== expectedDigest + ) fail('Inner Keep runtime registry does not match the authorized selection.'); +} + +export function verifyAuthorizedInnerKeepRuntimeRegistry(): void { + try { + assertInnerKeepRuntimeUseAuthorized(); + } catch { + fail( + 'Inner Keep activation is blocked: owner runtime-use authorization for the selected ' + + 'archive assets is not recorded.', + ); + } + const observedPaths = INNER_KEEP_RUNTIME_ROOTS.flatMap(presentRuntimeFiles).sort(); + if ( + observedPaths.length !== INNER_KEEP_PLANNED_RUNTIME_PATHS.length + || observedPaths.some((path, index) => path !== INNER_KEEP_PLANNED_RUNTIME_PATHS[index]) + ) fail('Inner Keep runtime registry paths do not match the authorized selection.'); + for (const model of INNER_KEEP_SELECTED_MODELS) { + verifyExactRuntimeFile(model.destinationPath, model.bytes, model.sha256); + } + for (const preview of INNER_KEEP_SELECTED_PREVIEWS) { + verifyExactRuntimeFile(preview.destinationPath, preview.bytes, preview.sha256); + } +} + +export function attestInnerKeepActivationArtifacts(args: InnerKeepOperatorArguments): void { + if (args.command !== 'activate-inner-keep') fail('Inner Keep activation attestation is unavailable.'); + let sourceCommit: string; + let moduleDigest: string; + let clientDigest: string; + let clientRelease: string; + try { + sourceCommit = attestExactProtectedWorkerRolloutMain(REPOSITORY_ROOT); + moduleDigest = digestExactArtifactFile(resolve(REPOSITORY_ROOT, 'spacetimedb/dist/bundle.js')); + clientDigest = attestCanonicalClientArtifactDirectory( + resolve(REPOSITORY_ROOT, 'dist'), + sourceCommit, + ).digest; + clientRelease = readPackageRelease(resolve(REPOSITORY_ROOT, 'package.json')); + } catch { + fail('Inner Keep activation requires a clean exact protected main and fresh exact artifacts.'); + } + if ( + sourceCommit !== args.sourceCommit + || moduleDigest !== args.moduleArtifactDigest + || clientDigest !== args.clientArtifactDigest + || clientRelease !== args.clientRelease + ) fail('Inner Keep activation artifact attestation changed before mutation.'); +} + +async function inspect(connection: DbConnection): Promise { + return projectInnerKeepStatus(await withOperationTimeout( + connection.procedures.adminGetInnerKeepStatusV1({}), + )); +} + +function output(value: Readonly>): void { + console.log(JSON.stringify(printableInnerKeepRecord(value))); +} + +function mutationResult( + command: MutationCommand, + status: InnerKeepStatus, + protectedStateProof: InnerKeepProtectedStateProof, +): Readonly> { + return Object.freeze({ + command, + mode: 'confirmed', + ...innerKeepStaticAttestation(), + ...status, + protectedStateProof, + dataDeletion: false, + identifiersIncluded: false, + }); +} + +export async function executeConnectedCommand( + connection: DbConnection, + args: InnerKeepOperatorArguments, + readProtectedState?: () => Promise, +): Promise>> { + if (args.command === 'inspect-inner-keep') return inspect(connection); + if (args.command === 'plan-inner-keep-catalog') { + return projectInnerKeepCatalogPlan(await withOperationTimeout( + connection.procedures.adminPlanInnerKeepCatalogV1({}), + )); + } + if (args.command === 'plan-inner-keep-builders') { + return projectInnerKeepBuilderPlan(await withOperationTimeout( + connection.procedures.adminPlanInnerKeepBuildersV1({}), + )); + } + + const command = args.command as MutationCommand; + const before = await inspect(connection); + let submitMutation: () => Promise; + if (command === 'seed-inner-keep-catalog') { + const plan = projectInnerKeepCatalogPlan(await withOperationTimeout( + connection.procedures.adminPlanInnerKeepCatalogV1({}), + )); + assertCatalogPlanMatchesArguments(plan, args); + submitMutation = async () => { + if (plan.ready) return; + await withOperationTimeout(connection.reducers.adminSeedInnerKeepCatalogV1({ + capability: innerKeepStaticAttestation().capability, + policyDigest: innerKeepStaticAttestation().policyDigest, + layoutDigest: innerKeepStaticAttestation().layoutDigest, + assetCatalogDigest: innerKeepStaticAttestation().assetCatalogDigest, + expectedMissingLayout: args.expectedMissingLayout!, + expectedMissingSlots: args.expectedMissingSlots!, + expectedMissingBuildings: args.expectedMissingBuildings!, + expectedMissingLevels: args.expectedMissingLevels!, + })); + }; + } else if (command === 'backfill-inner-keep-builders') { + const plan = projectInnerKeepBuilderPlan(await withOperationTimeout( + connection.procedures.adminPlanInnerKeepBuildersV1({}), + )); + assertBuilderPlanMatchesArguments(plan, args); + submitMutation = async () => { + if (plan.ready) return; + await withOperationTimeout(connection.reducers.adminBackfillInnerKeepBuildersV1({ + capability: innerKeepStaticAttestation().capability, + policyDigest: innerKeepStaticAttestation().policyDigest, + layoutDigest: innerKeepStaticAttestation().layoutDigest, + assetCatalogDigest: innerKeepStaticAttestation().assetCatalogDigest, + expectedCastles: args.expectedCastles!, + expectedExistingBuilders: args.expectedExistingBuilders!, + expectedMissingBuilders: args.expectedMissingBuilders!, + })); + }; + } else if (command === 'activate-inner-keep') { + assertStatusMatchesMutationArguments(before, args); + if (!before.readyForActivation || before.active) { + fail('Inner Keep activation preflight is not ready.'); + } + submitMutation = async () => { + await withOperationTimeout(connection.reducers.adminActivateInnerKeepV1({ + capability: innerKeepStaticAttestation().capability, + policyDigest: innerKeepStaticAttestation().policyDigest, + layoutDigest: innerKeepStaticAttestation().layoutDigest, + assetCatalogDigest: innerKeepStaticAttestation().assetCatalogDigest, + clientRelease: args.clientRelease!, + clientArtifactDigest: args.clientArtifactDigest!, + moduleArtifactDigest: args.moduleArtifactDigest!, + sourceCommit: args.sourceCommit!, + expectedCastleCount: args.expectedCastles!, + })); + }; + } else { + assertStatusMatchesMutationArguments(before, args); + if (!before.active) fail('Inner Keep is already inactive; no deactivation was submitted.'); + submitMutation = async () => { + await withOperationTimeout(connection.reducers.adminDeactivateInnerKeepV1( + innerKeepDeactivationReducerArguments(args), + )); + }; + } + + if (readProtectedState === undefined) { + fail('Inner Keep protected-state evidence is required before mutation.'); + } + // Keep the private byte snapshots immediately around the reviewed + // reducer/no-op boundary. Neither snapshot is ever included in output. + const protectedBefore = await readProtectedState(); + await submitMutation(); + const protectedAfter = await readProtectedState(); + const protectedStateProof = verifyInnerKeepProtectedStatePreserved( + protectedBefore, + protectedAfter, + ); + const after = await inspect(connection); + verifyInnerKeepMutationPostcondition(command, before, after); + return mutationResult(command, after, protectedStateProof); +} + +async function main(): Promise { + // Stdout is a closed counts-only JSON contract. SDK notices must not mix + // with it; all failures pass through the privacy-safe stderr boundary. + setGlobalLogLevel('error'); + const args = parseInnerKeepOperatorArguments(); + const target = assertCanonicalInnerKeepTarget(); + + if (!args.confirmed && ( + args.command === 'seed-inner-keep-catalog' + || args.command === 'backfill-inner-keep-builders' + || args.command === 'activate-inner-keep' + || args.command === 'deactivate-inner-keep' + )) { + output(Object.freeze({ + ...createInnerKeepDryRunRecord(args), + ...(args.command === 'activate-inner-keep' ? { + runtimeAssetAuthorization: INNER_KEEP_ASSET_SELECTION.authorization.status, + readyToConfirm: + INNER_KEEP_ASSET_SELECTION.authorization.officialRepositoryRuntimeUseAuthorized === true, + } : {}), + })); + return; + } + + // This gate intentionally precedes all credential reads and network access. + if (args.command === 'activate-inner-keep') { + verifyAuthorizedInnerKeepRuntimeRegistry(); + attestInnerKeepActivationArtifacts(args); + } + + const confirmedMutation = args.confirmed && ( + args.command === 'seed-inner-keep-catalog' + || args.command === 'backfill-inner-keep-builders' + || args.command === 'activate-inner-keep' + || args.command === 'deactivate-inner-keep' + ); + let protectedCli: ReturnType | undefined; + let privateSqlCredential: PrivateSqlCredential | undefined; + let connection: DbConnection | undefined; + try { + if (confirmedMutation) { + try { + protectedCli = attestPinnedSpacetimeCli( + process.env.SPACETIME_BIN ?? 'spacetime', + ); + } catch { + fail('Inner Keep protected-state evidence requires the exact reviewed SpacetimeDB CLI.'); + } + } + const secret = readAdminSecret( + process.env.WARPKEEP_ADMIN_TOKEN_SECRET, + process.env.WARPKEEP_ADMIN_TOKEN_SECRET_STDIN, + ); + const token = await requestAdminToken(target.bridge, secret); + if (confirmedMutation) privateSqlCredential = createPrivateSqlCredential(token); + connection = await connect(target.uri, target.database, token); + const readProtectedState = protectedCli === undefined || privateSqlCredential === undefined + ? undefined + : protectedStateReader( + protectedCli.path, + privateSqlCredential, + token, + target, + ); + output(await executeConnectedCommand(connection, args, readProtectedState)); + } finally { + try { + try { connection?.disconnect(); } catch { /* Keep the bounded operator boundary. */ } + } finally { + try { + privateSqlCredential?.cleanup(); + } finally { + protectedCli?.cleanup(); + } + } + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch((error) => { + console.error(error instanceof InnerKeepOperatorError + ? error.message + : privacySafeHermesErrorMessage(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/inner-keep-runtime-asset-contract.d.mts b/scripts/inner-keep-runtime-asset-contract.d.mts new file mode 100644 index 00000000..188d8b23 --- /dev/null +++ b/scripts/inner-keep-runtime-asset-contract.d.mts @@ -0,0 +1,36 @@ +export const INNER_KEEP_ASSET_SELECTION_RECORD: string; +export const INNER_KEEP_ASSET_SELECTION_DIGEST: string; +export const INNER_KEEP_ASSET_PROFILES: readonly ['high', 'balanced', 'compact']; +export const INNER_KEEP_ASSET_SELECTION: Readonly>; +export const INNER_KEEP_SELECTED_ASSETS: readonly Readonly>[]; +export const INNER_KEEP_SELECTED_MODELS: readonly Readonly>[]; +export const INNER_KEEP_SELECTED_PREVIEWS: readonly Readonly>[]; +export const INNER_KEEP_SELECTED_SOURCE_MEMBERS: readonly string[]; +export const INNER_KEEP_PLANNED_RUNTIME_PATHS: readonly string[]; + +export function sha256(bytes: Uint8Array): string; +export function calculateInnerKeepAssetSelectionDigest(record: Readonly>): string; +export function assertInnerKeepAssetSelectionRecord(record: unknown): void; +export function assertInnerKeepRuntimeUseAuthorized(record?: unknown): void; +export function assertSafeInnerKeepArchiveMembers( + observedPaths: readonly string[], + expectedPaths: readonly string[], + packageRoot: string +): void; +export function assertTrustedInnerKeepReleaseManifest(bytes: Buffer): Readonly>; +export function readInnerKeepGlbJson(bytes: Buffer, label: string): Record; +export function verifyInnerKeepSelectedGlb( + bytes: Buffer, + model: Readonly>, + label: string +): void; +export function verifyInnerKeepSelectedPreview( + bytes: Buffer, + preview: Readonly>, + label: string +): void; +export function assertInnerKeepSelectedSourceManifest( + bytes: Buffer, + asset: Readonly>, + label: string +): void; diff --git a/scripts/inner-keep-runtime-asset-contract.mjs b/scripts/inner-keep-runtime-asset-contract.mjs new file mode 100644 index 00000000..8b365c47 --- /dev/null +++ b/scripts/inner-keep-runtime-asset-contract.mjs @@ -0,0 +1,550 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { basename, dirname, resolve } from 'node:path'; + +export const INNER_KEEP_ASSET_SELECTION_RECORD = + 'docs/reference/assets/2026-08-02-inner-keep-3d-library/manifest.json'; +export const INNER_KEEP_ASSET_SELECTION_DIGEST = + '6763aeb1755d800b817a0d5174182474d3836a928c59beb4b4fdf65f5d1f6ec3'; +export const INNER_KEEP_ASSET_PROFILES = Object.freeze(['high', 'balanced', 'compact']); + +const ROOT = resolve(import.meta.dirname, '..'); +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const SAFE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const EXPECTED_FAMILY_COUNTS = Object.freeze({ + buildings: 4, + palisade: 6, + stone: 4, + 'town-items': 19, + trees: 3 +}); +const EXPECTED_REUSED_TREE_IDS = Object.freeze([ + 'warpkeep.tree.cypress.ancient-dark', + 'warpkeep.tree.oak.spring-broad', + 'warpkeep.tree.spruce.deep-narrow' +]); + +function fail(label, detail) { + throw new Error(`${label}: ${detail}`); +} + +function deeplyFreeze(value) { + if (Array.isArray(value)) value.forEach(deeplyFreeze); + else if (value && typeof value === 'object') Object.values(value).forEach(deeplyFreeze); + return Object.freeze(value); +} + +function exactArray(actual, expected) { + return Array.isArray(actual) + && actual.length === expected.length + && actual.every((entry, index) => entry === expected[index]); +} + +function finitePositiveDimensions(value) { + return Array.isArray(value) + && value.length === 3 + && value.every((entry) => Number.isFinite(entry) && entry > 0); +} + +function safeArchivePath(path, packageRoot) { + if ( + typeof path !== 'string' + || path.length === 0 + || path.includes('\\') + || path.includes('\0') + || path.startsWith('/') + || /^[A-Za-z]:/u.test(path) + || path !== path.normalize('NFC') + ) return false; + const segments = path.split('/'); + return segments[0] === packageRoot + && segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..'); +} + +function safeRepositoryPath(path, prefix, extension) { + if ( + typeof path !== 'string' + || !path.startsWith(prefix) + || !path.endsWith(extension) + || path.includes('\\') + || path.includes('\0') + || path !== path.normalize('NFC') + ) return false; + const segments = path.split('/'); + return segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..'); +} + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]) + ); + } + return value; +} + +export function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +export function calculateInnerKeepAssetSelectionDigest(record) { + const canonicalSelection = { + selectionId: record?.selectionId, + sourceRelease: record?.sourceRelease, + profiles: record?.selectionPolicy?.profiles, + assets: record?.assets, + existingRuntimeReuse: record?.existingRuntimeReuse + }; + return sha256(Buffer.from(JSON.stringify(canonicalize(canonicalSelection)), 'utf8')); +} + +function assertExactFileRecord(record, label) { + const sourcePath = record?.path ?? record?.sourcePath; + if ( + !record + || typeof sourcePath !== 'string' + || !Number.isSafeInteger(record.bytes) + || record.bytes <= 0 + || typeof record.sha256 !== 'string' + || !SHA256_PATTERN.test(record.sha256) + ) fail(label, 'must pin a path, positive byte length, and SHA-256 digest.'); +} + +function assertAssetRecord(asset, release, seenIds, seenSources, seenDestinations) { + const label = `Inner Keep asset ${asset?.id ?? ''}`; + if ( + !asset + || typeof asset.id !== 'string' + || !SAFE_ID_PATTERN.test(asset.id) + || typeof asset.family !== 'string' + || !(asset.family in EXPECTED_FAMILY_COUNTS) + || typeof asset.sourcePackage !== 'string' + || asset.sourcePackage.length === 0 + || typeof asset.sourceAssetId !== 'string' + || asset.sourceAssetId.length === 0 + || typeof asset.displayName !== 'string' + || asset.displayName.length === 0 + || typeof asset.intendedUse !== 'string' + || asset.intendedUse.length === 0 + || !finitePositiveDimensions(asset.boundsMeters) + || !Array.isArray(asset.models) + || asset.models.length !== INNER_KEEP_ASSET_PROFILES.length + ) fail(label, 'does not satisfy the reviewed selection shape.'); + if (seenIds.has(asset.id)) fail(label, 'duplicates another selected asset ID.'); + seenIds.add(asset.id); + + assertExactFileRecord(asset.sourceManifest, `${label} source manifest`); + if ( + !safeArchivePath(asset.sourceManifest.path, release.attachment.packageRoot) + || !asset.sourceManifest.path.startsWith( + `${release.attachment.packageRoot}/3d/${asset.sourcePackage}/Runtime/` + ) + || !asset.sourceManifest.path.endsWith('/runtime-manifest.json') + ) fail(label, 'source manifest escaped its exact package runtime directory.'); + if (seenSources.has(asset.sourceManifest.path)) fail(label, 'duplicates another source manifest.'); + seenSources.add(asset.sourceManifest.path); + + asset.models.forEach((model, index) => { + const profile = INNER_KEEP_ASSET_PROFILES[index]; + const modelLabel = `${label} ${profile} model`; + if ( + !model + || model.profile !== profile + || !Number.isSafeInteger(model.bytes) + || model.bytes <= 0 + || typeof model.sha256 !== 'string' + || !SHA256_PATTERN.test(model.sha256) + || !Number.isSafeInteger(model.triangles) + || model.triangles <= 0 + || !finitePositiveDimensions(model.boundsMeters) + || !safeArchivePath(model.sourcePath, release.attachment.packageRoot) + || dirname(model.sourcePath) !== dirname(asset.sourceManifest.path) + || !model.sourcePath.endsWith('.glb') + ) fail(modelLabel, 'does not pin an exact selected GLB.'); + const expectedDestination = + `public/models/hegemony/inner-keep/${asset.family}/inner-keep-${asset.id}-${profile}-` + + `${model.sha256.slice(0, 16)}.glb`; + if ( + model.destinationPath !== expectedDestination + || !safeRepositoryPath( + model.destinationPath, + `public/models/hegemony/inner-keep/${asset.family}/`, + '.glb' + ) + ) fail(modelLabel, 'does not use its digest-bearing runtime destination.'); + if (seenSources.has(model.sourcePath)) fail(modelLabel, 'duplicates another selected source member.'); + if (seenDestinations.has(model.destinationPath)) fail(modelLabel, 'duplicates another destination.'); + seenSources.add(model.sourcePath); + seenDestinations.add(model.destinationPath); + }); + + if (asset.family === 'buildings') { + assertExactFileRecord(asset.preview, `${label} catalogue preview`); + if ( + asset.preview.width !== 320 + || asset.preview.height !== 320 + || !safeArchivePath(asset.preview.sourcePath, release.attachment.packageRoot) + || !asset.preview.sourcePath.startsWith( + `${release.attachment.packageRoot}/3d/${asset.sourcePackage}/Previews/` + ) + || !asset.preview.sourcePath.endsWith('_MobilePreview_320.png') + || asset.preview.destinationPath + !== `public/images/inner-keep/catalog/${asset.id}-${asset.preview.sha256.slice(0, 16)}.png` + || !safeRepositoryPath( + asset.preview.destinationPath, + 'public/images/inner-keep/catalog/', + '.png' + ) + ) fail(label, 'catalogue preview does not satisfy the exact 320px content-addressed contract.'); + if (seenSources.has(asset.preview.sourcePath)) fail(label, 'duplicates another preview source.'); + if (seenDestinations.has(asset.preview.destinationPath)) fail(label, 'duplicates another destination.'); + seenSources.add(asset.preview.sourcePath); + seenDestinations.add(asset.preview.destinationPath); + } else if (asset.preview !== undefined) { + fail(label, 'only the four buildable buildings may select catalogue previews.'); + } +} + +export function assertInnerKeepAssetSelectionRecord(record) { + const label = 'Inner Keep asset selection record'; + const release = record?.sourceRelease; + if ( + !record + || record.schema !== 'warpkeep.inner-keep-asset-selection.v1' + || record.selectionId !== 'inner-keep-v1' + || record.recordedAt !== '2026-08-03' + || record.selectionDigestSha256 !== INNER_KEEP_ASSET_SELECTION_DIGEST + || record.selectionDigestAlgorithm !== 'sha256-ecmascript-canonical-json-v1' + || release?.repository !== 'ael-dev3/Warpkeep-Assets' + || release?.repositoryMainCommit !== 'b074ffb6317ff9a581f5b7fc7f0a0760e721a9b6' + || release?.releaseCommit !== '74033ebffb7f0a3ec371ccdabac10974bbe413b9' + || release?.tag !== 'inner-keep-3d-asset-library-2026-08-02' + || release?.attachment?.name !== 'inner-keep-3d-asset-library-2026-08-02-v1.zip' + || release?.attachment?.bytes !== 234_962_670 + || release?.attachment?.sha256 !== 'f13bc9e7b8e32a6767b1959307a202d894123f384e11fd19550d75e0dfe5f6c9' + || release?.attachment?.entries !== 1_347 + || release?.attachment?.packageRoot !== 'inner-keep-3d-asset-library-2026-08-02-v1' + || release?.trustedReleaseManifest?.repositoryPath + !== 'releases/inner-keep-3d-asset-library-2026-08-02/manifest.json' + || release?.trustedReleaseManifest?.bytes !== 552_321 + || release?.trustedReleaseManifest?.sha256 + !== '67a31bee8c63718143a9071e9cb906f2229b9776c55d9d6fce3fd87bf2f032ae' + ) fail(label, 'release identity or trusted-manifest coordinate changed.'); + if ( + record.authorization?.archiveDistributionAuthorized !== true + || record.authorization?.officialRepositoryRuntimeUseAuthorized !== false + || record.authorization?.status !== 'pending-owner-runtime-use-authorization' + || typeof record.authorization?.scopeBoundary !== 'string' + || !record.authorization.scopeBoundary.includes('remains blocked') + ) fail(label, 'must remain fail-closed until exact owner runtime-use authorization is recorded.'); + if ( + !exactArray(record.selectionPolicy?.profiles, INNER_KEEP_ASSET_PROFILES) + || record.selectionPolicy?.sourceAssetCount !== 36 + || record.selectionPolicy?.sourceModelCount !== 108 + || record.selectionPolicy?.sourcePreviewCount !== 4 + || !exactArray(record.selectionPolicy?.excludedProfiles, ['LOD3_Map']) + || !exactArray(record.selectionPolicy?.excludedPackages, [ + 'Warpkeep_CityBarracks_GameReady', + 'Warpkeep_GrandCovenantCathedral_GameReady' + ]) + || record.selectionPolicy?.inspectionCataloguesAllowed !== false + || record.selectionPolicy?.editableSourcesAllowed !== false + || record.selectionPolicy?.ordinaryBuildMayReadArchive !== false + || record.selectionPolicy?.ordinaryBuildMayUseNetwork !== false + || !Array.isArray(record.assets) + || record.assets.length !== 36 + ) fail(label, 'selection cardinality or exclusion policy changed.'); + + const seenIds = new Set(); + const seenSources = new Set(); + const seenDestinations = new Set(); + const familyCounts = Object.fromEntries(Object.keys(EXPECTED_FAMILY_COUNTS).map((family) => [family, 0])); + record.assets.forEach((asset) => { + assertAssetRecord(asset, release, seenIds, seenSources, seenDestinations); + familyCounts[asset.family] += 1; + }); + if (JSON.stringify(familyCounts) !== JSON.stringify(EXPECTED_FAMILY_COUNTS)) { + fail(label, 'selected family counts changed.'); + } + + for (const profile of INNER_KEEP_ASSET_PROFILES) { + const models = record.assets.flatMap((asset) => asset.models).filter((model) => model.profile === profile); + const bytes = models.reduce((total, model) => total + model.bytes, 0); + const triangles = models.reduce((total, model) => total + model.triangles, 0); + if ( + record.qualityTotals?.[profile]?.bytes !== bytes + || record.qualityTotals?.[profile]?.triangles !== triangles + ) fail(label, `${profile} quality totals changed.`); + } + const allModelBytes = record.assets + .flatMap((asset) => asset.models) + .reduce((total, model) => total + model.bytes, 0); + const previewBytes = record.assets + .map((asset) => asset.preview?.bytes ?? 0) + .reduce((total, bytes) => total + bytes, 0); + if ( + record.selectedModelBytesAllProfiles !== allModelBytes + || record.selectedPreviewBytes !== previewBytes + ) fail(label, 'selected byte totals changed.'); + + const reusedIds = record.existingRuntimeReuse?.map((entry) => entry?.assetId).sort(); + if ( + !exactArray(reusedIds, EXPECTED_REUSED_TREE_IDS) + || record.existingRuntimeReuse.some((entry) => ( + entry.sourceRecord !== 'docs/reference/assets/2026-07-18-hegemony-environment-trees/manifest.json' + || typeof entry.intendedUse !== 'string' + || entry.intendedUse.length === 0 + )) + ) fail(label, 'existing authorized tree reuse changed.'); + + for (const asset of record.assets) { + if ( + asset.sourcePackage.includes('CityBarracks') + || asset.sourcePackage.includes('GrandCovenantCathedral') + || asset.models.some((model) => ( + model.sourcePath.includes('LOD3_Map') + || model.sourcePath.toLocaleLowerCase('en-US').includes('catalogue') + || model.sourcePath.toLocaleLowerCase('en-US').includes('inspection') + )) + ) fail(label, 'an excluded building or inspection catalogue entered the selected set.'); + } + + const digest = calculateInnerKeepAssetSelectionDigest(record); + if (digest !== INNER_KEEP_ASSET_SELECTION_DIGEST) { + fail(label, `selection digest changed to ${digest}.`); + } +} + +function readSelectionRecord() { + let record; + try { + record = JSON.parse(readFileSync(resolve(ROOT, INNER_KEEP_ASSET_SELECTION_RECORD), 'utf8')); + } catch (error) { + fail( + 'Inner Keep asset selection record', + `cannot be read (${error instanceof Error ? error.message : String(error)})` + ); + } + assertInnerKeepAssetSelectionRecord(record); + return deeplyFreeze(record); +} + +const RECORD = readSelectionRecord(); + +export const INNER_KEEP_ASSET_SELECTION = RECORD; +export const INNER_KEEP_SELECTED_ASSETS = RECORD.assets; +export const INNER_KEEP_SELECTED_MODELS = Object.freeze( + RECORD.assets.flatMap((asset) => asset.models.map((model) => deeplyFreeze({ + ...model, + assetId: asset.id, + family: asset.family, + sourceAssetId: asset.sourceAssetId, + sourceManifest: asset.sourceManifest + }))) +); +export const INNER_KEEP_SELECTED_PREVIEWS = Object.freeze( + RECORD.assets.filter((asset) => asset.preview).map((asset) => deeplyFreeze({ + ...asset.preview, + assetId: asset.id, + sourceAssetId: asset.sourceAssetId, + sourceManifest: asset.sourceManifest + })) +); +export const INNER_KEEP_SELECTED_SOURCE_MEMBERS = Object.freeze([ + ...RECORD.assets.map((asset) => asset.sourceManifest.path), + ...INNER_KEEP_SELECTED_MODELS.map((model) => model.sourcePath), + ...INNER_KEEP_SELECTED_PREVIEWS.map((preview) => preview.sourcePath) +].sort()); +export const INNER_KEEP_PLANNED_RUNTIME_PATHS = Object.freeze([ + ...INNER_KEEP_SELECTED_MODELS.map((model) => model.destinationPath), + ...INNER_KEEP_SELECTED_PREVIEWS.map((preview) => preview.destinationPath) +].sort()); + +export function assertInnerKeepRuntimeUseAuthorized(record = RECORD) { + if ( + record.authorization?.officialRepositoryRuntimeUseAuthorized !== true + || record.authorization?.status !== 'authorized-owner-runtime-use' + ) { + fail( + 'Inner Keep runtime installation', + 'owner authorization for copying the selected archive-only assets into the public Warpkeep repository and official runtime is not recorded' + ); + } +} + +export function assertSafeInnerKeepArchiveMembers(observedPaths, expectedPaths, packageRoot) { + if (!Array.isArray(observedPaths) || !Array.isArray(expectedPaths)) { + fail('Inner Keep archive membership', 'observed and expected paths must be arrays.'); + } + const normalized = new Set(); + const folded = new Set(); + for (const path of observedPaths) { + if (!safeArchivePath(path, packageRoot)) { + fail('Inner Keep archive membership', `unsafe member ${JSON.stringify(path)}.`); + } + const canonical = path.normalize('NFC'); + const caseFolded = canonical.toLocaleLowerCase('en-US'); + if (normalized.has(canonical) || folded.has(caseFolded)) { + fail('Inner Keep archive membership', `normalized-path collision at ${JSON.stringify(path)}.`); + } + normalized.add(canonical); + folded.add(caseFolded); + } + const observed = observedPaths.slice().sort(); + const expected = expectedPaths.slice().sort(); + if (!exactArray(observed, expected)) { + fail( + 'Inner Keep archive membership', + `changed from the trusted release manifest (expected ${expected.length}, received ${observed.length}).` + ); + } +} + +export function assertTrustedInnerKeepReleaseManifest(bytes) { + const expected = RECORD.sourceRelease.trustedReleaseManifest; + if (!Buffer.isBuffer(bytes) || bytes.byteLength !== expected.bytes || sha256(bytes) !== expected.sha256) { + fail('Inner Keep trusted release manifest', 'does not match its exact pinned bytes.'); + } + let manifest; + try { + manifest = JSON.parse(bytes.toString('utf8')); + } catch { + fail('Inner Keep trusted release manifest', 'is not valid JSON.'); + } + const attachment = manifest?.attachments?.[0]; + if ( + manifest?.schemaVersion !== 1 + || manifest?.tag !== RECORD.sourceRelease.tag + || manifest?.repository !== RECORD.sourceRelease.repository + || manifest?.license?.status !== 'public-archive-authorized-no-separate-open-license' + || manifest?.verification?.runtimeIntegration !== 'Current in-game integration is not asserted by this archive deposit.' + || manifest?.attachments?.length !== 1 + || attachment?.name !== RECORD.sourceRelease.attachment.name + || attachment?.bytes !== RECORD.sourceRelease.attachment.bytes + || attachment?.sha256 !== RECORD.sourceRelease.attachment.sha256 + || !Array.isArray(attachment?.entries) + || attachment.entries.length !== RECORD.sourceRelease.attachment.entries + ) fail('Inner Keep trusted release manifest', 'release identity or license boundary changed.'); + const entryMap = new Map(); + for (const entry of attachment.entries) { + assertExactFileRecord(entry, `Inner Keep release entry ${entry?.path ?? ''}`); + if (!safeArchivePath(entry.path, RECORD.sourceRelease.attachment.packageRoot)) { + fail('Inner Keep trusted release manifest', `contains unsafe entry ${JSON.stringify(entry.path)}.`); + } + if (entryMap.has(entry.path)) { + fail('Inner Keep trusted release manifest', `duplicates entry ${JSON.stringify(entry.path)}.`); + } + entryMap.set(entry.path, entry); + } + for (const asset of RECORD.assets) { + const selected = [asset.sourceManifest, ...asset.models.map((model) => ({ + path: model.sourcePath, + bytes: model.bytes, + sha256: model.sha256 + }))]; + if (asset.preview) selected.push({ + path: asset.preview.sourcePath, + bytes: asset.preview.bytes, + sha256: asset.preview.sha256 + }); + for (const expectedEntry of selected) { + const observed = entryMap.get(expectedEntry.path); + if ( + observed?.bytes !== expectedEntry.bytes + || observed?.sha256 !== expectedEntry.sha256 + ) fail('Inner Keep trusted release manifest', `does not attest ${expectedEntry.path}.`); + } + } + return deeplyFreeze({ manifest, attachment, entryMap }); +} + +export function readInnerKeepGlbJson(bytes, label) { + if ( + !Buffer.isBuffer(bytes) + || bytes.byteLength < 20 + || bytes.subarray(0, 4).toString('ascii') !== 'glTF' + || bytes.readUInt32LE(4) !== 2 + || bytes.readUInt32LE(8) !== bytes.byteLength + ) fail(label, 'is not an intact glTF 2.0 binary.'); + const jsonLength = bytes.readUInt32LE(12); + const jsonEnd = 20 + jsonLength; + if (bytes.readUInt32LE(16) !== 0x4e4f534a || jsonEnd > bytes.byteLength) { + fail(label, 'has an invalid GLB JSON chunk.'); + } + try { + return JSON.parse(bytes.subarray(20, jsonEnd).toString('utf8').trim()); + } catch { + fail(label, 'has invalid GLB JSON.'); + } +} + +export function verifyInnerKeepSelectedGlb(bytes, model, label) { + if ( + !model + || !Buffer.isBuffer(bytes) + || bytes.byteLength !== model.bytes + || sha256(bytes) !== model.sha256 + ) fail(label, 'does not match its exact selected bytes.'); + const gltf = readInnerKeepGlbJson(bytes, label); + const hasExternalUri = [...(gltf.buffers ?? []), ...(gltf.images ?? [])] + .some((entry) => typeof entry?.uri === 'string'); + const triangles = (gltf.meshes ?? []).reduce((meshTotal, mesh) => ( + meshTotal + (mesh.primitives ?? []).reduce((primitiveTotal, primitive) => { + const indexAccessor = gltf.accessors?.[primitive.indices]; + const positionAccessor = gltf.accessors?.[primitive.attributes?.POSITION]; + const count = indexAccessor?.count ?? positionAccessor?.count; + return primitiveTotal + (Number.isSafeInteger(count) ? count / 3 : Number.NaN); + }, 0) + ), 0); + if ( + gltf.asset?.version !== '2.0' + || !Array.isArray(gltf.meshes) + || gltf.meshes.length === 0 + || hasExternalUri + || triangles !== model.triangles + ) fail(label, 'does not satisfy the selected embedded GLB structure.'); +} + +export function verifyInnerKeepSelectedPreview(bytes, preview, label) { + if ( + !preview + || !Buffer.isBuffer(bytes) + || bytes.byteLength !== preview.bytes + || sha256(bytes) !== preview.sha256 + || bytes.byteLength < 24 + || !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + || bytes.readUInt32BE(16) !== preview.width + || bytes.readUInt32BE(20) !== preview.height + ) fail(label, 'does not match the exact selected PNG preview.'); +} + +export function assertInnerKeepSelectedSourceManifest(bytes, asset, label) { + const expected = asset?.sourceManifest; + if ( + !expected + || !Buffer.isBuffer(bytes) + || bytes.byteLength !== expected.bytes + || sha256(bytes) !== expected.sha256 + ) fail(label, 'does not match its exact selected manifest bytes.'); + let manifest; + try { + manifest = JSON.parse(bytes.toString('utf8')); + } catch { + fail(label, 'is not valid JSON.'); + } + const identity = manifest.assetId ?? manifest.exactId ?? manifest.id; + const sourceModels = manifest.lods ?? manifest.files; + if (identity !== asset.sourceAssetId || !Array.isArray(sourceModels)) { + fail(label, 'asset identity or LOD list changed.'); + } + for (const model of asset.models) { + const sourceFilename = basename(model.sourcePath); + const source = sourceModels.find((entry) => entry?.file === sourceFilename); + if ( + source?.bytes !== model.bytes + || source?.sha256 !== model.sha256 + || source?.triangles !== model.triangles + ) fail(label, `does not attest ${sourceFilename}.`); + } +} diff --git a/scripts/install-inner-keep-runtime-assets.d.mts b/scripts/install-inner-keep-runtime-assets.d.mts new file mode 100644 index 00000000..5ae3cb0b --- /dev/null +++ b/scripts/install-inner-keep-runtime-assets.d.mts @@ -0,0 +1,6 @@ +export function parseInnerKeepAssetPreparationMode(argumentsList: readonly string[]): string; +export function auditInnerKeepArchive(options?: Readonly<{ + archivePath?: string; + releaseManifestPath?: string; +}>): Readonly>; +export function main(argumentsList?: readonly string[]): void; diff --git a/scripts/install-inner-keep-runtime-assets.mjs b/scripts/install-inner-keep-runtime-assets.mjs new file mode 100644 index 00000000..ccec52fa --- /dev/null +++ b/scripts/install-inner-keep-runtime-assets.mjs @@ -0,0 +1,281 @@ +import { spawnSync } from 'node:child_process'; +import { constants, closeSync, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, relative, resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { createAssetToolEnvironment } from './asset-tool-process.mjs'; +import { + ensureContainedDirectory, + installAtomicFileFamily +} from './atomic-install-file-family.mjs'; +import { + INNER_KEEP_ASSET_SELECTION, + INNER_KEEP_ASSET_SELECTION_DIGEST, + INNER_KEEP_SELECTED_ASSETS, + assertInnerKeepRuntimeUseAuthorized, + assertInnerKeepSelectedSourceManifest, + assertSafeInnerKeepArchiveMembers, + assertTrustedInnerKeepReleaseManifest, + sha256, + verifyInnerKeepSelectedGlb, + verifyInnerKeepSelectedPreview +} from './inner-keep-runtime-asset-contract.mjs'; +import { resolveAttestedSystemUnzip } from './system-unzip.mjs'; + +const ROOT = resolve(import.meta.dirname, '..'); +const RELEASE = INNER_KEEP_ASSET_SELECTION.sourceRelease; +const DEFAULT_CACHE_ROOT = resolve(ROOT, '.cache/warpkeep-assets', RELEASE.tag); + +function fail(detail) { + throw new Error(`Inner Keep runtime asset preparation: ${detail}`); +} + +function resolveInputPath(environmentName, fallback) { + const value = process.env[environmentName]; + return value ? resolve(value) : fallback; +} + +function readExactOrdinaryFile(path, expected, label) { + let descriptor; + try { + descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = fstatSync(descriptor); + if (!before.isFile() || before.size !== expected.bytes) { + fail(`${label} must be an exact regular non-symbolic file of ${expected.bytes} bytes.`); + } + const bytes = readFileSync(descriptor); + const after = fstatSync(descriptor); + const pathStatus = lstatSync(path, { throwIfNoEntry: false }); + if ( + !pathStatus?.isFile() + || pathStatus.isSymbolicLink() + || before.dev !== after.dev + || before.ino !== after.ino + || before.size !== after.size + || pathStatus.dev !== after.dev + || pathStatus.ino !== after.ino + || pathStatus.size !== after.size + || bytes.byteLength !== after.size + || sha256(bytes) !== expected.sha256 + ) fail(`${label} does not match its exact pinned bytes.`); + return bytes; + } catch (error) { + if (error instanceof Error && error.message.startsWith('Inner Keep runtime asset preparation:')) { + throw error; + } + fail(`${label} cannot be read safely (${error instanceof Error ? error.message : String(error)}).`); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +function makeUnzipRunner(unzipBinary, archive, workspace) { + const environment = createAssetToolEnvironment(workspace); + return (args, encoding = 'utf8', maxBuffer = 8 * 1024 * 1024) => { + const result = spawnSync(unzipBinary, [...args, archive], { + cwd: workspace, + env: environment, + encoding, + maxBuffer + }); + if (result.error) fail(`unzip failed (${result.error.message}).`); + if (result.status !== 0) { + fail(`unzip failed (${result.status}): ${String(result.stderr).trim()}`); + } + return result.stdout; + }; +} + +function makeArchiveReader(unzipBinary, archive, workspace) { + const environment = createAssetToolEnvironment(workspace); + return (member, maxBuffer = 8 * 1024 * 1024) => { + const result = spawnSync(unzipBinary, ['-p', archive, member], { + cwd: workspace, + env: environment, + encoding: null, + maxBuffer + }); + if (result.error) fail(`cannot read ${member} (${result.error.message}).`); + if (result.status !== 0 || !Buffer.isBuffer(result.stdout)) { + fail(`cannot read ${member} from the trusted archive (${result.status}).`); + } + return result.stdout; + }; +} + +function assertOnlyRegularZipMembers(listing, expectedCount) { + const memberLines = String(listing) + .split(/\r?\n/u) + .filter((line) => /^[bcdlps-][rwxStT-]{9}\s/u.test(line)); + if ( + memberLines.length !== expectedCount + || memberLines.some((line) => !line.startsWith('-')) + ) fail('archive contains a symbolic link, directory, or other non-regular member.'); +} + +function exactMember(bytes, record, label) { + if ( + !Buffer.isBuffer(bytes) + || bytes.byteLength !== record.bytes + || sha256(bytes) !== record.sha256 + ) fail(`${label} does not match its trusted release-manifest entry.`); + return bytes; +} + +function ensureInstallDirectories(paths) { + const directories = [...new Set(paths.map((path) => dirname(path)))].sort(); + for (const directory of directories) { + ensureContainedDirectory({ + root: ROOT, + relativePath: directory, + label: 'Inner Keep runtime destination' + }); + } +} + +function installPreparedOutputs(prepared) { + ensureInstallDirectories(prepared.map((entry) => entry.destinationPath)); + const publicRoot = resolve(ROOT, 'public'); + installAtomicFileFamily({ + destinationRoot: publicRoot, + entries: prepared.map((entry) => { + const destination = resolve(ROOT, entry.destinationPath); + const relation = relative(publicRoot, destination); + if ( + relation === '' + || relation === '..' + || relation.startsWith(`..${sep}`) + || relation.startsWith('/') + ) fail(`destination escaped public/: ${entry.destinationPath}.`); + return { + bytes: entry.bytes, + label: entry.label, + relativePath: relation + }; + }) + }); +} + +export function parseInnerKeepAssetPreparationMode(argumentsList) { + if ( + !Array.isArray(argumentsList) + || argumentsList.length !== 1 + || !['--audit-only', '--install'].includes(argumentsList[0]) + ) fail('use exactly one mode: --audit-only or --install.'); + return argumentsList[0]; +} + +export function auditInnerKeepArchive(options = {}) { + const archivePath = options.archivePath ?? resolveInputPath( + 'WARPKEEP_INNER_KEEP_ARCHIVE', + resolve(DEFAULT_CACHE_ROOT, RELEASE.attachment.name) + ); + const releaseManifestPath = options.releaseManifestPath ?? resolveInputPath( + 'WARPKEEP_INNER_KEEP_RELEASE_MANIFEST', + resolve(DEFAULT_CACHE_ROOT, 'manifest.json') + ); + const releaseManifestBytes = readExactOrdinaryFile( + releaseManifestPath, + RELEASE.trustedReleaseManifest, + 'trusted Warpkeep-Assets release manifest' + ); + const trusted = assertTrustedInnerKeepReleaseManifest(releaseManifestBytes); + const archiveBytes = readExactOrdinaryFile( + archivePath, + RELEASE.attachment, + `${RELEASE.tag}/${RELEASE.attachment.name}` + ); + const workspace = mkdtempSync(resolve(tmpdir(), 'warpkeep-inner-keep-assets-')); + const validationArchive = resolve(workspace, RELEASE.attachment.name); + try { + writeFileSync(validationArchive, archiveBytes, { flag: 'wx', mode: 0o600 }); + archiveBytes.fill(0); + const unzipBinary = resolveAttestedSystemUnzip(); + const runUnzip = makeUnzipRunner(unzipBinary, validationArchive, workspace); + const readMember = makeArchiveReader(unzipBinary, validationArchive, workspace); + const observedPaths = String(runUnzip(['-Z1'])) + .split(/\r?\n/u) + .filter(Boolean); + const expectedPaths = trusted.attachment.entries.map((entry) => entry.path); + assertSafeInnerKeepArchiveMembers( + observedPaths, + expectedPaths, + RELEASE.attachment.packageRoot + ); + assertOnlyRegularZipMembers(runUnzip(['-Z', '-l']), RELEASE.attachment.entries); + + const prepared = []; + for (const asset of INNER_KEEP_SELECTED_ASSETS) { + const manifestBytes = exactMember( + readMember(asset.sourceManifest.path), + asset.sourceManifest, + `${asset.displayName} runtime manifest` + ); + assertInnerKeepSelectedSourceManifest( + manifestBytes, + asset, + `${asset.displayName} runtime manifest` + ); + for (const model of asset.models) { + const bytes = exactMember( + readMember(model.sourcePath, Math.max(8 * 1024 * 1024, model.bytes + 1024)), + model, + `${asset.displayName} ${model.profile} GLB` + ); + verifyInnerKeepSelectedGlb(bytes, model, `${asset.displayName} ${model.profile} GLB`); + prepared.push({ + bytes, + destinationPath: model.destinationPath, + label: `${asset.displayName} ${model.profile} Inner Keep runtime` + }); + } + if (asset.preview) { + const bytes = exactMember( + readMember(asset.preview.sourcePath), + asset.preview, + `${asset.displayName} catalogue preview` + ); + verifyInnerKeepSelectedPreview(bytes, asset.preview, `${asset.displayName} catalogue preview`); + prepared.push({ + bytes, + destinationPath: asset.preview.destinationPath, + label: `${asset.displayName} Inner Keep catalogue preview` + }); + } + } + return Object.freeze({ + archivePath, + releaseManifestPath, + prepared, + assetCount: INNER_KEEP_SELECTED_ASSETS.length, + modelCount: prepared.filter((entry) => entry.destinationPath.endsWith('.glb')).length, + previewCount: prepared.filter((entry) => entry.destinationPath.endsWith('.png')).length + }); + } finally { + archiveBytes.fill(0); + rmSync(workspace, { force: true, recursive: true }); + } +} + +export function main(argumentsList = process.argv.slice(2)) { + const mode = parseInnerKeepAssetPreparationMode(argumentsList); + if (mode === '--install') assertInnerKeepRuntimeUseAuthorized(); + const result = auditInnerKeepArchive(); + if (mode === '--install') { + installPreparedOutputs(result.prepared); + console.log( + `Installed ${result.modelCount} selected Inner Keep GLBs and ${result.previewCount} previews.` + ); + } else { + console.log( + `Audited ${result.assetCount} selected Inner Keep assets, ${result.modelCount} GLBs, and ` + + `${result.previewCount} previews without writing runtime output.` + ); + } + console.log(`Selection digest: ${INNER_KEEP_ASSET_SELECTION_DIGEST}`); +} + +const isMain = process.argv[1] + && import.meta.url === pathToFileURL(resolve(process.argv[1])).href; +if (isMain) main(); diff --git a/scripts/publish-spacetime-dev.d.mts b/scripts/publish-spacetime-dev.d.mts index 11cdac56..d5128d08 100644 --- a/scripts/publish-spacetime-dev.d.mts +++ b/scripts/publish-spacetime-dev.d.mts @@ -1,4 +1,6 @@ export const GENESIS_WORLD_PUBLISH_STAGE: Readonly>; +export const INNER_KEEP_MODULE_PREDECESSOR: Readonly>; +export const INNER_KEEP_PUBLICATION_STAGE: Readonly>; export const PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS: Readonly>; export const RESOURCE_PUBLISH_ROLLOUT_STAGE: Readonly>; export const WORKER_FORWARD_REPAIR: Readonly>; @@ -20,6 +22,11 @@ export const DAILY_MARK_V14_TABLE_CONTRACTS: Readonly>>; +export const INNER_KEEP_V15_TABLE_CONTRACTS: Readonly>>; export interface FoundedPublishExpectations { readonly expectedEnabledAllowedFidCount: number; @@ -28,9 +35,20 @@ export interface FoundedPublishExpectations { readonly expectedTermsAcceptanceCount: number; } +export interface MigrationArtifactReceipt { + readonly artifactPath: string; + readonly v11TableSchemaDigest: string; + readonly v12TableSchemaDigest: string; + readonly v13TableSchemaDigest: string; + readonly v14TableSchemaDigest: string; + readonly v15TableSchemaDigest: string; + readonly artifactDigest: string; +} + export function alphaV8AggregateChildArguments(...args: any[]): any; export function alphaV10AggregateChildArguments(...args: any[]): any; export function alphaV12AggregateChildArguments(...args: any[]): any; +export function accessRequestV13InspectChildArguments(...args: any[]): any; export function attestPinnedSpacetimeCli( executable: string, ): Readonly<{ @@ -41,6 +59,8 @@ export function attestPinnedSpacetimeCli( export function canonicalSchemaDescribeChildArguments(...args: any[]): any; export function createPrivatePublishSnapshot(...args: any[]): any; export function dailyMarksV14InspectChildArguments(...args: any[]): any; +export function executeProtocolV15InactivePublicationLane(...args: any[]): Promise; +export function innerKeepV15InspectChildArguments(...args: any[]): any; export function parseCanonicalSchemaDescription(...args: any[]): any; export function parseMigrationProofReceipt(...args: any[]): any; export function parsePublishArguments(...args: any[]): any; @@ -53,12 +73,18 @@ export function readFoundedPublishExpectations( source?: Record, ): Readonly; export function requireCanonicalPublishCoordinates(...args: any[]): any; +export function requireReviewedAdditivePublicationLane( + receipt: MigrationArtifactReceipt, + innerKeepModulePredecessor?: string, + innerKeepPublicationStage?: string, +): Readonly; export function runCurrentAdditiveMigrationProof(...args: any[]): any; export function validateIssuerDeployment(...args: any[]): any; export function verifyCanonicalDatabaseList(...args: any[]): any; export function verifyFreshAlphaStatusV8Aggregate(...args: any[]): any; export function verifyFreshAlphaStatusV10Aggregate(...args: any[]): any; export function verifyFreshAlphaStatusV12Aggregate(...args: any[]): any; +export function verifyFreshAccessRequestV13Aggregate(...args: any[]): any; export function verifyFreshPublishExactV12Aggregate( secret: string, expectations: FoundedPublishExpectations, @@ -73,6 +99,7 @@ export function verifyFreshProductionV11Schema(...args: any[]): any; export function verifyFreshProductionV12ModuleSchema(...args: any[]): any; export function verifyFreshProductionV13ModuleSchema(...args: any[]): any; export function verifyFreshProductionV14ModuleSchema(...args: any[]): any; +export function verifyFreshProductionV14InnerKeepPredecessor(...args: any[]): any; export function verifyFreshActiveDailyMarksV14( secret: string, expectations: FoundedPublishExpectations, @@ -86,6 +113,7 @@ export function verifyFreshFoundedProtocolV3Aggregate( export function verifyFreshResourceProtocolV4PrebackfillAggregate(...args: any[]): any; export function verifyFreshResourceProtocolV4ReadyAggregate(...args: any[]): any; export function verifyMigrationArtifactReceipt(...args: any[]): any; +export function verifyEmptyInactiveInnerKeepV15StatusOutput(...args: any[]): any; export function verifyEmptyDailyMarksV14StatusOutput(...args: any[]): any; export function verifyActiveDailyMarksV14StatusOutput( output: string, @@ -96,6 +124,7 @@ export function verifyPinnedCliAttestation(...args: any[]): any; export function verifyPostPublishAlphaStatusV8Aggregate(...args: any[]): any; export function verifyPostPublishAlphaStatusV10Aggregate(...args: any[]): any; export function verifyPostPublishAlphaStatusV12Aggregate(...args: any[]): any; +export function verifyPostPublishAccessRequestV13Aggregate(...args: any[]): any; export function verifyPostPublishCombinedV12Aggregate( secret: string, expectations: FoundedPublishExpectations, @@ -113,7 +142,9 @@ export function verifyPostPublishProductionV13ActiveModuleSchema(...args: any[]) export function verifyPostPublishProductionV13SchemaFromV11(...args: any[]): any; export function verifyPostPublishProductionV14ModuleSchema(...args: any[]): any; export function verifyPostPublishProductionV14ActiveModuleSchema(...args: any[]): any; +export function verifyPostPublishProductionV15InactiveModuleSchema(...args: any[]): any; export function verifyPostPublishEmptyDailyMarksV14(...args: any[]): any; +export function verifyPostPublishEmptyInactiveInnerKeepV15(...args: any[]): any; export function verifyPostPublishActiveDailyMarksV14( secret: string, expectations: FoundedPublishExpectations, @@ -129,6 +160,7 @@ export function verifyPostPublishResourcePublicationCheckpoints( export function verifyPrivacySafeAlphaStatusV8Output(...args: any[]): any; export function verifyPrivacySafeAlphaStatusV10Output(...args: any[]): any; export function verifyPrivacySafeAlphaStatusV12Output(...args: any[]): any; +export function verifyPrivacySafeAccessRequestV13AggregateOutput(...args: any[]): any; export function verifyPrivacySafePublishPostV12Output(...args: any[]): any; export function verifyPrivacySafePublishPreV12Output(...args: any[]): any; export function verifyActiveAlphaStatusV12(...args: any[]): any; @@ -143,6 +175,12 @@ export function verifyExactProductionV13SchemaFromV11(...args: any[]): any; export function verifyExactProductionV13ModuleSchema(...args: any[]): any; export function verifyExactProductionV14Schema(...args: any[]): any; export function verifyExactProductionV14ModuleSchema(...args: any[]): any; +export function verifyExactProductionV14InnerKeepPredecessor(...args: any[]): any; +export function verifyExactProductionV15Schema(...args: any[]): any; +export function verifyExactProductionV15ModuleSchema(...args: any[]): any; +export function verifyHistoricalPublicationAggregateUnchanged(...args: any[]): any; +export function verifyInnerKeepV14PredecessorAbi(...args: any[]): any; +export function verifyInnerKeepV15ModuleAbi(...args: any[]): any; export function verifyWorkerV12ModuleAbi(...args: any[]): any; export function verifyWorkerV12ModulePredecessor(...args: any[]): any; export function verifyWorkerV13ModulePredecessor(...args: any[]): any; diff --git a/scripts/publish-spacetime-dev.mjs b/scripts/publish-spacetime-dev.mjs index 766b0229..c17ad7fb 100644 --- a/scripts/publish-spacetime-dev.mjs +++ b/scripts/publish-spacetime-dev.mjs @@ -106,6 +106,12 @@ export const WORKER_FORWARD_REPAIR = Object.freeze({ NONE: 'none', RETURN_NODE_REUSE_V1: 'return-node-reuse-v1', }); +export const INNER_KEEP_MODULE_PREDECESSOR = Object.freeze({ + EXACT_V14_ACTIVE: 'exact-v14-active', +}); +export const INNER_KEEP_PUBLICATION_STAGE = Object.freeze({ + APPEND_INACTIVE: 'append-inactive', +}); export const WORKER_FORWARD_REPAIR_CHECKPOINT = Object.freeze({ HEALTHY: 'healthy', ACTIVE_PREDECESSOR: 'active-predecessor', @@ -255,6 +261,79 @@ export const DAILY_MARK_V14_TABLE_CONTRACTS = Object.freeze({ ]), }), }); +export const INNER_KEEP_V15_TABLE_CONTRACTS = Object.freeze({ + inner_keep_layout_v1: Object.freeze({ + productTypeRef: 56, + access: 'Public', + fields: Object.freeze([ + 'layout_id', 'layout_version', 'policy_version', 'slot_count', + 'medium_slot_count', 'large_slot_count', 'asset_catalog_digest', + 'layout_digest', 'active', 'created_at', 'activated_at', + ]), + }), + inner_keep_slot_v1: Object.freeze({ + productTypeRef: 57, + access: 'Public', + fields: Object.freeze([ + 'slot_id', 'layout_id', 'footprint_class', 'local_x_microunits', + 'local_z_microunits', 'rotation_milli_degrees', 'sort_order', 'active', + ]), + }), + inner_keep_building_catalog_v1: Object.freeze({ + productTypeRef: 58, + access: 'Public', + fields: Object.freeze([ + 'building_kind', 'public_label', 'category', 'footprint_class', + 'maximum_level', 'unique_per_castle', 'matching_discount_resource', + 'discount_basis_points_per_level', 'discount_cap_basis_points', + 'runtime_asset_id', 'preview_asset_id', 'active', 'policy_version', + ]), + }), + inner_keep_build_level_v1: Object.freeze({ + productTypeRef: 59, + access: 'Public', + fields: Object.freeze([ + 'level_key', 'building_kind', 'target_level', 'base_food_cost', + 'base_wood_cost', 'base_stone_cost', 'base_gold_cost', + 'level_multiplier_basis_points', 'duration_micros', 'policy_version', + ]), + }), + castle_inner_keep_building_v1: Object.freeze({ + productTypeRef: 60, + access: 'Public', + fields: Object.freeze([ + 'building_key', 'castle_id', 'slot_key', 'slot_id', 'building_kind', + 'completed_level', 'target_level', 'phase', 'started_at_micros', + 'completes_at_micros', 'revision', 'policy_version', + ]), + }), + castle_inner_builder_v1: Object.freeze({ + productTypeRef: 61, + access: 'Private', + fields: Object.freeze([ + 'castle_id', 'fid', 'active_building_key', 'busy_until_micros', + 'revision', 'policy_version', 'created_at', 'updated_at', + ]), + }), + castle_inner_build_receipt_v1: Object.freeze({ + productTypeRef: 62, + access: 'Private', + fields: Object.freeze([ + 'receipt_key', 'fid', 'request_key', 'castle_id', 'building_key', + 'slot_id', 'building_kind', 'target_level', 'deducted_food', + 'deducted_wood', 'deducted_stone', 'deducted_gold', 'started_at', + 'policy_version', + ]), + }), + castle_inner_construction_schedule_v_1: Object.freeze({ + productTypeRef: 63, + access: 'Private', + fields: Object.freeze([ + 'schedule_id', 'scheduled_at', 'building_key', 'expected_revision', + 'expected_target_level', + ]), + }), +}); const WORKER_V12_PREDECESSOR_ACTIVATION_FIELDS = Object.freeze([ ['capability', 'String'], @@ -569,6 +648,172 @@ const WORKER_V12_ATOMIC_PROCEDURE_FIELDS = Object.freeze({ get_my_worker_control_state_v1: WORKER_V12_CONTROL_STATE_FIELDS, }); +const INNER_KEEP_OPTION_STRING_TYPE = workerSumType([ + ['some', 'String'], + ['none', workerProductType([])], +]); +const INNER_KEEP_OPTION_U64_TYPE = workerSumType([ + ['some', 'U64'], + ['none', workerProductType([])], +]); +const INNER_KEEP_OPTION_U32_TYPE = workerSumType([ + ['some', 'U32'], + ['none', workerProductType([])], +]); +const INNER_KEEP_V15_SCHEDULE_ROW_FIELDS = Object.freeze([ + ['schedule_id', 'U64'], + ['scheduled_at', WORKER_V12_TIMESTAMP_TYPE], + ['building_key', 'String'], + ['expected_revision', 'U64'], + ['expected_target_level', 'U32'], +]); +const INNER_KEEP_V15_REDUCER_FIELDS = Object.freeze({ + inner_keep_start_project_v1: Object.freeze([ + ['slotId', 'String'], + ['buildingKind', 'String'], + ['requestKey', 'String'], + ]), + admin_seed_inner_keep_catalog_v1: Object.freeze([ + ['capability', 'String'], + ['policyDigest', 'String'], + ['layoutDigest', 'String'], + ['assetCatalogDigest', 'String'], + ['expectedMissingLayout', 'U32'], + ['expectedMissingSlots', 'U32'], + ['expectedMissingBuildings', 'U32'], + ['expectedMissingLevels', 'U32'], + ]), + admin_backfill_inner_keep_builders_v1: Object.freeze([ + ['capability', 'String'], + ['policyDigest', 'String'], + ['layoutDigest', 'String'], + ['assetCatalogDigest', 'String'], + ['expectedCastles', 'U32'], + ['expectedExistingBuilders', 'U32'], + ['expectedMissingBuilders', 'U32'], + ]), + admin_activate_inner_keep_v1: Object.freeze([ + ['capability', 'String'], + ['policyDigest', 'String'], + ['layoutDigest', 'String'], + ['assetCatalogDigest', 'String'], + ['clientRelease', 'String'], + ['clientArtifactDigest', 'String'], + ['moduleArtifactDigest', 'String'], + ['sourceCommit', 'String'], + ['expectedCastleCount', 'U32'], + ]), + admin_deactivate_inner_keep_v1: Object.freeze([ + ['capability', 'String'], + ['expectedCastleCount', 'U32'], + ['expectedActiveProjects', 'U32'], + ]), + run_inner_keep_construction_schedule_v_1: Object.freeze([ + ['arg', workerRefType(INNER_KEEP_V15_SCHEDULE_ROW_FIELDS)], + ]), +}); +const INNER_KEEP_V15_STATE_FIELDS = Object.freeze([ + ['castleId', 'U64'], + ['componentActive', 'Bool'], + ['componentReady', 'Bool'], + ['builderPresent', 'Bool'], + ['builderBusy', 'Bool'], + ['activeBuildingKey', INNER_KEEP_OPTION_STRING_TYPE], + ['busyUntilMicros', INNER_KEEP_OPTION_U64_TYPE], + ['builderRevision', 'U64'], + ['storedFood', 'U64'], + ['storedWood', 'U64'], + ['storedStone', 'U64'], + ['storedGold', 'U64'], + ['projectedFood', 'U64'], + ['projectedWood', 'U64'], + ['projectedStone', 'U64'], + ['projectedGold', 'U64'], + ['resourceRevision', 'U64'], + ['observedAtMicros', 'U64'], + ['policyVersion', 'String'], + ['layoutDigest', 'String'], + ['assetCatalogDigest', 'String'], +]); +const INNER_KEEP_V15_REQUEST_STATUS_FIELDS = Object.freeze([ + ['found', 'Bool'], + ['castleId', INNER_KEEP_OPTION_U64_TYPE], + ['buildingKey', INNER_KEEP_OPTION_STRING_TYPE], + ['slotId', INNER_KEEP_OPTION_STRING_TYPE], + ['buildingKind', INNER_KEEP_OPTION_STRING_TYPE], + ['targetLevel', INNER_KEEP_OPTION_U32_TYPE], + ['deductedFood', INNER_KEEP_OPTION_U64_TYPE], + ['deductedWood', INNER_KEEP_OPTION_U64_TYPE], + ['deductedStone', INNER_KEEP_OPTION_U64_TYPE], + ['deductedGold', INNER_KEEP_OPTION_U64_TYPE], + ['startedAtMicros', INNER_KEEP_OPTION_U64_TYPE], + ['policyVersion', INNER_KEEP_OPTION_STRING_TYPE], +]); +const INNER_KEEP_V15_ADMIN_STATUS_FIELDS = Object.freeze([ + ['layoutRows', 'U64'], + ['slotRows', 'U64'], + ['buildingCatalogRows', 'U64'], + ['levelPolicyRows', 'U64'], + ['castleRows', 'U64'], + ['builderRows', 'U64'], + ['buildingRows', 'U64'], + ['activeProjects', 'U64'], + ['receiptRows', 'U64'], + ['scheduleRows', 'U64'], + ['missingBuilders', 'U64'], + ['orphanBuilders', 'U64'], + ['invalidBuilders', 'U64'], + ['invalidBuildings', 'U64'], + ['invalidSchedules', 'U64'], + ['builderProjectMismatches', 'U64'], + ['staticCatalogExact', 'Bool'], + ['workerSystemReady', 'Bool'], + ['readyForCatalogSeed', 'Bool'], + ['readyForBuilderBackfill', 'Bool'], + ['readyForActivation', 'Bool'], + ['active', 'Bool'], + ['policyVersion', 'String'], + ['policyDigest', 'String'], + ['layoutPolicyVersion', 'String'], + ['layoutDigest', 'String'], + ['assetCatalogDigest', 'String'], +]); +const INNER_KEEP_V15_CATALOG_PLAN_FIELDS = Object.freeze([ + ['missingLayout', 'U32'], + ['missingSlots', 'U32'], + ['missingBuildings', 'U32'], + ['missingLevels', 'U32'], + ['ready', 'Bool'], +]); +const INNER_KEEP_V15_BUILDER_PLAN_FIELDS = Object.freeze([ + ['expectedCastles', 'U32'], + ['existingBuilders', 'U32'], + ['missingBuilders', 'U32'], + ['ready', 'Bool'], +]); +const INNER_KEEP_V15_PROCEDURE_ABI = Object.freeze({ + get_my_inner_keep_state_v1: Object.freeze({ + params: Object.freeze([]), + returns: INNER_KEEP_V15_STATE_FIELDS, + }), + get_my_inner_keep_request_status_v1: Object.freeze({ + params: Object.freeze([['requestKey', 'String']]), + returns: INNER_KEEP_V15_REQUEST_STATUS_FIELDS, + }), + admin_get_inner_keep_status_v1: Object.freeze({ + params: Object.freeze([]), + returns: INNER_KEEP_V15_ADMIN_STATUS_FIELDS, + }), + admin_plan_inner_keep_catalog_v1: Object.freeze({ + params: Object.freeze([]), + returns: INNER_KEEP_V15_CATALOG_PLAN_FIELDS, + }), + admin_plan_inner_keep_builders_v1: Object.freeze({ + params: Object.freeze([]), + returns: INNER_KEEP_V15_BUILDER_PLAN_FIELDS, + }), +}); + const ALPHA_V8_COUNT_FIELDS = Object.freeze([ 'goldSites', 'canonicalGoldSites', @@ -731,6 +976,46 @@ const DAILY_MARK_V14_STATUS_KEYS = Object.freeze([ ...DAILY_MARK_V14_COUNT_FIELDS, ...DAILY_MARK_V14_BOOLEAN_FIELDS, ].sort()); +const INNER_KEEP_V15_POLICY_VERSION = 'genesis-001-inner-keep-construction-v1'; +const INNER_KEEP_V15_LAYOUT_POLICY_VERSION = 'genesis-001-inner-keep-layout-v1'; +const INNER_KEEP_V15_STATUS_COUNT_FIELDS = Object.freeze([ + 'layoutRows', + 'slotRows', + 'buildingCatalogRows', + 'levelPolicyRows', + 'castleRows', + 'builderRows', + 'buildingRows', + 'activeProjects', + 'receiptRows', + 'scheduleRows', + 'missingBuilders', + 'orphanBuilders', + 'invalidBuilders', + 'invalidBuildings', + 'invalidSchedules', + 'builderProjectMismatches', +]); +const INNER_KEEP_V15_STATUS_BOOLEAN_FIELDS = Object.freeze([ + 'staticCatalogExact', + 'workerSystemReady', + 'readyForCatalogSeed', + 'readyForBuilderBackfill', + 'readyForActivation', + 'active', +]); +const INNER_KEEP_V15_STATUS_STRING_FIELDS = Object.freeze([ + 'policyVersion', + 'policyDigest', + 'layoutPolicyVersion', + 'layoutDigest', + 'assetCatalogDigest', +]); +const INNER_KEEP_V15_STATUS_KEYS = Object.freeze([ + ...INNER_KEEP_V15_STATUS_COUNT_FIELDS, + ...INNER_KEEP_V15_STATUS_BOOLEAN_FIELDS, + ...INNER_KEEP_V15_STATUS_STRING_FIELDS, +].sort()); const EMPTY_WORKER_V12_ZERO_FIELDS = Object.freeze([ 'systemRows', 'expectedCastleCount', @@ -1073,6 +1358,8 @@ export function parsePublishArguments(arguments_ = process.argv.slice(2)) { let workerModulePredecessor = WORKER_MODULE_PREDECESSOR.V11; let workerModulePredecessorExplicit = false; let workerForwardRepair; + let innerKeepModulePredecessor; + let innerKeepPublicationStage; for (const argument of arguments_) { if (argument === '--dry-run' && !dryRun) { dryRun = true; @@ -1129,7 +1416,27 @@ export function parsePublishArguments(arguments_ = process.argv.slice(2)) { continue; } } - fail('Usage: publish-spacetime-dev.mjs [--dry-run] --resource-rollout-stage= --genesis-world-stage= --worker-rollout-stage= [--worker-module-predecessor=] --worker-forward-repair=. Unknown or duplicate arguments are rejected.'); + if ( + argument.startsWith('--inner-keep-module-predecessor=') + && innerKeepModulePredecessor === undefined + ) { + const value = argument.slice('--inner-keep-module-predecessor='.length); + if (Object.values(INNER_KEEP_MODULE_PREDECESSOR).includes(value)) { + innerKeepModulePredecessor = value; + continue; + } + } + if ( + argument.startsWith('--inner-keep-publication-stage=') + && innerKeepPublicationStage === undefined + ) { + const value = argument.slice('--inner-keep-publication-stage='.length); + if (Object.values(INNER_KEEP_PUBLICATION_STAGE).includes(value)) { + innerKeepPublicationStage = value; + continue; + } + } + fail('Usage: publish-spacetime-dev.mjs [--dry-run] --resource-rollout-stage= --genesis-world-stage= --worker-rollout-stage= [--worker-module-predecessor=] --worker-forward-repair= [--inner-keep-module-predecessor=exact-v14-active --inner-keep-publication-stage=append-inactive]. Unknown or duplicate arguments are rejected.'); } if (resourceRolloutStage === undefined) { fail('An explicit resource rollout stage is required: prebackfill for the first additive publication or ready for an already-backfilled republish.'); @@ -1187,6 +1494,25 @@ export function parsePublishArguments(arguments_ = process.argv.slice(2)) { ) { fail('The return-node-reuse-v1 forward repair requires the exact ready, expanded, active-v12 production predecessor or exact active-v13 production predecessor.'); } + if ((innerKeepModulePredecessor === undefined) !== (innerKeepPublicationStage === undefined)) { + fail('The Inner Keep publication lane requires both an explicit module predecessor and an explicit publication stage.'); + } + if ( + innerKeepModulePredecessor !== undefined + && ( + innerKeepModulePredecessor + !== INNER_KEEP_MODULE_PREDECESSOR.EXACT_V14_ACTIVE + || innerKeepPublicationStage + !== INNER_KEEP_PUBLICATION_STAGE.APPEND_INACTIVE + || resourceRolloutStage !== RESOURCE_PUBLISH_ROLLOUT_STAGE.READY + || genesisWorldRolloutStage !== GENESIS_WORLD_PUBLISH_STAGE.EXPANDED + || workerRolloutStage !== WORKER_PUBLISH_ROLLOUT_STAGE.ACTIVE + || workerModulePredecessor !== WORKER_MODULE_PREDECESSOR.EXACT_V14_ACTIVE + || workerForwardRepair !== WORKER_FORWARD_REPAIR.NONE + ) + ) { + fail('The Inner Keep v15 append requires the exact active-v14 predecessor, append-inactive stage, resource ready, Genesis expanded, Worker active, and worker-forward-repair=none.'); + } return Object.freeze({ dryRun, resourceRolloutStage, @@ -1194,6 +1520,10 @@ export function parsePublishArguments(arguments_ = process.argv.slice(2)) { workerRolloutStage, workerModulePredecessor, workerForwardRepair, + ...(innerKeepModulePredecessor === undefined ? {} : { + innerKeepModulePredecessor, + innerKeepPublicationStage, + }), }); } @@ -1539,6 +1869,14 @@ function productionV14TableRefs() { }); } +function productionV15TableRefs() { + return Object.freeze({ + ...productionV14TableRefs(), + ...Object.fromEntries(Object.entries(INNER_KEEP_V15_TABLE_CONTRACTS) + .map(([name, contract]) => [name, contract.productTypeRef])), + }); +} + function requireCapturedTableSignatures(signatures, refs, boundary) { if ( !signatures @@ -1737,6 +2075,59 @@ function verifyProductionV14Contracts( }); } +function verifyProductionV15Contracts( + description, + expectedV12TableSchemaDigest, + expectedV13TableSchemaDigest, + expectedV14TableSchemaDigest, + expectedV15TableSchemaDigest, +) { + const v14Names = new Set(Object.keys(productionV14TableRefs())); + const v14Contracts = verifyProductionV14Contracts( + { + ...description, + tables: Array.isArray(description?.tables) + ? description.tables.filter(table => v14Names.has(table?.name)) + : description?.tables, + }, + expectedV12TableSchemaDigest, + expectedV13TableSchemaDigest, + expectedV14TableSchemaDigest, + ); + const v15Refs = productionV15TableRefs(); + verifyExactTableIdentities(description, v15Refs); + for (const [name, contract] of Object.entries( + INNER_KEEP_V15_TABLE_CONTRACTS, + )) { + if ( + schemaTableAccess(description, name) !== contract.access + || canonicalJson(schemaFieldNames(description, name)) + !== canonicalJson(contract.fields) + ) fail('The appended Inner Keep schema did not match the exact v15 contract.'); + } + try { + if ( + typeof expectedV15TableSchemaDigest !== 'string' + || !SHA256_DIGEST.test(expectedV15TableSchemaDigest) + || canonicalTableSchemaBoundaryDigest( + description, + Object.keys(v15Refs), + ) !== expectedV15TableSchemaDigest + ) fail('The canonical v15 table schema did not match the proven publication boundary.'); + } catch (error) { + if ( + error instanceof SafePublishError + && error.message + === 'The canonical v15 table schema did not match the proven publication boundary.' + ) throw error; + fail('The canonical v15 table schema did not match the proven publication boundary.'); + } + return Object.freeze({ + ...v14Contracts, + v15Refs, + }); +} + /** * Require the exact two-table v14 append over a captured active v13 module. * Every v13 signature and independently proven v12/v13 projection is retained; @@ -1771,6 +2162,42 @@ export function verifyExactProductionV14Schema( }); } +/** + * Require the exact eight-table v15 append over one captured production v14 + * boundary. Every ref 0-55 signature remains byte-for-byte canonical and only + * the reviewed public/private Inner Keep refs 56-63 may be introduced. + */ +export function verifyExactProductionV15Schema( + predecessorSignatures, + description, + expectedV12TableSchemaDigest, + expectedV13TableSchemaDigest, + expectedV14TableSchemaDigest, + expectedV15TableSchemaDigest, +) { + const v14Refs = productionV14TableRefs(); + requireCapturedTableSignatures(predecessorSignatures, v14Refs, 'v14'); + const contracts = verifyProductionV15Contracts( + description, + expectedV12TableSchemaDigest, + expectedV13TableSchemaDigest, + expectedV14TableSchemaDigest, + expectedV15TableSchemaDigest, + ); + for (const name of Object.keys(v14Refs)) { + if ( + canonicalJson(schemaTableSignature(description, name)) + !== predecessorSignatures[name] + ) fail('A pre-existing production table changed during the v15 publication.'); + } + return Object.freeze({ + predecessorTableCount: Object.keys(v14Refs).length, + appendedInnerKeepTableCount: + Object.keys(INNER_KEEP_V15_TABLE_CONTRACTS).length, + totalTableCount: Object.keys(contracts.v15Refs).length, + }); +} + /** * Retain the reviewed v11 exceptional lane while binding both append * boundaries. This proves the six exact Worker tables and the one exact @@ -2023,6 +2450,91 @@ function surfaceMatches(actual, expected) { && actualNames.every(name => fieldsMatch(actual[name], expected[name])); } +function criticalInnerKeepAbiName(name) { + return typeof name === 'string' && name.includes('inner_keep'); +} + +function collectInnerKeepReducerAbi(description) { + if (!Array.isArray(description?.reducers)) { + fail('The canonical schema did not expose an Inner Keep reducer ABI.'); + } + const reducers = Object.create(null); + for (const reducer of description.reducers.filter( + candidate => criticalInnerKeepAbiName(candidate?.name), + )) { + if ( + Object.hasOwn(reducers, reducer.name) + || canonicalJson(reducer.lifecycle) !== canonicalJson({ none: [] }) + ) fail('The canonical schema did not contain one exact required Inner Keep reducer.'); + reducers[reducer.name] = workerProductFields( + description, + reducer?.params?.elements, + `${reducer.name} reducer`, + ); + } + return reducers; +} + +function collectInnerKeepProcedureAbi(description) { + if (!Array.isArray(description?.misc_exports)) { + fail('The canonical schema did not expose an Inner Keep procedure ABI.'); + } + const procedures = Object.create(null); + const matches = description.misc_exports + .map(entry => entry?.Procedure) + .filter(procedure => criticalInnerKeepAbiName(procedure?.name)); + for (const procedure of matches) { + if (Object.hasOwn(procedures, procedure.name)) { + fail('The canonical schema did not contain one exact required Inner Keep procedure.'); + } + const returnType = procedure?.return_type; + if ( + !returnType + || typeof returnType !== 'object' + || Array.isArray(returnType) + || Object.keys(returnType).length !== 1 + || !Number.isSafeInteger(returnType.Ref) + || returnType.Ref < 0 + || !Array.isArray(description?.typespace?.types) + || !description.typespace.types[returnType.Ref]?.Product + ) fail('The required Inner Keep procedure return type was invalid.'); + procedures[procedure.name] = Object.freeze({ + params: workerProductFields( + description, + procedure?.params?.elements, + `${procedure.name} procedure parameters`, + ), + returns: workerProductFields( + description, + description.typespace.types[returnType.Ref].Product.elements, + `${procedure.name} procedure`, + new Set([returnType.Ref]), + ), + }); + } + return procedures; +} + +/** v14 must contain no partial, shadow, or already-installed Inner Keep ABI. */ +export function verifyInnerKeepV14PredecessorAbi(description) { + if ( + Object.keys(collectInnerKeepReducerAbi(description)).length !== 0 + || Object.keys(collectInnerKeepProcedureAbi(description)).length !== 0 + ) fail('The production v14 predecessor already exposed an Inner Keep ABI.'); + return 'absent'; +} + +/** Require the complete reviewed v15 reducer/procedure surface and no extras. */ +export function verifyInnerKeepV15ModuleAbi(description) { + const reducers = collectInnerKeepReducerAbi(description); + const procedures = collectInnerKeepProcedureAbi(description); + if ( + !surfaceMatches(reducers, INNER_KEEP_V15_REDUCER_FIELDS) + || canonicalJson(procedures) !== canonicalJson(INNER_KEEP_V15_PROCEDURE_ABI) + ) fail('The production Inner Keep v15 module ABI was partial, unknown, or changed.'); + return 'candidate'; +} + /** * Distinguish the historical inert-v12 boundary, the exact active Worker-v12 * predecessor, and the one Alpha 0.3.21 additive repair candidate. Every @@ -2343,6 +2855,144 @@ export function verifyFreshProductionV14ModuleSchema( } } +/** + * Exact v14/current-live predecessor for the Inner Keep append. This layers an + * explicit absence check for every Inner Keep API over the existing frozen + * v14 schema and reviewed Worker-candidate ABI checkpoint. + */ +export function verifyExactProductionV14InnerKeepPredecessor( + description, + expectedV12TableSchemaDigest, + expectedV13TableSchemaDigest, + expectedV14TableSchemaDigest, +) { + const predecessor = verifyExactProductionV14ModuleSchema( + description, + expectedV12TableSchemaDigest, + expectedV13TableSchemaDigest, + expectedV14TableSchemaDigest, + ); + return Object.freeze({ + ...predecessor, + innerKeepModuleState: verifyInnerKeepV14PredecessorAbi(description), + }); +} + +export function verifyFreshProductionV14InnerKeepPredecessor( + executable, + expectedV12TableSchemaDigest, + expectedV13TableSchemaDigest, + expectedV14TableSchemaDigest, + spawnSyncProcess = spawnSync, +) { + try { + const result = runBoundedSync( + executable, + canonicalSchemaDescribeChildArguments(), + { timeout: 30_000 }, + spawnSyncProcess, + ); + return verifyExactProductionV14InnerKeepPredecessor( + parseCanonicalSchemaDescription(result.stdout), + expectedV12TableSchemaDigest, + expectedV13TableSchemaDigest, + expectedV14TableSchemaDigest, + ); + } catch { + fail('Exact active production v14 Inner Keep predecessor schema and ABI preflight failed. No publish was attempted.'); + } +} + +/** Bind the proven v15 schema to the complete reviewed Inner Keep ABI. */ +export function verifyExactProductionV15ModuleSchema( + description, + expectedV12TableSchemaDigest, + expectedV13TableSchemaDigest, + expectedV14TableSchemaDigest, + expectedV15TableSchemaDigest, +) { + const contracts = verifyProductionV15Contracts( + description, + expectedV12TableSchemaDigest, + expectedV13TableSchemaDigest, + expectedV14TableSchemaDigest, + expectedV15TableSchemaDigest, + ); + if (verifyWorkerV12ModuleAbi(description) !== 'candidate') { + fail('The exact production v15 module did not preserve the reviewed Worker candidate ABI.'); + } + const tableSignatures = Object.freeze(Object.fromEntries( + Object.keys(contracts.v15Refs).map(name => [ + name, + canonicalJson(schemaTableSignature(description, name)), + ]), + )); + return Object.freeze({ + moduleState: 'candidate', + innerKeepModuleState: verifyInnerKeepV15ModuleAbi(description), + tableSignatures, + totalTableCount: Object.keys(contracts.v15Refs).length, + }); +} + +/** + * Postflight for the sole v14-active -> v15-inactive publication lane. A + * success response from `spacetime publish` is never enough: the fresh schema + * description must prove the captured refs 0-55, exact refs 56-63, and both + * complete Worker and Inner Keep ABIs before any later operation is allowed. + */ +export function verifyPostPublishProductionV15InactiveModuleSchema( + executable, + predecessor, + expectedV12TableSchemaDigest, + expectedV13TableSchemaDigest, + expectedV14TableSchemaDigest, + expectedV15TableSchemaDigest, + spawnSyncProcess = spawnSync, +) { + try { + if ( + !predecessor + || predecessor.moduleState !== 'candidate' + || predecessor.innerKeepModuleState !== 'absent' + ) fail('The captured production v14 Inner Keep predecessor ABI was invalid.'); + requireCapturedTableSignatures( + predecessor.tableSignatures, + productionV14TableRefs(), + 'v14', + ); + const result = runBoundedSync( + executable, + canonicalSchemaDescribeChildArguments(), + { timeout: 30_000 }, + spawnSyncProcess, + ); + const description = parseCanonicalSchemaDescription(result.stdout); + const schema = verifyExactProductionV15Schema( + predecessor.tableSignatures, + description, + expectedV12TableSchemaDigest, + expectedV13TableSchemaDigest, + expectedV14TableSchemaDigest, + expectedV15TableSchemaDigest, + ); + const module = verifyExactProductionV15ModuleSchema( + description, + expectedV12TableSchemaDigest, + expectedV13TableSchemaDigest, + expectedV14TableSchemaDigest, + expectedV15TableSchemaDigest, + ); + return Object.freeze({ + ...schema, + moduleState: module.moduleState, + innerKeepModuleState: module.innerKeepModuleState, + }); + } catch { + fail('Post-publication inactive-v15 module checkpoint is indeterminate; perform fresh anonymous schema/ABI and protected aggregate inspections before any catalog seed, Builder backfill, activation, client deployment, or further publication decision.'); + } +} + export function verifyPostPublishProductionV12ModuleSchema( executable, predecessor, @@ -2668,12 +3318,13 @@ function validateMigrationArtifactReceiptShape(receipt) { receipt === null || typeof receipt !== 'object' || Object.keys(receipt).sort().join(',') - !== 'artifactDigest,artifactPath,v11TableSchemaDigest,v12TableSchemaDigest,v13TableSchemaDigest,v14TableSchemaDigest' + !== 'artifactDigest,artifactPath,v11TableSchemaDigest,v12TableSchemaDigest,v13TableSchemaDigest,v14TableSchemaDigest,v15TableSchemaDigest' || receipt.artifactPath !== PROVEN_ARTIFACT_PATH || !SHA256_DIGEST.test(receipt.v11TableSchemaDigest ?? '') || !SHA256_DIGEST.test(receipt.v12TableSchemaDigest ?? '') || !SHA256_DIGEST.test(receipt.v13TableSchemaDigest ?? '') || !SHA256_DIGEST.test(receipt.v14TableSchemaDigest ?? '') + || !SHA256_DIGEST.test(receipt.v15TableSchemaDigest ?? '') || !SHA256_DIGEST.test(receipt.artifactDigest ?? '') ) { fail('The additive migration proof artifact receipt was invalid.'); @@ -2684,6 +3335,7 @@ function validateMigrationArtifactReceiptShape(receipt) { v12TableSchemaDigest: receipt.v12TableSchemaDigest, v13TableSchemaDigest: receipt.v13TableSchemaDigest, v14TableSchemaDigest: receipt.v14TableSchemaDigest, + v15TableSchemaDigest: receipt.v15TableSchemaDigest, artifactDigest: receipt.artifactDigest, }); } @@ -2697,6 +3349,28 @@ export function verifyMigrationArtifactReceipt(receipt) { return validated; } +/** + * The current artifact contains protocol v15. Keep every historical publisher + * lane closed unless the operator explicitly selects the one reviewed + * active-v14 -> inactive-v15 predecessor/stage pair. + */ +export function requireReviewedAdditivePublicationLane( + receipt, + innerKeepModulePredecessor, + innerKeepPublicationStage, +) { + const validated = validateMigrationArtifactReceiptShape(receipt); + if ( + innerKeepModulePredecessor + !== INNER_KEEP_MODULE_PREDECESSOR.EXACT_V14_ACTIVE + || innerKeepPublicationStage + !== INNER_KEEP_PUBLICATION_STAGE.APPEND_INACTIVE + ) { + fail('Protocol-v15 publication requires the explicit exact-v14-active predecessor and append-inactive stage; no publish was attempted.'); + } + return validated; +} + export function parseMigrationProofReceipt(output) { let proofReceipt; try { @@ -2710,6 +3384,7 @@ export function parseMigrationProofReceipt(output) { v12TableSchemaDigest: proofReceipt.v12TableSchemaDigest, v13TableSchemaDigest: proofReceipt.v13TableSchemaDigest, v14TableSchemaDigest: proofReceipt.v14TableSchemaDigest, + v15TableSchemaDigest: proofReceipt.v15TableSchemaDigest, artifactDigest: proofReceipt.artifactDigest, }); } @@ -2875,6 +3550,18 @@ export function publishPostV12AggregateChildArguments(tsxCli) { ]; } +export function accessRequestV13InspectChildArguments(tsxCli) { + return [ + tsxCli, + 'scripts/hermes-admin.ts', + 'list-access-requests', + '--limit', + '1', + '--include-resolved', + '--json', + ]; +} + export function dailyMarksV14InspectChildArguments(tsxCli) { return [ tsxCli, @@ -2883,6 +3570,102 @@ export function dailyMarksV14InspectChildArguments(tsxCli) { ]; } +/** + * Reduce the private access-request page to counts only. The bounded child may + * read one row to obtain authoritative totals, but no FID, cursor, timestamp, + * or entry leaves this parser. + */ +export function verifyPrivacySafeAccessRequestV13AggregateOutput(output) { + let page; + try { + page = JSON.parse(output); + } catch { + fail('Access-request v13 inspection did not return machine-readable JSON.'); + } + if ( + !page + || typeof page !== 'object' + || Array.isArray(page) + || Object.keys(page).sort().join(',') + !== 'entries,hasMore,nextCursor,pendingRequests,totalRequests' + || !Array.isArray(page.entries) + || page.entries.length > 1 + || typeof page.hasMore !== 'boolean' + || (page.nextCursor !== null && ( + typeof page.nextCursor !== 'object' + || Array.isArray(page.nextCursor) + )) + ) fail('Access-request v13 inspection returned an invalid private page envelope.'); + for (const field of ['totalRequests', 'pendingRequests']) { + if ( + typeof page[field] !== 'string' + || !/^(?:0|[1-9]\d*)$/.test(page[field]) + || page[field].length > 20 + || BigInt(page[field]) > U64_MAXIMUM + ) fail('Access-request v13 inspection returned an invalid aggregate count.'); + } + if ( + BigInt(page.pendingRequests) > BigInt(page.totalRequests) + || BigInt(page.entries.length) > BigInt(page.totalRequests) + || (page.hasMore !== (page.nextCursor !== null)) + ) fail('Access-request v13 inspection returned inconsistent aggregate counts.'); + return Object.freeze({ + totalRequests: page.totalRequests, + pendingRequests: page.pendingRequests, + }); +} + +function inspectAccessRequestV13Aggregate( + secret, + spawnSyncProcess, +) { + const secretBytes = typeof secret === 'string' + ? new TextEncoder().encode(secret).byteLength + : 0; + if (secretBytes < 32 || secretBytes > 512) { + fail('A local 32-to-512-byte Hermes credential is required for the access-request v13 checkpoint.'); + } + const tsxCli = resolve(repositoryRoot, 'node_modules/tsx/dist/cli.mjs'); + const result = runBoundedSync( + process.execPath, + accessRequestV13InspectChildArguments(tsxCli), + { + env: { + WARPKEEP_SPACETIMEDB_URI: CANONICAL_MAINCLOUD_URI, + WARPKEEP_SPACETIMEDB_DATABASE: CANONICAL_DATABASE_IDENTITY, + WARPKEEP_AUTH_BRIDGE_URL: CANONICAL_BRIDGE, + WARPKEEP_ADMIN_TOKEN_SECRET_STDIN: '1', + }, + input: secret, + timeout: 30_000, + }, + spawnSyncProcess, + ); + return verifyPrivacySafeAccessRequestV13AggregateOutput(result.stdout); +} + +export function verifyFreshAccessRequestV13Aggregate( + secret, + spawnSyncProcess = spawnSync, +) { + try { + return inspectAccessRequestV13Aggregate(secret, spawnSyncProcess); + } catch { + fail('Fresh protected access-request v13 aggregate checkpoint failed. No publish was attempted.'); + } +} + +export function verifyPostPublishAccessRequestV13Aggregate( + secret, + spawnSyncProcess = spawnSync, +) { + try { + return inspectAccessRequestV13Aggregate(secret, spawnSyncProcess); + } catch { + fail('Post-publication access-request v13 aggregate checkpoint is indeterminate; perform a fresh protected counts-only inspection before any catalog seed, Builder backfill, activation, client deployment, or further publication decision.'); + } +} + /** Parse only the operator's closed, counts-only daily-Marks status. */ function parseDailyMarksV14StatusOutput(output) { let status; @@ -3090,6 +3873,149 @@ export function verifyPostPublishEmptyDailyMarksV14( } } +export function innerKeepV15InspectChildArguments(tsxCli) { + return [ + tsxCli, + 'scripts/inner-keep-operator.ts', + 'inspect-inner-keep', + ]; +} + +/** Accept only the exact empty, inactive Inner Keep aggregate after append. */ +export function verifyEmptyInactiveInnerKeepV15StatusOutput( + output, + expectedCastleCount, +) { + if ( + !Number.isSafeInteger(expectedCastleCount) + || expectedCastleCount < 1 + || expectedCastleCount > 100 + ) fail('The inactive Inner Keep v15 expected castle count was invalid.'); + let status; + try { + status = JSON.parse(output); + } catch { + fail('Inner Keep v15 inspection did not return machine-readable JSON.'); + } + if ( + !status + || typeof status !== 'object' + || Array.isArray(status) + || Object.keys(status).sort().join(',') !== INNER_KEEP_V15_STATUS_KEYS.join(',') + ) fail('Inner Keep v15 inspection returned unexpected fields.'); + for (const field of INNER_KEEP_V15_STATUS_COUNT_FIELDS) { + const value = status[field]; + if ( + typeof value !== 'string' + || !/^(?:0|[1-9]\d*)$/.test(value) + || value.length > 20 + || BigInt(value) > U64_MAXIMUM + ) fail('Inner Keep v15 inspection returned an invalid aggregate count.'); + } + for (const field of INNER_KEEP_V15_STATUS_BOOLEAN_FIELDS) { + if (typeof status[field] !== 'boolean') { + fail('Inner Keep v15 inspection returned an invalid status flag.'); + } + } + if ( + status.policyVersion !== INNER_KEEP_V15_POLICY_VERSION + || status.layoutPolicyVersion !== INNER_KEEP_V15_LAYOUT_POLICY_VERSION + || !SHA256_DIGEST.test(status.policyDigest ?? '') + || !SHA256_DIGEST.test(status.layoutDigest ?? '') + || !SHA256_DIGEST.test(status.assetCatalogDigest ?? '') + ) fail('Inner Keep v15 inspection returned invalid policy attestations.'); + const expectedCastles = String(expectedCastleCount); + if ( + status.castleRows !== expectedCastles + || status.missingBuilders !== expectedCastles + || [ + 'layoutRows', + 'slotRows', + 'buildingCatalogRows', + 'levelPolicyRows', + 'builderRows', + 'buildingRows', + 'activeProjects', + 'receiptRows', + 'scheduleRows', + 'orphanBuilders', + 'invalidBuilders', + 'invalidBuildings', + 'invalidSchedules', + 'builderProjectMismatches', + ].some(field => status[field] !== '0') + || status.staticCatalogExact !== false + || status.workerSystemReady !== true + || status.readyForCatalogSeed !== true + || status.readyForBuilderBackfill !== false + || status.readyForActivation !== false + || status.active !== false + ) fail('Inner Keep v15 did not prove the exact empty inactive post-publication state.'); + return Object.freeze({ ...status }); +} + +function inspectEmptyInactiveInnerKeepV15( + secret, + expectedCastleCount, + spawnSyncProcess, +) { + const secretBytes = typeof secret === 'string' + ? new TextEncoder().encode(secret).byteLength + : 0; + if (secretBytes < 32 || secretBytes > 512) { + fail('A local 32-to-512-byte Hermes credential is required for the inactive Inner Keep v15 checkpoint.'); + } + const tsxCli = resolve(repositoryRoot, 'node_modules/tsx/dist/cli.mjs'); + const result = runBoundedSync( + process.execPath, + innerKeepV15InspectChildArguments(tsxCli), + { + env: { + WARPKEEP_SPACETIMEDB_URI: CANONICAL_MAINCLOUD_URI, + WARPKEEP_SPACETIMEDB_DATABASE: CANONICAL_DATABASE_IDENTITY, + WARPKEEP_AUTH_BRIDGE_URL: CANONICAL_BRIDGE, + WARPKEEP_ADMIN_TOKEN_SECRET_STDIN: '1', + }, + input: secret, + timeout: 30_000, + }, + spawnSyncProcess, + ); + return verifyEmptyInactiveInnerKeepV15StatusOutput( + result.stdout, + expectedCastleCount, + ); +} + +export function verifyPostPublishEmptyInactiveInnerKeepV15( + secret, + expectedCastleCount, + spawnSyncProcess = spawnSync, +) { + try { + return inspectEmptyInactiveInnerKeepV15( + secret, + expectedCastleCount, + spawnSyncProcess, + ); + } catch { + fail('Post-publication inactive Inner Keep v15 checkpoint is indeterminate; perform a fresh protected counts-only inspection before any catalog seed, Builder backfill, activation, client deployment, or further publication decision.'); + } +} + +export function verifyHistoricalPublicationAggregateUnchanged(before, after) { + if ( + !before + || typeof before !== 'object' + || Array.isArray(before) + || !after + || typeof after !== 'object' + || Array.isArray(after) + || canonicalJson(before) !== canonicalJson(after) + ) fail('Historical aggregate state changed during the v15 publication; stop before any seed, backfill, activation, deployment, or further publication decision.'); + return Object.freeze({ ...after }); +} + function parsePublishAggregateEnvelope(output, expectedKeys, label) { let envelope; try { @@ -3915,6 +4841,173 @@ export async function publishModule( } } +/** + * Execute only the reviewed active-v14 -> inactive-v15 lane. Dependencies are + * injectable for pure safety tests; production uses the closed functions in + * this module. Dry-run deliberately performs every network read preflight and + * returns before the sole publish dependency can be reached. + */ +export async function executeProtocolV15InactivePublicationLane( + options, + dependencies = {}, +) { + if ( + !options + || typeof options !== 'object' + || typeof options.dryRun !== 'boolean' + || options.resourceRolloutStage !== RESOURCE_PUBLISH_ROLLOUT_STAGE.READY + || options.genesisWorldRolloutStage !== GENESIS_WORLD_PUBLISH_STAGE.EXPANDED + || options.workerRolloutStage !== WORKER_PUBLISH_ROLLOUT_STAGE.ACTIVE + || options.workerModulePredecessor !== WORKER_MODULE_PREDECESSOR.EXACT_V14_ACTIVE + || options.workerForwardRepair !== WORKER_FORWARD_REPAIR.NONE + || options.innerKeepModulePredecessor + !== INNER_KEEP_MODULE_PREDECESSOR.EXACT_V14_ACTIVE + || options.innerKeepPublicationStage + !== INNER_KEEP_PUBLICATION_STAGE.APPEND_INACTIVE + ) fail('The protocol-v15 inactive publication plan was invalid. No publish was attempted.'); + + const artifactReceipt = ( + dependencies.verifyMigrationArtifactReceipt + ?? verifyMigrationArtifactReceipt + )(requireReviewedAdditivePublicationLane( + options.artifactReceipt, + options.innerKeepModulePredecessor, + options.innerKeepPublicationStage, + )); + const verifyPredecessor = dependencies.verifyFreshProductionV14InnerKeepPredecessor + ?? verifyFreshProductionV14InnerKeepPredecessor; + const predecessor = verifyPredecessor( + options.executable, + artifactReceipt.v12TableSchemaDigest, + artifactReceipt.v13TableSchemaDigest, + artifactReceipt.v14TableSchemaDigest, + ); + (dependencies.verifyWorkerV14ModulePredecessor + ?? verifyWorkerV14ModulePredecessor)( + predecessor.moduleState, + options.workerModulePredecessor, + ); + const publicationPlan = planWorkerV12CodePublication( + predecessor.moduleState, + options.workerForwardRepair, + ); + const preHistorical = ( + dependencies.verifyFreshPublishExactV12Aggregate + ?? verifyFreshPublishExactV12Aggregate + )( + options.adminTokenSecret, + options.foundedExpectations, + options.resourceRolloutStage, + options.workerRolloutStage, + dependencies.spawnSyncProcess ?? spawnSync, + options.genesisWorldRolloutStage, + options.workerForwardRepair, + publicationPlan.prePublicationCheckpoint, + ); + const preDailyMarks = ( + dependencies.verifyFreshActiveDailyMarksV14 + ?? verifyFreshActiveDailyMarksV14 + )( + options.adminTokenSecret, + options.foundedExpectations, + dependencies.spawnSyncProcess ?? spawnSync, + ); + const preAccessRequests = ( + dependencies.verifyFreshAccessRequestV13Aggregate + ?? verifyFreshAccessRequestV13Aggregate + )( + options.adminTokenSecret, + dependencies.spawnSyncProcess ?? spawnSync, + ); + + if (options.dryRun) { + return Object.freeze({ + publication: 'dry-run-verified', + protocol: 'v15', + stage: INNER_KEEP_PUBLICATION_STAGE.APPEND_INACTIVE, + predecessor: INNER_KEEP_MODULE_PREDECESSOR.EXACT_V14_ACTIVE, + deletion: 'disabled', + networkMode: 'read-only', + }); + } + + await (dependencies.publishModule ?? publishModule)( + options.executable, + CANONICAL_DATABASE_IDENTITY, + artifactReceipt, + ); + const postSchema = ( + dependencies.verifyPostPublishProductionV15InactiveModuleSchema + ?? verifyPostPublishProductionV15InactiveModuleSchema + )( + options.executable, + predecessor, + artifactReceipt.v12TableSchemaDigest, + artifactReceipt.v13TableSchemaDigest, + artifactReceipt.v14TableSchemaDigest, + artifactReceipt.v15TableSchemaDigest, + ); + const postHistorical = ( + dependencies.verifyPostPublishResourcePublicationCheckpoints + ?? verifyPostPublishResourcePublicationCheckpoints + )( + options.adminTokenSecret, + options.foundedExpectations, + options.resourceRolloutStage, + options.workerRolloutStage, + dependencies.spawnSyncProcess ?? spawnSync, + options.genesisWorldRolloutStage, + options.workerForwardRepair, + publicationPlan.postPublicationCheckpoint, + ); + const postDailyMarks = ( + dependencies.verifyPostPublishActiveDailyMarksV14 + ?? verifyPostPublishActiveDailyMarksV14 + )( + options.adminTokenSecret, + options.foundedExpectations, + dependencies.spawnSyncProcess ?? spawnSync, + ); + const postAccessRequests = ( + dependencies.verifyPostPublishAccessRequestV13Aggregate + ?? verifyPostPublishAccessRequestV13Aggregate + )( + options.adminTokenSecret, + dependencies.spawnSyncProcess ?? spawnSync, + ); + (dependencies.verifyHistoricalPublicationAggregateUnchanged + ?? verifyHistoricalPublicationAggregateUnchanged)( + preHistorical, + postHistorical, + ); + (dependencies.verifyHistoricalPublicationAggregateUnchanged + ?? verifyHistoricalPublicationAggregateUnchanged)( + preDailyMarks, + postDailyMarks, + ); + (dependencies.verifyHistoricalPublicationAggregateUnchanged + ?? verifyHistoricalPublicationAggregateUnchanged)( + preAccessRequests, + postAccessRequests, + ); + (dependencies.verifyPostPublishEmptyInactiveInnerKeepV15 + ?? verifyPostPublishEmptyInactiveInnerKeepV15)( + options.adminTokenSecret, + options.foundedExpectations.expectedFounderCount, + dependencies.spawnSyncProcess ?? spawnSync, + ); + return Object.freeze({ + publication: 'verified', + protocol: 'v15', + stage: INNER_KEEP_PUBLICATION_STAGE.APPEND_INACTIVE, + predecessor: INNER_KEEP_MODULE_PREDECESSOR.EXACT_V14_ACTIVE, + deletion: 'disabled', + historicalAggregateExact: true, + appendedTableCount: postSchema.appendedInnerKeepTableCount, + innerKeepActive: false, + }); +} + async function main() { const { dryRun, @@ -3923,6 +5016,8 @@ async function main() { workerRolloutStage, workerModulePredecessor, workerForwardRepair, + innerKeepModulePredecessor, + innerKeepPublicationStage, } = parsePublishArguments(); requireCanonicalPublishCoordinates(); if (database !== CANONICAL_DATABASE) fail('The production publisher target was not canonical.'); @@ -3947,7 +5042,39 @@ async function main() { // Keep every proof, inspection, publish, and checkpoint bound to the one // attested CLI copy for this complete publication lifecycle. const executable = executableSnapshot.path; - const artifactReceipt = runCurrentAdditiveMigrationProof(executable); + const artifactReceipt = requireReviewedAdditivePublicationLane( + runCurrentAdditiveMigrationProof(executable), + innerKeepModulePredecessor, + innerKeepPublicationStage, + ); + if ( + innerKeepModulePredecessor + === INNER_KEEP_MODULE_PREDECESSOR.EXACT_V14_ACTIVE + && innerKeepPublicationStage + === INNER_KEEP_PUBLICATION_STAGE.APPEND_INACTIVE + ) { + await validateIssuerDeployment(issuer); + // The list/describe/aggregate dry-run path is network-read-only. It uses + // the same immutable identity as publication and returns before the + // publish dependency inside the lane can be reached. + attestCanonicalDatabase(executable); + const result = await executeProtocolV15InactivePublicationLane({ + dryRun, + executable, + artifactReceipt, + adminTokenSecret, + foundedExpectations, + resourceRolloutStage, + genesisWorldRolloutStage, + workerRolloutStage, + workerModulePredecessor, + workerForwardRepair, + innerKeepModulePredecessor, + innerKeepPublicationStage, + }); + console.log(JSON.stringify(result)); + return; + } if (dryRun) { await validateIssuerDeployment(issuer); console.log(`Dry run: verified the pinned CLI, current additive migration, founded-state expectation contract, explicit ${resourceRolloutStage} resource stage, explicit ${genesisWorldRolloutStage} Genesis world stage, explicit ${workerRolloutStage} Worker stage, explicit ${workerModulePredecessor} module predecessor, explicit ${workerForwardRepair} Worker forward-repair selection, and ${issuer}; would update the canonical existing database without deleting data.`); diff --git a/scripts/qa-observer/inner-keep-browser-probe.mjs b/scripts/qa-observer/inner-keep-browser-probe.mjs new file mode 100644 index 00000000..7e3a771d --- /dev/null +++ b/scripts/qa-observer/inner-keep-browser-probe.mjs @@ -0,0 +1,491 @@ +import { chmod, lstat, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + DevtoolsPipeSession, + attestStableHeadlessChromeExecutable, + cleanupRenderedWebglProbeResources, + createLoopbackViteServer, + exactChromeExecutableIdentity, + formatRenderedWebglLocalDiagnostic, + isAllowedRenderedWebglPageUrl, + readReviewedChromeExecutableIdentity, + selectBlankPageTarget, + spawnHeadlessChromeProbe, +} from './rendered-webgl-browser-probe.mjs'; +import { + INNER_KEEP_QA_CASE_COUNT, + INNER_KEEP_QA_MAX_READY_MILLISECONDS, + assertInnerKeepQaScenarioEvidence, + innerKeepQaBrowserCases, +} from './inner-keep-qa-contract.mjs'; + +const CDP_TIMEOUT_MILLISECONDS = 10_000; +const POLL_MILLISECONDS = 40; +const SCREENSHOT_CASES = new Set([ + 'empty', + 'construction-50-percent', + 'compact-quality', + 'missing-asset-fallback', + '2d-fallback', +]); + +function delay(milliseconds) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); +} + +function runtimeValue(result) { + const candidate = result?.result; + if ( + !candidate + || typeof candidate !== 'object' + || candidate.type !== 'object' + || !Object.hasOwn(candidate, 'value') + || result.exceptionDetails !== undefined + ) throw new Error('Inner Keep QA browser returned invalid aggregate evidence.'); + return candidate.value; +} + +function evidenceExpression(expectedLevel) { + return `(() => { + const root = document.querySelector('[data-inner-keep-qa-scenario]'); + const screen = document.querySelector('.inner-keep'); + if (!(root instanceof HTMLElement) || !(screen instanceof HTMLElement)) return null; + const integer = (name) => { + const value = root.getAttribute(name); + return value !== null && /^(?:0|[1-9]\\d*)$/.test(value) ? Number(value) : -1; + }; + const progressValue = root.getAttribute('data-inner-keep-qa-progress-bps'); + const progressBasisPoints = progressValue === 'none' + ? null + : progressValue !== null && /^(?:0|[1-9]\\d*)$/.test(progressValue) + ? Number(progressValue) + : -1; + const bodyText = document.body?.innerText ?? ''; + const documentWidth = Math.max( + document.documentElement?.scrollWidth ?? 0, + document.body?.scrollWidth ?? 0 + ); + const documentHeight = Math.max( + document.documentElement?.scrollHeight ?? 0, + document.body?.scrollHeight ?? 0 + ); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const expectedLevel = ${expectedLevel === null ? 'null' : JSON.stringify(expectedLevel)}; + return { + version: 1, + scenario: root.getAttribute('data-inner-keep-qa-scenario'), + renderMode: root.getAttribute('data-inner-keep-qa-render-mode'), + innerKeepRenderer: screen.getAttribute('data-inner-keep-renderer'), + quality: root.getAttribute('data-inner-keep-qa-quality'), + reducedMotion: root.getAttribute('data-inner-keep-qa-reduced-motion') === 'true', + status: root.getAttribute('data-inner-keep-qa-status'), + progressBasisPoints, + canvasCount: document.querySelectorAll('canvas[data-inner-keep-qa-canvas]').length, + rendererCount: integer('data-inner-keep-qa-renderer-count'), + webglContextCount: integer('data-inner-keep-qa-webgl-context-count'), + rafOwnerCount: integer('data-inner-keep-qa-raf-owner-count'), + maximumPendingRafCount: integer('data-inner-keep-qa-maximum-pending-raf-count'), + slotControlCount: document.querySelectorAll('[data-inner-keep-slot-id]').length, + enabledSlotControlCount: + document.querySelectorAll('[data-inner-keep-slot-id]:not(:disabled)').length, + slotCount: integer('data-inner-keep-qa-slot-count'), + slotGeometryCount: integer('data-inner-keep-qa-slot-geometry-count'), + smokeSpriteCount: integer('data-inner-keep-qa-smoke-sprite-count'), + constructionSiteCount: integer('data-inner-keep-qa-construction-site-count'), + completedBuildingCount: integer('data-inner-keep-qa-completed-building-count'), + finalModelCount: integer('data-inner-keep-qa-final-model-count'), + scaffoldPresent: root.getAttribute('data-inner-keep-qa-scaffold-present') === 'true', + completionRevealActive: + root.getAttribute('data-inner-keep-qa-completion-reveal-active') === 'true', + assetFallbackCount: document.querySelectorAll('.inner-keep-building-art-fallback').length, + builderBusyVisible: bodyText.includes('BUILDER OCCUPIED'), + insufficientResourcesVisible: bodyText.includes('Not enough Food.'), + levelVisible: expectedLevel === null || bodyText.includes('Level ' + expectedLevel), + viewportWidth, + viewportHeight, + documentWidth, + documentHeight, + horizontalOverflow: documentWidth > viewportWidth + 1, + verticalOverflow: documentHeight > viewportHeight + 1 + }; + })()`; +} + +async function readEvidence(session, probeCase) { + const result = await session.command('Runtime.evaluate', { + expression: evidenceExpression(probeCase.scenario.level), + returnByValue: true, + }); + return runtimeValue(result); +} + +async function waitForEvidence(session, probeCase, phase = 'steady') { + const deadline = Date.now() + INNER_KEEP_QA_MAX_READY_MILLISECONDS; + let lastFailure; + while (Date.now() < deadline) { + try { + const value = await readEvidence(session, probeCase); + return assertInnerKeepQaScenarioEvidence(value, probeCase.id, phase); + } catch (error) { + lastFailure = error; + await delay(POLL_MILLISECONDS); + } + } + throw new Error('Inner Keep QA browser readiness deadline exceeded.', { + cause: lastFailure, + }); +} + +function assertPngCapture(value, viewport) { + if ( + typeof value !== 'string' + || value.length < 4_096 + || value.length > 16 * 1_024 * 1_024 + || !/^[A-Za-z0-9+/]+={0,2}$/u.test(value) + ) throw new Error('Inner Keep QA screenshot capture was invalid.'); + const bytes = Buffer.from(value, 'base64'); + try { + if ( + bytes.byteLength < 33 + || bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' + || bytes.readUInt32BE(16) !== viewport.width + || bytes.readUInt32BE(20) !== viewport.height + ) throw new Error('Inner Keep QA screenshot dimensions mismatched.'); + } finally { + bytes.fill(0); + } +} + +async function captureSyntheticScreenshot(session, viewport) { + const result = await session.command('Page.captureScreenshot', { + captureBeyondViewport: false, + format: 'png', + fromSurface: true, + }, CDP_TIMEOUT_MILLISECONDS * 2); + if (Object.keys(result ?? {}).length !== 1) { + throw new Error('Inner Keep QA screenshot response was invalid.'); + } + assertPngCapture(result.data, viewport); +} + +async function assertReducedMotionSettles(session) { + const expression = `document.querySelector('[data-inner-keep-qa-scenario]') + ?.getAttribute('data-inner-keep-qa-requested-raf-count') ?? null`; + await delay(250); + const first = runtimeValue(await session.command('Runtime.evaluate', { + expression: `({ value: ${expression} })`, + returnByValue: true, + }))?.value; + await delay(350); + const second = runtimeValue(await session.command('Runtime.evaluate', { + expression: `({ value: ${expression} })`, + returnByValue: true, + }))?.value; + if (!/^(?:0|[1-9]\d*)$/u.test(first ?? '') || first !== second) { + throw new Error('Inner Keep reduced-motion presentation retained an animation loop.'); + } +} + +async function observeCompletionReveal(session, probeCase) { + const clicked = await session.command('Runtime.evaluate', { + expression: `(() => { + const control = document.querySelector('[data-inner-keep-qa-complete="true"]'); + if (!(control instanceof HTMLButtonElement) || control.disabled) return { clicked: false }; + control.click(); + return { clicked: true }; + })()`, + returnByValue: true, + }); + if (runtimeValue(clicked)?.clicked !== true) { + throw new Error('Inner Keep completion reveal control was unavailable.'); + } + const revealed = await waitForEvidence(session, probeCase, 'reveal'); + await captureSyntheticScreenshot(session, probeCase.viewport); + const completed = await waitForEvidence(session, probeCase, 'completed'); + return Object.freeze({ revealed, completed }); +} + +async function exerciseFunctionalFallback(session) { + const activated = await session.command('Runtime.evaluate', { + expression: `(async () => { + const control = document.querySelector('[data-inner-keep-slot-id]'); + if (!(control instanceof HTMLButtonElement) || control.disabled) { + return { activated: false, panelVisible: false, retainedFallback: false }; + } + control.click(); + await new Promise((resolve) => window.setTimeout(resolve, 20)); + return { + activated: true, + panelVisible: document.querySelector('.inner-keep-panel') !== null, + retainedFallback: + document.querySelector('.inner-keep')?.getAttribute('data-inner-keep-renderer') + === 'fallback' + && document.querySelectorAll('canvas[data-inner-keep-qa-canvas]').length === 0 + }; + })()`, + awaitPromise: true, + returnByValue: true, + }); + const value = runtimeValue(activated); + if ( + value?.activated !== true + || value.panelVisible !== true + || value.retainedFallback !== true + ) throw new Error('Inner Keep functional fallback interaction failed.'); +} + +async function exerciseWebglNativeKeyboardActivation(session) { + const focused = runtimeValue(await session.command('Runtime.evaluate', { + expression: `(() => { + const control = document.querySelector( + '[data-inner-keep-slot-id="inner-keep-slot-m01"]' + ); + if (!(control instanceof HTMLButtonElement) || control.disabled) return null; + control.focus({ preventScroll: true }); + return { + active: document.activeElement === control, + pointerEvents: getComputedStyle(control).pointerEvents, + renderer: document.querySelector('.inner-keep') + ?.getAttribute('data-inner-keep-renderer') ?? null + }; + })()`, + returnByValue: true, + })); + if ( + focused?.active !== true + || focused.pointerEvents !== 'none' + || focused.renderer !== 'webgl' + ) throw new Error('Inner Keep WebGL semantic site index was unavailable.'); + + await session.command('Input.dispatchKeyEvent', { + code: 'Enter', + key: 'Enter', + nativeVirtualKeyCode: 13, + type: 'keyDown', + windowsVirtualKeyCode: 13, + }); + await session.command('Input.dispatchKeyEvent', { + code: 'Enter', + key: 'Enter', + nativeVirtualKeyCode: 13, + type: 'keyUp', + windowsVirtualKeyCode: 13, + }); + + const deadline = Date.now() + CDP_TIMEOUT_MILLISECONDS; + while (Date.now() < deadline) { + const activation = runtimeValue(await session.command('Runtime.evaluate', { + expression: `(() => { + const control = document.querySelector( + '[data-inner-keep-slot-id="inner-keep-slot-m01"]' + ); + const panel = document.querySelector('.inner-keep-panel'); + return { + panelLabel: panel?.getAttribute('aria-label') ?? null, + selected: control?.getAttribute('aria-pressed') === 'true' + }; + })()`, + returnByValue: true, + })); + if (activation?.selected === true && activation.panelLabel === 'West Courtyard') return; + await delay(POLL_MILLISECONDS); + } + throw new Error('Inner Keep WebGL native Enter activation did not open the site.'); +} + +async function navigateCase(session, probeCase) { + await session.command('Emulation.setDeviceMetricsOverride', { + deviceScaleFactor: 1, + height: probeCase.viewport.height, + mobile: probeCase.viewport.width <= 480, + screenHeight: probeCase.viewport.height, + screenWidth: probeCase.viewport.width, + width: probeCase.viewport.width, + }); + await session.command('Emulation.setEmulatedMedia', { + features: [{ + name: 'prefers-reduced-motion', + value: probeCase.scenario.reducedMotion ? 'reduce' : 'no-preference', + }], + }); + await session.command('Page.navigate', { url: probeCase.url }); +} + +export async function runInnerKeepBrowserProbe(options = {}) { + const onEvidence = options.onEvidence; + if (onEvidence !== undefined && typeof onEvidence !== 'function') { + throw new TypeError('Invalid Inner Keep QA evidence callback.'); + } + const reviewedChromeIdentity = await attestStableHeadlessChromeExecutable(); + const temporaryProfile = await mkdtemp(join(tmpdir(), 'warpkeep-inner-keep-qa-')); + let chrome; + let devtools; + let vite; + try { + const metadata = await lstat(temporaryProfile); + const expectedUid = typeof process.getuid === 'function' ? process.getuid() : undefined; + if ( + !metadata.isDirectory() + || metadata.isSymbolicLink() + || (expectedUid !== undefined && metadata.uid !== expectedUid) + ) throw new Error('The disposable Inner Keep Chrome profile is unsafe.'); + const profileDirectory = await realpath(temporaryProfile); + await chmod(profileDirectory, 0o700); + vite = await createLoopbackViteServer(profileDirectory); + const loopbackOrigin = `http://127.0.0.1:${vite.port}`; + const cases = innerKeepQaBrowserCases(vite.port); + if ( + cases.length !== INNER_KEEP_QA_CASE_COUNT + || new Set(cases.map((probeCase) => probeCase.id)).size !== INNER_KEEP_QA_CASE_COUNT + ) throw new Error('Inner Keep QA case manifest was invalid.'); + + await attestStableHeadlessChromeExecutable(reviewedChromeIdentity); + chrome = spawnHeadlessChromeProbe(profileDirectory); + const launchedIdentity = await readReviewedChromeExecutableIdentity(); + if (!exactChromeExecutableIdentity(reviewedChromeIdentity, launchedIdentity)) { + throw new Error('The reviewed Google Chrome executable changed at launch.'); + } + const allowedUrls = new Set(cases.map((probeCase) => probeCase.url)); + const state = { targetId: '', violation: '' }; + devtools = new DevtoolsPipeSession(chrome, (method, params, session) => { + if (method === 'Fetch.requestPaused') { + const requestId = params?.requestId; + const url = params?.request?.url; + if (typeof requestId !== 'string') { + state.violation = 'fetch-shape'; + return; + } + if (isAllowedRenderedWebglPageUrl(url, loopbackOrigin)) { + void session.command('Fetch.continueRequest', { requestId }).catch(() => { + state.violation = 'fetch-continue'; + }); + } else { + state.violation = 'fetch'; + void session.command('Fetch.failRequest', { + errorReason: 'BlockedByClient', + requestId, + }).catch(() => {}); + } + return; + } + if (method === 'Page.frameNavigated' && !params?.frame?.parentId) { + const url = params?.frame?.url; + if (url !== 'about:blank' && !allowedUrls.has(url)) state.violation = 'navigation'; + return; + } + if (method === 'Network.requestWillBeSent') { + if (!isAllowedRenderedWebglPageUrl(params?.request?.url, loopbackOrigin)) { + state.violation = 'network'; + } + return; + } + if (method === 'Network.webSocketCreated') { + if (!isAllowedRenderedWebglPageUrl(params?.url, loopbackOrigin)) { + state.violation = 'websocket'; + } + return; + } + if ( + method === 'Runtime.exceptionThrown' + || (method === 'Runtime.consoleAPICalled' && ['assert', 'error'].includes(params?.type)) + || (method === 'Log.entryAdded' && ['warning', 'error'].includes(params?.entry?.level)) + ) { + state.violation = 'browser-runtime'; + return; + } + if (method === 'Page.windowOpen' || method === 'Page.downloadWillBegin') { + state.violation = 'page-side-effect'; + return; + } + if ( + method === 'Target.targetCrashed' + || method === 'Target.detachedFromTarget' + || method === 'Inspector.detached' + ) state.violation = 'browser-target'; + }); + await devtools.open(); + const target = selectBlankPageTarget(await devtools.browserCommand( + 'Target.getTargets', + { filter: [{ type: 'page', exclude: false }, { exclude: true }] } + )); + state.targetId = target.targetId; + await devtools.attachToPage(target.targetId); + await Promise.all([ + devtools.command('Page.enable'), + devtools.command('Runtime.enable'), + devtools.command('Log.enable'), + devtools.command('Network.enable'), + devtools.command('Page.setDownloadBehavior', { behavior: 'deny' }), + devtools.command('Fetch.enable', { + patterns: [{ requestStage: 'Request', urlPattern: '*' }], + }), + ]); + + for (const probeCase of cases) { + await navigateCase(devtools, probeCase); + const evidence = await waitForEvidence(devtools, probeCase); + if (probeCase.id === '2d-fallback') { + await exerciseFunctionalFallback(devtools); + } + if (SCREENSHOT_CASES.has(probeCase.id)) { + await captureSyntheticScreenshot(devtools, probeCase.viewport); + } + if (probeCase.id === 'empty') { + await exerciseWebglNativeKeyboardActivation(devtools); + } + if (probeCase.id === 'completion-reveal') { + await observeCompletionReveal(devtools, probeCase); + } + if (probeCase.id === 'reduced-motion') { + await assertReducedMotionSettles(devtools); + } + if (state.violation) { + throw new Error(`Inner Keep QA left its local boundary: ${state.violation}.`); + } + onEvidence?.(Object.freeze({ + scenario: evidence.scenario, + renderMode: evidence.renderMode, + responsive: !evidence.horizontalOverflow && !evidence.verticalOverflow, + })); + } + return cases.length; + } finally { + await cleanupRenderedWebglProbeResources({ + chrome, + devtools, + removeProfile: () => rm(temporaryProfile, { force: true, recursive: true }), + vite, + }); + } +} + +async function main() { + if (process.argv.length !== 2) { + process.stderr.write('Usage: inner-keep-browser-probe\n'); + process.exitCode = 64; + return; + } + try { + const passed = await runInnerKeepBrowserProbe(); + process.stdout.write( + `Warpkeep local Inner Keep QA passed ${passed} synthetic cases with ` + + 'single-renderer, construction, completion, fallback, and responsive evidence.\n' + ); + } catch (error) { + if (process.env.WARPKEEP_QA_LOCAL_DIAGNOSTICS === '1') { + process.stderr.write( + `Local Inner Keep QA failure: ${formatRenderedWebglLocalDiagnostic(error)}\n` + ); + } + process.stderr.write('Warpkeep Inner Keep rendered QA failed closed.\n'); + process.exitCode = 1; + } +} + +const isMain = process.argv[1] + && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) await main(); diff --git a/scripts/qa-observer/inner-keep-qa-contract.d.mts b/scripts/qa-observer/inner-keep-qa-contract.d.mts new file mode 100644 index 00000000..d4b0fbbf --- /dev/null +++ b/scripts/qa-observer/inner-keep-qa-contract.d.mts @@ -0,0 +1,61 @@ +import type { + InnerKeepQaScenario, + InnerKeepQaScenarioId +} from '../../src/dev/innerKeepQaScenarioManifest.mjs'; + +export const INNER_KEEP_QA_ROUTE: '/dev/inner-keep-qa.html'; +export const INNER_KEEP_QA_CASE_COUNT: number; +export const INNER_KEEP_QA_MAX_READY_MILLISECONDS: 30000; + +export type InnerKeepQaEvidence = Readonly<{ + version: 1; + scenario: InnerKeepQaScenarioId; + renderMode: 'webgl' | 'fallback'; + innerKeepRenderer: 'webgl' | 'fallback'; + quality: 'high' | 'balanced' | 'reduced'; + reducedMotion: boolean; + status: 'ready'; + progressBasisPoints: number | null; + canvasCount: number; + rendererCount: number; + webglContextCount: number; + rafOwnerCount: number; + maximumPendingRafCount: number; + slotControlCount: number; + enabledSlotControlCount: number; + slotCount: number; + slotGeometryCount: number; + smokeSpriteCount: number; + constructionSiteCount: number; + completedBuildingCount: number; + finalModelCount: number; + scaffoldPresent: boolean; + completionRevealActive: boolean; + assetFallbackCount: number; + builderBusyVisible: boolean; + insufficientResourcesVisible: boolean; + levelVisible: boolean; + viewportWidth: number; + viewportHeight: number; + documentWidth: number; + documentHeight: number; + horizontalOverflow: boolean; + verticalOverflow: boolean; +}>; + +export function innerKeepQaUrl(options?: Readonly<{ + port?: number; + scenario?: InnerKeepQaScenarioId; +}>): string; +export function parseInnerKeepQaEvidence(value: unknown): InnerKeepQaEvidence; +export function assertInnerKeepQaScenarioEvidence( + value: unknown, + expectedScenarioId: InnerKeepQaScenarioId, + phase?: 'steady' | 'reveal' | 'completed' +): InnerKeepQaEvidence; +export function innerKeepQaBrowserCases(port: number): readonly Readonly<{ + id: InnerKeepQaScenarioId; + scenario: InnerKeepQaScenario; + url: string; + viewport: Readonly<{ width: number; height: number }>; +}>[]; diff --git a/scripts/qa-observer/inner-keep-qa-contract.mjs b/scripts/qa-observer/inner-keep-qa-contract.mjs new file mode 100644 index 00000000..86718b7c --- /dev/null +++ b/scripts/qa-observer/inner-keep-qa-contract.mjs @@ -0,0 +1,258 @@ +import { + INNER_KEEP_QA_SCENARIO_IDS, + INNER_KEEP_QA_SCENARIO_MANIFEST, + innerKeepQaScenarioById, +} from '../../src/dev/innerKeepQaScenarioManifest.mjs'; + +export const INNER_KEEP_QA_ROUTE = '/dev/inner-keep-qa.html'; +export const INNER_KEEP_QA_CASE_COUNT = INNER_KEEP_QA_SCENARIO_MANIFEST.length; +export const INNER_KEEP_QA_MAX_READY_MILLISECONDS = 30_000; + +function exactPort(value) { + if (!Number.isSafeInteger(value) || value < 1 || value > 65_535) { + throw new TypeError('Invalid Inner Keep QA loopback port.'); + } + return value; +} + +export function innerKeepQaUrl(options = {}) { + const selectedPort = exactPort(options.port ?? 5173); + const scenario = innerKeepQaScenarioById(options.scenario ?? 'empty'); + if (options.scenario !== undefined && scenario.id !== options.scenario) { + throw new TypeError('Invalid Inner Keep QA scenario.'); + } + const url = new URL(INNER_KEEP_QA_ROUTE, `http://127.0.0.1:${selectedPort}`); + url.searchParams.set('scenario', scenario.id); + return url.toString(); +} + +function exactRecord(value, message) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(message); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError(message); + } + return value; +} + +function exactInteger(value, minimum = 0, maximum = 1_000_000_000) { + return Number.isSafeInteger(value) && value >= minimum && value <= maximum; +} + +const EVIDENCE_KEYS = Object.freeze([ + 'assetFallbackCount', + 'builderBusyVisible', + 'canvasCount', + 'completedBuildingCount', + 'completionRevealActive', + 'constructionSiteCount', + 'documentHeight', + 'documentWidth', + 'enabledSlotControlCount', + 'finalModelCount', + 'horizontalOverflow', + 'innerKeepRenderer', + 'insufficientResourcesVisible', + 'levelVisible', + 'maximumPendingRafCount', + 'progressBasisPoints', + 'quality', + 'rafOwnerCount', + 'reducedMotion', + 'renderMode', + 'rendererCount', + 'scaffoldPresent', + 'scenario', + 'slotControlCount', + 'slotCount', + 'slotGeometryCount', + 'smokeSpriteCount', + 'status', + 'version', + 'verticalOverflow', + 'viewportHeight', + 'viewportWidth', + 'webglContextCount', +]); + +export function parseInnerKeepQaEvidence(value) { + const candidate = exactRecord(value, 'Invalid Inner Keep QA evidence.'); + const keys = Object.keys(candidate).sort(); + if ( + keys.length !== EVIDENCE_KEYS.length + || keys.some((key, index) => key !== EVIDENCE_KEYS[index]) + || candidate.version !== 1 + || !INNER_KEEP_QA_SCENARIO_IDS.includes(candidate.scenario) + || !['webgl', 'fallback'].includes(candidate.renderMode) + || !['webgl', 'fallback'].includes(candidate.innerKeepRenderer) + || !['high', 'balanced', 'reduced'].includes(candidate.quality) + || candidate.status !== 'ready' + || typeof candidate.reducedMotion !== 'boolean' + || typeof candidate.scaffoldPresent !== 'boolean' + || typeof candidate.completionRevealActive !== 'boolean' + || typeof candidate.horizontalOverflow !== 'boolean' + || typeof candidate.verticalOverflow !== 'boolean' + || typeof candidate.builderBusyVisible !== 'boolean' + || typeof candidate.insufficientResourcesVisible !== 'boolean' + || typeof candidate.levelVisible !== 'boolean' + || ![ + candidate.assetFallbackCount, + candidate.canvasCount, + candidate.completedBuildingCount, + candidate.constructionSiteCount, + candidate.documentHeight, + candidate.documentWidth, + candidate.enabledSlotControlCount, + candidate.finalModelCount, + candidate.maximumPendingRafCount, + candidate.rafOwnerCount, + candidate.rendererCount, + candidate.slotControlCount, + candidate.slotCount, + candidate.slotGeometryCount, + candidate.smokeSpriteCount, + candidate.viewportHeight, + candidate.viewportWidth, + candidate.webglContextCount, + ].every((entry) => exactInteger(entry)) + || !( + candidate.progressBasisPoints === null + || exactInteger(candidate.progressBasisPoints, 0, 10_000) + ) + ) throw new TypeError('Invalid Inner Keep QA evidence.'); + return Object.freeze({ ...candidate }); +} + +function expectCompletedPresentation(evidence) { + return evidence.completedBuildingCount === 1 + && evidence.constructionSiteCount === 0 + && evidence.finalModelCount === (evidence.renderMode === 'webgl' ? 1 : 0) + && evidence.scaffoldPresent === false + && evidence.smokeSpriteCount === 0; +} + +function expectConstructionPresentation(evidence) { + return evidence.completedBuildingCount === 0 + && evidence.constructionSiteCount === 1 + && evidence.finalModelCount === 0 + && evidence.scaffoldPresent === true + && evidence.smokeSpriteCount > 0; +} + +/** + * Validates only aggregate synthetic presentation facts. No text, IDs, costs, + * timestamps, screenshots, or browser URLs are accepted into the result. + */ +export function assertInnerKeepQaScenarioEvidence( + value, + expectedScenarioId, + phase = 'steady', +) { + const evidence = parseInnerKeepQaEvidence(value); + const scenario = innerKeepQaScenarioById(expectedScenarioId); + const expectedProgress = scenario.progressBasisPoints; + if ( + scenario.id !== expectedScenarioId + || evidence.scenario !== scenario.id + || evidence.renderMode !== scenario.renderMode + || evidence.innerKeepRenderer !== scenario.renderMode + || evidence.quality !== scenario.quality + || evidence.reducedMotion !== scenario.reducedMotion + || evidence.progressBasisPoints !== expectedProgress + || evidence.slotControlCount !== 12 + || evidence.enabledSlotControlCount !== 12 + || evidence.slotCount !== 12 + || evidence.horizontalOverflow + || evidence.verticalOverflow + || evidence.documentWidth > evidence.viewportWidth + 1 + || evidence.documentHeight > evidence.viewportHeight + 1 + ) throw new TypeError('Inner Keep QA scenario evidence mismatched.'); + + if (scenario.renderMode === 'webgl') { + if ( + evidence.canvasCount !== 1 + || evidence.rendererCount !== 1 + || evidence.webglContextCount !== 1 + || evidence.rafOwnerCount !== 1 + || evidence.maximumPendingRafCount > 1 + || evidence.slotGeometryCount !== 12 + ) throw new TypeError('Inner Keep QA single-renderer evidence mismatched.'); + } else if ( + evidence.canvasCount !== 0 + || evidence.rendererCount !== 0 + || evidence.webglContextCount !== 0 + || evidence.rafOwnerCount !== 0 + || evidence.maximumPendingRafCount !== 0 + || evidence.slotGeometryCount !== 0 + ) { + throw new TypeError('Inner Keep QA fallback resource evidence mismatched.'); + } + + const constructing = [ + 'constructing', + 'completion-reveal', + 'builder-busy', + ].includes(scenario.state); + const completed = ['complete', 'missing-asset'].includes(scenario.state); + if ( + phase === 'reveal' + && scenario.state === 'completion-reveal' + ) { + if ( + evidence.completedBuildingCount !== 1 + || evidence.constructionSiteCount !== 0 + || evidence.finalModelCount !== 1 + || !evidence.scaffoldPresent + || !evidence.completionRevealActive + || evidence.smokeSpriteCount < 1 + ) throw new TypeError('Inner Keep QA completion reveal evidence mismatched.'); + } else if ( + phase === 'completed' + && scenario.state === 'completion-reveal' + ) { + if (!expectCompletedPresentation(evidence) || evidence.completionRevealActive) { + throw new TypeError('Inner Keep QA completed reveal evidence mismatched.'); + } + } else if (constructing) { + if (!expectConstructionPresentation(evidence) || evidence.completionRevealActive) { + throw new TypeError('Inner Keep QA construction evidence mismatched.'); + } + } else if (completed) { + if (!expectCompletedPresentation(evidence) || evidence.completionRevealActive) { + throw new TypeError('Inner Keep QA completed-building evidence mismatched.'); + } + } else if ( + evidence.completedBuildingCount !== 0 + || evidence.constructionSiteCount !== 0 + || evidence.finalModelCount !== 0 + || evidence.scaffoldPresent + || evidence.smokeSpriteCount !== 0 + ) { + throw new TypeError('Inner Keep QA empty presentation evidence mismatched.'); + } + + if ( + (scenario.state === 'missing-asset' && evidence.assetFallbackCount < 1) + || (scenario.state === 'builder-busy' && !evidence.builderBusyVisible) + || (scenario.state === 'insufficient' && !evidence.insufficientResourcesVisible) + || (scenario.level !== null && !evidence.levelVisible) + ) throw new TypeError('Inner Keep QA scenario UI evidence mismatched.'); + return evidence; +} + +export function innerKeepQaBrowserCases(port) { + const selectedPort = exactPort(port); + return Object.freeze(INNER_KEEP_QA_SCENARIO_MANIFEST.map((scenario) => Object.freeze({ + id: scenario.id, + scenario, + url: innerKeepQaUrl({ port: selectedPort, scenario: scenario.id }), + viewport: Object.freeze( + ['construction-99-percent', 'compact-quality', 'reduced-motion', + 'missing-asset-fallback', '2d-fallback'].includes(scenario.id) + ? { width: 390, height: 844 } + : { width: 1_440, height: 900 } + ) + }))); +} diff --git a/scripts/qa-observer/local-fullstack-browser-probe.mjs b/scripts/qa-observer/local-fullstack-browser-probe.mjs index 94aecdd2..fd1a22f2 100644 --- a/scripts/qa-observer/local-fullstack-browser-probe.mjs +++ b/scripts/qa-observer/local-fullstack-browser-probe.mjs @@ -1272,6 +1272,664 @@ async function exerciseRestoredEntryAgreementContinuity(session) { return Object.freeze({ ...value }); } +export async function dispatchInnerKeepTouchCompatibilityClick(session, target) { + await session.command('Emulation.setTouchEmulationEnabled', { + enabled: true, + maxTouchPoints: 1, + }); + try { + await session.command('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ x: target.x, y: target.y }], + }); + await session.command('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + }); + // Keep the compatibility click in the same input train. The first native + // activation must synchronously seal the command before this reaches it. + await session.command('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: target.x, + y: target.y, + button: 'left', + buttons: 1, + clickCount: 1, + pointerType: 'mouse', + }); + await session.command('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: target.x, + y: target.y, + button: 'left', + buttons: 0, + clickCount: 1, + pointerType: 'mouse', + }); + } finally { + await session.command('Emulation.setTouchEmulationEnabled', { + enabled: false, + }); + } +} + +async function prepareLocalInnerKeepFirstStart(session) { + const result = await session.command('Runtime.evaluate', { + expression: `(async () => { + const deadline = performance.now() + ${PRESENTATION_TIMEOUT_MILLISECONDS}; + const waitFor = async (predicate) => { + while (performance.now() <= deadline) { + try { + const value = predicate(); + if (value) return value; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 32)); + } + return undefined; + }; + const visible = (element) => { + if (!(element instanceof HTMLElement)) return false; + const style = getComputedStyle(element); + const bounds = element.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && Number(style.opacity || '1') > 0 + && bounds.width > 0 + && bounds.height > 0; + }; + const buttonWithText = (text, root = document) => [...root.querySelectorAll('button')] + .find((button) => visible(button) && (button.textContent ?? '').trim() === text); + const enterMenu = await waitFor(() => { + const candidate = document.querySelector('button[data-command="enter-realm"]'); + return candidate instanceof HTMLButtonElement && !candidate.disabled + && candidate.closest('[inert]') === null && visible(candidate) + ? candidate : undefined; + }); + if (!(enterMenu instanceof HTMLButtonElement)) return { stage: 'inner-keep-menu' }; + enterMenu.click(); + const dialog = await waitFor(() => document.querySelector('[role="dialog"][aria-modal="true"]')); + if (!(dialog instanceof HTMLElement)) return { stage: 'inner-keep-terms' }; + const checkbox = dialog.querySelector('input[type="checkbox"]'); + if (!(checkbox instanceof HTMLInputElement)) return { stage: 'inner-keep-terms-checkbox' }; + if (!checkbox.checked) checkbox.click(); + const continueButton = [...dialog.querySelectorAll('button')].find((button) => ( + /^CONTINUE TO /.test((button.textContent ?? '').trim()) + )); + if (!(continueButton instanceof HTMLButtonElement) || continueButton.disabled) { + return { stage: 'inner-keep-terms-continue' }; + } + continueButton.click(); + const readyProbe = await waitFor(() => { + const probe = document.querySelector('[data-local-fullstack-backend]'); + return probe?.getAttribute('data-local-fullstack-auth') === 'authenticated' + && probe.getAttribute('data-local-fullstack-backend') === 'ready' + && probe.getAttribute('data-local-fullstack-workers') === '4' + && probe.getAttribute('data-local-fullstack-public-workers') === '28' + ? probe : undefined; + }); + if (!(readyProbe instanceof HTMLOutputElement)) return { stage: 'inner-keep-authority' }; + const enterRealm = await waitFor(() => buttonWithText('ENTER REALM')); + if (!(enterRealm instanceof HTMLButtonElement)) return { stage: 'inner-keep-enter-realm' }; + enterRealm.click(); + const realm = await waitFor(() => { + const candidate = document.querySelector('main.realm-map-screen'); + return candidate instanceof HTMLElement + && visible(candidate) + && candidate.getAttribute('data-renderer') === 'webgl' + && candidate.getAttribute('data-renderer-state') === 'ready' + ? candidate : undefined; + }); + if (!(realm instanceof HTMLElement)) return { stage: 'inner-keep-realm' }; + const canvas = await waitFor(() => { + const candidate = realm.querySelector('canvas[data-realm-canvas-active="true"]'); + return candidate instanceof HTMLCanvasElement + && candidate.getAttribute('data-realm-camera-settled') === 'true' + && (candidate.getAttribute('data-realm-camera-state-token') ?? '').length > 0 + ? candidate : undefined; + }); + if (!(canvas instanceof HTMLCanvasElement)) return { stage: 'inner-keep-camera' }; + const cameraStateToken = canvas.getAttribute('data-realm-camera-state-token'); + const sceneGeneration = realm.getAttribute('data-renderer-generation'); + const sceneCreationCount = realm.getAttribute('data-realm-scene-creation-count'); + const sceneDisposalCount = realm.getAttribute('data-realm-scene-disposal-count'); + const menuTrigger = realm.querySelector('.realm-profile-trigger'); + if (!(menuTrigger instanceof HTMLButtonElement)) return { stage: 'inner-keep-realm-menu' }; + menuTrigger.click(); + const realmMenu = await waitFor(() => document.querySelector('.realm-profile-menu__panel')); + if (!(realmMenu instanceof HTMLElement)) return { stage: 'inner-keep-realm-menu-open' }; + const innerKeepEntry = [...realmMenu.querySelectorAll('button')].find((button) => ( + (button.querySelector('strong')?.textContent ?? '').trim() === 'INNER KEEP' + )); + if (!(innerKeepEntry instanceof HTMLButtonElement) || innerKeepEntry.disabled) { + return { stage: 'inner-keep-entry' }; + } + innerKeepEntry.click(); + const innerKeep = await waitFor(() => { + const candidate = document.querySelector('.inner-keep'); + return candidate instanceof HTMLElement + && candidate.getAttribute('data-inner-keep-phase') === 'ready' + && candidate.getAttribute('data-inner-keep-valid') === 'true' + && candidate.getAttribute('data-inner-keep-renderer') === 'webgl' + && realm.getAttribute('data-realm-scene-mode') === 'INNER_KEEP' + ? candidate : undefined; + }); + if (!(innerKeep instanceof HTMLElement)) return { stage: 'inner-keep-ready' }; + const slots = innerKeep.querySelectorAll('[data-inner-keep-slot-id]'); + const exactResources = ['Food', 'Wood', 'Stone', 'Gold'].every((resource) => ( + innerKeep.querySelector('[aria-label="10,000 stored ' + resource + '"]') !== null + )); + if (slots.length !== 12 || !exactResources) return { stage: 'inner-keep-initial-state' }; + const millSlot = innerKeep.querySelector('[data-inner-keep-slot-id="inner-keep-slot-m01"]'); + if (!(millSlot instanceof HTMLButtonElement)) return { stage: 'inner-keep-mill-slot' }; + millSlot.click(); + const sitePanel = await waitFor(() => document.querySelector('.inner-keep-panel[data-panel-kind="site"]')); + if (!(sitePanel instanceof HTMLElement)) return { stage: 'inner-keep-site-panel' }; + const millReview = sitePanel.querySelector('[data-inner-keep-building-kind="city-mill"]'); + if (!(millReview instanceof HTMLButtonElement) || millReview.disabled) { + return { stage: 'inner-keep-mill-review' }; + } + const readCost = (root, resource) => ( + root.querySelector('.inner-keep-project-cost [data-resource="' + resource + '"] dd') + ?.textContent?.replaceAll(',', '').trim() ?? '' + ); + if ( + readCost(millReview.closest('li'), 'food') !== '300' + || readCost(millReview.closest('li'), 'wood') !== '900' + || readCost(millReview.closest('li'), 'stone') !== '600' + || readCost(millReview.closest('li'), 'gold') !== '0' + ) return { stage: 'inner-keep-mill-quote' }; + millReview.click(); + const confirmation = await waitFor(() => document.querySelector('.inner-keep-confirmation')); + if (!(confirmation instanceof HTMLElement)) return { stage: 'inner-keep-mill-confirmation' }; + const start = buttonWithText('START CONSTRUCTION', confirmation); + if (!(start instanceof HTMLButtonElement) || start.disabled) { + return { stage: 'inner-keep-mill-start' }; + } + start.scrollIntoView({ block: 'center', inline: 'center' }); + await new Promise((resolve) => requestAnimationFrame(() => ( + requestAnimationFrame(resolve) + ))); + const bounds = start.getBoundingClientRect(); + const activationX = bounds.left + bounds.width / 2; + const activationY = bounds.top + bounds.height / 2; + const hit = document.elementFromPoint(activationX, activationY); + return { + stage: 'inner-keep-first-start-ready', + activationX, + activationY, + targetHit: hit === start || (hit instanceof Node && start.contains(hit)), + initialAttemptCount: Number(document.documentElement.getAttribute( + 'data-local-fullstack-inner-keep-attempt' + ) ?? '0'), + cameraStateToken, + sceneGeneration, + sceneCreationCount, + sceneDisposalCount, + }; + })()`, + awaitPromise: true, + returnByValue: true, + }); + const value = result?.result?.value; + if ( + result?.exceptionDetails + || value === null + || typeof value !== 'object' + || value.stage !== 'inner-keep-first-start-ready' + || !Number.isFinite(value.activationX) + || !Number.isFinite(value.activationY) + || value.activationX < 0 + || value.activationX > VIEWPORT.width + || value.activationY < 0 + || value.activationY > VIEWPORT.height + || value.targetHit !== true + || value.initialAttemptCount !== 0 + || typeof value.cameraStateToken !== 'string' + || value.cameraStateToken.length === 0 + || !/^\d+$/.test(value.sceneGeneration ?? '') + || value.sceneCreationCount !== '1' + || value.sceneDisposalCount !== '0' + ) { + throw new LocalFullstackBrowserError( + `Disposable Inner Keep first start preparation failed at ${ + typeof value?.stage === 'string' ? value.stage : 'runtime' + }.` + ); + } + return Object.freeze({ ...value }); +} + +async function observeLocalInnerKeepFirstStart(session) { + const result = await session.command('Runtime.evaluate', { + expression: `(async () => { + const deadline = performance.now() + ${PRESENTATION_TIMEOUT_MILLISECONDS}; + const waitFor = async (predicate) => { + while (performance.now() <= deadline) { + try { + const value = predicate(); + if (value) return value; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 32)); + } + return undefined; + }; + const constructing = await waitFor(() => { + const candidate = document.querySelector('.inner-keep'); + const slot = candidate?.querySelector( + '[data-inner-keep-slot-id="inner-keep-slot-m01"]' + ); + return candidate instanceof HTMLElement + && candidate.getAttribute('data-inner-keep-phase') === 'constructing' + && document.documentElement.getAttribute( + 'data-local-fullstack-inner-keep-state' + ) === 'accepted' + && slot?.querySelector('.inner-keep-worksite') !== null + ? { candidate, slot } : undefined; + }); + if (!constructing) return { stage: 'inner-keep-mill-constructing' }; + const requestKey = document.documentElement.getAttribute( + 'data-local-fullstack-inner-keep-request-key' + ); + const activationAttemptCount = Number(document.documentElement.getAttribute( + 'data-local-fullstack-inner-keep-attempt' + ) ?? '0'); + const noFinalModel = constructing.slot.querySelector( + '.inner-keep-building-art, .inner-keep-building-art-fallback' + ) === null; + const readyProbe = document.querySelector('[data-local-fullstack-backend]'); + if (!(readyProbe instanceof HTMLOutputElement)) { + return { stage: 'inner-keep-first-start-authority-probe' }; + } + return { + stage: 'inner-keep-first-start-complete', + requestKey, + activationAttemptCount, + noFinalModel, + scaffold: constructing.slot.querySelector('.inner-keep-worksite__scaffold') !== null, + smoke: constructing.slot.querySelector('.inner-keep-worksite__smoke') !== null, + workerCount: Number(readyProbe.getAttribute('data-local-fullstack-workers')), + publicWorkerCount: Number( + readyProbe.getAttribute('data-local-fullstack-public-workers') + ) + }; + })()`, + awaitPromise: true, + returnByValue: true, + }); + const value = result?.result?.value; + if ( + result?.exceptionDetails + || value === null + || typeof value !== 'object' + || value.stage !== 'inner-keep-first-start-complete' + || typeof value.requestKey !== 'string' + || !/^[a-z0-9][a-z0-9-]{15,79}$/.test(value.requestKey) + || value.activationAttemptCount !== 1 + || value.noFinalModel !== true + || value.scaffold !== true + || value.smoke !== true + || value.workerCount !== 4 + || value.publicWorkerCount !== 28 + ) { + throw new LocalFullstackBrowserError( + `Disposable Inner Keep first start failed at ${ + typeof value?.stage === 'string' ? value.stage : 'runtime' + }.` + ); + } + return Object.freeze({ ...value }); +} + +async function exerciseLocalInnerKeepFirstStart(session) { + const prepared = await prepareLocalInnerKeepFirstStart(session); + await dispatchInnerKeepTouchCompatibilityClick(session, { + x: prepared.activationX, + y: prepared.activationY, + }); + const completed = await observeLocalInnerKeepFirstStart(session); + return Object.freeze({ ...prepared, ...completed }); +} + +async function exerciseLocalInnerKeepCompletionAndSecondStart(session, firstStart) { + const expected = JSON.stringify({ + cameraStateToken: firstStart.cameraStateToken, + firstRequestKey: firstStart.requestKey, + sceneCreationCount: firstStart.sceneCreationCount, + sceneDisposalCount: firstStart.sceneDisposalCount, + sceneGeneration: firstStart.sceneGeneration, + }); + const result = await session.command('Runtime.evaluate', { + expression: `(async () => { + const expected = ${expected}; + const deadline = performance.now() + ${PRESENTATION_TIMEOUT_MILLISECONDS}; + const waitFor = async (predicate) => { + while (performance.now() <= deadline) { + try { + const value = predicate(); + if (value) return value; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 32)); + } + return undefined; + }; + const visible = (element) => { + if (!(element instanceof HTMLElement)) return false; + const style = getComputedStyle(element); + const bounds = element.getBoundingClientRect(); + return style.display !== 'none' && style.visibility !== 'hidden' + && Number(style.opacity || '1') > 0 && bounds.width > 0 && bounds.height > 0; + }; + const buttonWithText = (text, root = document) => [...root.querySelectorAll('button')] + .find((button) => visible(button) && (button.textContent ?? '').trim() === text); + const innerKeep = await waitFor(() => { + const candidate = document.querySelector('.inner-keep'); + const mill = candidate?.querySelector( + '[data-inner-keep-slot-id="inner-keep-slot-m01"]' + ); + return candidate instanceof HTMLElement + && candidate.getAttribute('data-inner-keep-phase') === 'ready' + && mill?.querySelector('.inner-keep-worksite') === null + && mill?.querySelector( + '.inner-keep-building-art, .inner-keep-building-art-fallback' + ) !== null + ? candidate : undefined; + }); + if (!(innerKeep instanceof HTMLElement)) return { stage: 'inner-keep-first-completion' }; + const millSlot = innerKeep.querySelector( + '[data-inner-keep-slot-id="inner-keep-slot-m01"]' + ); + if (!(millSlot instanceof HTMLButtonElement)) return { stage: 'inner-keep-first-reveal' }; + millSlot.click(); + const completedDetail = await waitFor(() => { + const candidate = document.querySelector('.inner-keep-building-detail'); + return candidate instanceof HTMLElement + && /COMPLETED BUILDING/.test(candidate.textContent ?? '') + && /City Mill/.test(candidate.textContent ?? '') + && /Level 1/.test(candidate.textContent ?? '') + && /Food construction costs -5%\./.test(candidate.textContent ?? '') + ? candidate : undefined; + }); + if (!(completedDetail instanceof HTMLElement)) return { stage: 'inner-keep-discount-reveal' }; + const lumberSlot = innerKeep.querySelector( + '[data-inner-keep-slot-id="inner-keep-slot-m02"]' + ); + if (!(lumberSlot instanceof HTMLButtonElement)) return { stage: 'inner-keep-lumber-slot' }; + lumberSlot.click(); + const sitePanel = await waitFor(() => document.querySelector( + '.inner-keep-panel[data-panel-kind="site"]' + )); + if (!(sitePanel instanceof HTMLElement)) return { stage: 'inner-keep-lumber-site' }; + const lumberReview = sitePanel.querySelector( + '[data-inner-keep-building-kind="lumber-camp"]' + ); + if (!(lumberReview instanceof HTMLButtonElement) || lumberReview.disabled) { + return { stage: 'inner-keep-lumber-review' }; + } + const readCost = (root, resource) => ( + root.querySelector('.inner-keep-project-cost [data-resource="' + resource + '"] dd') + ?.textContent?.replaceAll(',', '').trim() ?? '' + ); + const lumberCard = lumberReview.closest('li'); + if ( + !(lumberCard instanceof HTMLLIElement) + || readCost(lumberCard, 'food') !== '480' + || readCost(lumberCard, 'wood') !== '700' + || readCost(lumberCard, 'stone') !== '650' + || readCost(lumberCard, 'gold') !== '0' + ) return { stage: 'inner-keep-discounted-quote' }; + lumberReview.click(); + const confirmation = await waitFor(() => document.querySelector('.inner-keep-confirmation')); + if (!(confirmation instanceof HTMLElement)) return { stage: 'inner-keep-lumber-confirmation' }; + const start = buttonWithText('START CONSTRUCTION', confirmation); + if (!(start instanceof HTMLButtonElement) || start.disabled) { + return { stage: 'inner-keep-lumber-start' }; + } + start.click(); + const secondProject = await waitFor(() => { + const candidate = document.querySelector('.inner-keep'); + const mill = candidate?.querySelector( + '[data-inner-keep-slot-id="inner-keep-slot-m01"]' + ); + const lumber = candidate?.querySelector( + '[data-inner-keep-slot-id="inner-keep-slot-m02"]' + ); + const requestKey = document.documentElement.getAttribute( + 'data-local-fullstack-inner-keep-request-key' + ); + return candidate instanceof HTMLElement + && candidate.getAttribute('data-inner-keep-phase') === 'constructing' + && document.documentElement.getAttribute( + 'data-local-fullstack-inner-keep-attempt' + ) === '2' + && document.documentElement.getAttribute( + 'data-local-fullstack-inner-keep-state' + ) === 'accepted' + && requestKey !== expected.firstRequestKey + && typeof requestKey === 'string' + && mill?.querySelector( + '.inner-keep-building-art, .inner-keep-building-art-fallback' + ) !== null + && lumber?.querySelector('.inner-keep-worksite') !== null + ? { candidate, lumber, requestKey } : undefined; + }); + if (!secondProject) return { stage: 'inner-keep-second-project' }; + const worksiteCount = innerKeep.querySelectorAll('.inner-keep-worksite').length; + const builderCopy = innerKeep.querySelector('.inner-keep-builder')?.textContent ?? ''; + const secondNoFinalModel = secondProject.lumber.querySelector( + '.inner-keep-building-art, .inner-keep-building-art-fallback' + ) === null; + const close = buttonWithText('CLOSE TO REALM', innerKeep); + if (!(close instanceof HTMLButtonElement)) return { stage: 'inner-keep-close' }; + close.click(); + const realmContinuity = await waitFor(() => { + const realm = document.querySelector('main.realm-map-screen'); + const canvas = realm?.querySelector('canvas[data-realm-canvas-active="true"]'); + const probe = document.querySelector('[data-local-fullstack-backend]'); + return realm instanceof HTMLElement + && canvas instanceof HTMLCanvasElement + && document.querySelector('.inner-keep') === null + && realm.getAttribute('data-realm-scene-mode') === 'WORLD' + && realm.getAttribute('data-renderer-generation') === expected.sceneGeneration + && realm.getAttribute('data-realm-scene-creation-count') === expected.sceneCreationCount + && realm.getAttribute('data-realm-scene-disposal-count') === expected.sceneDisposalCount + && canvas.getAttribute('data-realm-camera-state-token') === expected.cameraStateToken + && canvas.getAttribute('data-realm-worker-presented-count') === '28' + && probe?.getAttribute('data-local-fullstack-workers') === '4' + && probe.getAttribute('data-local-fullstack-deployed-workers') === '0' + ? { realm, canvas, probe } : undefined; + }); + if (!realmContinuity) return { stage: 'inner-keep-realm-continuity' }; + return { + stage: 'inner-keep-second-start-complete', + secondRequestKey: secondProject.requestKey, + completedBuildingRevealed: true, + discountBasisPoints: 500, + discountedFoodCost: 480, + worksiteCount, + builderOccupied: /BUILDER OCCUPIED/.test(builderCopy), + secondNoFinalModel, + cameraPreserved: true, + scenePreserved: true, + workerCount: Number( + realmContinuity.probe.getAttribute('data-local-fullstack-workers') + ), + publicWorkerCount: Number( + realmContinuity.probe.getAttribute('data-local-fullstack-public-workers') + ) + }; + })()`, + awaitPromise: true, + returnByValue: true, + }); + const value = result?.result?.value; + if ( + result?.exceptionDetails + || value === null + || typeof value !== 'object' + || value.stage !== 'inner-keep-second-start-complete' + || typeof value.secondRequestKey !== 'string' + || !/^[a-z0-9][a-z0-9-]{15,79}$/.test(value.secondRequestKey) + || value.secondRequestKey === firstStart.requestKey + || value.completedBuildingRevealed !== true + || value.discountBasisPoints !== 500 + || value.discountedFoodCost !== 480 + || value.worksiteCount !== 1 + || value.builderOccupied !== true + || value.secondNoFinalModel !== true + || value.cameraPreserved !== true + || value.scenePreserved !== true + || value.workerCount !== 4 + || value.publicWorkerCount !== 28 + ) { + throw new LocalFullstackBrowserError( + `Disposable Inner Keep completion failed at ${ + typeof value?.stage === 'string' ? value.stage : 'runtime' + }.` + ); + } + return Object.freeze({ ...value }); +} + +async function exerciseLocalInnerKeepReloadPersistence(session) { + const result = await session.command('Runtime.evaluate', { + expression: `(async () => { + const deadline = performance.now() + ${PRESENTATION_TIMEOUT_MILLISECONDS}; + const waitFor = async (predicate) => { + while (performance.now() <= deadline) { + try { const value = predicate(); if (value) return value; } catch {} + await new Promise((resolve) => setTimeout(resolve, 32)); + } + return undefined; + }; + const visible = (element) => { + if (!(element instanceof HTMLElement)) return false; + const style = getComputedStyle(element); + const bounds = element.getBoundingClientRect(); + return style.display !== 'none' && style.visibility !== 'hidden' + && Number(style.opacity || '1') > 0 && bounds.width > 0 && bounds.height > 0; + }; + const buttonWithText = (text, root = document) => [...root.querySelectorAll('button')] + .find((button) => visible(button) && (button.textContent ?? '').trim() === text); + const enterMenu = await waitFor(() => { + const candidate = document.querySelector('button[data-command="enter-realm"]'); + return candidate instanceof HTMLButtonElement && !candidate.disabled + && candidate.closest('[inert]') === null && visible(candidate) + ? candidate : undefined; + }); + if (!(enterMenu instanceof HTMLButtonElement)) return { stage: 'inner-keep-reload-menu' }; + enterMenu.click(); + const dialog = await waitFor(() => document.querySelector('[role="dialog"][aria-modal="true"]')); + if (!(dialog instanceof HTMLElement)) return { stage: 'inner-keep-reload-terms' }; + const checkbox = dialog.querySelector('input[type="checkbox"]'); + if (!(checkbox instanceof HTMLInputElement)) return { stage: 'inner-keep-reload-checkbox' }; + if (!checkbox.checked) checkbox.click(); + const continueButton = [...dialog.querySelectorAll('button')].find((button) => ( + /^CONTINUE TO /.test((button.textContent ?? '').trim()) + )); + if (!(continueButton instanceof HTMLButtonElement) || continueButton.disabled) { + return { stage: 'inner-keep-reload-continue' }; + } + continueButton.click(); + const probe = await waitFor(() => { + const candidate = document.querySelector('[data-local-fullstack-backend]'); + return candidate?.getAttribute('data-local-fullstack-auth') === 'authenticated' + && candidate.getAttribute('data-local-fullstack-backend') === 'ready' + && candidate.getAttribute('data-local-fullstack-workers') === '4' + ? candidate : undefined; + }); + if (!(probe instanceof HTMLOutputElement)) return { stage: 'inner-keep-reload-authority' }; + const enterRealm = await waitFor(() => buttonWithText('ENTER REALM')); + if (!(enterRealm instanceof HTMLButtonElement)) return { stage: 'inner-keep-reload-enter' }; + enterRealm.click(); + const realm = await waitFor(() => { + const candidate = document.querySelector('main.realm-map-screen'); + return candidate instanceof HTMLElement + && candidate.getAttribute('data-renderer-state') === 'ready' + ? candidate : undefined; + }); + if (!(realm instanceof HTMLElement)) return { stage: 'inner-keep-reload-realm' }; + const trigger = realm.querySelector('.realm-profile-trigger'); + if (!(trigger instanceof HTMLButtonElement)) return { stage: 'inner-keep-reload-menu-trigger' }; + trigger.click(); + const menu = await waitFor(() => document.querySelector('.realm-profile-menu__panel')); + const innerEntry = menu instanceof HTMLElement + ? [...menu.querySelectorAll('button')].find((button) => ( + (button.querySelector('strong')?.textContent ?? '').trim() === 'INNER KEEP' + )) + : undefined; + if (!(innerEntry instanceof HTMLButtonElement) || innerEntry.disabled) { + return { stage: 'inner-keep-reload-entry' }; + } + innerEntry.click(); + const innerKeep = await waitFor(() => { + const candidate = document.querySelector('.inner-keep'); + const mill = candidate?.querySelector( + '[data-inner-keep-slot-id="inner-keep-slot-m01"]' + ); + const lumber = candidate?.querySelector( + '[data-inner-keep-slot-id="inner-keep-slot-m02"]' + ); + return candidate instanceof HTMLElement + && candidate.getAttribute('data-inner-keep-phase') === 'constructing' + && candidate.getAttribute('data-inner-keep-valid') === 'true' + && mill?.querySelector( + '.inner-keep-building-art, .inner-keep-building-art-fallback' + ) !== null + && mill?.querySelector('.inner-keep-worksite') === null + && lumber?.querySelector('.inner-keep-worksite') !== null + && candidate.querySelectorAll('.inner-keep-worksite').length === 1 + && /BUILDER OCCUPIED/.test( + candidate.querySelector('.inner-keep-builder')?.textContent ?? '' + ) + ? candidate : undefined; + }); + if (!(innerKeep instanceof HTMLElement)) return { stage: 'inner-keep-reload-persistence' }; + const close = buttonWithText('CLOSE TO REALM', innerKeep); + if (!(close instanceof HTMLButtonElement)) return { stage: 'inner-keep-reload-close' }; + close.click(); + const world = await waitFor(() => { + const canvas = realm.querySelector('canvas[data-realm-canvas-active="true"]'); + return document.querySelector('.inner-keep') === null + && realm.getAttribute('data-realm-scene-mode') === 'WORLD' + && canvas?.getAttribute('data-realm-worker-presented-count') === '28' + && probe.getAttribute('data-local-fullstack-workers') === '4' + && probe.getAttribute('data-local-fullstack-deployed-workers') === '0' + ? canvas : undefined; + }); + if (!(world instanceof HTMLCanvasElement)) return { stage: 'inner-keep-reload-world' }; + return { + stage: 'inner-keep-reload-complete', + firstBuildingComplete: true, + secondProjectConstructing: true, + singleBuilderBusy: true, + workerCount: Number(probe.getAttribute('data-local-fullstack-workers')), + publicWorkerCount: Number(probe.getAttribute('data-local-fullstack-public-workers')) + }; + })()`, + awaitPromise: true, + returnByValue: true, + }); + const value = result?.result?.value; + if ( + result?.exceptionDetails + || value === null + || typeof value !== 'object' + || value.stage !== 'inner-keep-reload-complete' + || value.firstBuildingComplete !== true + || value.secondProjectConstructing !== true + || value.singleBuilderBusy !== true + || value.workerCount !== 4 + || value.publicWorkerCount !== 28 + ) { + throw new LocalFullstackBrowserError( + `Disposable Inner Keep reload failed at ${ + typeof value?.stage === 'string' ? value.stage : 'runtime' + }.` + ); + } + return Object.freeze({ ...value }); +} + async function exerciseLocalFullstackJourney(session, journeyMode = 'complete') { if (!['complete', 'persistent-worker-reentry'].includes(journeyMode)) { throw new LocalFullstackBrowserError('Disposable journey mode was invalid.'); @@ -5207,6 +5865,13 @@ export async function runLocalFullstackBrowserProbe(options = {}) { if ( database.seedAttestation?.castleCount !== 7 || database.seedAttestation.workerCount !== 28 + || database.seedAttestation.innerKeepActive !== true + || database.seedAttestation.innerKeepBuilderCount !== 7 + || database.seedAttestation.innerKeepCatalogRows !== 37 + || database.seedAttestation.innerKeepInitialResources?.food !== '10000' + || database.seedAttestation.innerKeepInitialResources.wood !== '10000' + || database.seedAttestation.innerKeepInitialResources.stone !== '10000' + || database.seedAttestation.innerKeepInitialResources.gold !== '10000' || database.seedAttestation.entryAgreementAcceptedCurrent !== true || database.seedAttestation.entryAgreementRequiredVersion !== '2026-07-31-hegemony-entry-agreement-v4' @@ -5507,6 +6172,55 @@ export async function runLocalFullstackBrowserProbe(options = {}) { `Disposable restored Terms browser left its boundary: ${state.violation}.` ); } + probeStage = 'inner-keep-journey-navigation'; + await devtools.command('Page.navigate', { url: pageUrl }); + await delay(500); + probeStage = 'inner-keep-first-project-client'; + const innerKeepFirstStart = await exerciseLocalInnerKeepFirstStart(devtools); + probeStage = 'inner-keep-first-project-authority'; + const innerKeepFirstAuthority = await database.inspectInnerKeepFirstProject( + innerKeepFirstStart.requestKey + ); + probeStage = 'inner-keep-completion-and-second-project'; + const innerKeepSecondStart = + await exerciseLocalInnerKeepCompletionAndSecondStart( + devtools, + innerKeepFirstStart + ); + probeStage = 'inner-keep-final-authority'; + const innerKeepAuthority = await database.attestInnerKeepJourney([ + innerKeepFirstStart.requestKey, + innerKeepSecondStart.secondRequestKey, + ]); + probeStage = 'inner-keep-hard-reload-navigation'; + await devtools.command('Page.reload', { ignoreCache: true }); + await delay(500); + probeStage = 'inner-keep-hard-reload-persistence'; + const innerKeepReload = await exerciseLocalInnerKeepReloadPersistence(devtools); + const innerKeepJourney = Object.freeze({ + firstAuthority: innerKeepFirstAuthority, + firstStart: Object.freeze({ + noFinalModel: innerKeepFirstStart.noFinalModel, + scaffold: innerKeepFirstStart.scaffold, + smoke: innerKeepFirstStart.smoke, + }), + authority: innerKeepAuthority, + reload: innerKeepReload, + secondStart: Object.freeze({ + builderOccupied: innerKeepSecondStart.builderOccupied, + cameraPreserved: innerKeepSecondStart.cameraPreserved, + completedBuildingRevealed: innerKeepSecondStart.completedBuildingRevealed, + discountedFoodCost: innerKeepSecondStart.discountedFoodCost, + scenePreserved: innerKeepSecondStart.scenePreserved, + secondNoFinalModel: innerKeepSecondStart.secondNoFinalModel, + worksiteCount: innerKeepSecondStart.worksiteCount, + }), + }); + if (state.violation) { + throw new Error( + `Disposable Inner Keep browser left its boundary: ${state.violation}.` + ); + } probeStage = 'title-browser-stop'; devtools.close(); await terminateHeadlessChromeProcessGroup(chrome); @@ -5694,6 +6408,7 @@ export async function runLocalFullstackBrowserProbe(options = {}) { throw new Error(`Disposable browser left its boundary: ${state.violation}.`); } return Object.freeze({ + innerKeepJourney, journey, hardReloadWorkerContinuity, moduleDigest: database.moduleDigest, @@ -5748,6 +6463,9 @@ async function main() { + `matrix (${result.titleGatewayDepartureFocus.caseCount} cases, ${ result.titleGatewayDepartureFocus.frameCount } frames), one restored exact-current server Terms continuity skip, ` + + 'one authoritative Inner Keep City Mill start/completion and discounted ' + + 'Lumber Camp start with one Builder, hard-reload persistence, and preserved ' + + 'Realm camera/Worker presentation, ' + 'one synthetic cold auth/bootstrap/Terms ' + 'journey, one hard four-phase Worker re-entry from a fresh browser process ' + '(two outbound, one gathering, one returning) with delayed/failing private ' diff --git a/scripts/qa-observer/local-fullstack-spacetime.mjs b/scripts/qa-observer/local-fullstack-spacetime.mjs index 8831bb65..8998ef71 100644 --- a/scripts/qa-observer/local-fullstack-spacetime.mjs +++ b/scripts/qa-observer/local-fullstack-spacetime.mjs @@ -29,6 +29,12 @@ export const LOCAL_FULLSTACK_PROFILE_URL = 'https://i.imgur.com/warpkeep-local-keeper.png'; export const LOCAL_FULLSTACK_FOUNDER_COUNT = 7; export const LOCAL_FULLSTACK_WORKER_COUNT = LOCAL_FULLSTACK_FOUNDER_COUNT * 4; +export const LOCAL_FULLSTACK_INNER_KEEP_RESOURCES = Object.freeze({ + food: 10_000n, + wood: 10_000n, + stone: 10_000n, + gold: 10_000n, +}); const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); const SOURCE_MODULE = join(REPOSITORY_ROOT, 'spacetimedb'); @@ -40,6 +46,16 @@ const PROFILE_POLICY_VERSION = 'trusted-snapchain-profile-v3'; const RESOURCE_POLICY_VERSION = 'genesis-resource-yield-v1'; const WORKER_PROTOCOL_CAPABILITY = 'generic-castle-workers-v1'; const ENTRY_AGREEMENT_VERSION = '2026-07-31-hegemony-entry-agreement-v4'; +const INNER_KEEP_PROTOCOL_CAPABILITY = 'inner-keep-construction-v1'; +const INNER_KEEP_POLICY_VERSION = 'genesis-001-inner-keep-construction-v1'; +const INNER_KEEP_POLICY_DIGEST = + 'b3ca0d7ce3a30d3f89e0fe295864dc9c7237fbf5dedc3d8e8c2ed45586d2355e'; +const INNER_KEEP_LAYOUT_POLICY_VERSION = 'genesis-001-inner-keep-layout-v1'; +const INNER_KEEP_LAYOUT_DIGEST = + 'dc314255b0046f5b43be836b52ab4b7af94a2d25992031f75aee89b1a81490c7'; +const INNER_KEEP_ASSET_CATALOG_DIGEST = + '6763aeb1755d800b817a0d5174182474d3836a928c59beb4b4fdf65f5d1f6ec3'; +const LOCAL_INNER_KEEP_FIRST_COMPLETION_DELAY_MICROS = 15_000_000n; const LOCAL_FULLSTACK_FOUNDERS = Object.freeze(Array.from( { length: LOCAL_FULLSTACK_FOUNDER_COUNT }, (_, index) => Object.freeze({ @@ -106,6 +122,54 @@ const LOCAL_WORKER_GATHERING_DURATION_SOURCE_DECLARATIONS = Object.freeze( }); }) ); +const LOCAL_INNER_KEEP_RESOURCE_SOURCE_DECLARATION = `export const GENESIS_STARTING_RESOURCE_BALANCES: ResourceBalances = Object.freeze({ + food: 0n, + wood: 0n, + stone: 0n, + gold: 0n, +});`; +const LOCAL_INNER_KEEP_RESOURCE_QA_DECLARATION = `export const GENESIS_STARTING_RESOURCE_BALANCES: ResourceBalances = Object.freeze({ + food: ${LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.food}n, + wood: ${LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.wood}n, + stone: ${LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.stone}n, + gold: ${LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.gold}n, +});`; +const LOCAL_INNER_KEEP_COMPLETION_SOURCE_DECLARATION = + " if (now < building.completesAtMicros) fail('INNER_KEEP_COMPLETION_EARLY');"; +const LOCAL_INNER_KEEP_COMPLETION_QA_DECLARATION = ` // warpkeep-disposable-inner-keep-completion-v1 + const completionAuthorityMicros = ( + building.buildingKind === 'city-mill' + && building.targetLevel === 1 + ) + ? safeAddU64( + building.startedAtMicros, + ${LOCAL_INNER_KEEP_FIRST_COMPLETION_DELAY_MICROS}n, + 'INNER_KEEP_TIME_OVERFLOW', + ) + : building.completesAtMicros; + if (now < completionAuthorityMicros) fail('INNER_KEEP_COMPLETION_EARLY');`; +const LOCAL_INNER_KEEP_SCHEDULE_SOURCE_DECLARATION = + ' scheduledAt: ScheduleAt.time(project.completesAtMicros),'; +const LOCAL_INNER_KEEP_SCHEDULE_QA_DECLARATION = ` scheduledAt: ScheduleAt.time( + project.buildingKind === 'city-mill' && project.targetLevel === 1 + ? safeAddU64( + startedAtMicros, + ${LOCAL_INNER_KEEP_FIRST_COMPLETION_DELAY_MICROS}n, + 'INNER_KEEP_TIME_OVERFLOW', + ) + : project.completesAtMicros + ),`; +const LOCAL_INNER_KEEP_SCHEDULE_MATCH_SOURCE_DECLARATION = + ' && scheduledAtMicros === building.completesAtMicros'; +const LOCAL_INNER_KEEP_SCHEDULE_MATCH_QA_DECLARATION = ` && scheduledAtMicros === ( + building.buildingKind === 'city-mill' && building.targetLevel === 1 + ? safeAddU64( + building.startedAtMicros, + ${LOCAL_INNER_KEEP_FIRST_COMPLETION_DELAY_MICROS}n, + 'INNER_KEEP_TIME_OVERFLOW', + ) + : building.completesAtMicros + )`; const LOCAL_CLIENT_ARTIFACT_DIGEST = createHash('sha256') .update('warpkeep-disposable-local-fullstack-client-v1') .digest('hex'); @@ -313,7 +377,10 @@ async function callLocalProcedure({ } const text = await readBoundedResponse(response, credential); if (response.status !== expectedStatus) { - fail(`Local SpacetimeDB call ${name} failed safely.`); + const innerKeepCode = /\b(INNER_KEEP_[A-Z0-9_]{1,80})\b/.exec(text)?.[1]; + fail(`Local SpacetimeDB call ${name} failed safely${ + innerKeepCode ? ` (${innerKeepCode})` : '' + }.`); } return text; } @@ -453,6 +520,26 @@ async function createLocalModule(runtimeDirectory) { declaration.qa ); } + await rewriteCopiedConstant( + 'resourceAuthorityPolicy.ts', + LOCAL_INNER_KEEP_RESOURCE_SOURCE_DECLARATION, + LOCAL_INNER_KEEP_RESOURCE_QA_DECLARATION + ); + await rewriteCopiedConstant( + 'innerKeepAuthority.ts', + LOCAL_INNER_KEEP_COMPLETION_SOURCE_DECLARATION, + LOCAL_INNER_KEEP_COMPLETION_QA_DECLARATION + ); + await rewriteCopiedConstant( + 'innerKeepAuthority.ts', + LOCAL_INNER_KEEP_SCHEDULE_SOURCE_DECLARATION, + LOCAL_INNER_KEEP_SCHEDULE_QA_DECLARATION + ); + await rewriteCopiedConstant( + 'innerKeepAuthority.ts', + LOCAL_INNER_KEEP_SCHEDULE_MATCH_SOURCE_DECLARATION, + LOCAL_INNER_KEEP_SCHEDULE_MATCH_QA_DECLARATION + ); return moduleDirectory; } @@ -579,6 +666,224 @@ function readOptionalString(value) { fail(`Worker control projection option shape was invalid (${shape}).`); } +function readOptionalValue(value) { + if (value === null || value === undefined) return undefined; + if (Array.isArray(value) && value.length === 2) { + if (value[0] === 0) return value[1]; + if (value[0] === 1) return undefined; + } + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + const keys = Object.keys(value); + if (keys.length === 1 && keys[0] === 'some') return value.some; + if (keys.length === 1 && keys[0] === 'none') return undefined; + } + return value; +} + +function readOptionalUnsigned(value) { + const unwrapped = readOptionalValue(value); + return unwrapped === undefined ? undefined : readUnsigned(unwrapped); +} + +function parseInnerKeepCatalogPlan(text) { + let value; + try { + value = JSON.parse(text); + } catch { + fail('Inner Keep catalog plan JSON was invalid.'); + } + if ( + !Array.isArray(value) + || value.length !== 5 + || ![0n, 1n].includes(readUnsigned(value[0])) + || ![0n, 12n].includes(readUnsigned(value[1])) + || ![0n, 4n].includes(readUnsigned(value[2])) + || ![0n, 20n].includes(readUnsigned(value[3])) + || typeof value[4] !== 'boolean' + ) fail('Inner Keep catalog plan was invalid.'); + const plan = Object.freeze({ + missingLayout: Number(readUnsigned(value[0])), + missingSlots: Number(readUnsigned(value[1])), + missingBuildings: Number(readUnsigned(value[2])), + missingLevels: Number(readUnsigned(value[3])), + ready: value[4], + }); + if (plan.ready !== ( + plan.missingLayout === 0 + && plan.missingSlots === 0 + && plan.missingBuildings === 0 + && plan.missingLevels === 0 + )) fail('Inner Keep catalog plan readiness was invalid.'); + return plan; +} + +function parseInnerKeepBuilderPlan(text) { + let value; + try { + value = JSON.parse(text); + } catch { + fail('Inner Keep Builder plan JSON was invalid.'); + } + if (!Array.isArray(value) || value.length !== 4 || typeof value[3] !== 'boolean') { + fail('Inner Keep Builder plan was invalid.'); + } + const plan = Object.freeze({ + expectedCastles: Number(readUnsigned(value[0])), + existingBuilders: Number(readUnsigned(value[1])), + missingBuilders: Number(readUnsigned(value[2])), + ready: value[3], + }); + if ( + plan.expectedCastles !== LOCAL_FULLSTACK_FOUNDER_COUNT + || plan.existingBuilders + plan.missingBuilders !== plan.expectedCastles + || plan.ready !== (plan.existingBuilders === plan.expectedCastles) + ) fail('Inner Keep Builder plan readiness was invalid.'); + return plan; +} + +function parseInnerKeepStatus(text) { + let value; + try { + value = JSON.parse(text); + } catch { + fail('Inner Keep aggregate JSON was invalid.'); + } + if (!Array.isArray(value) || value.length !== 27) { + fail('Inner Keep aggregate shape was invalid.'); + } + const status = Object.freeze({ + layoutRows: readUnsigned(value[0]), + slotRows: readUnsigned(value[1]), + buildingCatalogRows: readUnsigned(value[2]), + levelPolicyRows: readUnsigned(value[3]), + castleRows: readUnsigned(value[4]), + builderRows: readUnsigned(value[5]), + buildingRows: readUnsigned(value[6]), + activeProjects: readUnsigned(value[7]), + receiptRows: readUnsigned(value[8]), + scheduleRows: readUnsigned(value[9]), + missingBuilders: readUnsigned(value[10]), + orphanBuilders: readUnsigned(value[11]), + invalidBuilders: readUnsigned(value[12]), + invalidBuildings: readUnsigned(value[13]), + invalidSchedules: readUnsigned(value[14]), + builderProjectMismatches: readUnsigned(value[15]), + staticCatalogExact: value[16], + workerSystemReady: value[17], + readyForCatalogSeed: value[18], + readyForBuilderBackfill: value[19], + readyForActivation: value[20], + active: value[21], + policyVersion: value[22], + policyDigest: value[23], + layoutPolicyVersion: value[24], + layoutDigest: value[25], + assetCatalogDigest: value[26], + }); + if ( + [ + status.staticCatalogExact, + status.workerSystemReady, + status.readyForCatalogSeed, + status.readyForBuilderBackfill, + status.readyForActivation, + status.active, + ].some((entry) => typeof entry !== 'boolean') + || status.policyVersion !== INNER_KEEP_POLICY_VERSION + || status.policyDigest !== INNER_KEEP_POLICY_DIGEST + || status.layoutPolicyVersion !== INNER_KEEP_LAYOUT_POLICY_VERSION + || status.layoutDigest !== INNER_KEEP_LAYOUT_DIGEST + || status.assetCatalogDigest !== INNER_KEEP_ASSET_CATALOG_DIGEST + ) fail('Inner Keep aggregate policy was invalid.'); + return status; +} + +function parseInnerKeepPrivateState(text) { + let value; + try { + value = JSON.parse(text); + } catch { + fail('Inner Keep private projection JSON was invalid.'); + } + if (!Array.isArray(value) || value.length !== 21) { + fail('Inner Keep private projection shape was invalid.'); + } + const state = Object.freeze({ + castleId: readUnsigned(value[0]), + componentActive: value[1], + componentReady: value[2], + builderPresent: value[3], + builderBusy: value[4], + activeBuildingKey: readOptionalString(value[5]), + busyUntilMicros: readOptionalUnsigned(value[6]), + builderRevision: readUnsigned(value[7]), + food: readUnsigned(value[8]), + wood: readUnsigned(value[9]), + stone: readUnsigned(value[10]), + gold: readUnsigned(value[11]), + projectedFood: readUnsigned(value[12]), + projectedWood: readUnsigned(value[13]), + projectedStone: readUnsigned(value[14]), + projectedGold: readUnsigned(value[15]), + resourceRevision: readUnsigned(value[16]), + observedAtMicros: readUnsigned(value[17]), + policyVersion: value[18], + layoutDigest: value[19], + assetCatalogDigest: value[20], + }); + if ( + [state.componentActive, state.componentReady, state.builderPresent, state.builderBusy] + .some((entry) => typeof entry !== 'boolean') + || state.castleId === 0n + || state.policyVersion !== INNER_KEEP_POLICY_VERSION + || state.layoutDigest !== INNER_KEEP_LAYOUT_DIGEST + || state.assetCatalogDigest !== INNER_KEEP_ASSET_CATALOG_DIGEST + || (state.activeBuildingKey === undefined) !== (state.busyUntilMicros === undefined) + ) fail('Inner Keep private projection was invalid.'); + return state; +} + +function parseInnerKeepReceipt(text, expectedRequestKey) { + let value; + try { + value = JSON.parse(text); + } catch { + fail('Inner Keep receipt JSON was invalid.'); + } + if (!Array.isArray(value) || value.length !== 12 || value[0] !== true) { + fail('Inner Keep receipt shape was invalid.'); + } + const receipt = Object.freeze({ + castleId: readOptionalUnsigned(value[1]), + buildingKey: readOptionalString(value[2]), + slotId: readOptionalString(value[3]), + buildingKind: readOptionalString(value[4]), + targetLevel: readOptionalUnsigned(value[5]), + deductedFood: readOptionalUnsigned(value[6]), + deductedWood: readOptionalUnsigned(value[7]), + deductedStone: readOptionalUnsigned(value[8]), + deductedGold: readOptionalUnsigned(value[9]), + startedAtMicros: readOptionalUnsigned(value[10]), + policyVersion: readOptionalString(value[11]), + requestKey: expectedRequestKey, + }); + if ( + receipt.castleId === undefined + || receipt.castleId === 0n + || receipt.buildingKey === undefined + || receipt.slotId === undefined + || receipt.buildingKind === undefined + || receipt.targetLevel === undefined + || receipt.deductedFood === undefined + || receipt.deductedWood === undefined + || receipt.deductedStone === undefined + || receipt.deductedGold === undefined + || receipt.startedAtMicros === undefined + || receipt.policyVersion !== INNER_KEEP_POLICY_VERSION + ) fail('Inner Keep receipt was incomplete.'); + return receipt; +} + function parseWorkerControlState(text, expectedFid = BigInt(LOCAL_FULLSTACK_FID)) { let value; try { @@ -757,6 +1062,80 @@ async function seedLocalRealm(server, privateKey, moduleDigest) { const active = parseWorkerRollout(await callAdmin('admin_get_worker_rollout_status_v2')); if (active.phase !== 'active') fail('Worker system did not activate locally.'); + const catalogPlan = parseInnerKeepCatalogPlan( + await callAdmin('admin_plan_inner_keep_catalog_v1') + ); + if ( + catalogPlan.missingLayout !== 1 + || catalogPlan.missingSlots !== 12 + || catalogPlan.missingBuildings !== 4 + || catalogPlan.missingLevels !== 20 + || catalogPlan.ready + ) fail('Inner Keep catalog did not begin from the exact empty state.'); + await callAdmin('admin_seed_inner_keep_catalog_v1', JSON.stringify([ + INNER_KEEP_PROTOCOL_CAPABILITY, + INNER_KEEP_POLICY_DIGEST, + INNER_KEEP_LAYOUT_DIGEST, + INNER_KEEP_ASSET_CATALOG_DIGEST, + catalogPlan.missingLayout, + catalogPlan.missingSlots, + catalogPlan.missingBuildings, + catalogPlan.missingLevels, + ])); + const builderPlan = parseInnerKeepBuilderPlan( + await callAdmin('admin_plan_inner_keep_builders_v1') + ); + if ( + builderPlan.expectedCastles !== LOCAL_FULLSTACK_FOUNDER_COUNT + || builderPlan.existingBuilders !== 0 + || builderPlan.missingBuilders !== LOCAL_FULLSTACK_FOUNDER_COUNT + || builderPlan.ready + ) fail('Inner Keep Builder graph did not begin from the exact empty state.'); + await callAdmin('admin_backfill_inner_keep_builders_v1', JSON.stringify([ + INNER_KEEP_PROTOCOL_CAPABILITY, + INNER_KEEP_POLICY_DIGEST, + INNER_KEEP_LAYOUT_DIGEST, + INNER_KEEP_ASSET_CATALOG_DIGEST, + builderPlan.expectedCastles, + builderPlan.existingBuilders, + builderPlan.missingBuilders, + ])); + await callAdmin('admin_activate_inner_keep_v1', JSON.stringify([ + INNER_KEEP_PROTOCOL_CAPABILITY, + INNER_KEEP_POLICY_DIGEST, + INNER_KEEP_LAYOUT_DIGEST, + INNER_KEEP_ASSET_CATALOG_DIGEST, + 'alpha-0.3.18', + LOCAL_CLIENT_ARTIFACT_DIGEST, + moduleDigest, + LOCAL_SOURCE_COMMIT, + LOCAL_FULLSTACK_FOUNDER_COUNT, + ])); + const activeInnerKeep = parseInnerKeepStatus( + await callAdmin('admin_get_inner_keep_status_v1') + ); + if ( + activeInnerKeep.layoutRows !== 1n + || activeInnerKeep.slotRows !== 12n + || activeInnerKeep.buildingCatalogRows !== 4n + || activeInnerKeep.levelPolicyRows !== 20n + || activeInnerKeep.castleRows !== BigInt(LOCAL_FULLSTACK_FOUNDER_COUNT) + || activeInnerKeep.builderRows !== BigInt(LOCAL_FULLSTACK_FOUNDER_COUNT) + || activeInnerKeep.buildingRows !== 0n + || activeInnerKeep.activeProjects !== 0n + || activeInnerKeep.receiptRows !== 0n + || activeInnerKeep.scheduleRows !== 0n + || activeInnerKeep.missingBuilders !== 0n + || activeInnerKeep.orphanBuilders !== 0n + || activeInnerKeep.invalidBuilders !== 0n + || activeInnerKeep.invalidBuildings !== 0n + || activeInnerKeep.invalidSchedules !== 0n + || activeInnerKeep.builderProjectMismatches !== 0n + || !activeInnerKeep.staticCatalogExact + || !activeInnerKeep.workerSystemReady + || !activeInnerKeep.active + ) fail('Inner Keep did not activate from the exact disposable state.'); + const playerCredential = createEphemeralJwt( privateKey, localPlayerClaims(LOCAL_FULLSTACK_FID) @@ -798,15 +1177,206 @@ async function seedLocalRealm(server, privateKey, moduleDigest) { fail('Disposable founder did not become locally ready.'); } + const initialInnerKeepState = parseInnerKeepPrivateState( + await callPlayer('get_my_inner_keep_state_v1') + ); + if ( + !initialInnerKeepState.componentActive + || !initialInnerKeepState.componentReady + || !initialInnerKeepState.builderPresent + || initialInnerKeepState.builderBusy + || initialInnerKeepState.activeBuildingKey !== undefined + || initialInnerKeepState.busyUntilMicros !== undefined + || initialInnerKeepState.builderRevision !== 0n + || initialInnerKeepState.food !== LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.food + || initialInnerKeepState.wood !== LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.wood + || initialInnerKeepState.stone !== LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.stone + || initialInnerKeepState.gold !== LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.gold + || initialInnerKeepState.projectedFood !== LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.food + || initialInnerKeepState.projectedWood !== LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.wood + || initialInnerKeepState.projectedStone !== LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.stone + || initialInnerKeepState.projectedGold !== LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.gold + || initialInnerKeepState.resourceRevision !== 0n + ) fail('Disposable Inner Keep founder did not begin with exact bounded authority.'); + const readControlState = async () => parseWorkerControlState( await callPlayer('get_my_worker_control_state_v1') ); + let innerKeepJourneyAttested = false; + const inspectInnerKeepFirstProject = async (requestKey) => { + if ( + typeof requestKey !== 'string' + || !/^[a-z0-9][a-z0-9-]{15,79}$/.test(requestKey) + ) fail('Disposable Inner Keep request key was invalid.'); + const [receipt, state, aggregate, workers] = await Promise.all([ + callPlayer( + 'get_my_inner_keep_request_status_v1', + JSON.stringify([requestKey]) + ).then((text) => parseInnerKeepReceipt(text, requestKey)), + callPlayer('get_my_inner_keep_state_v1').then(parseInnerKeepPrivateState), + callAdmin('admin_get_inner_keep_status_v1').then(parseInnerKeepStatus), + readControlState(), + ]); + if ( + receipt.slotId !== 'inner-keep-slot-m01' + || receipt.buildingKind !== 'city-mill' + || receipt.targetLevel !== 1n + || receipt.deductedFood !== 300n + || receipt.deductedWood !== 900n + || receipt.deductedStone !== 600n + || receipt.deductedGold !== 0n + || !state.componentActive + || !state.componentReady + || !state.builderPresent + || !state.builderBusy + || state.activeBuildingKey !== receipt.buildingKey + || state.busyUntilMicros === undefined + || state.builderRevision !== 1n + || state.food !== LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.food - 300n + || state.wood !== LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.wood - 900n + || state.stone !== LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.stone - 600n + || state.gold !== LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.gold + || state.resourceRevision !== 1n + || aggregate.buildingRows !== 1n + || aggregate.activeProjects !== 1n + || aggregate.receiptRows !== 1n + || aggregate.scheduleRows !== 1n + || aggregate.builderProjectMismatches !== 0n + || aggregate.invalidBuildings !== 0n + || aggregate.invalidSchedules !== 0n + || workers.revision !== 1n + || workers.workers.some((worker) => ( + worker.status !== 'idle' + || worker.revision !== 0n + || worker.resourceKind !== undefined + || worker.siteId !== undefined + )) + ) fail('Disposable Inner Keep first project was not exact and atomic.'); + return Object.freeze({ + activeProjects: 1, + builderRows: Number(aggregate.builderRows), + buildingRows: 1, + deductedFood: receipt.deductedFood.toString(), + deductedGold: receipt.deductedGold.toString(), + deductedStone: receipt.deductedStone.toString(), + deductedWood: receipt.deductedWood.toString(), + receiptRows: 1, + resourceRevision: state.resourceRevision.toString(), + scheduleRows: 1, + workerCount: workers.workers.length, + }); + }; + const attestInnerKeepJourney = async (requestKeys) => { + if ( + !Array.isArray(requestKeys) + || requestKeys.length !== 2 + || new Set(requestKeys).size !== 2 + || requestKeys.some((key) => ( + typeof key !== 'string' + || !/^[a-z0-9][a-z0-9-]{15,79}$/.test(key) + )) + ) fail('Disposable Inner Keep request keys were invalid.'); + const [firstReceipt, secondReceipt] = await Promise.all(requestKeys.map( + async (requestKey) => parseInnerKeepReceipt( + await callPlayer( + 'get_my_inner_keep_request_status_v1', + JSON.stringify([requestKey]) + ), + requestKey + ) + )); + const [state, aggregate, workers] = await Promise.all([ + callPlayer('get_my_inner_keep_state_v1').then(parseInnerKeepPrivateState), + callAdmin('admin_get_inner_keep_status_v1').then(parseInnerKeepStatus), + readControlState(), + ]); + const expectedFood = LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.food - 300n - 480n; + const expectedWood = LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.wood - 900n - 700n; + const expectedStone = LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.stone - 600n - 650n; + if ( + firstReceipt.slotId !== 'inner-keep-slot-m01' + || firstReceipt.buildingKind !== 'city-mill' + || firstReceipt.targetLevel !== 1n + || firstReceipt.deductedFood !== 300n + || firstReceipt.deductedWood !== 900n + || firstReceipt.deductedStone !== 600n + || firstReceipt.deductedGold !== 0n + || secondReceipt.slotId !== 'inner-keep-slot-m02' + || secondReceipt.buildingKind !== 'lumber-camp' + || secondReceipt.targetLevel !== 1n + || secondReceipt.deductedFood !== 480n + || secondReceipt.deductedWood !== 700n + || secondReceipt.deductedStone !== 650n + || secondReceipt.deductedGold !== 0n + || firstReceipt.castleId !== secondReceipt.castleId + || firstReceipt.buildingKey === secondReceipt.buildingKey + || !state.componentActive + || !state.componentReady + || !state.builderPresent + || !state.builderBusy + || state.activeBuildingKey !== secondReceipt.buildingKey + || state.busyUntilMicros === undefined + || state.builderRevision !== 3n + || state.food !== expectedFood + || state.wood !== expectedWood + || state.stone !== expectedStone + || state.gold !== LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.gold + || state.projectedFood !== expectedFood + || state.projectedWood !== expectedWood + || state.projectedStone !== expectedStone + || state.projectedGold !== LOCAL_FULLSTACK_INNER_KEEP_RESOURCES.gold + || state.resourceRevision !== 2n + || aggregate.layoutRows !== 1n + || aggregate.slotRows !== 12n + || aggregate.buildingCatalogRows !== 4n + || aggregate.levelPolicyRows !== 20n + || aggregate.castleRows !== BigInt(LOCAL_FULLSTACK_FOUNDER_COUNT) + || aggregate.builderRows !== BigInt(LOCAL_FULLSTACK_FOUNDER_COUNT) + || aggregate.buildingRows !== 2n + || aggregate.activeProjects !== 1n + || aggregate.receiptRows !== 2n + || aggregate.scheduleRows !== 1n + || aggregate.missingBuilders !== 0n + || aggregate.orphanBuilders !== 0n + || aggregate.invalidBuilders !== 0n + || aggregate.invalidBuildings !== 0n + || aggregate.invalidSchedules !== 0n + || aggregate.builderProjectMismatches !== 0n + || !aggregate.staticCatalogExact + || !aggregate.workerSystemReady + || !aggregate.active + || workers.revision !== 2n + || workers.workers.some((worker) => ( + worker.status !== 'idle' + || worker.revision !== 0n + || worker.resourceKind !== undefined + || worker.siteId !== undefined + )) + ) fail('Disposable Inner Keep journey did not retain exact authority.'); + innerKeepJourneyAttested = true; + return Object.freeze({ + activeProjects: Number(aggregate.activeProjects), + builderRevision: state.builderRevision.toString(), + buildingRows: Number(aggregate.buildingRows), + exactDeductions: Object.freeze([ + Object.freeze({ food: '300', wood: '900', stone: '600', gold: '0' }), + Object.freeze({ food: '480', wood: '700', stone: '650', gold: '0' }), + ]), + receiptRows: Number(aggregate.receiptRows), + resourceRevision: state.resourceRevision.toString(), + scheduleRows: Number(aggregate.scheduleRows), + workerCount: workers.workers.length, + }); + }; let preparedAttestation; const prepareWorkerScenario = async () => { if (preparedAttestation) return preparedAttestation; + if (!innerKeepJourneyAttested) { + fail('Disposable Worker preparation requires the completed Inner Keep journey.'); + } const idleState = await readControlState(); if ( - idleState.revision !== 0n + idleState.revision !== 2n || idleState.workers.some((worker) => ( worker.status !== 'idle' || worker.revision !== 0n @@ -919,6 +1489,20 @@ async function seedLocalRealm(server, privateKey, moduleDigest) { seedAttestation: Object.freeze({ castleCount: Number(active.expectedCastleCount), workerCount: Number(active.actualWorkerCount), + innerKeepActive: activeInnerKeep.active, + innerKeepBuilderCount: Number(activeInnerKeep.builderRows), + innerKeepCatalogRows: Number( + activeInnerKeep.layoutRows + + activeInnerKeep.slotRows + + activeInnerKeep.buildingCatalogRows + + activeInnerKeep.levelPolicyRows + ), + innerKeepInitialResources: Object.freeze({ + food: initialInnerKeepState.food.toString(), + wood: initialInnerKeepState.wood.toString(), + stone: initialInnerKeepState.stone.toString(), + gold: initialInnerKeepState.gold.toString(), + }), entryAgreementAcceptedCurrent: currentEntryAgreement[1], entryAgreementRequiredVersion: currentEntryAgreement[0], genericAssignments: Number(active.genericAssignments), @@ -928,6 +1512,8 @@ async function seedLocalRealm(server, privateKey, moduleDigest) { legacyOccupations: Number(active.legacyOccupations), legacySchedules: Number(active.legacySchedules), }), + attestInnerKeepJourney, + inspectInnerKeepFirstProject, prepareWorkerScenario, }); } @@ -1109,6 +1695,8 @@ export async function startDisposableLocalFullstackSpacetime(options = {}) { close, moduleDigest, runtimeDirectory, + attestInnerKeepJourney: localRealm.attestInnerKeepJourney, + inspectInnerKeepFirstProject: localRealm.inspectInnerKeepFirstProject, prepareWorkerScenario: localRealm.prepareWorkerScenario, seedAttestation: localRealm.seedAttestation, serverProcess, diff --git a/scripts/qa/agent.mjs b/scripts/qa/agent.mjs index 35d558cb..e78815f2 100644 --- a/scripts/qa/agent.mjs +++ b/scripts/qa/agent.mjs @@ -119,7 +119,7 @@ const QA_AGENT_SCRIPT_CONTRACT = Object.freeze([ Object.freeze({ path: 'package.json', scripts: Object.freeze({ - build: 'tsc -b && node scripts/verify-runtime-assets.mjs && node scripts/verify-hegemony-gold-mine-candidates.mjs && node scripts/verify-hegemony-gold-mine-runtime.mjs && node scripts/verify-hegemony-wheat-farm-runtime-assets.mjs && node scripts/verify-hegemony-logging-camp-runtime-assets.mjs && node scripts/verify-hegemony-stone-quarry-runtime-assets.mjs && node scripts/verify-hegemony-supply-wagon-assets.mjs && node scripts/verify-hegemony-tree-runtime-assets.mjs && vite build && node scripts/verify-production-dist-exclusions.mjs && node scripts/verify-farcaster-miniapp.mjs', + build: 'tsc -b && node scripts/verify-runtime-assets.mjs && node scripts/verify-inner-keep-runtime-assets.mjs && node scripts/verify-hegemony-gold-mine-candidates.mjs && node scripts/verify-hegemony-gold-mine-runtime.mjs && node scripts/verify-hegemony-wheat-farm-runtime-assets.mjs && node scripts/verify-hegemony-logging-camp-runtime-assets.mjs && node scripts/verify-hegemony-stone-quarry-runtime-assets.mjs && node scripts/verify-hegemony-supply-wagon-assets.mjs && node scripts/verify-hegemony-tree-runtime-assets.mjs && vite build && node scripts/verify-production-dist-exclusions.mjs && node scripts/verify-farcaster-miniapp.mjs', 'stdb:verify-additive-migration': 'node scripts/verify-spacetime-additive-migration.mjs', 'stdb:verify-bindings': 'node scripts/verify-spacetime-bindings.mjs', 'stdb:verify-worker-migration': 'node scripts/verify-castle-worker-additive-migration.mjs', diff --git a/scripts/spacetime-additive-migration-proof.mjs b/scripts/spacetime-additive-migration-proof.mjs index 5577f923..71295f57 100644 --- a/scripts/spacetime-additive-migration-proof.mjs +++ b/scripts/spacetime-additive-migration-proof.mjs @@ -3,18 +3,20 @@ const V11_TABLE_SCHEMA_RECEIPT_FIELD = 'v11_table_schema_sha256'; const V12_TABLE_SCHEMA_RECEIPT_FIELD = 'v12_table_schema_sha256'; const V13_TABLE_SCHEMA_RECEIPT_FIELD = 'v13_table_schema_sha256'; const V14_TABLE_SCHEMA_RECEIPT_FIELD = 'v14_table_schema_sha256'; +const V15_TABLE_SCHEMA_RECEIPT_FIELD = 'v15_table_schema_sha256'; const ARTIFACT_RECEIPT_FIELD = 'artifact_sha256'; const RECEIPT_FIELDS = Object.freeze([ V11_TABLE_SCHEMA_RECEIPT_FIELD, V12_TABLE_SCHEMA_RECEIPT_FIELD, V13_TABLE_SCHEMA_RECEIPT_FIELD, V14_TABLE_SCHEMA_RECEIPT_FIELD, + V15_TABLE_SCHEMA_RECEIPT_FIELD, ARTIFACT_RECEIPT_FIELD, ]); const INVALID_RECEIPT_MESSAGE = 'The current additive migration proof did not produce its exact success receipt.'; -export const ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 14; +export const ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 15; 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. @@ -35,6 +37,7 @@ export function formatAdditiveMigrationProofReceipt({ v12TableSchemaDigest, v13TableSchemaDigest, v14TableSchemaDigest, + v15TableSchemaDigest, artifactDigest, }) { if ( @@ -51,6 +54,8 @@ export function formatAdditiveMigrationProofReceipt({ || !SHA256_DIGEST.test(v13TableSchemaDigest) || typeof v14TableSchemaDigest !== 'string' || !SHA256_DIGEST.test(v14TableSchemaDigest) + || typeof v15TableSchemaDigest !== 'string' + || !SHA256_DIGEST.test(v15TableSchemaDigest) || typeof artifactDigest !== 'string' || !SHA256_DIGEST.test(artifactDigest) ) { @@ -61,6 +66,7 @@ export function formatAdditiveMigrationProofReceipt({ + `${V12_TABLE_SCHEMA_RECEIPT_FIELD}=${v12TableSchemaDigest} ` + `${V13_TABLE_SCHEMA_RECEIPT_FIELD}=${v13TableSchemaDigest} ` + `${V14_TABLE_SCHEMA_RECEIPT_FIELD}=${v14TableSchemaDigest} ` + + `${V15_TABLE_SCHEMA_RECEIPT_FIELD}=${v15TableSchemaDigest} ` + `${ARTIFACT_RECEIPT_FIELD}=${artifactDigest}`; } @@ -84,11 +90,13 @@ export function parseAdditiveMigrationProofReceipt(output) { const v12TableSchemaDigest = digestFields[V12_TABLE_SCHEMA_RECEIPT_FIELD][0][1]; const v13TableSchemaDigest = digestFields[V13_TABLE_SCHEMA_RECEIPT_FIELD][0][1]; const v14TableSchemaDigest = digestFields[V14_TABLE_SCHEMA_RECEIPT_FIELD][0][1]; + const v15TableSchemaDigest = digestFields[V15_TABLE_SCHEMA_RECEIPT_FIELD][0][1]; const artifactDigest = digestFields[ARTIFACT_RECEIPT_FIELD][0][1]; const receiptSuffix = ` ${V11_TABLE_SCHEMA_RECEIPT_FIELD}=${v11TableSchemaDigest}` + ` ${V12_TABLE_SCHEMA_RECEIPT_FIELD}=${v12TableSchemaDigest}` + ` ${V13_TABLE_SCHEMA_RECEIPT_FIELD}=${v13TableSchemaDigest}` + ` ${V14_TABLE_SCHEMA_RECEIPT_FIELD}=${v14TableSchemaDigest}` + + ` ${V15_TABLE_SCHEMA_RECEIPT_FIELD}=${v15TableSchemaDigest}` + ` ${ARTIFACT_RECEIPT_FIELD}=${artifactDigest}`; if ( !proofLine.startsWith(`${SUCCESS_PREFIX} `) @@ -96,6 +104,7 @@ export function parseAdditiveMigrationProofReceipt(output) { || !SHA256_DIGEST.test(v12TableSchemaDigest) || !SHA256_DIGEST.test(v13TableSchemaDigest) || !SHA256_DIGEST.test(v14TableSchemaDigest) + || !SHA256_DIGEST.test(v15TableSchemaDigest) || !SHA256_DIGEST.test(artifactDigest) || !proofLine.endsWith(receiptSuffix) || proofLine.slice(SUCCESS_PREFIX.length + 1, -receiptSuffix.length).length === 0 @@ -108,6 +117,7 @@ export function parseAdditiveMigrationProofReceipt(output) { v12TableSchemaDigest, v13TableSchemaDigest, v14TableSchemaDigest, + v15TableSchemaDigest, artifactDigest, }); } diff --git a/scripts/verify-access-request-additive-migration.mjs b/scripts/verify-access-request-additive-migration.mjs index 249b3531..03ad46b1 100644 --- a/scripts/verify-access-request-additive-migration.mjs +++ b/scripts/verify-access-request-additive-migration.mjs @@ -58,10 +58,20 @@ assert.deepEqual(v13Registrations.slice(0, 53), v12Registrations); assert.deepEqual(candidateRegistrations.slice(0, 53), v12Registrations); assert.deepEqual(v13Registrations.slice(53), ['accessRequestV1']); assert.deepEqual(candidateRegistrations.slice(0, 54), v13Registrations); -assert.deepEqual(candidateRegistrations.slice(54), [ +assert.deepEqual(candidateRegistrations.slice(54, 56), [ 'dailyMarkGrantV1', 'dailyMarkScheduleV1', ]); +assert.deepEqual(candidateRegistrations.slice(56), [ + 'innerKeepLayoutV1', + 'innerKeepSlotV1', + 'innerKeepBuildingCatalogV1', + 'innerKeepBuildLevelV1', + 'castleInnerKeepBuildingV1', + 'castleInnerBuilderV1', + 'castleInnerBuildReceiptV1', + 'castleInnerConstructionScheduleV1', +]); const v13TailStart = v13Fixture.indexOf( '/** v13 private, append-only expression of interest in manual admission. */', @@ -126,16 +136,18 @@ assert.match(proof, /arguments_\.filter\(value => value === '--delete-data=never assert.match(proof, /arguments_\.some\(value => value\.startsWith\('--delete-data='/); assert.doesNotMatch(proof, /--delete-data=(?:always|on-conflict|if-required)/); -assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 14/); +assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 15/); assert.match(receipt, /v13_table_schema_sha256/); assert.match(receipt, /v13TableSchemaDigest/); assert.match(receipt, /v14_table_schema_sha256/); assert.match(receipt, /v14TableSchemaDigest/); +assert.match(receipt, /v15_table_schema_sha256/); +assert.match(receipt, /v15TableSchemaDigest/); console.log( 'access-request additive migration proof passed: exact v12 refs 0–52 preserved, ' + 'private access_request_v1 remains the exact v13 ref 53 boundary, ' - + 'the reviewed v14 daily Marks suffix is the only allowed extension, ' + + 'the reviewed v14 daily Marks suffix remains frozen before the exact v15 Inner Keep extension, ' + 'the loopback proof exercises 2/10/50 same-cycle calls and two FIDs, ' + 'and every rehearsal remains deletion-disabled', ); diff --git a/scripts/verify-inner-keep-runtime-assets.mjs b/scripts/verify-inner-keep-runtime-assets.mjs new file mode 100644 index 00000000..a171737b --- /dev/null +++ b/scripts/verify-inner-keep-runtime-assets.mjs @@ -0,0 +1,73 @@ +import { lstatSync, readdirSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { HEGEMONY_TREE_RUNTIME_ASSETS } from './hegemony-tree-runtime-contract.mjs'; +import { + INNER_KEEP_ASSET_SELECTION, + INNER_KEEP_ASSET_SELECTION_DIGEST, + INNER_KEEP_PLANNED_RUNTIME_PATHS, + INNER_KEEP_SELECTED_MODELS, + INNER_KEEP_SELECTED_PREVIEWS, + calculateInnerKeepAssetSelectionDigest +} from './inner-keep-runtime-asset-contract.mjs'; + +const ROOT = resolve(import.meta.dirname, '..'); +const PLANNED_ROOTS = Object.freeze([ + 'public/models/hegemony/inner-keep', + 'public/images/inner-keep/catalog' +]); + +function fail(detail) { + throw new Error(`Inner Keep runtime asset verification: ${detail}`); +} + +function collectPresentEntries(relativePath) { + const absolute = resolve(ROOT, relativePath); + const status = lstatSync(absolute, { throwIfNoEntry: false }); + if (!status) return []; + if (status.isSymbolicLink()) fail(`${relativePath} must not be a symbolic link.`); + if (!status.isDirectory()) return [relativePath]; + return readdirSync(absolute, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + .flatMap((entry) => collectPresentEntries(`${relativePath}/${entry.name}`)); +} + +if ( + INNER_KEEP_ASSET_SELECTION.authorization.officialRepositoryRuntimeUseAuthorized !== false + || INNER_KEEP_ASSET_SELECTION.authorization.status !== 'pending-owner-runtime-use-authorization' +) fail('authorization gate changed without a corresponding runtime verifier update.'); + +const observedDigest = calculateInnerKeepAssetSelectionDigest(INNER_KEEP_ASSET_SELECTION); +if (observedDigest !== INNER_KEEP_ASSET_SELECTION_DIGEST) { + fail(`selection digest changed to ${observedDigest}.`); +} +if ( + INNER_KEEP_SELECTED_MODELS.length !== 108 + || INNER_KEEP_SELECTED_PREVIEWS.length !== 4 + || INNER_KEEP_PLANNED_RUNTIME_PATHS.length !== 112 +) fail('curated allowlist cardinality changed.'); + +const leaked = PLANNED_ROOTS.flatMap(collectPresentEntries); +const individuallyPresent = INNER_KEEP_PLANNED_RUNTIME_PATHS.filter((path) => ( + lstatSync(resolve(ROOT, path), { throwIfNoEntry: false }) !== undefined +)); +if (leaked.length > 0 || individuallyPresent.length > 0) { + fail( + 'archive-only outputs exist before owner runtime-use authorization: ' + + [...new Set([...leaked, ...individuallyPresent])].sort().join(', ') + ); +} + +const existingTreeIds = new Set(HEGEMONY_TREE_RUNTIME_ASSETS.map((asset) => asset.id)); +for (const reused of INNER_KEEP_ASSET_SELECTION.existingRuntimeReuse) { + if (!existingTreeIds.has(reused.assetId)) { + fail(`authorized existing tree ${reused.assetId} is unavailable.`); + } +} + +console.log( + `Verified the ${INNER_KEEP_SELECTED_MODELS.length}-GLB / ` + + `${INNER_KEEP_SELECTED_PREVIEWS.length}-preview Inner Keep allowlist, ` + + 'its pending authorization gate, and absence of archive-only runtime output.' +); +console.log(`Selection digest: ${INNER_KEEP_ASSET_SELECTION_DIGEST}`); diff --git a/scripts/verify-production-dist-exclusions.mjs b/scripts/verify-production-dist-exclusions.mjs index 113aa4e8..101dbacf 100644 --- a/scripts/verify-production-dist-exclusions.mjs +++ b/scripts/verify-production-dist-exclusions.mjs @@ -19,6 +19,7 @@ const forbiddenPathFragments = Object.freeze([ 'qa-journey.html', 'realm-observer-qa.html', 'realm-rendered-webgl-qa.html', + 'inner-keep-qa.html', 'castle-lod-visual-evidence.html', 'realm-qa.html' ]); @@ -56,6 +57,12 @@ const forbiddenContent = Object.freeze([ 'stonekeeper', 'frontierseer', 'RenderedWebglQaHarness', + 'InnerKeepQaHarness', + 'innerKeepQaMain', + 'innerKeepQaScenarioManifest', + 'INNER_KEEP_QA_SCENARIO_MANIFEST', + 'LOCAL INNER KEEP QA', + 'SYNTHETIC · LOOPBACK ONLY · NO AUTHORITY', 'realmRenderedWebglQaMain', 'renderedWebglQaFixtureSnapshot', 'createRenderedWebglQaFixtureRealm', diff --git a/scripts/verify-spacetime-additive-migration.mjs b/scripts/verify-spacetime-additive-migration.mjs index 02c15b2f..6e0bc89c 100644 --- a/scripts/verify-spacetime-additive-migration.mjs +++ b/scripts/verify-spacetime-additive-migration.mjs @@ -79,6 +79,10 @@ const additiveV14SchemaFixture = resolve( repositoryRoot, 'spacetimedb/migration-fixtures/additive-v14-schema', ); +const additiveV15SchemaFixture = resolve( + repositoryRoot, + 'spacetimedb/migration-fixtures/additive-v15-schema', +); const additiveModule = resolve(repositoryRoot, 'spacetimedb'); const command = process.env.SPACETIME_BIN || 'spacetime'; const expectedCliVersion = ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION; @@ -289,6 +293,16 @@ const additiveV14Tables = Object.freeze([ 'daily_mark_grant_v1', 'daily_mark_schedule_v_1', ]); +const additiveV15Tables = Object.freeze([ + 'inner_keep_layout_v1', + 'inner_keep_slot_v1', + 'inner_keep_building_catalog_v1', + 'inner_keep_build_level_v1', + 'castle_inner_keep_building_v1', + 'castle_inner_builder_v1', + 'castle_inner_build_receipt_v1', + 'castle_inner_construction_schedule_v_1', +]); const deployedV3Tables = Object.freeze([ ...existingTables, ...additiveV3Tables, @@ -337,6 +351,10 @@ const deployedV14Tables = Object.freeze([ ...deployedV13Tables, ...additiveV14Tables, ]); +const deployedV15Tables = Object.freeze([ + ...deployedV14Tables, + ...additiveV15Tables, +]); const expectedProductTypeRefs = Object.freeze({ allowed_fid: 0, world_tile: 1, @@ -394,6 +412,14 @@ const expectedProductTypeRefs = Object.freeze({ access_request_v1: 53, daily_mark_grant_v1: 54, daily_mark_schedule_v_1: 55, + inner_keep_layout_v1: 56, + inner_keep_slot_v1: 57, + inner_keep_building_catalog_v1: 58, + inner_keep_build_level_v1: 59, + castle_inner_keep_building_v1: 60, + castle_inner_builder_v1: 61, + castle_inner_build_receipt_v1: 62, + castle_inner_construction_schedule_v_1: 63, }); const childEnvironmentKeys = Object.freeze([ 'PATH', 'HOME', 'USER', 'LOGNAME', 'TMPDIR', 'TMP', 'TEMP', @@ -1440,6 +1466,99 @@ function assertAdditiveV14Schema(before, after) { } } +function assertDeployedV14TablesUnchanged(before, after) { + for (const name of deployedV14Tables) { + assert.deepEqual(tableSignature(after, name), tableSignature(before, name)); + assert.equal( + tableSignature(after, name).product_type_ref, + expectedProductTypeRefs[name], + ); + } +} + +function assertAdditiveV15Schema(before, after) { + assertDeployedV14TablesUnchanged(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, [...additiveV15Tables].sort()); + const contracts = { + inner_keep_layout_v1: { + access: 'Public', + fields: [ + 'layout_id', 'layout_version', 'policy_version', 'slot_count', + 'medium_slot_count', 'large_slot_count', 'asset_catalog_digest', + 'layout_digest', 'active', 'created_at', 'activated_at', + ], + }, + inner_keep_slot_v1: { + access: 'Public', + fields: [ + 'slot_id', 'layout_id', 'footprint_class', 'local_x_microunits', + 'local_z_microunits', 'rotation_milli_degrees', 'sort_order', 'active', + ], + }, + inner_keep_building_catalog_v1: { + access: 'Public', + fields: [ + 'building_kind', 'public_label', 'category', 'footprint_class', + 'maximum_level', 'unique_per_castle', 'matching_discount_resource', + 'discount_basis_points_per_level', 'discount_cap_basis_points', + 'runtime_asset_id', 'preview_asset_id', 'active', 'policy_version', + ], + }, + inner_keep_build_level_v1: { + access: 'Public', + fields: [ + 'level_key', 'building_kind', 'target_level', 'base_food_cost', + 'base_wood_cost', 'base_stone_cost', 'base_gold_cost', + 'level_multiplier_basis_points', 'duration_micros', 'policy_version', + ], + }, + castle_inner_keep_building_v1: { + access: 'Public', + fields: [ + 'building_key', 'castle_id', 'slot_key', 'slot_id', 'building_kind', + 'completed_level', 'target_level', 'phase', 'started_at_micros', + 'completes_at_micros', 'revision', 'policy_version', + ], + }, + castle_inner_builder_v1: { + access: 'Private', + fields: [ + 'castle_id', 'fid', 'active_building_key', 'busy_until_micros', + 'revision', 'policy_version', 'created_at', 'updated_at', + ], + }, + castle_inner_build_receipt_v1: { + access: 'Private', + fields: [ + 'receipt_key', 'fid', 'request_key', 'castle_id', 'building_key', + 'slot_id', 'building_kind', 'target_level', 'deducted_food', + 'deducted_wood', 'deducted_stone', 'deducted_gold', 'started_at', + 'policy_version', + ], + }, + castle_inner_construction_schedule_v_1: { + access: 'Private', + fields: [ + 'schedule_id', 'scheduled_at', 'building_key', 'expected_revision', + 'expected_target_level', + ], + }, + }; + 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(); @@ -3009,9 +3128,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 v14 candidate schema. Reverting to a - // predecessor fixture after Stone is appended would be destructive. - const inspectionArtifactPath = join(additiveV14SchemaFixture, 'dist', 'bundle.js'); + // Keep inspection on the complete v15 candidate schema. Reverting to a + // predecessor fixture after Inner Keep is appended would be destructive. + const inspectionArtifactPath = join(additiveV15SchemaFixture, 'dist', 'bundle.js'); const useActualModule = async () => { if (activeModule === 'actual') return; await publishBuiltArtifact(server, ownerToken, actualArtifactPath, database); @@ -3938,7 +4057,7 @@ async function verifyActualModuleExpeditionLifecycles( const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js'); // Reusing the candidate fixture preserves the complete v14 suffix during // SQL inspection; publishing any predecessor would request a downgrade. - const inspectionArtifactPath = join(additiveV14SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV15SchemaFixture, 'dist', 'bundle.js'); const useActualModule = async () => { if (activeModule === 'actual') return; await publishBuiltArtifact(server, ownerToken, actualArtifactPath, database); @@ -5750,7 +5869,7 @@ async function verifyActualModuleWaterLifecycle(server, database, privateKey, ow let stage = 'publish'; let activeModule = 'actual'; const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js'); - const inspectionArtifactPath = join(additiveV14SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV15SchemaFixture, 'dist', 'bundle.js'); const adminCredential = () => createEphemeralJwt(privateKey, adminServiceClaims()); const useActualModule = async () => { if (activeModule === 'actual') return; @@ -6221,7 +6340,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(additiveV14SchemaFixture, 'dist', 'bundle.js'); + const inspectionArtifactPath = join(additiveV15SchemaFixture, 'dist', 'bundle.js'); const adminCredential = () => createEphemeralJwt(privateKey, adminServiceClaims()); await publishBuiltArtifact(server, ownerToken, fixtureArtifactPath, database); @@ -7411,6 +7530,106 @@ async function main() { assert.equal(await count(server, owner.token, dailyMarksMigrationDatabase, table), 0n); } + // Populate both private v14 tables before the v15 append. This freezes the + // actual predecessor boundary rather than proving only empty-table shape. + await callLoopbackReducer( + server, + dailyMarksMigrationDatabase, + 'fixture_seed_daily_marks_sentinel_v14', + owner.token, + '[]', + 200, + ); + const dailyMarksPopulatedV14Rows = await tableRowDigests( + server, + owner.token, + dailyMarksMigrationDatabase, + deployedV14Tables, + ); + for (const table of additiveV14Tables) { + assert.equal(await count(server, owner.token, dailyMarksMigrationDatabase, table), 1n); + } + + await publish( + server, + owner.token, + additiveV15SchemaFixture, + dailyMarksMigrationDatabase, + ); + const dailyMarksV15 = await describe( + server, + owner.token, + dailyMarksMigrationDatabase, + ); + assertAdditiveV15Schema(dailyMarksV14, dailyMarksV15); + assert.deepEqual( + await tableRowDigests( + server, + owner.token, + dailyMarksMigrationDatabase, + deployedV14Tables, + ), + dailyMarksPopulatedV14Rows, + ); + for (const table of additiveV15Tables) { + assert.equal(await count(server, owner.token, dailyMarksMigrationDatabase, table), 0n); + } + await callLoopbackReducer( + server, + dailyMarksMigrationDatabase, + 'fixture_seed_inner_keep_sentinel_v15', + owner.token, + '[]', + 200, + ); + const dailyMarksPopulatedV15Rows = await tableRowDigests( + server, + owner.token, + dailyMarksMigrationDatabase, + deployedV15Tables, + ); + const populatedDailyMarksV15SchemaDigest = schemaDigest(dailyMarksV15); + await publish( + server, + owner.token, + additiveV15SchemaFixture, + dailyMarksMigrationDatabase, + ); + assert.equal( + schemaDigest(await describe(server, owner.token, dailyMarksMigrationDatabase)), + populatedDailyMarksV15SchemaDigest, + ); + assert.deepEqual( + await tableRowDigests( + server, + owner.token, + dailyMarksMigrationDatabase, + deployedV15Tables, + ), + dailyMarksPopulatedV15Rows, + ); + await publish( + server, + owner.token, + additiveV14SchemaFixture, + dailyMarksMigrationDatabase, + false, + /break|delete|remove|migration|incompatible|data loss|table/i, + ); + assert.equal( + schemaDigest(await describe(server, owner.token, dailyMarksMigrationDatabase)), + populatedDailyMarksV15SchemaDigest, + ); + assert.deepEqual( + await tableRowDigests( + server, + owner.token, + dailyMarksMigrationDatabase, + deployedV15Tables, + ), + dailyMarksPopulatedV15Rows, + ); + // Freeze v14 independently before the real candidate. Every v13 table // remains byte-identical and both private daily-Mark tables start empty. await publish(server, owner.token, additiveV14SchemaFixture, emptyDatabase); @@ -7465,36 +7684,108 @@ async function main() { ); } - // Advance every database to the real v14 candidate so the implementation - // is exercised against the exact v14 table contract without production. + // Freeze v15 independently before the real candidate. The complete v14 + // predecessor remains unchanged and all eight Inner Keep tables start + // empty on every representative database. + await publish(server, owner.token, additiveV15SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, actualModuleDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, resourceLifecycleDatabase); + await publish( + server, + owner.token, + additiveV15SchemaFixture, + populatedWaterStoneMigrationDatabase, + ); + const emptyV15 = await describe(server, owner.token, emptyDatabase); + const nonemptyV15 = await describe(server, owner.token, nonemptyDatabase); + const actualModuleV15 = await describe(server, owner.token, actualModuleDatabase); + const populatedWaterStoneV15 = await describe( + server, + owner.token, + populatedWaterStoneMigrationDatabase, + ); + assertAdditiveV15Schema(emptyV14, emptyV15); + assertAdditiveV15Schema(nonemptyV14, nonemptyV15); + assertAdditiveV15Schema(actualModuleV14, actualModuleV15); + assertAdditiveV15Schema(populatedWaterStoneV14, populatedWaterStoneV15); + const fixtureV15TableSchemaDigest = canonicalTableSchemaBoundaryDigest( + emptyV15, + deployedV15Tables, + ); + for (const description of [nonemptyV15, actualModuleV15, populatedWaterStoneV15]) { + assert.equal( + canonicalTableSchemaBoundaryDigest(description, deployedV15Tables), + fixtureV15TableSchemaDigest, + ); + } + for (const database of [ + emptyDatabase, + nonemptyDatabase, + actualModuleDatabase, + resourceLifecycleDatabase, + populatedWaterStoneMigrationDatabase, + ]) { + for (const table of additiveV15Tables) { + assert.equal(await count(server, owner.token, database, table), 0n); + } + } + + // Advance every database to the real v15 candidate so the implementation + // is exercised against the exact append-only 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 populatedWaterStoneCandidateV14 = await describe( + await publish(server, owner.token, additiveModule, dailyMarksMigrationDatabase); + const populatedWaterStoneCandidateV15 = await describe( server, owner.token, populatedWaterStoneMigrationDatabase, ); - for (const name of deployedV14Tables) { + const dailyMarksCandidateV15 = await describe( + server, + owner.token, + dailyMarksMigrationDatabase, + ); + for (const name of deployedV15Tables) { assert.deepEqual( - tableSignature(populatedWaterStoneCandidateV14, name), - tableSignature(emptyV14, name), + tableSignature(populatedWaterStoneCandidateV15, name), + tableSignature(emptyV15, name), + ); + assert.deepEqual( + tableSignature(dailyMarksCandidateV15, name), + tableSignature(emptyV15, name), ); } + await publish( + server, + owner.token, + additiveV15SchemaFixture, + dailyMarksMigrationDatabase, + ); + assert.deepEqual( + await tableRowDigests( + server, + owner.token, + dailyMarksMigrationDatabase, + deployedV15Tables, + ), + dailyMarksPopulatedV15Rows, + ); await verifyAccessRequestHttpLifecycle( server, populatedWaterStoneMigrationDatabase, privateKey, ); - // Return to the exact auth-neutral v14 schema only for private owner SQL. + // Return to the exact auth-neutral v15 schema only for private owner SQL. // The append-only request row remains while every v12 row digest must stay // byte-for-byte identical to its pre-request baseline. await publish( server, owner.token, - additiveV14SchemaFixture, + additiveV15SchemaFixture, populatedWaterStoneMigrationDatabase, ); assert.deepEqual( @@ -7642,10 +7933,10 @@ async function main() { workerRolloutV11Database, ); assertAdditiveV13Schema(workerRolloutCandidateV12, workerRolloutCandidateV13); - await publishBuiltArtifact( + await publish( server, owner.token, - builtArtifactPath, + additiveV14SchemaFixture, workerRolloutV11Database, ); const workerRolloutCandidateV14 = await describe( @@ -7654,14 +7945,43 @@ async function main() { workerRolloutV11Database, ); assertAdditiveV14Schema(workerRolloutCandidateV13, workerRolloutCandidateV14); + await publish( + server, + owner.token, + additiveV15SchemaFixture, + workerRolloutV11Database, + ); + const workerRolloutCandidateV15Fixture = await describe( + server, + owner.token, + workerRolloutV11Database, + ); + assertAdditiveV15Schema(workerRolloutCandidateV14, workerRolloutCandidateV15Fixture); + await publishBuiltArtifact( + server, + owner.token, + builtArtifactPath, + workerRolloutV11Database, + ); + const workerRolloutCandidateV15 = await describe( + server, + owner.token, + workerRolloutV11Database, + ); + for (const name of deployedV15Tables) { + assert.deepEqual( + tableSignature(workerRolloutCandidateV15, name), + tableSignature(workerRolloutCandidateV15Fixture, name), + ); + } // The real module rejects the disposable owner identity by design. Swap - // only this database to the schema-identical auth-neutral v14 fixture + // only this database to the schema-identical auth-neutral v15 fixture // before private SQL proves that every v11 row survived and all additive // suffixes remain empty. await publish( server, owner.token, - additiveV14SchemaFixture, + additiveV15SchemaFixture, workerRolloutV11Database, ); assert.deepEqual( @@ -7694,6 +8014,12 @@ async function main() { 0n, ); } + for (const table of additiveV15Tables) { + assert.equal( + await count(server, owner.token, workerRolloutV11Database, table), + 0n, + ); + } await publishBuiltArtifact( server, owner.token, @@ -7720,25 +8046,30 @@ async function main() { const builtArtifactDigest = createHash('sha256') .update(await readFile(builtArtifactPath)) .digest('hex'); - const emptyCandidateV14 = await describe(server, owner.token, emptyDatabase); - const nonemptyCandidateV14 = await describe(server, owner.token, nonemptyDatabase); - const actualCandidateV14 = await describe(server, owner.token, actualModuleDatabase); + const emptyCandidateV15 = await describe(server, owner.token, emptyDatabase); + const nonemptyCandidateV15 = await describe(server, owner.token, nonemptyDatabase); + const actualCandidateV15 = await describe(server, owner.token, actualModuleDatabase); const provenV12TableSchemaDigest = projectedTableSchemaBoundaryDigest( - emptyCandidateV14, + emptyCandidateV15, deployedV12Tables, ); assert.equal(provenV12TableSchemaDigest, fixtureV12TableSchemaDigest); const provenV13TableSchemaDigest = projectedTableSchemaBoundaryDigest( - emptyCandidateV14, + emptyCandidateV15, deployedV13Tables, ); assert.equal(provenV13TableSchemaDigest, fixtureV13TableSchemaDigest); - const provenV14TableSchemaDigest = canonicalTableSchemaBoundaryDigest( - emptyCandidateV14, + const provenV14TableSchemaDigest = projectedTableSchemaBoundaryDigest( + emptyCandidateV15, deployedV14Tables, ); assert.equal(provenV14TableSchemaDigest, fixtureV14TableSchemaDigest); - for (const description of [nonemptyCandidateV14, actualCandidateV14]) { + const provenV15TableSchemaDigest = canonicalTableSchemaBoundaryDigest( + emptyCandidateV15, + deployedV15Tables, + ); + assert.equal(provenV15TableSchemaDigest, fixtureV15TableSchemaDigest); + for (const description of [nonemptyCandidateV15, actualCandidateV15]) { assert.equal( projectedTableSchemaBoundaryDigest(description, deployedV12Tables), provenV12TableSchemaDigest, @@ -7748,28 +8079,32 @@ async function main() { provenV13TableSchemaDigest, ); assert.equal( - canonicalTableSchemaBoundaryDigest(description, deployedV14Tables), + projectedTableSchemaBoundaryDigest(description, deployedV14Tables), provenV14TableSchemaDigest, ); + assert.equal( + canonicalTableSchemaBoundaryDigest(description, deployedV15Tables), + provenV15TableSchemaDigest, + ); } for (const name of deployedV12Tables) { assert.deepEqual( - tableSignature(actualCandidateV14, name), + tableSignature(actualCandidateV15, name), tableSignature(emptyV12, name), ); assert.deepEqual( - tableSignature(nonemptyCandidateV14, name), + tableSignature(nonemptyCandidateV15, name), tableSignature(nonemptyV12, name), ); assert.deepEqual( - tableSignature(actualCandidateV14, name), + tableSignature(actualCandidateV15, name), tableSignature(actualModuleV12, name), ); } for (const description of [ - emptyCandidateV14, - nonemptyCandidateV14, - actualCandidateV14, + emptyCandidateV15, + nonemptyCandidateV15, + actualCandidateV15, ]) { assert.equal(access(description, 'access_request_v1'), 'Private'); assert.deepEqual(fieldNames(description, 'access_request_v1'), [ @@ -7794,12 +8129,12 @@ async function main() { ]); } // The candidate's on-connect policy intentionally rejects the disposable - // owner identity. Reuse the table-identical, auth-neutral v14 fixture before + // owner identity. Reuse the table-identical, auth-neutral v15 fixture before // owner SQL reads and never downgrade the schema suffix. - await publish(server, owner.token, additiveV14SchemaFixture, emptyDatabase); - await publish(server, owner.token, additiveV14SchemaFixture, nonemptyDatabase); - await publish(server, owner.token, additiveV14SchemaFixture, actualModuleDatabase); - // SQL preservation reads remain on the complete v14 candidate. No reducer + await publish(server, owner.token, additiveV15SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, actualModuleDatabase); + // SQL preservation reads remain on the complete v15 candidate. No reducer // is invoked by these owner-only queries. for (const [database, beforeRows] of [ [emptyDatabase, emptyV7Rows], @@ -7812,7 +8147,7 @@ async function main() { ); } - const idempotentSchemaBefore = schemaDigest(nonemptyCandidateV14); + const idempotentSchemaBefore = schemaDigest(nonemptyCandidateV15); await publishBuiltArtifact( server, owner.token, @@ -7823,10 +8158,10 @@ async function main() { schemaDigest(await describe(server, owner.token, nonemptyDatabase)), idempotentSchemaBefore, ); - await publish(server, owner.token, additiveV14SchemaFixture, nonemptyDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, nonemptyDatabase); // The actual module correctly rejects the disposable local identity at its - // on-connect boundary; owner SQL still reads the unchanged v14 rows. + // on-connect boundary; owner SQL still reads the unchanged v15 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); @@ -7854,6 +8189,7 @@ async function main() { ...additiveV12Tables, ...additiveV13Tables, ...additiveV14Tables, + ...additiveV15Tables, ]) { assert.equal(await count(server, owner.token, database, table), 0n); } @@ -7884,7 +8220,7 @@ async function main() { )), actualModuleWorldBefore); // Identity columns reject arbitrary SQL literals after the candidate's - // issuer boundary is active. The auth-neutral v14 fixture inserts the + // issuer boundary is active. The auth-neutral v15 fixture inserts the // caller's verified sender identity through a disposable reducer instead. await callLoopbackReducer( server, @@ -7909,10 +8245,11 @@ async function main() { ...additiveV12Tables, ...additiveV13Tables, ...additiveV14Tables, + ...additiveV15Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } - const populatedV14SchemaDigest = schemaDigest(await describe(server, owner.token, emptyDatabase)); + const populatedV15SchemaDigest = schemaDigest(await describe(server, owner.token, emptyDatabase)); await callLoopbackReducer( server, @@ -7934,7 +8271,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1); assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n); @@ -7950,6 +8287,7 @@ async function main() { ...additiveV12Tables, ...additiveV13Tables, ...additiveV14Tables, + ...additiveV15Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -7963,7 +8301,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1); assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n); @@ -7979,6 +8317,7 @@ async function main() { ...additiveV12Tables, ...additiveV13Tables, ...additiveV14Tables, + ...additiveV15Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -7992,7 +8331,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); await publish( server, @@ -8004,7 +8343,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); await publish( server, @@ -8016,9 +8355,23 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); - // The v14 boundary must refuse its immediate predecessor before either + // The v15 boundary must refuse its immediate predecessor before any + // Inner Keep state can be removed. + await publish( + server, + owner.token, + additiveV14SchemaFixture, + emptyDatabase, + false, + /break|delete|remove|migration|incompatible|data loss|table/i, + ); + assert.equal( + schemaDigest(await describe(server, owner.token, emptyDatabase)), + populatedV15SchemaDigest, + ); + // The v14 boundary must also refuse its own predecessor before either // private daily-Mark table can be removed. await publish( server, @@ -8030,7 +8383,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); // The v13 boundary also remains protected from its own predecessor. await publish( @@ -8043,7 +8396,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); // Older predecessors must also remain unable to remove Worker or Water // state. @@ -8057,7 +8410,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); await publish( server, @@ -8069,7 +8422,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); await publish( server, @@ -8081,7 +8434,7 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); // Older fixture rollbacks remain refused as well. await publish( @@ -8094,17 +8447,17 @@ async function main() { ); assert.equal( schemaDigest(await describe(server, owner.token, emptyDatabase)), - populatedV14SchemaDigest, + populatedV15SchemaDigest, ); await publish(server, owner.token, additiveModule, emptyDatabase); - assertAdditiveV14Schema( - emptyV13, + assertAdditiveV15Schema( + emptyV14, 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, additiveV14SchemaFixture, emptyDatabase); + await publish(server, owner.token, additiveV15SchemaFixture, 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 [ @@ -8119,6 +8472,7 @@ async function main() { ...additiveV12Tables, ...additiveV13Tables, ...additiveV14Tables, + ...additiveV15Tables, ]) { assert.equal(await count(server, owner.token, emptyDatabase, table), 0n); } @@ -8144,10 +8498,11 @@ async function main() { + 'identity-safe generic worker readiness, roster, assignment, occupation, bounded receipt, and private schedule tables appended at exact refs 47-52, ' + 'private access-request intent and authoritative request timestamp appended at exact ref 53, ' + 'private exactly-once daily Mark receipts and identity-free cadence appended at exact refs 54-55, ' - + '61-tile empty, synthetic nonempty, and populated Water/Stone/Water-revision fixtures remained preserved through v14, ' + + 'public Inner Keep layout, slots, building catalog, levels, and castle buildings plus private builders, receipts, and schedules appended at exact refs 56-63, ' + + '61-tile empty, synthetic nonempty, and populated Water/Stone/Water-revision fixtures remained preserved through v15, ' + 'every v12 table was populated and retained through the real candidate, the v13 request suffix survived populated, ' - + 'both v14 tables started empty, fixture republish remained idempotent, ' - + 'and the complete state was protected from v14-to-v13 and older downgrades, ' + + 'both v14 tables and all eight v15 tables started empty, fixture republish remained idempotent, ' + + 'and the complete state was protected from v15-to-v14 and older downgrades, ' + '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}, ` @@ -8160,12 +8515,13 @@ 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 v14, ' - + 'and guarded v13/v12/v11/v10/v9/v8/v7/v6/v5/v4/v3/v2 rollbacks refused before schema change.', + + 'prebuilt-artifact republish idempotent, populated v3-prefix state retained through v15, ' + + 'and guarded v14/v13/v12/v11/v10/v9/v8/v7/v6/v5/v4/v3/v2 rollbacks refused before schema change.', v11TableSchemaDigest: provenV11TableSchemaDigest, v12TableSchemaDigest: provenV12TableSchemaDigest, v13TableSchemaDigest: provenV13TableSchemaDigest, v14TableSchemaDigest: provenV14TableSchemaDigest, + v15TableSchemaDigest: provenV15TableSchemaDigest, artifactDigest: builtArtifactDigest, })); } finally { diff --git a/spacetimedb/README.md b/spacetimedb/README.md index 36d90650..e8695890 100644 --- a/spacetimedb/README.md +++ b/spacetimedb/README.md @@ -12,11 +12,12 @@ 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 | 14 (daily Marks suffix) | +| Append-only schema generation | 15 (inactive Inner Keep suffix) | | Alpha 0.3.12 suffix | Water refs 37–40; Stone refs 41–45 | | Generic worker suffix | refs 47–52; active | | Access-request suffix | ref 53; active | | Daily Marks suffix | private refs 54–55; activation is separate | +| Inner Keep suffix | refs 56–63; inactive until separate seed, backfill, client, asset, and activation gates | Deployed tables retain their original declaration order and shape. Later features append new tables; they do not rename or delete existing data. The @@ -60,6 +61,8 @@ Public subscriptions contain only shared-world presentation: timeline, and origin castle; - active four-worker roster and generic node-lease projections; the public rows contain no FID, cargo, accrual, balance, request, or auth data; +- the inactive Inner Keep layout, twelve fixed slots, four-building policy, + twenty target-level recipes, and identity-minimized castle building rows; - public Community Marks projection only when its policy permits it. Private tables contain admission, ownership, unclaimed-slot decisions, resource @@ -68,6 +71,11 @@ expedition state, retry receipts, and balances. Retired compatibility tables remain private and frozen to preserve the deployed append-only schema; current authority paths do not write or interpret them. +Inner Keep Builder rows, exact cost receipts, idempotency keys, and construction +schedules are private. Player clients obtain only their own Builder/resources +projection and accepted-request status through caller-authenticated procedures; +browser bindings contain none of those private tables. + The pinned SDK requires scheduled expedition rows to be public. Those rows are therefore deliberately minimal: schedule/stage identifiers, site, origin castle, and an already-public lifecycle timestamp. They contain no FID, @@ -118,6 +126,34 @@ Module publication never repeats those mutations. A bounded admin-only forward-repair path can restore one specifically attested missing return schedule; it cannot select a player row, alter balances, or delete data. +## Inactive Inner Keep construction + +Schema generation 15 appends eight tables without changing refs 0–55: + +| Ref | Table | Visibility and purpose | +| ---: | --- | --- | +| 56 | `inner_keep_layout_v1` | public inactive layout root and digests | +| 57 | `inner_keep_slot_v1` | public twelve-slot fixed catalogue | +| 58 | `inner_keep_building_catalog_v1` | public four-building policy | +| 59 | `inner_keep_build_level_v1` | public exact recipes and timers | +| 60 | `castle_inner_keep_building_v1` | public durable building/project projection | +| 61 | `castle_inner_builder_v1` | private one-Builder authority | +| 62 | `castle_inner_build_receipt_v1` | private exact deduction/idempotency receipt | +| 63 | `castle_inner_construction_schedule_v_1` | private scheduler correlation | + +One project reducer accepts only a slot ID, building kind, and bounded request +key. The server derives ownership, target level, discounts, stored-resource +cost, timestamps, Builder capacity, and completion. It settles current Worker +accrual first, then commits deduction, project, Builder, schedule, and receipt +atomically. The four gathering Workers remain independent from the one internal +Builder. + +The source tree does not make this component playable. A merge to protected +`main` triggers the existing verified Pages deployment of the compatible, +dormant client. Module publication, catalog seed, Builder backfill, +archive-asset authorization, and activation remain distinct owner-reviewed +operations. + ## Entry agreement and Marks Entry and gameplay require the exact current Alpha Terms and Hegemony Social @@ -177,6 +213,27 @@ npm run stdb:inspect-alpha-v12 -- --json npm run stdb:daily-marks:inspect ``` +Inner Keep v15 stays independently inactive. Its counts-only inspection and +deterministic plans use the guarded source-only operator: + +```sh +npm run stdb:inner-keep:inspect +npm run stdb:inner-keep:plan-catalog +npm run stdb:inner-keep:plan-builders +``` + +Catalog seed, Builder backfill, activation, and deactivation are separate +commands. They require exact plan counts and default to a local dry-run record; +`--confirm` is mandatory for any reducer call. Activation is additionally +blocked before credentials or network while Inner Keep runtime-use +authorization remains pending. See the +[Inner Keep activation runbook](../docs/operations/inner-keep-activation.md) +for the complete future owner-reviewed sequence. The publisher has one +explicit active-v14-to-inactive-v15 lane whose dry run performs only bounded +network reads and whose publish path uses `--delete-data=never`; it performs no +seed, Builder backfill, client deployment, or activation. No command is +authorized by this source PR. + Component setup is separate from module publication and must be reviewed one component at a time: diff --git a/spacetimedb/migration-fixtures/additive-v14-schema/src/index.ts b/spacetimedb/migration-fixtures/additive-v14-schema/src/index.ts index b747c7b0..b5dba6a0 100644 --- a/spacetimedb/migration-fixtures/additive-v14-schema/src/index.ts +++ b/spacetimedb/migration-fixtures/additive-v14-schema/src/index.ts @@ -251,6 +251,29 @@ export const runDailyMarkScheduleV1 = db.reducer( () => {}, ); +/** Typed populated v14 suffix for the next additive migration proof. */ +export const fixtureSeedDailyMarksSentinelV14 = db.reducer( + { name: 'fixture_seed_daily_marks_sentinel_v14' }, + ctx => { + if (ctx.db.dailyMarkGrantV1.count() !== 0n || ctx.db.dailyMarkScheduleV1.count() !== 0n) { + throw new Error('FIXTURE_DAILY_MARKS_NOT_EMPTY'); + } + ctx.db.dailyMarkGrantV1.insert({ + grantKey: 'migration-daily-mark-grant-v14', + fid: 991_251n, + utcDay: 20_000n, + amountMicros: 1_000_000n, + policyVersion: 'migration-daily-marks-v14', + grantedAt: ctx.timestamp, + }); + ctx.db.dailyMarkScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + 31_536_000_000_000n), + policyVersion: 'migration-daily-marks-v14', + }); + }, +); + /** Retain the v13 populated-suffix fixture reducer across the v14 append. */ export const fixtureSeedAccessRequestSentinelV13 = db.reducer( { name: 'fixture_seed_access_request_sentinel_v13' }, diff --git a/spacetimedb/migration-fixtures/additive-v15-schema/package.json b/spacetimedb/migration-fixtures/additive-v15-schema/package.json new file mode 100644 index 00000000..f430c4fe --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v15-schema/package.json @@ -0,0 +1,13 @@ +{ + "name": "warpkeep-additive-v15-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-v15-schema/src/index.ts b/spacetimedb/migration-fixtures/additive-v15-schema/src/index.ts new file mode 100644 index 00000000..d269086a --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v15-schema/src/index.ts @@ -0,0 +1,805 @@ +import { schema, table, t } from 'spacetimedb/server'; +import { ScheduleAt, Timestamp } from 'spacetimedb'; +import { SenderError } from 'spacetimedb/server'; +import { + goldExpeditionErrorCode, + runGoldExpeditionSchedule, +} from '../../../src/goldExpeditionAuthority'; +import { + foodExpeditionErrorCode, + runFoodExpeditionSchedule, +} from '../../../src/foodExpeditionAuthority'; +import { + woodExpeditionErrorCode, + runWoodExpeditionSchedule, +} from '../../../src/woodExpeditionAuthority'; +import { + stoneExpeditionErrorCode, + runStoneExpeditionSchedule, +} from '../../../src/stoneExpeditionAuthority'; + +const allowedFid = table({ name: 'allowed_fid' }, { + fid: t.u64().primaryKey(), enabled: t.bool(), authEpoch: t.u32(), + invitedAt: t.timestamp(), invitedBy: t.string(), note: t.string(), +}); +const worldTile = table({ name: 'world_tile', public: true }, { + key: t.string().primaryKey(), q: t.i32(), r: t.i32(), biome: t.string(), + terrainSeed: t.u32(), occupantCastleId: t.option(t.u64()), +}); +const player = table({ name: 'player', public: true }, { + fid: t.u64().primaryKey(), identity: t.identity().unique(), username: t.option(t.string()), + displayName: t.option(t.string()), pfpUrl: t.option(t.string()), joinedAt: t.timestamp(), status: t.string(), +}); +const castle = table({ name: 'castle', public: true }, { + castleId: t.u64().primaryKey().autoInc(), ownerFid: t.u64().unique(), tileKey: t.string().unique(), + q: t.i32(), r: t.i32(), level: t.i32(), name: t.string(), createdAt: t.timestamp(), +}); +const adminAudit = table({ name: 'admin_audit' }, { + id: t.u64().primaryKey().autoInc(), action: t.string(), targetFid: t.option(t.u64()), + actorSubject: t.string(), createdAt: t.timestamp(), note: t.string(), +}); +const playerV2 = table({ name: 'player_v2', public: true }, { + fid: t.u64().primaryKey(), username: t.option(t.string()), displayName: t.option(t.string()), + pfpUrl: t.option(t.string()), joinedAt: t.timestamp(), status: t.string(), +}); +const playerOwnershipV2 = table({ name: 'player_ownership_v2' }, { + fid: t.u64().primaryKey(), identity: t.identity().unique(), +}); +const realmV1 = table({ name: 'realm_v1', public: true }, { + realmId: t.string().primaryKey(), publicName: t.string(), seedName: t.string(), numericSeed: t.u32(), + generationVersion: t.u32(), authoritativeRadius: t.u32(), renderRadius: t.u32(), playerCapacity: t.u32(), + active: t.bool(), createdAt: t.timestamp(), +}); +const worldTileMetaV1 = table({ + name: 'world_tile_meta_v1', public: true, + indexes: [{ accessor: 'byRealmAndRing', algorithm: 'btree', columns: ['realmId', 'ring'] as const }] as const, +}, { + tileKey: t.string().primaryKey(), realmId: t.string().index(), s: t.i32(), ring: t.u32(), sector: t.u32(), + terrainKind: t.string(), passable: t.bool(), movementCost: t.u32(), staticContentKind: t.string(), generationVersion: t.u32(), +}); +const castleSlotV1 = table({ name: 'castle_slot_v1', public: true }, { + slotId: t.u32().primaryKey(), realmId: t.string().index(), tileKey: t.string().unique(), q: t.i32(), r: t.i32(), generationVersion: t.u32(), +}); +const castleSlotClaimV1 = table({ name: 'castle_slot_claim_v1' }, { + slotId: t.u32().primaryKey(), ownerFid: t.u64().unique(), castleId: t.u64().unique(), claimedAt: t.timestamp(), generationVersion: t.u32(), +}); +const realmProfileV1 = table({ name: 'realm_profile_v1', public: true }, { + fid: t.u64().primaryKey(), canonicalUsername: t.option(t.string()), displayName: t.option(t.string()), pfpUrl: t.option(t.string()), publicBio: t.option(t.string()), + admittedAt: t.timestamp(), firstAuthenticatedAt: t.option(t.timestamp()), profileUpdatedAt: t.timestamp(), publicStatus: t.string(), communityStatsVisible: t.bool(), + totalSnapBurnedMicros: t.option(t.u128()), marksEarnedMicros: t.option(t.u128()), marksSpentMicros: t.option(t.u128()), marksBalanceMicros: t.option(t.u128()), marksPolicyVersion: t.option(t.string()), +}); +const markAccountV1 = table({ name: 'mark_account_v1' }, { + fid: t.u64().primaryKey(), totalSnapBurnedMicros: t.u128(), earnedMicros: t.u128(), spentMicros: t.u128(), balanceMicros: t.u128(), policyVersion: t.string(), updatedAt: t.timestamp(), +}); +const snapBurnCreditV1 = table({ name: 'snap_burn_credit_v1' }, { + eventKey: t.string().primaryKey(), batchId: t.string().index(), chainId: t.u32(), tokenContract: t.string(), transactionHash: t.string(), logIndex: t.u32(), burnReference: t.string().unique(), burnMethod: t.string(), senderAddress: t.string(), blockNumber: t.u64(), blockHash: t.string(), amountMicros: t.u128(), attributedFid: t.u64().index(), attributionPolicyVersion: t.string(), contractCodeHash: t.string(), creditedAt: t.timestamp(), +}); +const fidWalletAttributionV1 = table({ + name: 'fid_wallet_attribution_v1', indexes: [{ accessor: 'bySnapshotAndAddress', algorithm: 'btree', columns: ['snapshotGeneration', 'address'] as const }] as const, +}, { + snapshotAttributionKey: t.string().primaryKey(), attributionKey: t.string(), snapshotGeneration: t.u64(), fid: t.u64().index(), address: t.string(), addressType: t.string(), source: t.string(), snapshotAt: t.timestamp(), attributionPolicyVersion: t.string(), active: t.bool(), +}); +const walletAttributionSnapshotV1 = table({ name: 'wallet_attribution_snapshot_v1' }, { + snapshotKey: t.string().primaryKey(), generation: t.u64(), snapshotId: t.string(), policyVersion: t.string(), attributionCount: t.u32(), snapshotAt: t.timestamp(), +}); +const snapScanCursorV1 = table({ name: 'snap_scan_cursor_v1' }, { + cursorKey: t.string().primaryKey(), chainId: t.u32(), tokenContract: t.string(), policyVersion: t.string(), deploymentStartBlock: t.u64(), lastFinalizedBlock: t.u64(), lastFinalizedBlockHash: t.string(), proxyCodeHash: t.string(), implementationAddress: t.string(), implementationCodeHash: t.string(), walletSnapshotGeneration: t.u64(), walletSnapshotId: t.string(), scannedAt: t.timestamp(), +}); +const snapScanBatchV1 = table({ + name: 'snap_scan_batch_v1', indexes: [{ accessor: 'byCursorAndStatus', algorithm: 'btree', columns: ['cursorKey', 'status'] as const }] as const, +}, { + batchId: t.string().primaryKey(), cursorKey: t.string(), status: t.string(), previousFinalizedBlock: t.u64(), previousFinalizedBlockHash: t.string(), throughFinalizedBlock: t.u64(), throughFinalizedBlockHash: t.string(), walletSnapshotGeneration: t.u64(), walletSnapshotId: t.string(), walletAttributionCount: t.u32(), expectedCredits: t.u32(), expectedMicros: t.u128(), appliedCredits: t.u32(), appliedMicros: t.u128(), proxyCodeHash: t.string(), implementationAddress: t.string(), implementationCodeHash: t.string(), startedAt: t.timestamp(), finalizedAt: t.option(t.timestamp()), +}); +const alphaTermsAcceptanceV1 = table({ name: 'alpha_terms_acceptance_v1' }, { + acceptanceKey: t.string().primaryKey(), fid: t.u64().index(), termsVersion: t.string(), acceptedAt: t.timestamp(), +}); +const resourceAccountV1 = table({ name: 'resource_account_v1' }, { + fid: t.u64().primaryKey(), castleId: t.u64().unique(), realmId: t.string().index(), food: t.u64(), wood: t.u64(), stone: t.u64(), gold: t.u64(), settledThroughMicros: t.u64(), revision: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp(), +}); + +const goldSiteV1 = table({ name: 'gold_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const goldNodeOccupationV1 = table({ name: 'gold_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const goldExpeditionV1 = table({ name: 'gold_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedGold: t.u64(), creditedGold: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const goldExpeditionIdempotencyV1 = table({ name: 'gold_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const goldExpeditionScheduleV1 = table({ name: 'gold_expedition_schedule_v_1', public: true, scheduled: (): any => runGoldExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmForestLayoutV1 = table({ name: 'realm_forest_layout_v1', public: true }, { realmId: t.string().primaryKey(), layoutVersion: t.u32(), policyVersion: t.string(), layoutDigest: t.string(), assetCatalogDigest: t.string(), instanceCount: t.u32(), seededAt: t.timestamp() }); +const realmForestInstanceV1 = table({ name: 'realm_forest_instance_v1', public: true }, { treeId: t.string().primaryKey(), realmId: t.string().index(), tileKey: t.string(), q: t.i32(), r: t.i32(), localXMicrounits: t.i64(), localZMicrounits: t.i64(), worldXMicrounits: t.i64(), worldZMicrounits: t.i64(), rotationMilliDegrees: t.u32(), scaleBasisPoints: t.u32(), speciesId: t.string(), habitat: t.string(), layoutVersion: t.u32() }); + +const foodSiteV1 = table({ name: 'food_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const foodNodeOccupationV1 = table({ name: 'food_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const foodExpeditionV1 = table({ name: 'food_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedFood: t.u64(), creditedFood: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const foodExpeditionIdempotencyV1 = table({ name: 'food_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const foodExpeditionScheduleV1 = table({ name: 'food_expedition_schedule_v_1', public: true, scheduled: (): any => runFoodExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const woodSiteV1 = table({ name: 'wood_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const woodNodeOccupationV1 = table({ name: 'wood_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const woodExpeditionV1 = table({ name: 'wood_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedWood: t.u64(), creditedWood: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const woodExpeditionIdempotencyV1 = table({ name: 'wood_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const woodExpeditionScheduleV1 = table({ name: 'wood_expedition_schedule_v_1', public: true, scheduled: (): any => runWoodExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmWaterLayoutV1 = table({ name: 'realm_water_layout_v1', public: true }, { realmId: t.string().primaryKey(), layoutVersion: t.u32(), policyVersion: t.string(), generationVersion: t.u32(), canonicalLandCellCount: t.u32(), oceanCellCount: t.u32(), lakeCellCount: t.u32(), lakeBodyCount: t.u32(), riverCount: t.u32(), riverCellCount: t.u32(), seaLevelMilli: t.i32(), seaLevelPolicyVersion: t.string(), fogStartDepthCells: t.u32(), fogFullDepthCells: t.u32(), hiddenBufferCells: t.u32(), layoutDigest: t.string(), sourceCommit: t.string(), activated: t.bool(), seededAt: t.timestamp(), activatedAt: t.option(t.timestamp()) }); +const realmWaterBodyV1 = table({ name: 'realm_water_body_v1', public: true, indexes: [{ accessor: 'byRealmAndRegime', algorithm: 'btree', columns: ['realmId', 'regime'] as const }] as const }, { bodyId: t.string().primaryKey(), realmId: t.string().index(), regime: t.string(), cellCount: t.u32(), sourceCellKey: t.string(), mouthCellKey: t.string(), surfaceLevelMilli: t.i32(), flowDirectionXQ15: t.i32(), flowDirectionZQ15: t.i32(), wavePreset: t.string(), ordinal: t.u32(), seed: t.u32(), generationVersion: t.u32(), layoutVersion: t.u32() }); +const realmWaterCellV1 = table({ name: 'realm_water_cell_v1', public: true, indexes: [{ accessor: 'byRealmAndRegime', algorithm: 'btree', columns: ['realmId', 'regime'] as const }, { accessor: 'byBody', algorithm: 'btree', columns: ['bodyId'] as const }] as const }, { cellKey: t.string().primaryKey(), realmId: t.string().index(), q: t.i32(), r: t.i32(), regime: t.string(), bodyId: t.string(), depthCells: t.u32(), elevationMilli: t.i32(), surfaceLevelMilli: t.i32(), ring: t.u32(), s: t.i32(), underlyingTileKey: t.option(t.string()), riverOrdinal: t.option(t.u32()), riverOrder: t.option(t.u32()), downstreamWaterCellKey: t.option(t.string()), flowAccumulation: t.u32(), depthClass: t.u32(), oceanDepth: t.u32(), bankSeed: t.u32(), generationVersion: t.u32(), fogBand: t.string(), layoutVersion: t.u32() }); +const realmEnvironmentV1 = table({ name: 'realm_environment_v1', public: true }, { realmId: t.string().primaryKey(), environmentEpoch: t.u64(), waterLayoutVersion: t.u32(), seaLevelMilli: t.i32(), sunDirectionXMicro: t.i32(), sunDirectionYMicro: t.i32(), sunDirectionZMicro: t.i32(), updatedAt: t.timestamp() }); + +const stoneSiteV1 = table({ name: 'stone_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const stoneNodeOccupationV1 = table({ name: 'stone_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const stoneExpeditionV1 = table({ name: 'stone_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedStone: t.u64(), creditedStone: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const stoneExpeditionIdempotencyV1 = table({ name: 'stone_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const stoneExpeditionScheduleV1 = table({ name: 'stone_expedition_schedule_v_1', public: true, scheduled: (): any => runStoneExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmWaterRevisionV1 = table({ name: 'realm_water_revision_v1', public: true }, { + realmId: t.string().primaryKey(), revisionVersion: t.u32(), policyVersion: t.string(), + baseLayoutVersion: t.u32(), baseLayoutDigest: t.string(), oceanBodyCount: t.u32(), + riverBodyCount: t.u32(), enabledBodyCount: t.u32(), oceanCellCount: t.u32(), + riverCellCount: t.u32(), enabledCellCount: t.u32(), lakeBodyCount: t.u32(), + lakeCellCount: t.u32(), riverWidthCells: t.u32(), navigationFogBoundaryDepthCells: t.u32(), + hiddenBufferCells: t.u32(), revisionDigest: t.string(), sourceCommit: t.string(), + activated: t.bool(), seededAt: t.timestamp(), activatedAt: t.option(t.timestamp()), +}); + +/** v12 generic-worker suffix. Public rows contain only identity/lifecycle data. */ +const realmWorkerSystemV1 = table({ name: 'realm_worker_system_v1', public: true }, { + realmId: t.string().primaryKey(), policyVersion: t.string(), workersPerCastle: t.u32(), + expectedCastleCount: t.u32(), expectedWorkerCount: t.u32(), rosterDigest: t.string(), + mode: t.string(), legacyDrainRequired: t.bool(), createdAt: t.timestamp(), + activatedAt: t.option(t.timestamp()), +}); +const castleWorkerV1 = table({ + name: 'castle_worker_v1', public: true, + indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const, +}, { + workerId: t.string().primaryKey(), originCastleId: t.u64(), ordinal: t.u32(), status: t.string(), + resourceKind: t.option(t.string()), siteId: t.option(t.string()), + startedAtMicros: t.option(t.u64()), arrivesAtMicros: t.option(t.u64()), gatheringEndsAtMicros: t.option(t.u64()), + returnStartedAtMicros: t.option(t.u64()), returnsAtMicros: t.option(t.u64()), routeSteps: t.option(t.u32()), + returnStartProgressBasisPoints: t.option(t.u32()), timelineRevision: t.u32(), revision: t.u64(), +}); +const workerAssignmentV1 = table({ + name: 'worker_assignment_v1', + indexes: [ + { accessor: 'byFid', algorithm: 'btree', columns: ['fid'] as const }, + { accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }, + ] as const, +}, { + assignmentId: t.string().primaryKey(), workerId: t.string().unique(), fid: t.u64(), + originCastleId: t.u64(), resourceKind: t.string(), siteId: t.string().index(), phase: t.string(), + startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), + returnStartedAtMicros: t.option(t.u64()), returnsAtMicros: t.u64(), routeSteps: t.u32(), + returnStartProgressBasisPoints: t.u32(), settledThroughMicros: t.u64(), accruedAmount: t.u64(), + materializedAmount: t.u64(), timelineRevision: t.u32(), policyVersion: t.string(), + createdAt: t.timestamp(), updatedAt: t.timestamp(), +}); +const workerNodeOccupationV1 = table({ + name: 'worker_node_occupation_v1', public: true, + indexes: [ + { accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }, + { accessor: 'byWorker', algorithm: 'btree', columns: ['workerId'] as const }, + ] as const, +}, { + nodeKey: t.string().primaryKey(), resourceKind: t.string(), siteId: t.string(), workerId: t.string(), + workerOrdinal: t.u32(), originCastleId: t.u64(), phase: t.string(), + startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), timelineRevision: t.u32(), +}); +const workerCommandIdempotencyV1 = table({ + name: 'worker_command_idempotency_v1', + indexes: [{ accessor: 'byFid', algorithm: 'btree', columns: ['fid'] as const }] as const, +}, { + requestKey: t.string().primaryKey(), fid: t.u64(), workerId: t.option(t.string()), commandKind: t.string(), + resourceKind: t.option(t.string()), siteId: t.option(t.string()), assignmentId: t.option(t.string()), + resultRevision: t.u64(), createdAt: t.timestamp(), +}); +const workerAssignmentScheduleV1 = table({ + name: 'worker_assignment_schedule_v_1', + indexes: [ + { accessor: 'byAssignment', algorithm: 'btree', columns: ['assignmentId'] as const }, + { accessor: 'byWorker', algorithm: 'btree', columns: ['workerId'] as const }, + ] as const, + scheduled: (): any => runWorkerAssignmentScheduleV1, +}, { + scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), assignmentId: t.string(), + workerId: t.string(), timelineRevision: t.u32(), stage: t.string(), +}); + +/** v13 private, append-only expression of interest in manual admission. */ +const accessRequestV1 = table({ name: 'access_request_v1' }, { + fid: t.u64().primaryKey(), requestCycle: t.u64(), requestedAt: t.timestamp(), +}); + +/** v14 private, exactly-once admitted-player UTC-day Mark receipt. */ +const dailyMarkGrantV1 = table({ name: 'daily_mark_grant_v1' }, { + grantKey: t.string().primaryKey(), fid: t.u64().index(), utcDay: t.u64().index(), + amountMicros: t.u128(), policyVersion: t.string(), grantedAt: t.timestamp(), +}); + +/** v14 identity-free, private scheduler singleton. */ +const dailyMarkScheduleV1 = table({ + name: 'daily_mark_schedule_v_1', + scheduled: (): any => runDailyMarkScheduleV1, +}, { + scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), + policyVersion: t.string().unique(), +}); + +/** v15 public, separately activated Inner Keep root and static catalog. */ +const innerKeepLayoutV1 = table({ name: 'inner_keep_layout_v1', public: true }, { + layoutId: t.string().primaryKey(), layoutVersion: t.u32(), policyVersion: t.string(), + slotCount: t.u32(), mediumSlotCount: t.u32(), largeSlotCount: t.u32(), + assetCatalogDigest: t.string(), layoutDigest: t.string(), active: t.bool(), + createdAt: t.timestamp(), activatedAt: t.option(t.timestamp()), +}); +const innerKeepSlotV1 = table({ name: 'inner_keep_slot_v1', public: true }, { + slotId: t.string().primaryKey(), layoutId: t.string().index(), footprintClass: t.string(), + localXMicrounits: t.i64(), localZMicrounits: t.i64(), rotationMilliDegrees: t.u32(), + sortOrder: t.u32(), active: t.bool(), +}); +const innerKeepBuildingCatalogV1 = table({ name: 'inner_keep_building_catalog_v1', public: true }, { + buildingKind: t.string().primaryKey(), publicLabel: t.string(), category: t.string(), + footprintClass: t.string(), maximumLevel: t.u32(), uniquePerCastle: t.bool(), + matchingDiscountResource: t.string(), discountBasisPointsPerLevel: t.u32(), + discountCapBasisPoints: t.u32(), runtimeAssetId: t.string(), previewAssetId: t.string(), + active: t.bool(), policyVersion: t.string(), +}); +const innerKeepBuildLevelV1 = table({ name: 'inner_keep_build_level_v1', public: true }, { + levelKey: t.string().primaryKey(), buildingKind: t.string().index(), targetLevel: t.u32(), + baseFoodCost: t.u64(), baseWoodCost: t.u64(), baseStoneCost: t.u64(), baseGoldCost: t.u64(), + levelMultiplierBasisPoints: t.u32(), durationMicros: t.u64(), policyVersion: t.string(), +}); +const castleInnerKeepBuildingV1 = table({ + name: 'castle_inner_keep_building_v1', public: true, + indexes: [{ accessor: 'byCastle', algorithm: 'btree', columns: ['castleId'] as const }] as const, +}, { + buildingKey: t.string().primaryKey(), castleId: t.u64(), slotKey: t.string().unique(), + slotId: t.string(), buildingKind: t.string(), completedLevel: t.u32(), targetLevel: t.u32(), + phase: t.string(), startedAtMicros: t.u64(), completesAtMicros: t.u64(), revision: t.u64(), + policyVersion: t.string(), +}); + +/** v15 private Builder, receipt, and scheduler authority. */ +const castleInnerBuilderV1 = table({ name: 'castle_inner_builder_v1' }, { + castleId: t.u64().primaryKey(), fid: t.u64().unique(), activeBuildingKey: t.option(t.string()), + busyUntilMicros: t.option(t.u64()), revision: t.u64(), policyVersion: t.string(), + createdAt: t.timestamp(), updatedAt: t.timestamp(), +}); +const castleInnerBuildReceiptV1 = table({ name: 'castle_inner_build_receipt_v1' }, { + receiptKey: t.string().primaryKey(), fid: t.u64().index(), requestKey: t.string(), + castleId: t.u64(), buildingKey: t.string(), slotId: t.string(), buildingKind: t.string(), + targetLevel: t.u32(), deductedFood: t.u64(), deductedWood: t.u64(), deductedStone: t.u64(), + deductedGold: t.u64(), startedAt: t.timestamp(), policyVersion: t.string(), +}); +const castleInnerConstructionScheduleV1 = table({ + name: 'castle_inner_construction_schedule_v_1', + indexes: [{ accessor: 'byBuilding', algorithm: 'btree', columns: ['buildingKey'] as const }] as const, + scheduled: (): any => runInnerKeepConstructionScheduleV1, +}, { + scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), buildingKey: t.string(), + expectedRevision: t.u64(), expectedTargetLevel: t.u32(), +}); + +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, + accessRequestV1, dailyMarkGrantV1, dailyMarkScheduleV1, + innerKeepLayoutV1, innerKeepSlotV1, innerKeepBuildingCatalogV1, innerKeepBuildLevelV1, + castleInnerKeepBuildingV1, castleInnerBuilderV1, castleInnerBuildReceiptV1, + castleInnerConstructionScheduleV1, +}); + +export const runWorkerAssignmentScheduleV1 = db.reducer( + { name: 'run_worker_assignment_schedule_v_1' }, + { arg: workerAssignmentScheduleV1.rowType }, + () => {}, +); + +/** Scheduler-only v14 wire; the schema fixture deliberately performs no grant. */ +export const runDailyMarkScheduleV1 = db.reducer( + { name: 'run_daily_mark_schedule_v_1' }, + { arg: dailyMarkScheduleV1.rowType }, + () => {}, +); + +/** Retain the populated v14 suffix helper across the v15 append. */ +export const fixtureSeedDailyMarksSentinelV14 = db.reducer( + { name: 'fixture_seed_daily_marks_sentinel_v14' }, + ctx => { + if (ctx.db.dailyMarkGrantV1.count() !== 0n || ctx.db.dailyMarkScheduleV1.count() !== 0n) { + throw new Error('FIXTURE_DAILY_MARKS_NOT_EMPTY'); + } + ctx.db.dailyMarkGrantV1.insert({ + grantKey: 'migration-daily-mark-grant-v14', + fid: 991_251n, + utcDay: 20_000n, + amountMicros: 1_000_000n, + policyVersion: 'migration-daily-marks-v14', + grantedAt: ctx.timestamp, + }); + ctx.db.dailyMarkScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + 31_536_000_000_000n), + policyVersion: 'migration-daily-marks-v14', + }); + }, +); + +/** Scheduler-only v15 wire; fixture rows prove persistence, not gameplay. */ +export const runInnerKeepConstructionScheduleV1 = db.reducer( + { name: 'run_inner_keep_construction_schedule_v_1' }, + { arg: castleInnerConstructionScheduleV1.rowType }, + () => {}, +); + +/** Populates all eight v15 tables so forward republish and rollback are provable. */ +export const fixtureSeedInnerKeepSentinelV15 = db.reducer( + { name: 'fixture_seed_inner_keep_sentinel_v15' }, + ctx => { + if ( + ctx.db.innerKeepLayoutV1.count() !== 0n + || ctx.db.innerKeepSlotV1.count() !== 0n + || ctx.db.innerKeepBuildingCatalogV1.count() !== 0n + || ctx.db.innerKeepBuildLevelV1.count() !== 0n + || ctx.db.castleInnerKeepBuildingV1.count() !== 0n + || ctx.db.castleInnerBuilderV1.count() !== 0n + || ctx.db.castleInnerBuildReceiptV1.count() !== 0n + || ctx.db.castleInnerConstructionScheduleV1.count() !== 0n + ) throw new Error('FIXTURE_INNER_KEEP_NOT_EMPTY'); + const castleId = 991_301n; + const fid = 991_302n; + const layoutId = 'migration-inner-keep-layout'; + const slotId = 'migration-inner-keep-slot'; + const buildingKind = 'migration-inner-keep-building'; + const buildingKey = `${castleId}:${buildingKind}`; + const startedAtMicros = ctx.timestamp.microsSinceUnixEpoch; + const completesAtMicros = startedAtMicros + 86_400_000_000n; + ctx.db.innerKeepLayoutV1.insert({ + layoutId, layoutVersion: 1, policyVersion: 'migration-inner-keep-v1', + slotCount: 1, mediumSlotCount: 1, largeSlotCount: 0, + assetCatalogDigest: '2'.repeat(64), layoutDigest: '3'.repeat(64), + active: false, createdAt: ctx.timestamp, activatedAt: undefined, + }); + ctx.db.innerKeepSlotV1.insert({ + slotId, layoutId, footprintClass: 'medium', localXMicrounits: 0n, + localZMicrounits: 0n, rotationMilliDegrees: 0, sortOrder: 1, active: true, + }); + ctx.db.innerKeepBuildingCatalogV1.insert({ + buildingKind, publicLabel: 'Migration Building', category: 'economy', + footprintClass: 'medium', maximumLevel: 5, uniquePerCastle: true, + matchingDiscountResource: 'food', discountBasisPointsPerLevel: 500, + discountCapBasisPoints: 2_500, runtimeAssetId: 'migration.runtime', + previewAssetId: 'migration.preview', active: true, policyVersion: 'migration-inner-keep-v1', + }); + ctx.db.innerKeepBuildLevelV1.insert({ + levelKey: `${buildingKind}:1`, buildingKind, targetLevel: 1, baseFoodCost: 300n, + baseWoodCost: 900n, baseStoneCost: 600n, baseGoldCost: 0n, + levelMultiplierBasisPoints: 10_000, durationMicros: 86_400_000_000n, + policyVersion: 'migration-inner-keep-v1', + }); + ctx.db.castleInnerKeepBuildingV1.insert({ + buildingKey, castleId, slotKey: `${castleId}:${slotId}`, slotId, buildingKind, + completedLevel: 0, targetLevel: 1, phase: 'constructing', startedAtMicros, + completesAtMicros, revision: 0n, policyVersion: 'migration-inner-keep-v1', + }); + ctx.db.castleInnerBuilderV1.insert({ + castleId, fid, activeBuildingKey: buildingKey, busyUntilMicros: completesAtMicros, + revision: 1n, policyVersion: 'migration-inner-keep-v1', createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.castleInnerBuildReceiptV1.insert({ + receiptKey: `${fid}:migration-inner-keep-request`, fid, + requestKey: 'migration-inner-keep-request', castleId, buildingKey, slotId, buildingKind, + targetLevel: 1, deductedFood: 300n, deductedWood: 900n, deductedStone: 600n, + deductedGold: 0n, startedAt: ctx.timestamp, policyVersion: 'migration-inner-keep-v1', + }); + ctx.db.castleInnerConstructionScheduleV1.insert({ + scheduleId: 0n, scheduledAt: ScheduleAt.time(completesAtMicros), buildingKey, + expectedRevision: 0n, expectedTargetLevel: 1, + }); + }, +); + +/** Retain the v13 populated-suffix fixture reducer across the v14 append. */ +export const fixtureSeedAccessRequestSentinelV13 = db.reducer( + { name: 'fixture_seed_access_request_sentinel_v13' }, + ctx => { + if (ctx.db.accessRequestV1.count() !== 0n) { + throw new Error('FIXTURE_ACCESS_REQUEST_NOT_EMPTY'); + } + ctx.db.accessRequestV1.insert({ + fid: 991_201n, + requestCycle: 1n, + requestedAt: ctx.timestamp, + }); + }, +); + +export const runGoldExpeditionScheduleV1 = db.reducer( + { name: 'run_gold_expedition_schedule_v_1' }, + { arg: goldExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runGoldExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = goldExpeditionErrorCode(error); throw new SenderError(code ?? 'GOLD_SCHEDULE_ERROR'); } + }, +); +export const runFoodExpeditionScheduleV1 = db.reducer( + { name: 'run_food_expedition_schedule_v_1' }, + { arg: foodExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runFoodExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = foodExpeditionErrorCode(error); throw new SenderError(code ?? 'FOOD_SCHEDULE_ERROR'); } + }, +); +export const runWoodExpeditionScheduleV1 = db.reducer( + { name: 'run_wood_expedition_schedule_v_1' }, + { arg: woodExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runWoodExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = woodExpeditionErrorCode(error); throw new SenderError(code ?? 'WOOD_SCHEDULE_ERROR'); } + }, +); +export const runStoneExpeditionScheduleV1 = db.reducer( + { name: 'run_stone_expedition_schedule_v_1' }, + { arg: stoneExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runStoneExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = stoneExpeditionErrorCode(error); throw new SenderError(code ?? 'STONE_SCHEDULE_ERROR'); } + }, +); + +/** Auth-neutral identity fixture; SQL identity literals are issuer-bound. */ +export const fixtureInsertPlayerOwnershipV9 = db.reducer( + { name: 'fixture_insert_player_ownership_v9' }, + { fid: t.u64() }, + (ctx, { fid }) => { + if (ctx.db.playerOwnershipV2.fid.find(fid) !== null) throw new Error('FIXTURE_OWNERSHIP_EXISTS'); + ctx.db.playerOwnershipV2.insert({ fid, identity: ctx.sender }); + }, +); + +/** Bounded identity-row assertion; SQL cannot read identity columns across issuers. */ +export const fixtureAssertPlayerOwnershipV9 = db.reducer( + { name: 'fixture_assert_player_ownership_v9' }, + { fid: t.u64(), expectedCount: t.u64() }, + (ctx, { fid, expectedCount }) => { + if (ctx.db.playerOwnershipV2.count() !== expectedCount) throw new Error('FIXTURE_OWNERSHIP_COUNT_INVALID'); + if (expectedCount === 0n) { + if (ctx.db.playerOwnershipV2.fid.find(fid) !== null) throw new Error('FIXTURE_OWNERSHIP_UNEXPECTED'); + return; + } + if (expectedCount !== 1n || ctx.db.playerOwnershipV2.fid.find(fid) === null) { + throw new Error('FIXTURE_OWNERSHIP_ROW_INVALID'); + } + }, +); + +/** Preserve the v9 Water sentinel wire unchanged in the v10 fixture. */ +export const fixtureSeedWaterSentinelV9 = db.reducer( + { name: 'fixture_seed_water_sentinel_v9' }, + ctx => { + if ( + ctx.db.realmWaterLayoutV1.count() !== 0n + || ctx.db.realmWaterBodyV1.count() !== 0n + || ctx.db.realmWaterCellV1.count() !== 0n + || ctx.db.realmEnvironmentV1.count() !== 0n + ) throw new Error('FIXTURE_WATER_NOT_EMPTY'); + const realmId = 'MIGRATION_WATER_SENTINEL'; + const bodyId = 'migration-water-body'; + ctx.db.realmWaterLayoutV1.insert({ + realmId, + layoutVersion: 1, + policyVersion: 'migration-water-sentinel-v1', + generationVersion: 3, + canonicalLandCellCount: 10_000, + oceanCellCount: 1, + lakeCellCount: 0, + lakeBodyCount: 0, + riverCount: 0, + riverCellCount: 0, + seaLevelMilli: 0, + seaLevelPolicyVersion: 'migration-water-sentinel-v1', + fogStartDepthCells: 1, + fogFullDepthCells: 2, + hiddenBufferCells: 1, + layoutDigest: '0'.repeat(64), + sourceCommit: '0'.repeat(40), + activated: false, + seededAt: ctx.timestamp, + activatedAt: undefined, + }); + ctx.db.realmWaterBodyV1.insert({ + bodyId, + realmId, + regime: 'ocean', + cellCount: 1, + sourceCellKey: 'migration-water-cell', + mouthCellKey: 'migration-water-cell', + surfaceLevelMilli: 0, + flowDirectionXQ15: 0, + flowDirectionZQ15: 0, + wavePreset: 'migration', + ordinal: 0, + seed: 0, + generationVersion: 3, + layoutVersion: 1, + }); + ctx.db.realmWaterCellV1.insert({ + cellKey: 'migration-water-cell', + realmId, + q: 0, + r: 0, + regime: 'ocean', + bodyId, + depthCells: 1, + elevationMilli: 0, + surfaceLevelMilli: 0, + ring: 0, + s: 0, + underlyingTileKey: undefined, + riverOrdinal: undefined, + riverOrder: undefined, + downstreamWaterCellKey: undefined, + flowAccumulation: 0, + depthClass: 1, + oceanDepth: 1, + bankSeed: 0, + generationVersion: 3, + fogBand: 'clear', + layoutVersion: 1, + }); + ctx.db.realmEnvironmentV1.insert({ + realmId, + environmentEpoch: 1n, + waterLayoutVersion: 1, + seaLevelMilli: 0, + sunDirectionXMicro: 0, + sunDirectionYMicro: 1_000_000, + sunDirectionZMicro: 0, + updatedAt: ctx.timestamp, + }); + }, +); + +/** One typed row per v10 Stone table for the next additive migration. */ +export const fixtureSeedStoneSentinelV10 = db.reducer( + { name: 'fixture_seed_stone_sentinel_v10' }, + ctx => { + if ( + ctx.db.stoneSiteV1.count() !== 0n + || ctx.db.stoneNodeOccupationV1.count() !== 0n + || ctx.db.stoneExpeditionV1.count() !== 0n + || ctx.db.stoneExpeditionIdempotencyV1.count() !== 0n + || ctx.db.stoneExpeditionScheduleV1.count() !== 0n + ) throw new Error('FIXTURE_STONE_NOT_EMPTY'); + const startedAtMicros = ctx.timestamp.microsSinceUnixEpoch; + const arrivesAtMicros = startedAtMicros + 7n * 24n * 60n * 60n * 1_000_000n; + const gatheringEndsAtMicros = arrivesAtMicros + 24n * 60n * 60n * 1_000_000n; + const returnsAtMicros = gatheringEndsAtMicros + 24n * 60n * 60n * 1_000_000n; + const siteId = 'migration-stone-site'; + const expeditionId = 'migration-stone-expedition'; + const originCastleId = 991_001n; + const fid = 991_002n; + ctx.db.stoneSiteV1.insert({ siteId, q: 1, r: -1, tier: 1, active: true }); + ctx.db.stoneNodeOccupationV1.insert({ + siteId, + originCastleId, + phase: 'outbound', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnsAtMicros, + }); + ctx.db.stoneExpeditionV1.insert({ + expeditionId, + fid, + originCastleId, + siteId, + phase: 'outbound', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnsAtMicros, + settledThroughMicros: startedAtMicros, + accruedStone: 0n, + creditedStone: 0n, + policyVersion: 'migration-stone-sentinel-v1', + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.stoneExpeditionIdempotencyV1.insert({ + requestKey: 'migration-stone-sentinel-request-0001', + fid, + siteId, + expeditionId, + createdAt: ctx.timestamp, + }); + ctx.db.stoneExpeditionScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(arrivesAtMicros), + originCastleId, + siteId, + stage: 'arrival', + }); + }, +); + +/** Typed v11 sentinel used only to prove rollback refusal and row survival. */ +export const fixtureSeedWaterRevisionSentinelV11 = db.reducer( + { name: 'fixture_seed_water_revision_sentinel_v11' }, + ctx => { + if (ctx.db.realmWaterRevisionV1.count() !== 0n) { + throw new Error('FIXTURE_WATER_REVISION_NOT_EMPTY'); + } + ctx.db.realmWaterRevisionV1.insert({ + realmId: 'MIGRATION_WATER_SENTINEL', + revisionVersion: 2, + policyVersion: 'migration-water-revision-sentinel-v1', + baseLayoutVersion: 1, + baseLayoutDigest: '0'.repeat(64), + oceanBodyCount: 1, + riverBodyCount: 0, + enabledBodyCount: 1, + oceanCellCount: 1, + riverCellCount: 0, + enabledCellCount: 1, + lakeBodyCount: 0, + lakeCellCount: 0, + riverWidthCells: 1, + navigationFogBoundaryDepthCells: 2, + hiddenBufferCells: 1, + revisionDigest: '1'.repeat(64), + sourceCommit: '1'.repeat(40), + activated: false, + seededAt: ctx.timestamp, + activatedAt: undefined, + }); + }, +); + +const FIXTURE_RESOURCE_QUANTUM_MICROS = 600_000_000n; +const FIXTURE_RESOURCE_POLICY_VERSION = 'genesis-resource-yield-v1'; + +export const fixtureRewindResourceOneQuantum = db.reducer( + { name: 'fixture_rewind_resource_one_quantum' }, + { fid: t.u64() }, + (ctx, { fid }) => { + const row = ctx.db.resourceAccountV1.fid.find(fid); + if ( + row === null + || row.policyVersion !== FIXTURE_RESOURCE_POLICY_VERSION + || row.revision !== 0n + || row.food !== 0n + || row.wood !== 0n + || row.stone !== 0n + || row.gold !== 0n + || row.settledThroughMicros < FIXTURE_RESOURCE_QUANTUM_MICROS + ) throw new Error('FIXTURE_RESOURCE_STATE_INVALID'); + const rewoundMicros = row.settledThroughMicros - FIXTURE_RESOURCE_QUANTUM_MICROS; + ctx.db.resourceAccountV1.fid.update({ + ...row, + settledThroughMicros: rewoundMicros, + createdAt: new Timestamp(rewoundMicros), + updatedAt: ctx.timestamp, + }); + }, +); + +/** Populates every v12 table with bounded, auth-neutral rows for migration proof. */ +export const fixtureSeedGenericWorkerSentinelV12 = db.reducer( + { name: 'fixture_seed_generic_worker_sentinel_v12' }, + ctx => { + if ( + ctx.db.realmWorkerSystemV1.count() !== 0n + || ctx.db.castleWorkerV1.count() !== 0n + || ctx.db.workerAssignmentV1.count() !== 0n + || ctx.db.workerNodeOccupationV1.count() !== 0n + || ctx.db.workerCommandIdempotencyV1.count() !== 0n + || ctx.db.workerAssignmentScheduleV1.count() !== 0n + ) throw new Error('FIXTURE_WORKER_NOT_EMPTY'); + const castleId = 991_101n; + const fid = 991_102n; + const startedAtMicros = ctx.timestamp.microsSinceUnixEpoch; + const arrivesAtMicros = startedAtMicros + 30_000_000n; + const gatheringEndsAtMicros = arrivesAtMicros + 86_400_000_000n; + const returnsAtMicros = gatheringEndsAtMicros + 30_000_000n; + const assignmentId = 'migration-worker-assignment-0001'; + const workerId = 'genesis-001-castle-991101-worker-01'; + const siteId = 'migration-worker-site'; + ctx.db.realmWorkerSystemV1.insert({ + realmId: 'GENESIS_001', + policyVersion: 'genesis-001-castle-workers-v1', + workersPerCastle: 4, + expectedCastleCount: 1, + expectedWorkerCount: 4, + rosterDigest: 'migration-worker-roster-digest', + mode: 'staged', + legacyDrainRequired: true, + createdAt: ctx.timestamp, + activatedAt: undefined, + }); + for (let ordinal = 1; ordinal <= 4; ordinal += 1) { + ctx.db.castleWorkerV1.insert({ + workerId: `genesis-001-castle-991101-worker-0${ordinal}`, + originCastleId: castleId, + ordinal, + status: ordinal === 1 ? 'gathering' : 'idle', + resourceKind: ordinal === 1 ? 'stone' : undefined, + siteId: ordinal === 1 ? siteId : undefined, + startedAtMicros: ordinal === 1 ? startedAtMicros : undefined, + arrivesAtMicros: ordinal === 1 ? arrivesAtMicros : undefined, + gatheringEndsAtMicros: ordinal === 1 ? gatheringEndsAtMicros : undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: ordinal === 1 ? returnsAtMicros : undefined, + routeSteps: ordinal === 1 ? 1 : undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: 0, + revision: 0n, + }); + } + ctx.db.workerAssignmentV1.insert({ + assignmentId, + workerId, + fid, + originCastleId: castleId, + resourceKind: 'stone', + siteId, + phase: 'gathering', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnStartedAtMicros: undefined, + returnsAtMicros, + routeSteps: 1, + returnStartProgressBasisPoints: 0, + settledThroughMicros: arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + timelineRevision: 0, + policyVersion: 'genesis-001-castle-workers-v1', + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.workerNodeOccupationV1.insert({ + nodeKey: 'stone:migration-worker-site', + resourceKind: 'stone', + siteId, + workerId, + workerOrdinal: 1, + originCastleId: castleId, + phase: 'gathering', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + timelineRevision: 0, + }); + ctx.db.workerCommandIdempotencyV1.insert({ + requestKey: '991102:migration-worker-request-0001', + fid, + workerId, + commandKind: 'dispatch', + resourceKind: 'stone', + siteId, + assignmentId, + resultRevision: 0n, + createdAt: ctx.timestamp, + }); + ctx.db.workerAssignmentScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(gatheringEndsAtMicros), + assignmentId, + workerId, + timelineRevision: 0, + stage: 'gathering-expiry', + }); + }, +); + +export default db; diff --git a/spacetimedb/migration-fixtures/additive-v15-schema/tsconfig.json b/spacetimedb/migration-fixtures/additive-v15-schema/tsconfig.json new file mode 100644 index 00000000..ff6a5945 --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v15-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 649efdeb..c882ca64 100644 --- a/spacetimedb/pnpm-lock.yaml +++ b/spacetimedb/pnpm-lock.yaml @@ -72,6 +72,16 @@ importers: specifier: 5.6.3 version: 5.6.3 + migration-fixtures/additive-v15-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/foundingAuthority.ts b/spacetimedb/src/foundingAuthority.ts index 2d44dc02..5de8aff8 100644 --- a/spacetimedb/src/foundingAuthority.ts +++ b/spacetimedb/src/foundingAuthority.ts @@ -15,6 +15,10 @@ import { GENESIS_STARTING_RESOURCE_BALANCES, } from './resourceAuthorityPolicy'; import { ensureCastleWorkerRoster } from './castleWorkerRoster'; +import { + assertInnerKeepBuilderForExistingFounder, + insertInnerKeepBuilderForNewFounderIfEverActivated, +} from './innerKeepBuilderAuthority'; import { admissionProfileIsComplete, trustedProfilesEqual, @@ -225,6 +229,7 @@ export function ensureGenesisFounder( ) fail(); assertGenesisFoundingGraph(ctx); ensureCastleWorkerRoster(ctx, existingCastle); + assertInnerKeepBuilderForExistingFounder(ctx, existingCastle); return 'preserved'; } if ( @@ -324,6 +329,7 @@ export function ensureGenesisFounder( // workers in this same transaction. This advances roster readiness only: // staged/draining mode and every activation gate remain unchanged. ensureCastleWorkerRoster(ctx, castle); + insertInnerKeepBuilderForNewFounderIfEverActivated(ctx, castle); assertGenesisFoundingGraph(ctx); return 'created'; diff --git a/spacetimedb/src/index.ts b/spacetimedb/src/index.ts index 2d658f21..a395f973 100644 --- a/spacetimedb/src/index.ts +++ b/spacetimedb/src/index.ts @@ -110,4 +110,17 @@ export { runStoneExpeditionScheduleV1, runCastleWorkerScheduleV1, runDailyMarkScheduleV1, + runInnerKeepConstructionScheduleV1, } from './schema'; +export { + getMyInnerKeepStateV1, + getMyInnerKeepRequestStatusV1, + innerKeepStartProjectV1, + adminGetInnerKeepStatusV1, + adminPlanInnerKeepCatalogV1, + adminSeedInnerKeepCatalogV1, + adminPlanInnerKeepBuildersV1, + adminBackfillInnerKeepBuildersV1, + adminActivateInnerKeepV1, + adminDeactivateInnerKeepV1, +} from './reducers/innerKeep'; diff --git a/spacetimedb/src/innerKeepAuthority.ts b/spacetimedb/src/innerKeepAuthority.ts new file mode 100644 index 00000000..fb58d3f1 --- /dev/null +++ b/spacetimedb/src/innerKeepAuthority.ts @@ -0,0 +1,1176 @@ +import { ScheduleAt } from 'spacetimedb'; +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; + +import { settleAllWorkerAssignmentsForFid, projectMyWorkerState } from './castleWorkerAuthority'; +import { CASTLE_WORKERS_PER_CASTLE } from './castleWorkerPolicy'; +import { CASTLE_WORKER_MAX_CASTLES } from './castleWorkerRolloutPolicy'; +import { + innerKeepBuilderRowIsIdleAndCanonical, + innerKeepBuilderRowMatchesCastle, + InnerKeepBuilderAuthorityError, +} from './innerKeepBuilderAuthority'; +import { + CANONICAL_INNER_KEEP_LAYOUT, + CANONICAL_INNER_KEEP_SLOTS, + INNER_KEEP_ASSET_CATALOG_DIGEST, + INNER_KEEP_LAYOUT_DIGEST, + INNER_KEEP_LAYOUT_ID, + INNER_KEEP_LAYOUT_POLICY_VERSION, + innerKeepActivationLifecycle, + innerKeepLifecycleRequiresBuilders, + matchesCanonicalInnerKeepLayout, + matchesCanonicalInnerKeepSlot, +} from './innerKeepLayoutPolicy'; +import { + CANONICAL_INNER_KEEP_BUILDING_CATALOG, + CANONICAL_INNER_KEEP_LEVEL_POLICIES, + INNER_KEEP_MAXIMUM_LEVEL, + INNER_KEEP_POLICY_DIGEST, + INNER_KEEP_POLICY_VERSION, + INNER_KEEP_PROTOCOL_CAPABILITY, + InnerKeepPolicyError, + canonicalInnerKeepBuildingPolicy, + canonicalInnerKeepCost, + canonicalInnerKeepLevelPolicy, + innerKeepActivationRowsAreSafe, + matchesCanonicalInnerKeepBuildingPolicy, + matchesCanonicalInnerKeepLevelPolicy, + type InnerKeepBuildingKind, + type InnerKeepCompletedLevels, +} from './innerKeepPolicy'; +import { assertGenesisResourceForFid } from './resourceAuthority'; +import { GENESIS_RESOURCE_POLICY_VERSION } from './resourceAuthorityPolicy'; +import type warpkeep from './schema'; + +type WarpkeepReducerContext = ReducerCtx>; +type CastleRow = NonNullable>; +type LayoutRow = NonNullable>; +type BuildingRow = NonNullable>; +type BuilderRow = NonNullable>; +type ReceiptRow = NonNullable>; +type ScheduleRow = NonNullable>; + +const U64_MAX = (1n << 64n) - 1n; +const INNER_KEEP_BUILDINGS_PER_CASTLE = 4; +const INNER_KEEP_REQUEST_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{15,79}$/; +const SHA256_HEX = /^[0-9a-f]{64}$/; +const GIT_COMMIT_HEX = /^[0-9a-f]{40}$/; +const CLIENT_RELEASE_PATTERN = /^(?:alpha-)?0\.3\.[0-9]+(?:[-+][a-z0-9.-]+)?$/; + +export class InnerKeepAuthorityError extends Error { + constructor(readonly code: string) { + super(code); + this.name = 'InnerKeepAuthorityError'; + } +} + +function fail(code: string): never { + throw new InnerKeepAuthorityError(code); +} + +function safeNextU64(value: bigint, code: string): bigint { + if (value < 0n || value >= U64_MAX) fail(code); + return value + 1n; +} + +function safeAddU64(left: bigint, right: bigint, code: string): bigint { + if (left < 0n || right < 0n || left > U64_MAX || right > U64_MAX - left) fail(code); + return left + right; +} + +function boundedRows(rows: Iterable, maximum: number, code: string): readonly Row[] { + const result: Row[] = []; + for (const row of rows) { + if (result.length >= maximum) fail(code); + result.push(row); + } + return result; +} + +export function assertInnerKeepRequestKey(value: string): void { + if (!INNER_KEEP_REQUEST_KEY_PATTERN.test(value)) fail('INNER_KEEP_REQUEST_KEY_INVALID'); +} + +export function innerKeepBuildingKey(castleId: bigint, buildingKind: string): string { + if (castleId < 0n || castleId > U64_MAX) fail('INNER_KEEP_CASTLE_ID_INVALID'); + canonicalInnerKeepBuildingPolicy(buildingKind); + return `${castleId.toString()}:${buildingKind}`; +} + +export function innerKeepSlotKey(castleId: bigint, slotId: string): string { + if (castleId < 0n || castleId > U64_MAX || !/^inner-keep-slot-[ml][0-9]{2}$/.test(slotId)) { + fail('INNER_KEEP_SLOT_INVALID'); + } + return `${castleId.toString()}:${slotId}`; +} + +function receiptKey(fid: bigint, requestKey: string): string { + assertInnerKeepRequestKey(requestKey); + if (fid <= 0n || fid > U64_MAX) fail('INNER_KEEP_FID_INVALID'); + return `${fid.toString()}:${requestKey}`; +} + +function staticCatalogState(ctx: WarpkeepReducerContext): Readonly<{ + layout: LayoutRow | undefined; + layoutExact: boolean; + slotsExact: boolean; + buildingsExact: boolean; + levelsExact: boolean; + exact: boolean; +}> { + const storedLayout = ctx.db.innerKeepLayoutV1.layoutId.find(INNER_KEEP_LAYOUT_ID); + const layout = storedLayout ?? undefined; + const layoutLifecycle = layout === undefined ? 'invalid' : innerKeepActivationLifecycle(layout); + const layoutTimeValid = layout !== undefined + && layout.createdAt.microsSinceUnixEpoch >= 0n + && layoutLifecycle !== 'invalid' + && (layout.activatedAt === undefined + || layout.activatedAt.microsSinceUnixEpoch >= layout.createdAt.microsSinceUnixEpoch); + const layoutExact = ctx.db.innerKeepLayoutV1.count() === 1n + && layout !== undefined + && matchesCanonicalInnerKeepLayout(layout) + && layoutTimeValid; + + let slotsExact = ctx.db.innerKeepSlotV1.count() === BigInt(CANONICAL_INNER_KEEP_SLOTS.length); + if (slotsExact) { + for (const expected of CANONICAL_INNER_KEEP_SLOTS) { + const stored = ctx.db.innerKeepSlotV1.slotId.find(expected.slotId); + if (stored === null || !matchesCanonicalInnerKeepSlot(stored)) { + slotsExact = false; + break; + } + } + } + + let buildingsExact = ctx.db.innerKeepBuildingCatalogV1.count() + === BigInt(CANONICAL_INNER_KEEP_BUILDING_CATALOG.length); + if (buildingsExact) { + for (const expected of CANONICAL_INNER_KEEP_BUILDING_CATALOG) { + const stored = ctx.db.innerKeepBuildingCatalogV1.buildingKind.find(expected.buildingKind); + if (stored === null || !matchesCanonicalInnerKeepBuildingPolicy(stored)) { + buildingsExact = false; + break; + } + } + } + + let levelsExact = ctx.db.innerKeepBuildLevelV1.count() + === BigInt(CANONICAL_INNER_KEEP_LEVEL_POLICIES.length); + if (levelsExact) { + for (const expected of CANONICAL_INNER_KEEP_LEVEL_POLICIES) { + const stored = ctx.db.innerKeepBuildLevelV1.levelKey.find(expected.levelKey); + if (stored === null || !matchesCanonicalInnerKeepLevelPolicy(stored)) { + levelsExact = false; + break; + } + } + } + + return Object.freeze({ + layout, + layoutExact, + slotsExact, + buildingsExact, + levelsExact, + exact: layoutExact && slotsExact && buildingsExact && levelsExact, + }); +} + +function assertStaticCatalogExact(ctx: WarpkeepReducerContext): LayoutRow { + const state = staticCatalogState(ctx); + if (!state.exact || state.layout === undefined) fail('INNER_KEEP_CATALOG_INTEGRITY'); + return state.layout; +} + +function workerSystemIsReady(ctx: WarpkeepReducerContext): boolean { + const system = ctx.db.realmWorkerSystemV1.realmId.find('GENESIS_001'); + return ctx.db.realmWorkerSystemV1.count() === 1n + && system !== null + && system.mode === 'active' + && !system.legacyDrainRequired + && BigInt(system.expectedCastleCount) === ctx.db.castle.count() + && system.expectedWorkerCount === system.expectedCastleCount * CASTLE_WORKERS_PER_CASTLE + && BigInt(system.expectedWorkerCount) === ctx.db.castleWorkerV1.count() + && ctx.db.goldExpeditionV1.count() === 0n + && ctx.db.foodExpeditionV1.count() === 0n + && ctx.db.woodExpeditionV1.count() === 0n + && ctx.db.stoneExpeditionV1.count() === 0n + && ctx.db.goldNodeOccupationV1.count() === 0n + && ctx.db.foodNodeOccupationV1.count() === 0n + && ctx.db.woodNodeOccupationV1.count() === 0n + && ctx.db.stoneNodeOccupationV1.count() === 0n + && ctx.db.goldExpeditionScheduleV1.count() === 0n + && ctx.db.foodExpeditionScheduleV1.count() === 0n + && ctx.db.woodExpeditionScheduleV1.count() === 0n + && ctx.db.stoneExpeditionScheduleV1.count() === 0n; +} + +function assertInnerKeepComponentActive(ctx: WarpkeepReducerContext): LayoutRow { + const layout = assertStaticCatalogExact(ctx); + if (!layout.active || layout.activatedAt === undefined) fail('INNER_KEEP_UNAVAILABLE'); + if (!workerSystemIsReady(ctx)) fail('INNER_KEEP_BACKEND_SYNCHRONIZING'); + return layout; +} + +function buildingRowsForCastle(ctx: WarpkeepReducerContext, castleId: bigint): readonly BuildingRow[] { + return boundedRows( + ctx.db.castleInnerKeepBuildingV1.byCastle.filter(castleId), + INNER_KEEP_BUILDINGS_PER_CASTLE + 1, + 'INNER_KEEP_BUILDING_LIMIT', + ); +} + +function schedulesForBuilding(ctx: WarpkeepReducerContext, buildingKey: string): readonly ScheduleRow[] { + return boundedRows( + ctx.db.castleInnerConstructionScheduleV1.byBuilding.filter(buildingKey), + 2, + 'INNER_KEEP_SCHEDULE_LIMIT', + ); +} + +function scheduleMatchesBuilding(schedule: ScheduleRow, building: BuildingRow): boolean { + const scheduledValue = schedule.scheduledAt.value; + const scheduledAtMicros = typeof scheduledValue === 'object' + && scheduledValue !== null + && 'microsSinceUnixEpoch' in scheduledValue + && typeof scheduledValue.microsSinceUnixEpoch === 'bigint' + ? scheduledValue.microsSinceUnixEpoch + : undefined; + return schedule.scheduledAt.tag === 'Time' + && scheduledAtMicros === building.completesAtMicros + && schedule.buildingKey === building.buildingKey + && schedule.expectedRevision === building.revision + && schedule.expectedTargetLevel === building.targetLevel; +} + +function buildingRowIsConsistent(ctx: WarpkeepReducerContext, row: BuildingRow): boolean { + try { + const policy = canonicalInnerKeepBuildingPolicy(row.buildingKind); + const levelPolicy = canonicalInnerKeepLevelPolicy(row.buildingKind, row.targetLevel); + const castle = ctx.db.castle.castleId.find(row.castleId); + const slot = ctx.db.innerKeepSlotV1.slotId.find(row.slotId); + if ( + castle === null + || slot === null + || !matchesCanonicalInnerKeepSlot(slot) + || !slot.active + || slot.footprintClass !== policy.footprintClass + || row.buildingKey !== innerKeepBuildingKey(row.castleId, row.buildingKind) + || row.slotKey !== innerKeepSlotKey(row.castleId, row.slotId) + || row.completedLevel < 0 + || row.targetLevel < 1 + || row.targetLevel > policy.maximumLevel + || row.startedAtMicros > row.completesAtMicros + || row.completesAtMicros - row.startedAtMicros !== levelPolicy.durationMicros + || row.completesAtMicros > U64_MAX + || row.revision < 0n + || row.policyVersion !== INNER_KEEP_POLICY_VERSION + ) return false; + if (row.phase === 'constructing') return row.targetLevel === row.completedLevel + 1; + if (row.phase === 'complete') return row.completedLevel === row.targetLevel; + return false; + } catch (error) { + if (error instanceof InnerKeepPolicyError || error instanceof InnerKeepAuthorityError) return false; + throw error; + } +} + +function builderForCastle(ctx: WarpkeepReducerContext, castle: CastleRow): BuilderRow { + const byCastle = ctx.db.castleInnerBuilderV1.castleId.find(castle.castleId); + const byFid = ctx.db.castleInnerBuilderV1.fid.find(castle.ownerFid); + if ( + byCastle === null + || byFid === null + || byCastle.castleId !== byFid.castleId + || !innerKeepBuilderRowMatchesCastle(byCastle, castle) + ) fail('INNER_KEEP_BUILDER_INTEGRITY'); + return byCastle; +} + +function assertBuilderProjectGraph( + ctx: WarpkeepReducerContext, + castle: CastleRow, + builder: BuilderRow, +): readonly BuildingRow[] { + const buildings = buildingRowsForCastle(ctx, castle.castleId); + if (buildings.some(row => !buildingRowIsConsistent(ctx, row))) fail('INNER_KEEP_BUILDING_INTEGRITY'); + const constructing = buildings.filter(row => row.phase === 'constructing'); + if (constructing.length > 1) fail('INNER_KEEP_BUILDER_INTEGRITY'); + if (builder.activeBuildingKey === undefined) { + if (builder.busyUntilMicros !== undefined || constructing.length !== 0) fail('INNER_KEEP_BUILDER_INTEGRITY'); + } else { + if ( + builder.busyUntilMicros === undefined + || constructing.length !== 1 + || constructing[0]!.buildingKey !== builder.activeBuildingKey + || constructing[0]!.completesAtMicros !== builder.busyUntilMicros + ) fail('INNER_KEEP_BUILDER_INTEGRITY'); + } + for (const building of buildings) { + const schedules = schedulesForBuilding(ctx, building.buildingKey); + if (building.phase === 'complete' && schedules.length !== 0) fail('INNER_KEEP_SCHEDULE_INTEGRITY'); + if ( + building.phase === 'constructing' + && ( + schedules.length > 1 + || (schedules.length === 1 && !scheduleMatchesBuilding(schedules[0]!, building)) + ) + ) fail('INNER_KEEP_SCHEDULE_INTEGRITY'); + } + return buildings; +} + +function deleteSchedulesForBuilding(ctx: WarpkeepReducerContext, buildingKey: string): void { + for (const schedule of schedulesForBuilding(ctx, buildingKey)) { + ctx.db.castleInnerConstructionScheduleV1.scheduleId.delete(schedule.scheduleId); + } +} + +function completeProject( + ctx: WarpkeepReducerContext, + castle: CastleRow, + builder: BuilderRow, + building: BuildingRow, + now: bigint, +): BuildingRow { + if ( + building.phase !== 'constructing' + || builder.activeBuildingKey !== building.buildingKey + || builder.busyUntilMicros !== building.completesAtMicros + ) fail('INNER_KEEP_COMPLETION_INTEGRITY'); + if (now < building.completesAtMicros) fail('INNER_KEEP_COMPLETION_EARLY'); + if (!buildingRowIsConsistent(ctx, building)) fail('INNER_KEEP_BUILDING_INTEGRITY'); + deleteSchedulesForBuilding(ctx, building.buildingKey); + const completed = { + ...building, + completedLevel: building.targetLevel, + phase: 'complete', + revision: safeNextU64(building.revision, 'INNER_KEEP_BUILDING_REVISION'), + }; + ctx.db.castleInnerKeepBuildingV1.buildingKey.update(completed); + ctx.db.castleInnerBuilderV1.castleId.update({ + ...builder, + activeBuildingKey: undefined, + busyUntilMicros: undefined, + revision: safeNextU64(builder.revision, 'INNER_KEEP_BUILDER_REVISION'), + updatedAt: ctx.timestamp, + }); + const stored = ctx.db.castleInnerKeepBuildingV1.buildingKey.find(building.buildingKey); + const idle = ctx.db.castleInnerBuilderV1.castleId.find(castle.castleId); + if ( + stored === null + || idle === null + || !buildingRowIsConsistent(ctx, stored) + || !innerKeepBuilderRowIsIdleAndCanonical(idle, castle) + ) fail('INNER_KEEP_COMPLETION_INTEGRITY'); + return stored; +} + +function reconcileOverdueProject( + ctx: WarpkeepReducerContext, + castle: CastleRow, + builder: BuilderRow, + now: bigint, +): BuilderRow { + const buildings = assertBuilderProjectGraph(ctx, castle, builder); + if (builder.activeBuildingKey === undefined) return builder; + const building = buildings.find(row => row.buildingKey === builder.activeBuildingKey); + if (building === undefined) fail('INNER_KEEP_BUILDER_INTEGRITY'); + const schedules = schedulesForBuilding(ctx, building.buildingKey); + if (schedules.length === 1 && !scheduleMatchesBuilding(schedules[0]!, building)) { + fail('INNER_KEEP_SCHEDULE_INTEGRITY'); + } + if (now < building.completesAtMicros) { + // A missing callback row is recoverable only after the canonical project + // becomes overdue. Until then, fail without mutating so operators retain + // an unambiguous window for exact schedule delivery or forward repair. + if (schedules.length !== 1) fail('INNER_KEEP_SCHEDULE_INTEGRITY'); + return builder; + } + completeProject(ctx, castle, builder, building, now); + const idle = ctx.db.castleInnerBuilderV1.castleId.find(castle.castleId); + if (idle === null) fail('INNER_KEEP_BUILDER_INTEGRITY'); + return idle; +} + +/** + * Caller-owned entry synchronization. Inactive state is projection-only; + * active, Worker-ready state may finish one exact overdue project before the + * surrounding procedure projects it from the same transaction. + */ +export function synchronizeMyInnerKeepEntry( + ctx: WarpkeepReducerContext, + castle: CastleRow, +): void { + const catalog = staticCatalogState(ctx); + if (!catalog.exact || catalog.layout?.active !== true) return; + assertInnerKeepComponentActive(ctx); + const builder = builderForCastle(ctx, castle); + reconcileOverdueProject(ctx, castle, builder, ctx.timestamp.microsSinceUnixEpoch); +} + +function completedLevels(buildings: readonly BuildingRow[]): InnerKeepCompletedLevels { + const result: Partial> = {}; + for (const building of buildings) { + if (building.completedLevel <= 0) continue; + const policy = canonicalInnerKeepBuildingPolicy(building.buildingKind); + result[policy.buildingKind] = building.completedLevel; + } + return Object.freeze(result); +} + +function priorReceiptMatches( + row: ReceiptRow, + input: Readonly<{ + fid: bigint; + castle: CastleRow; + slotId: string; + buildingKind: string; + requestKey: string; + }>, +): boolean { + try { + return row.receiptKey === receiptKey(input.fid, input.requestKey) + && row.fid === input.fid + && row.requestKey === input.requestKey + && row.castleId === input.castle.castleId + && row.buildingKey === innerKeepBuildingKey(input.castle.castleId, input.buildingKind) + && row.slotId === input.slotId + && row.buildingKind === input.buildingKind + && row.targetLevel >= 1 + && row.targetLevel <= INNER_KEEP_MAXIMUM_LEVEL + && row.policyVersion === INNER_KEEP_POLICY_VERSION + && row.startedAt.microsSinceUnixEpoch >= input.castle.createdAt.microsSinceUnixEpoch; + } catch (error) { + if (error instanceof InnerKeepPolicyError || error instanceof InnerKeepAuthorityError) return false; + throw error; + } +} + +export type InnerKeepStartResult = Readonly<{ + building: BuildingRow; + receipt: ReceiptRow; + idempotent: boolean; +}>; + +/** Atomic initial-build/upgrade authority. The caller supplies no economic state. */ +export function startInnerKeepProject( + ctx: WarpkeepReducerContext, + input: Readonly<{ + fid: bigint; + castle: CastleRow; + slotId: string; + buildingKind: string; + requestKey: string; + }>, +): InnerKeepStartResult { + const requestReceiptKey = receiptKey(input.fid, input.requestKey); + const prior = ctx.db.castleInnerBuildReceiptV1.receiptKey.find(requestReceiptKey); + if (prior !== null) { + if (!priorReceiptMatches(prior, input)) fail('INNER_KEEP_IDEMPOTENCY_CONFLICT'); + const building = ctx.db.castleInnerKeepBuildingV1.buildingKey.find(prior.buildingKey); + if ( + building === null + || building.castleId !== input.castle.castleId + || building.slotId !== prior.slotId + || building.buildingKind !== prior.buildingKind + || building.completedLevel < prior.targetLevel && building.targetLevel !== prior.targetLevel + || !buildingRowIsConsistent(ctx, building) + ) fail('INNER_KEEP_IDEMPOTENCY_STALE'); + return Object.freeze({ building, receipt: prior, idempotent: true }); + } + + assertInnerKeepComponentActive(ctx); + if (input.castle.ownerFid !== input.fid) fail('INNER_KEEP_NOT_OWNED'); + const canonicalSlot = CANONICAL_INNER_KEEP_SLOTS.find(row => row.slotId === input.slotId); + const storedSlot = ctx.db.innerKeepSlotV1.slotId.find(input.slotId); + if ( + canonicalSlot === undefined + || storedSlot === null + || !matchesCanonicalInnerKeepSlot(storedSlot) + || !storedSlot.active + ) fail('INNER_KEEP_SLOT_UNAVAILABLE'); + const buildingPolicy = canonicalInnerKeepBuildingPolicy(input.buildingKind); + const storedCatalog = ctx.db.innerKeepBuildingCatalogV1.buildingKind.find(input.buildingKind); + if ( + storedCatalog === null + || !matchesCanonicalInnerKeepBuildingPolicy(storedCatalog) + || !storedCatalog.active + ) fail('INNER_KEEP_BUILDING_UNAVAILABLE'); + if (storedSlot.footprintClass !== buildingPolicy.footprintClass) { + fail('INNER_KEEP_FOOTPRINT_INCOMPATIBLE'); + } + + let builder = builderForCastle(ctx, input.castle); + builder = reconcileOverdueProject(ctx, input.castle, builder, ctx.timestamp.microsSinceUnixEpoch); + if (builder.activeBuildingKey !== undefined || builder.busyUntilMicros !== undefined) { + fail('INNER_KEEP_BUILDER_BUSY'); + } + const buildings = assertBuilderProjectGraph(ctx, input.castle, builder); + const projectKey = innerKeepBuildingKey(input.castle.castleId, input.buildingKind); + const requestedSlotKey = innerKeepSlotKey(input.castle.castleId, input.slotId); + const slotOccupant = ctx.db.castleInnerKeepBuildingV1.slotKey.find(requestedSlotKey); + let existing = ctx.db.castleInnerKeepBuildingV1.buildingKey.find(projectKey); + let targetLevel: number; + if (existing === null) { + if (slotOccupant !== null) fail('INNER_KEEP_SLOT_OCCUPIED'); + targetLevel = 1; + } else { + if ( + existing.slotId !== input.slotId + || existing.slotKey !== requestedSlotKey + || slotOccupant?.buildingKey !== existing.buildingKey + ) fail('INNER_KEEP_BUILDING_ALREADY_EXISTS'); + if (existing.phase !== 'complete' || !buildingRowIsConsistent(ctx, existing)) { + fail('INNER_KEEP_BUILDING_INTEGRITY'); + } + if (existing.completedLevel >= INNER_KEEP_MAXIMUM_LEVEL) fail('INNER_KEEP_MAXIMUM_LEVEL'); + targetLevel = existing.completedLevel + 1; + } + + const cost = canonicalInnerKeepCost( + input.buildingKind, + targetLevel, + completedLevels(buildings), + ); + // Materialize passive and generic-Worker quanta at the authoritative start + // timestamp while the existing reservation planner preserves active awards. + settleAllWorkerAssignmentsForFid(ctx, input.fid, ctx.timestamp.microsSinceUnixEpoch); + const resource = assertGenesisResourceForFid(ctx, input.fid).account; + if (resource.food < cost.effectiveCost.food) fail('INNER_KEEP_INSUFFICIENT_FOOD'); + if (resource.wood < cost.effectiveCost.wood) fail('INNER_KEEP_INSUFFICIENT_WOOD'); + if (resource.stone < cost.effectiveCost.stone) fail('INNER_KEEP_INSUFFICIENT_STONE'); + if (resource.gold < cost.effectiveCost.gold) fail('INNER_KEEP_INSUFFICIENT_GOLD'); + + ctx.db.resourceAccountV1.fid.update({ + ...resource, + food: resource.food - cost.effectiveCost.food, + wood: resource.wood - cost.effectiveCost.wood, + stone: resource.stone - cost.effectiveCost.stone, + gold: resource.gold - cost.effectiveCost.gold, + revision: safeNextU64(resource.revision, 'INNER_KEEP_RESOURCE_REVISION'), + policyVersion: GENESIS_RESOURCE_POLICY_VERSION, + updatedAt: ctx.timestamp, + }); + + const startedAtMicros = ctx.timestamp.microsSinceUnixEpoch; + const completesAtMicros = safeAddU64(startedAtMicros, cost.durationMicros, 'INNER_KEEP_TIME_OVERFLOW'); + const project: BuildingRow = existing === null + ? { + buildingKey: projectKey, + castleId: input.castle.castleId, + slotKey: requestedSlotKey, + slotId: input.slotId, + buildingKind: buildingPolicy.buildingKind, + completedLevel: 0, + targetLevel, + phase: 'constructing', + startedAtMicros, + completesAtMicros, + revision: 0n, + policyVersion: INNER_KEEP_POLICY_VERSION, + } + : { + ...existing, + targetLevel, + phase: 'constructing', + startedAtMicros, + completesAtMicros, + revision: safeNextU64(existing.revision, 'INNER_KEEP_BUILDING_REVISION'), + policyVersion: INNER_KEEP_POLICY_VERSION, + }; + if (existing === null) ctx.db.castleInnerKeepBuildingV1.insert(project); + else ctx.db.castleInnerKeepBuildingV1.buildingKey.update(project); + + ctx.db.castleInnerBuilderV1.castleId.update({ + ...builder, + activeBuildingKey: project.buildingKey, + busyUntilMicros: project.completesAtMicros, + revision: safeNextU64(builder.revision, 'INNER_KEEP_BUILDER_REVISION'), + updatedAt: ctx.timestamp, + }); + ctx.db.castleInnerConstructionScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(project.completesAtMicros), + buildingKey: project.buildingKey, + expectedRevision: project.revision, + expectedTargetLevel: project.targetLevel, + }); + const insertedReceipt = ctx.db.castleInnerBuildReceiptV1.insert({ + receiptKey: requestReceiptKey, + fid: input.fid, + requestKey: input.requestKey, + castleId: input.castle.castleId, + buildingKey: project.buildingKey, + slotId: input.slotId, + buildingKind: buildingPolicy.buildingKind, + targetLevel, + deductedFood: cost.effectiveCost.food, + deductedWood: cost.effectiveCost.wood, + deductedStone: cost.effectiveCost.stone, + deductedGold: cost.effectiveCost.gold, + startedAt: ctx.timestamp, + policyVersion: INNER_KEEP_POLICY_VERSION, + }); + + const busy = builderForCastle(ctx, input.castle); + const storedProject = ctx.db.castleInnerKeepBuildingV1.buildingKey.find(project.buildingKey); + const storedSchedules = storedProject === null + ? [] + : schedulesForBuilding(ctx, storedProject.buildingKey); + if ( + storedProject === null + || !buildingRowIsConsistent(ctx, storedProject) + || busy.activeBuildingKey !== storedProject.buildingKey + || busy.busyUntilMicros !== storedProject.completesAtMicros + || storedSchedules.length !== 1 + || !scheduleMatchesBuilding(storedSchedules[0]!, storedProject) + ) fail('INNER_KEEP_START_INTEGRITY'); + return Object.freeze({ building: storedProject, receipt: insertedReceipt, idempotent: false }); +} + +/** Exact schedule completion; stale or early authority fails closed. */ +export function runInnerKeepConstructionSchedule( + ctx: WarpkeepReducerContext, + schedule: ScheduleRow, +): void { + const persisted = ctx.db.castleInnerConstructionScheduleV1.scheduleId.find(schedule.scheduleId); + if (persisted === null) return; + const building = ctx.db.castleInnerKeepBuildingV1.buildingKey.find(persisted.buildingKey); + if (building === null) fail('INNER_KEEP_SCHEDULE_INTEGRITY'); + if ( + building.phase !== 'constructing' + || persisted.scheduleId !== schedule.scheduleId + || !scheduleMatchesBuilding(persisted, building) + || !scheduleMatchesBuilding(schedule, building) + ) fail('INNER_KEEP_SCHEDULE_INTEGRITY'); + const castle = ctx.db.castle.castleId.find(building.castleId); + if (castle === null) fail('INNER_KEEP_SCHEDULE_INTEGRITY'); + const builder = builderForCastle(ctx, castle); + if ( + builder.activeBuildingKey !== building.buildingKey + || builder.busyUntilMicros !== building.completesAtMicros + ) fail('INNER_KEEP_SCHEDULE_INTEGRITY'); + completeProject(ctx, castle, builder, building, ctx.timestamp.microsSinceUnixEpoch); +} + +export type MyInnerKeepState = Readonly<{ + castleId: bigint; + componentActive: boolean; + componentReady: boolean; + builderPresent: boolean; + builderBusy: boolean; + activeBuildingKey: string | undefined; + busyUntilMicros: bigint | undefined; + builderRevision: bigint; + storedFood: bigint; + storedWood: bigint; + storedStone: bigint; + storedGold: bigint; + projectedFood: bigint; + projectedWood: bigint; + projectedStone: bigint; + projectedGold: bigint; + resourceRevision: bigint; + observedAtMicros: bigint; + policyVersion: string; + layoutDigest: string; + assetCatalogDigest: string; +}>; + +export function projectMyInnerKeepState( + ctx: WarpkeepReducerContext, + fid: bigint, + castle: CastleRow, +): MyInnerKeepState { + const catalog = staticCatalogState(ctx); + const componentActive = catalog.exact && catalog.layout?.active === true; + const resource = assertGenesisResourceForFid(ctx, fid).account; + const builder = ctx.db.castleInnerBuilderV1.castleId.find(castle.castleId); + if (builder !== null && !innerKeepBuilderRowMatchesCastle(builder, castle)) { + fail('INNER_KEEP_BUILDER_INTEGRITY'); + } + const builderRequired = catalog.exact + && catalog.layout !== undefined + && innerKeepLifecycleRequiresBuilders(innerKeepActivationLifecycle(catalog.layout)); + if (builderRequired && builder === null) fail('INNER_KEEP_BUILDER_INTEGRITY'); + const componentReady = componentActive && builder !== null && workerSystemIsReady(ctx); + const projected = componentReady + ? projectMyWorkerState(ctx, fid, ctx.timestamp.microsSinceUnixEpoch).balances + : resource; + return Object.freeze({ + castleId: castle.castleId, + componentActive, + componentReady, + builderPresent: builder !== null, + builderBusy: builder?.activeBuildingKey !== undefined, + activeBuildingKey: builder?.activeBuildingKey, + busyUntilMicros: builder?.busyUntilMicros, + builderRevision: builder?.revision ?? 0n, + storedFood: resource.food, + storedWood: resource.wood, + storedStone: resource.stone, + storedGold: resource.gold, + projectedFood: projected.food, + projectedWood: projected.wood, + projectedStone: projected.stone, + projectedGold: projected.gold, + resourceRevision: resource.revision, + observedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + policyVersion: INNER_KEEP_POLICY_VERSION, + layoutDigest: INNER_KEEP_LAYOUT_DIGEST, + assetCatalogDigest: INNER_KEEP_ASSET_CATALOG_DIGEST, + }); +} + +export function getMyInnerKeepRequestStatus( + ctx: WarpkeepReducerContext, + fid: bigint, + requestKey: string, +): ReceiptRow | undefined { + const key = receiptKey(fid, requestKey); + const receipt = ctx.db.castleInnerBuildReceiptV1.receiptKey.find(key); + if (receipt === null) return undefined; + const castle = ctx.db.castle.ownerFid.find(fid); + if (castle === null || !priorReceiptMatches(receipt, { + fid, + castle, + slotId: receipt.slotId, + buildingKind: receipt.buildingKind, + requestKey, + })) fail('INNER_KEEP_RECEIPT_INTEGRITY'); + return receipt; +} + +export type InnerKeepCatalogPlan = Readonly<{ + missingLayout: number; + missingSlots: number; + missingBuildings: number; + missingLevels: number; + ready: boolean; +}>; + +/** Exact deterministic seed plan. Conflicting rows are never overwritten. */ +export function planInnerKeepCatalogSeed(ctx: WarpkeepReducerContext): InnerKeepCatalogPlan { + if ( + ctx.db.innerKeepLayoutV1.count() > 1n + || ctx.db.innerKeepSlotV1.count() > BigInt(CANONICAL_INNER_KEEP_SLOTS.length) + || ctx.db.innerKeepBuildingCatalogV1.count() > BigInt(CANONICAL_INNER_KEEP_BUILDING_CATALOG.length) + || ctx.db.innerKeepBuildLevelV1.count() > BigInt(CANONICAL_INNER_KEEP_LEVEL_POLICIES.length) + ) fail('INNER_KEEP_CATALOG_CONFLICT'); + const layout = ctx.db.innerKeepLayoutV1.layoutId.find(INNER_KEEP_LAYOUT_ID); + if (layout !== null && !matchesCanonicalInnerKeepLayout(layout)) fail('INNER_KEEP_CATALOG_CONFLICT'); + let missingSlots = 0; + for (const expected of CANONICAL_INNER_KEEP_SLOTS) { + const stored = ctx.db.innerKeepSlotV1.slotId.find(expected.slotId); + if (stored === null) missingSlots += 1; + else if (!matchesCanonicalInnerKeepSlot(stored)) fail('INNER_KEEP_CATALOG_CONFLICT'); + } + let missingBuildings = 0; + for (const expected of CANONICAL_INNER_KEEP_BUILDING_CATALOG) { + const stored = ctx.db.innerKeepBuildingCatalogV1.buildingKind.find(expected.buildingKind); + if (stored === null) missingBuildings += 1; + else if (!matchesCanonicalInnerKeepBuildingPolicy(stored)) fail('INNER_KEEP_CATALOG_CONFLICT'); + } + let missingLevels = 0; + for (const expected of CANONICAL_INNER_KEEP_LEVEL_POLICIES) { + const stored = ctx.db.innerKeepBuildLevelV1.levelKey.find(expected.levelKey); + if (stored === null) missingLevels += 1; + else if (!matchesCanonicalInnerKeepLevelPolicy(stored)) fail('INNER_KEEP_CATALOG_CONFLICT'); + } + const missingLayout = layout === null ? 1 : 0; + if ( + ctx.db.innerKeepLayoutV1.count() !== BigInt(1 - missingLayout) + || ctx.db.innerKeepSlotV1.count() + !== BigInt(CANONICAL_INNER_KEEP_SLOTS.length - missingSlots) + || ctx.db.innerKeepBuildingCatalogV1.count() + !== BigInt(CANONICAL_INNER_KEEP_BUILDING_CATALOG.length - missingBuildings) + || ctx.db.innerKeepBuildLevelV1.count() + !== BigInt(CANONICAL_INNER_KEEP_LEVEL_POLICIES.length - missingLevels) + ) fail('INNER_KEEP_CATALOG_CONFLICT'); + if ( + missingLayout === 1 + && ( + ctx.db.castleInnerKeepBuildingV1.count() !== 0n + || ctx.db.castleInnerBuilderV1.count() !== 0n + || ctx.db.castleInnerBuildReceiptV1.count() !== 0n + || ctx.db.castleInnerConstructionScheduleV1.count() !== 0n + ) + ) fail('INNER_KEEP_CATALOG_CONFLICT'); + return Object.freeze({ + missingLayout, + missingSlots, + missingBuildings, + missingLevels, + ready: missingLayout === 0 && missingSlots === 0 && missingBuildings === 0 && missingLevels === 0, + }); +} + +export function seedInnerKeepCatalog(ctx: WarpkeepReducerContext): InnerKeepCatalogPlan { + const plan = planInnerKeepCatalogSeed(ctx); + if (plan.missingLayout === 1) { + ctx.db.innerKeepLayoutV1.insert({ + ...CANONICAL_INNER_KEEP_LAYOUT, + active: false, + createdAt: ctx.timestamp, + activatedAt: undefined, + }); + } + for (const row of CANONICAL_INNER_KEEP_SLOTS) { + if (ctx.db.innerKeepSlotV1.slotId.find(row.slotId) === null) ctx.db.innerKeepSlotV1.insert({ ...row }); + } + for (const row of CANONICAL_INNER_KEEP_BUILDING_CATALOG) { + if (ctx.db.innerKeepBuildingCatalogV1.buildingKind.find(row.buildingKind) !== null) continue; + const { baseCost: _baseCost, ...stored } = row; + ctx.db.innerKeepBuildingCatalogV1.insert(stored); + } + for (const row of CANONICAL_INNER_KEEP_LEVEL_POLICIES) { + if (ctx.db.innerKeepBuildLevelV1.levelKey.find(row.levelKey) === null) { + ctx.db.innerKeepBuildLevelV1.insert({ ...row }); + } + } + const after = planInnerKeepCatalogSeed(ctx); + if (!after.ready || !staticCatalogState(ctx).exact) fail('INNER_KEEP_CATALOG_INTEGRITY'); + return plan; +} + +export type InnerKeepBuilderPlan = Readonly<{ + expectedCastles: number; + existingBuilders: number; + missingBuilders: number; + ready: boolean; +}>; + +export function planInnerKeepBuilderBackfill(ctx: WarpkeepReducerContext): InnerKeepBuilderPlan { + const layout = assertStaticCatalogExact(ctx); + if (innerKeepActivationLifecycle(layout) !== 'never-activated') { + fail('INNER_KEEP_BACKFILL_AFTER_ACTIVATION'); + } + if ( + ctx.db.castle.count() > BigInt(CASTLE_WORKER_MAX_CASTLES) + || ctx.db.castleInnerKeepBuildingV1.count() !== 0n + || ctx.db.castleInnerBuildReceiptV1.count() !== 0n + || ctx.db.castleInnerConstructionScheduleV1.count() !== 0n + ) fail('INNER_KEEP_BACKFILL_PRECONDITION'); + const castles = [...ctx.db.castle.iter()]; + const castleIds = new Set(castles.map(castle => castle.castleId.toString())); + for (const builder of ctx.db.castleInnerBuilderV1.iter()) { + const castle = ctx.db.castle.castleId.find(builder.castleId); + if ( + castle === null + || !castleIds.has(builder.castleId.toString()) + || !innerKeepBuilderRowIsIdleAndCanonical(builder, castle) + ) fail('INNER_KEEP_BUILDER_CONFLICT'); + } + let missingBuilders = 0; + for (const castle of castles) { + const byCastle = ctx.db.castleInnerBuilderV1.castleId.find(castle.castleId); + const byFid = ctx.db.castleInnerBuilderV1.fid.find(castle.ownerFid); + if (byCastle === null && byFid === null) missingBuilders += 1; + else if ( + byCastle === null + || byFid === null + || byCastle.castleId !== byFid.castleId + || !innerKeepBuilderRowIsIdleAndCanonical(byCastle, castle) + ) fail('INNER_KEEP_BUILDER_CONFLICT'); + } + return Object.freeze({ + expectedCastles: castles.length, + existingBuilders: Number(ctx.db.castleInnerBuilderV1.count()), + missingBuilders, + ready: missingBuilders === 0 && ctx.db.castleInnerBuilderV1.count() === ctx.db.castle.count(), + }); +} + +export function backfillInnerKeepBuilders(ctx: WarpkeepReducerContext): InnerKeepBuilderPlan { + const plan = planInnerKeepBuilderBackfill(ctx); + const castles = [...ctx.db.castle.iter()].sort((left, right) => ( + left.castleId < right.castleId ? -1 : left.castleId > right.castleId ? 1 : 0 + )); + for (const castle of castles) { + if (ctx.db.castleInnerBuilderV1.castleId.find(castle.castleId) !== null) continue; + ctx.db.castleInnerBuilderV1.insert({ + castleId: castle.castleId, + fid: castle.ownerFid, + activeBuildingKey: undefined, + busyUntilMicros: undefined, + revision: 0n, + policyVersion: INNER_KEEP_POLICY_VERSION, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + } + if (!planInnerKeepBuilderBackfill(ctx).ready) fail('INNER_KEEP_BUILDER_INTEGRITY'); + return plan; +} + +export type InnerKeepActivationAttestation = Readonly<{ + capability: string; + policyDigest: string; + layoutDigest: string; + assetCatalogDigest: string; + clientRelease: string; + clientArtifactDigest: string; + moduleArtifactDigest: string; + sourceCommit: string; + expectedCastleCount: number; +}>; + +function validateActivationAttestation(attestation: InnerKeepActivationAttestation): void { + if ( + attestation.capability !== INNER_KEEP_PROTOCOL_CAPABILITY + || attestation.policyDigest !== INNER_KEEP_POLICY_DIGEST + || attestation.layoutDigest !== INNER_KEEP_LAYOUT_DIGEST + || attestation.assetCatalogDigest !== INNER_KEEP_ASSET_CATALOG_DIGEST + || !CLIENT_RELEASE_PATTERN.test(attestation.clientRelease) + || !SHA256_HEX.test(attestation.clientArtifactDigest) + || !SHA256_HEX.test(attestation.moduleArtifactDigest) + || !GIT_COMMIT_HEX.test(attestation.sourceCommit) + || !Number.isSafeInteger(attestation.expectedCastleCount) + || attestation.expectedCastleCount < 0 + || attestation.expectedCastleCount > CASTLE_WORKER_MAX_CASTLES + ) fail('INNER_KEEP_ACTIVATION_ATTESTATION_INVALID'); +} + +export function activateInnerKeep( + ctx: WarpkeepReducerContext, + attestation: InnerKeepActivationAttestation, +): LayoutRow { + validateActivationAttestation(attestation); + const layout = assertStaticCatalogExact(ctx); + const aggregate = inspectInnerKeep(ctx); + if (layout.active) { + if ( + aggregate.builderRows !== aggregate.castleRows + || aggregate.missingBuilders !== 0n + || aggregate.orphanBuilders !== 0n + || aggregate.invalidBuilders !== 0n + || aggregate.invalidBuildings !== 0n + || aggregate.invalidSchedules !== 0n + || aggregate.builderProjectMismatches !== 0n + || aggregate.castleRows !== BigInt(attestation.expectedCastleCount) + || !workerSystemIsReady(ctx) + ) fail('INNER_KEEP_ACTIVATION_NOT_READY'); + return layout; + } + const activationRowsSafe = innerKeepActivationRowsAreSafe({ + previouslyActivated: innerKeepActivationLifecycle(layout) !== 'never-activated', + buildingRows: aggregate.buildingRows, + activeProjects: aggregate.activeProjects, + receiptRows: aggregate.receiptRows, + scheduleRows: aggregate.scheduleRows, + }); + if ( + aggregate.builderRows !== aggregate.castleRows + || aggregate.missingBuilders !== 0n + || aggregate.orphanBuilders !== 0n + || aggregate.invalidBuilders !== 0n + || aggregate.invalidBuildings !== 0n + || aggregate.invalidSchedules !== 0n + || aggregate.builderProjectMismatches !== 0n + || !activationRowsSafe + || aggregate.castleRows !== BigInt(attestation.expectedCastleCount) + || !workerSystemIsReady(ctx) + ) fail('INNER_KEEP_ACTIVATION_NOT_READY'); + return ctx.db.innerKeepLayoutV1.layoutId.update({ + ...layout, + active: true, + // Preserve the first activation as durable lifecycle history. A later + // deactivate/reactivate cycle must never make this look newly initialized. + activatedAt: layout.activatedAt ?? ctx.timestamp, + }); +} + +export function deactivateInnerKeep( + ctx: WarpkeepReducerContext, + capability: string, +): LayoutRow { + if (capability !== INNER_KEEP_PROTOCOL_CAPABILITY) fail('INNER_KEEP_DEACTIVATION_ATTESTATION_INVALID'); + const layout = assertStaticCatalogExact(ctx); + if (!layout.active) return layout; + return ctx.db.innerKeepLayoutV1.layoutId.update({ ...layout, active: false }); +} + +export type InnerKeepAggregate = Readonly<{ + layoutRows: bigint; + slotRows: bigint; + buildingCatalogRows: bigint; + levelPolicyRows: bigint; + castleRows: bigint; + builderRows: bigint; + buildingRows: bigint; + activeProjects: bigint; + receiptRows: bigint; + scheduleRows: bigint; + missingBuilders: bigint; + orphanBuilders: bigint; + invalidBuilders: bigint; + invalidBuildings: bigint; + invalidSchedules: bigint; + builderProjectMismatches: bigint; + staticCatalogExact: boolean; + workerSystemReady: boolean; + readyForCatalogSeed: boolean; + readyForBuilderBackfill: boolean; + readyForActivation: boolean; + active: boolean; + policyVersion: string; + policyDigest: string; + layoutPolicyVersion: string; + layoutDigest: string; + assetCatalogDigest: string; +}>; + +/** Counts-only aggregate; no FID, balance, receipt key, or private row payload. */ +export function inspectInnerKeep(ctx: WarpkeepReducerContext): InnerKeepAggregate { + const catalog = staticCatalogState(ctx); + let missingBuilders = 0n; + for (const castle of ctx.db.castle.iter()) { + if (ctx.db.castleInnerBuilderV1.castleId.find(castle.castleId) === null) missingBuilders += 1n; + } + let orphanBuilders = 0n; + let invalidBuilders = 0n; + let builderProjectMismatches = 0n; + for (const builder of ctx.db.castleInnerBuilderV1.iter()) { + const castle = ctx.db.castle.castleId.find(builder.castleId); + if (castle === null || castle.ownerFid !== builder.fid) { + orphanBuilders += 1n; + continue; + } + if (!innerKeepBuilderRowMatchesCastle(builder, castle)) invalidBuilders += 1n; + const project = builder.activeBuildingKey === undefined + ? undefined + : ctx.db.castleInnerKeepBuildingV1.buildingKey.find(builder.activeBuildingKey) ?? undefined; + if ( + (builder.activeBuildingKey === undefined && builder.busyUntilMicros !== undefined) + || (builder.activeBuildingKey !== undefined && ( + project === undefined + || project.castleId !== builder.castleId + || project.phase !== 'constructing' + || project.completesAtMicros !== builder.busyUntilMicros + )) + ) builderProjectMismatches += 1n; + } + let invalidBuildings = 0n; + let activeProjects = 0n; + for (const building of ctx.db.castleInnerKeepBuildingV1.iter()) { + if (!buildingRowIsConsistent(ctx, building)) invalidBuildings += 1n; + const builder = ctx.db.castleInnerBuilderV1.castleId.find(building.castleId); + const schedules = schedulesForBuilding(ctx, building.buildingKey); + if (building.phase === 'constructing') { + activeProjects += 1n; + const constructingForCastle = buildingRowsForCastle(ctx, building.castleId) + .filter(row => row.phase === 'constructing'); + if ( + builder === null + || builder.activeBuildingKey !== building.buildingKey + || builder.busyUntilMicros !== building.completesAtMicros + || constructingForCastle.length !== 1 + ) builderProjectMismatches += 1n; + } else if ( + building.phase === 'complete' + && (builder?.activeBuildingKey === building.buildingKey || schedules.length !== 0) + ) builderProjectMismatches += 1n; + } + let invalidSchedules = 0n; + for (const schedule of ctx.db.castleInnerConstructionScheduleV1.iter()) { + const building = ctx.db.castleInnerKeepBuildingV1.buildingKey.find(schedule.buildingKey); + if ( + building === null + || building.phase !== 'constructing' + || !scheduleMatchesBuilding(schedule, building) + || schedulesForBuilding(ctx, schedule.buildingKey).length !== 1 + ) invalidSchedules += 1n; + } + for (const building of ctx.db.castleInnerKeepBuildingV1.iter()) { + if ( + building.phase === 'constructing' + && schedulesForBuilding(ctx, building.buildingKey).length === 0 + ) invalidSchedules += 1n; + } + const zeroGameplay = ctx.db.castleInnerKeepBuildingV1.count() === 0n + && ctx.db.castleInnerBuildReceiptV1.count() === 0n + && ctx.db.castleInnerConstructionScheduleV1.count() === 0n; + const allBuildersIdle = [...ctx.db.castleInnerBuilderV1.iter()].every(builder => { + const castle = ctx.db.castle.castleId.find(builder.castleId); + return castle !== null && innerKeepBuilderRowIsIdleAndCanonical(builder, castle); + }); + const buildersReady = missingBuilders === 0n + && orphanBuilders === 0n + && invalidBuilders === 0n + && ctx.db.castleInnerBuilderV1.count() === ctx.db.castle.count(); + const workerReady = workerSystemIsReady(ctx); + const activationRowsSafe = innerKeepActivationRowsAreSafe({ + previouslyActivated: catalog.layout !== undefined + && innerKeepActivationLifecycle(catalog.layout) !== 'never-activated', + buildingRows: ctx.db.castleInnerKeepBuildingV1.count(), + activeProjects, + receiptRows: ctx.db.castleInnerBuildReceiptV1.count(), + scheduleRows: ctx.db.castleInnerConstructionScheduleV1.count(), + }); + return Object.freeze({ + layoutRows: ctx.db.innerKeepLayoutV1.count(), + slotRows: ctx.db.innerKeepSlotV1.count(), + buildingCatalogRows: ctx.db.innerKeepBuildingCatalogV1.count(), + levelPolicyRows: ctx.db.innerKeepBuildLevelV1.count(), + castleRows: ctx.db.castle.count(), + builderRows: ctx.db.castleInnerBuilderV1.count(), + buildingRows: ctx.db.castleInnerKeepBuildingV1.count(), + activeProjects, + receiptRows: ctx.db.castleInnerBuildReceiptV1.count(), + scheduleRows: ctx.db.castleInnerConstructionScheduleV1.count(), + missingBuilders, + orphanBuilders, + invalidBuilders, + invalidBuildings, + invalidSchedules, + builderProjectMismatches, + staticCatalogExact: catalog.exact, + workerSystemReady: workerReady, + readyForCatalogSeed: ctx.db.innerKeepLayoutV1.count() === 0n + && ctx.db.innerKeepSlotV1.count() === 0n + && ctx.db.innerKeepBuildingCatalogV1.count() === 0n + && ctx.db.innerKeepBuildLevelV1.count() === 0n + && ctx.db.castleInnerBuilderV1.count() === 0n + && zeroGameplay, + readyForBuilderBackfill: catalog.exact + && catalog.layout !== undefined + && innerKeepActivationLifecycle(catalog.layout) === 'never-activated' + && zeroGameplay + && orphanBuilders === 0n + && invalidBuilders === 0n + && allBuildersIdle, + readyForActivation: catalog.exact + && !catalog.layout?.active + && buildersReady + && allBuildersIdle + && activationRowsSafe + && invalidBuildings === 0n + && invalidSchedules === 0n + && builderProjectMismatches === 0n + && workerReady, + active: catalog.exact && catalog.layout?.active === true, + policyVersion: INNER_KEEP_POLICY_VERSION, + policyDigest: INNER_KEEP_POLICY_DIGEST, + layoutPolicyVersion: INNER_KEEP_LAYOUT_POLICY_VERSION, + layoutDigest: INNER_KEEP_LAYOUT_DIGEST, + assetCatalogDigest: INNER_KEEP_ASSET_CATALOG_DIGEST, + }); +} + +export function innerKeepErrorCode(error: unknown): string | undefined { + if (error instanceof InnerKeepAuthorityError) return error.code; + if (error instanceof InnerKeepBuilderAuthorityError) return error.code; + if (error instanceof InnerKeepPolicyError) return error.code; + return undefined; +} + +/** Keep entry synchronization failures useful without exposing private graph shape. */ +export function innerKeepEntryErrorCode(error: unknown): string | undefined { + const code = innerKeepErrorCode(error); + if ( + code === 'INNER_KEEP_UNAVAILABLE' + || code === 'INNER_KEEP_BACKEND_SYNCHRONIZING' + ) return code; + return code === undefined ? undefined : 'INNER_KEEP_STATE_INTEGRITY'; +} diff --git a/spacetimedb/src/innerKeepBuilderAuthority.ts b/spacetimedb/src/innerKeepBuilderAuthority.ts new file mode 100644 index 00000000..738231cb --- /dev/null +++ b/spacetimedb/src/innerKeepBuilderAuthority.ts @@ -0,0 +1,124 @@ +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; + +import { + CANONICAL_INNER_KEEP_LAYOUT, + INNER_KEEP_LAYOUT_ID, + innerKeepActivationLifecycle, + innerKeepLifecycleRequiresBuilders, + matchesCanonicalInnerKeepLayout, +} from './innerKeepLayoutPolicy'; +import { INNER_KEEP_POLICY_VERSION } from './innerKeepPolicy'; +import type warpkeep from './schema'; + +type WarpkeepReducerContext = ReducerCtx>; +type CastleRow = NonNullable< + ReturnType +>; +type BuilderRow = NonNullable< + ReturnType +>; + +export class InnerKeepBuilderAuthorityError extends Error { + constructor(readonly code: string) { + super(code); + this.name = 'InnerKeepBuilderAuthorityError'; + } +} + +function fail(code: string): never { + throw new InnerKeepBuilderAuthorityError(code); +} + +function componentLifecycle( + ctx: WarpkeepReducerContext, +): 'never-activated' | 'active' | 'inactive-after-activation' { + if (ctx.db.innerKeepLayoutV1.count() === 0n) return 'never-activated'; + if (ctx.db.innerKeepLayoutV1.count() !== 1n) fail('INNER_KEEP_LAYOUT_INTEGRITY'); + const row = ctx.db.innerKeepLayoutV1.layoutId.find(INNER_KEEP_LAYOUT_ID); + const lifecycle = row === null ? 'invalid' : innerKeepActivationLifecycle(row); + if ( + row === null + || !matchesCanonicalInnerKeepLayout(row) + || lifecycle === 'invalid' + || ( + row.activatedAt !== undefined + && row.activatedAt.microsSinceUnixEpoch < row.createdAt.microsSinceUnixEpoch + ) + ) fail('INNER_KEEP_LAYOUT_INTEGRITY'); + return lifecycle; +} + +export function innerKeepBuilderRowIsIdleAndCanonical( + row: BuilderRow, + castle: CastleRow, +): boolean { + return innerKeepBuilderRowMatchesCastle(row, castle) + && row.activeBuildingKey === undefined + && row.busyUntilMicros === undefined; +} + +export function innerKeepBuilderRowMatchesCastle( + row: BuilderRow, + castle: CastleRow, +): boolean { + return row.castleId === castle.castleId + && row.fid === castle.ownerFid + && (row.activeBuildingKey === undefined) === (row.busyUntilMicros === undefined) + && row.revision >= 0n + && row.policyVersion === INNER_KEEP_POLICY_VERSION + && row.createdAt.microsSinceUnixEpoch >= 0n + && row.updatedAt.microsSinceUnixEpoch >= row.createdAt.microsSinceUnixEpoch; +} + +/** + * Preserve a backfilled Builder for an existing founder and require it once + * the component has ever activated. Existing founder calls never silently + * repair rows, including while construction is temporarily deactivated. + */ +export function assertInnerKeepBuilderForExistingFounder( + ctx: WarpkeepReducerContext, + castle: CastleRow, +): void { + const byCastle = ctx.db.castleInnerBuilderV1.castleId.find(castle.castleId); + const byFid = ctx.db.castleInnerBuilderV1.fid.find(castle.ownerFid); + if (byCastle === null && byFid === null) { + if (innerKeepLifecycleRequiresBuilders(componentLifecycle(ctx))) { + fail('INNER_KEEP_BUILDER_MISSING'); + } + return; + } + if ( + byCastle === null + || byFid === null + || byCastle.castleId !== byFid.castleId + || !innerKeepBuilderRowMatchesCastle(byCastle, castle) + ) fail('INNER_KEEP_BUILDER_INTEGRITY'); +} + +/** New founders receive one idle Builder after the component has ever activated. */ +export function insertInnerKeepBuilderForNewFounderIfEverActivated( + ctx: WarpkeepReducerContext, + castle: CastleRow, +): void { + if (!innerKeepLifecycleRequiresBuilders(componentLifecycle(ctx))) return; + if ( + ctx.db.castleInnerBuilderV1.castleId.find(castle.castleId) !== null + || ctx.db.castleInnerBuilderV1.fid.find(castle.ownerFid) !== null + ) fail('INNER_KEEP_BUILDER_CONFLICT'); + ctx.db.castleInnerBuilderV1.insert({ + castleId: castle.castleId, + fid: castle.ownerFid, + activeBuildingKey: undefined, + busyUntilMicros: undefined, + revision: 0n, + policyVersion: INNER_KEEP_POLICY_VERSION, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); +} + +// Keep the import live and fail visibly if the root policy is accidentally +// initialized with a different canonical ID. +if (CANONICAL_INNER_KEEP_LAYOUT.layoutId !== INNER_KEEP_LAYOUT_ID) { + throw new Error('INNER_KEEP_LAYOUT_ID_DRIFT'); +} diff --git a/spacetimedb/src/innerKeepLayoutPolicy.ts b/spacetimedb/src/innerKeepLayoutPolicy.ts new file mode 100644 index 00000000..3d60da12 --- /dev/null +++ b/spacetimedb/src/innerKeepLayoutPolicy.ts @@ -0,0 +1,192 @@ +import type { InnerKeepFootprintClass } from './innerKeepPolicy'; + +// Browser-pinned digest of the reviewable client presentation-layout manifest. +// Kept as a scalar here so dense decorative data never enters module authority. +export const INNER_KEEP_PRESENTATION_LAYOUT_DIGEST = + '96c20cac900e02234e53b36a15069d4e7c12057e5f1737c183f6c31cdb38b8b6'; + +export const INNER_KEEP_LAYOUT_ID = 'genesis-001-inner-keep-v1'; +export const INNER_KEEP_LAYOUT_VERSION = 1; +export const INNER_KEEP_SLOT_COUNT = 12; +export const INNER_KEEP_MEDIUM_SLOT_COUNT = 8; +export const INNER_KEEP_LARGE_SLOT_COUNT = 4; +export const INNER_KEEP_LAYOUT_POLICY_VERSION = 'genesis-001-inner-keep-layout-v1'; + +export type InnerKeepSlotPolicy = Readonly<{ + slotId: string; + layoutId: string; + footprintClass: InnerKeepFootprintClass; + localXMicrounits: bigint; + localZMicrounits: bigint; + rotationMilliDegrees: number; + sortOrder: number; + active: boolean; +}>; + +/** + * Fixed construction pads around the central keep and civic spine. Medium + * pads are active in V1; the four large pads are visible future reservations. + */ +export const CANONICAL_INNER_KEEP_SLOTS: readonly InnerKeepSlotPolicy[] = Object.freeze([ + Object.freeze({ slotId: 'inner-keep-slot-m01', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'medium', localXMicrounits: -7_000_000n, localZMicrounits: -3_200_000n, rotationMilliDegrees: 25_000, sortOrder: 1, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-m02', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'medium', localXMicrounits: -3_800_000n, localZMicrounits: -4_800_000n, rotationMilliDegrees: 15_000, sortOrder: 2, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-m03', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'medium', localXMicrounits: 3_800_000n, localZMicrounits: -4_800_000n, rotationMilliDegrees: 345_000, sortOrder: 3, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-m04', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'medium', localXMicrounits: 7_000_000n, localZMicrounits: -3_200_000n, rotationMilliDegrees: 335_000, sortOrder: 4, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-m05', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'medium', localXMicrounits: -7_200_000n, localZMicrounits: 1_900_000n, rotationMilliDegrees: 155_000, sortOrder: 5, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-m06', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'medium', localXMicrounits: -4_300_000n, localZMicrounits: 4_900_000n, rotationMilliDegrees: 170_000, sortOrder: 6, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-m07', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'medium', localXMicrounits: 4_300_000n, localZMicrounits: 4_900_000n, rotationMilliDegrees: 190_000, sortOrder: 7, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-m08', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'medium', localXMicrounits: 7_200_000n, localZMicrounits: 1_900_000n, rotationMilliDegrees: 205_000, sortOrder: 8, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-l01', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'large', localXMicrounits: -10_200_000n, localZMicrounits: -6_900_000n, rotationMilliDegrees: 35_000, sortOrder: 9, active: false }), + Object.freeze({ slotId: 'inner-keep-slot-l02', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'large', localXMicrounits: 10_200_000n, localZMicrounits: -6_900_000n, rotationMilliDegrees: 325_000, sortOrder: 10, active: false }), + Object.freeze({ slotId: 'inner-keep-slot-l03', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'large', localXMicrounits: -10_000_000n, localZMicrounits: 7_200_000n, rotationMilliDegrees: 145_000, sortOrder: 11, active: false }), + Object.freeze({ slotId: 'inner-keep-slot-l04', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'large', localXMicrounits: 10_000_000n, localZMicrounits: 7_200_000n, rotationMilliDegrees: 215_000, sortOrder: 12, active: false }), +]); + +export function canonicalInnerKeepSlot(slotId: string): InnerKeepSlotPolicy | undefined { + return CANONICAL_INNER_KEEP_SLOTS.find(slot => slot.slotId === slotId); +} + +export type InnerKeepSlotPolicyRowLike = Readonly<{ + slotId: string; + layoutId: string; + footprintClass: string; + localXMicrounits: bigint; + localZMicrounits: bigint; + rotationMilliDegrees: number; + sortOrder: number; + active: boolean; +}>; + +export function matchesCanonicalInnerKeepSlot(row: InnerKeepSlotPolicyRowLike): boolean { + const expected = canonicalInnerKeepSlot(row.slotId); + return expected !== undefined + && row.layoutId === expected.layoutId + && row.footprintClass === expected.footprintClass + && row.localXMicrounits === expected.localXMicrounits + && row.localZMicrounits === expected.localZMicrounits + && row.rotationMilliDegrees === expected.rotationMilliDegrees + && row.sortOrder === expected.sortOrder + && row.active === expected.active; +} + +/** Stable source for the SHA-256 layout digest pinned below. */ +export function canonicalInnerKeepLayoutDigestInput(): string { + return [ + INNER_KEEP_LAYOUT_POLICY_VERSION, + INNER_KEEP_LAYOUT_ID, + INNER_KEEP_LAYOUT_VERSION, + INNER_KEEP_SLOT_COUNT, + INNER_KEEP_MEDIUM_SLOT_COUNT, + INNER_KEEP_LARGE_SLOT_COUNT, + INNER_KEEP_PRESENTATION_LAYOUT_DIGEST, + ...CANONICAL_INNER_KEEP_SLOTS.flatMap(slot => [ + slot.slotId, + slot.layoutId, + slot.footprintClass, + slot.localXMicrounits, + slot.localZMicrounits, + slot.rotationMilliDegrees, + slot.sortOrder, + slot.active, + ]), + ].join('|'); +} + +// SHA-256 of canonicalInnerKeepLayoutDigestInput(). +export const INNER_KEEP_LAYOUT_DIGEST = + 'dc314255b0046f5b43be836b52ab4b7af94a2d25992031f75aee89b1a81490c7'; + +/** + * Logical asset-family digest. Runtime copy is separately authorization-gated; + * activation must attest the exact runtime registry before this row can flip. + */ +export const INNER_KEEP_ASSET_CATALOG_DIGEST = + '6763aeb1755d800b817a0d5174182474d3836a928c59beb4b4fdf65f5d1f6ec3'; + +export type InnerKeepLayoutRoot = Readonly<{ + layoutId: string; + layoutVersion: number; + policyVersion: string; + slotCount: number; + mediumSlotCount: number; + largeSlotCount: number; + assetCatalogDigest: string; + layoutDigest: string; +}>; + +export const CANONICAL_INNER_KEEP_LAYOUT: InnerKeepLayoutRoot = Object.freeze({ + layoutId: INNER_KEEP_LAYOUT_ID, + layoutVersion: INNER_KEEP_LAYOUT_VERSION, + policyVersion: INNER_KEEP_LAYOUT_POLICY_VERSION, + slotCount: INNER_KEEP_SLOT_COUNT, + mediumSlotCount: INNER_KEEP_MEDIUM_SLOT_COUNT, + largeSlotCount: INNER_KEEP_LARGE_SLOT_COUNT, + assetCatalogDigest: INNER_KEEP_ASSET_CATALOG_DIGEST, + layoutDigest: INNER_KEEP_LAYOUT_DIGEST, +}); + +export type InnerKeepLayoutRootLike = Readonly<{ + layoutId: string; + layoutVersion: number; + policyVersion: string; + slotCount: number; + mediumSlotCount: number; + largeSlotCount: number; + assetCatalogDigest: string; + layoutDigest: string; +}>; + +export type InnerKeepActivationLifecycle = + | 'never-activated' + | 'active' + | 'inactive-after-activation' + | 'invalid'; + +/** + * `activatedAt` is durable lifecycle history. Deactivation only closes new + * construction; it never makes the component behave as if it was never live. + */ +export function innerKeepActivationLifecycle(row: Readonly<{ + active: boolean; + activatedAt: unknown | undefined; +}>): InnerKeepActivationLifecycle { + if (row.active) return row.activatedAt === undefined ? 'invalid' : 'active'; + return row.activatedAt === undefined ? 'never-activated' : 'inactive-after-activation'; +} + +export function innerKeepLifecycleRequiresBuilders( + lifecycle: InnerKeepActivationLifecycle, +): boolean { + return lifecycle === 'active' || lifecycle === 'inactive-after-activation'; +} + +export function matchesCanonicalInnerKeepLayout(row: InnerKeepLayoutRootLike): boolean { + const expected = CANONICAL_INNER_KEEP_LAYOUT; + return row.layoutId === expected.layoutId + && row.layoutVersion === expected.layoutVersion + && row.policyVersion === expected.policyVersion + && row.slotCount === expected.slotCount + && row.mediumSlotCount === expected.mediumSlotCount + && row.largeSlotCount === expected.largeSlotCount + && row.assetCatalogDigest === expected.assetCatalogDigest + && row.layoutDigest === expected.layoutDigest; +} + +if ( + CANONICAL_INNER_KEEP_SLOTS.length !== INNER_KEEP_SLOT_COUNT + || new Set(CANONICAL_INNER_KEEP_SLOTS.map(slot => slot.slotId)).size !== INNER_KEEP_SLOT_COUNT + || new Set(CANONICAL_INNER_KEEP_SLOTS.map(slot => slot.sortOrder)).size !== INNER_KEEP_SLOT_COUNT + || CANONICAL_INNER_KEEP_SLOTS.filter(slot => slot.footprintClass === 'medium').length + !== INNER_KEEP_MEDIUM_SLOT_COUNT + || CANONICAL_INNER_KEEP_SLOTS.filter(slot => slot.footprintClass === 'large').length + !== INNER_KEEP_LARGE_SLOT_COUNT + || CANONICAL_INNER_KEEP_SLOTS.some(slot => ( + slot.layoutId !== INNER_KEEP_LAYOUT_ID + || slot.rotationMilliDegrees < 0 + || slot.rotationMilliDegrees >= 360_000 + || (slot.footprintClass === 'large' && slot.active) + || (slot.footprintClass === 'medium' && !slot.active) + )) +) { + throw new Error('INNER_KEEP_LAYOUT_POLICY_DRIFT'); +} diff --git a/spacetimedb/src/innerKeepPolicy.ts b/spacetimedb/src/innerKeepPolicy.ts new file mode 100644 index 00000000..fff45e4f --- /dev/null +++ b/spacetimedb/src/innerKeepPolicy.ts @@ -0,0 +1,454 @@ +/** + * Canonical, additive Inner Keep construction policy. + * + * This file is the sole hand-authored source for building recipes, target-level + * multipliers, durations, footprint requirements, and completed-building + * discounts. Seeded public rows are projections of these constants; gameplay + * authority revalidates those projections before accepting a project. + */ + +export const INNER_KEEP_POLICY_VERSION = 'genesis-001-inner-keep-construction-v1'; +export const INNER_KEEP_PROTOCOL_CAPABILITY = 'inner-keep-construction-v1'; +export const INNER_KEEP_MAXIMUM_LEVEL = 5; +export const INNER_KEEP_DISCOUNT_BPS_PER_LEVEL = 500; +export const INNER_KEEP_DISCOUNT_CAP_BPS = 2_500; +export const INNER_KEEP_BASIS_POINTS = 10_000; +export const INNER_KEEP_RESOURCE_BALANCE_CAP = 1_000_000n; +export const INNER_KEEP_U64_MAX = (1n << 64n) - 1n; + +export type InnerKeepResourceKind = 'food' | 'wood' | 'stone' | 'gold'; +export type InnerKeepBuildingKind = + | 'city-mill' + | 'lumber-camp' + | 'city-stoneworks' + | 'city-goldworks'; +export type InnerKeepFootprintClass = 'medium' | 'large'; + +export type InnerKeepResourceCost = Readonly<{ + food: bigint; + wood: bigint; + stone: bigint; + gold: bigint; +}>; + +export type InnerKeepBuildingPolicy = Readonly<{ + buildingKind: InnerKeepBuildingKind; + publicLabel: string; + category: 'economy'; + footprintClass: 'medium'; + maximumLevel: number; + uniquePerCastle: true; + matchingDiscountResource: InnerKeepResourceKind; + discountBasisPointsPerLevel: number; + discountCapBasisPoints: number; + runtimeAssetId: string; + previewAssetId: string; + active: true; + policyVersion: string; + baseCost: InnerKeepResourceCost; +}>; + +export type InnerKeepLevelPolicy = Readonly<{ + levelKey: string; + buildingKind: InnerKeepBuildingKind; + targetLevel: number; + baseFoodCost: bigint; + baseWoodCost: bigint; + baseStoneCost: bigint; + baseGoldCost: bigint; + levelMultiplierBasisPoints: number; + durationMicros: bigint; + policyVersion: string; +}>; + +export class InnerKeepPolicyError extends Error { + constructor(readonly code: string) { + super(code); + this.name = 'InnerKeepPolicyError'; + } +} + +function fail(code: string): never { + throw new InnerKeepPolicyError(code); +} + +function freezeCost(cost: InnerKeepResourceCost): InnerKeepResourceCost { + return Object.freeze(cost); +} + +const BUILDING_POLICIES: Readonly> = + Object.freeze({ + 'city-mill': Object.freeze({ + buildingKind: 'city-mill', + publicLabel: 'City Mill', + category: 'economy', + footprintClass: 'medium', + maximumLevel: INNER_KEEP_MAXIMUM_LEVEL, + uniquePerCastle: true, + matchingDiscountResource: 'food', + discountBasisPointsPerLevel: INNER_KEEP_DISCOUNT_BPS_PER_LEVEL, + discountCapBasisPoints: INNER_KEEP_DISCOUNT_CAP_BPS, + runtimeAssetId: 'warpkeep.city-buildings.city-mill', + previewAssetId: 'warpkeep.inner-keep.preview.city-mill', + active: true, + policyVersion: INNER_KEEP_POLICY_VERSION, + baseCost: freezeCost({ food: 300n, wood: 900n, stone: 600n, gold: 0n }), + }), + 'lumber-camp': Object.freeze({ + buildingKind: 'lumber-camp', + publicLabel: 'Lumber Camp', + category: 'economy', + footprintClass: 'medium', + maximumLevel: INNER_KEEP_MAXIMUM_LEVEL, + uniquePerCastle: true, + matchingDiscountResource: 'wood', + discountBasisPointsPerLevel: INNER_KEEP_DISCOUNT_BPS_PER_LEVEL, + discountCapBasisPoints: INNER_KEEP_DISCOUNT_CAP_BPS, + runtimeAssetId: 'warpkeep.city-buildings.lumber-camp', + previewAssetId: 'warpkeep.inner-keep.preview.lumber-camp', + active: true, + policyVersion: INNER_KEEP_POLICY_VERSION, + baseCost: freezeCost({ food: 500n, wood: 700n, stone: 650n, gold: 0n }), + }), + 'city-stoneworks': Object.freeze({ + buildingKind: 'city-stoneworks', + publicLabel: 'City Stoneworks', + category: 'economy', + footprintClass: 'medium', + maximumLevel: INNER_KEEP_MAXIMUM_LEVEL, + uniquePerCastle: true, + matchingDiscountResource: 'stone', + discountBasisPointsPerLevel: INNER_KEEP_DISCOUNT_BPS_PER_LEVEL, + discountCapBasisPoints: INNER_KEEP_DISCOUNT_CAP_BPS, + runtimeAssetId: 'warpkeep.city-buildings.city-stoneworks', + previewAssetId: 'warpkeep.inner-keep.preview.city-stoneworks', + active: true, + policyVersion: INNER_KEEP_POLICY_VERSION, + baseCost: freezeCost({ food: 500n, wood: 900n, stone: 450n, gold: 0n }), + }), + 'city-goldworks': Object.freeze({ + buildingKind: 'city-goldworks', + publicLabel: 'City Goldworks', + category: 'economy', + footprintClass: 'medium', + maximumLevel: INNER_KEEP_MAXIMUM_LEVEL, + uniquePerCastle: true, + matchingDiscountResource: 'gold', + discountBasisPointsPerLevel: INNER_KEEP_DISCOUNT_BPS_PER_LEVEL, + discountCapBasisPoints: INNER_KEEP_DISCOUNT_CAP_BPS, + runtimeAssetId: 'warpkeep.city-buildings.city-goldworks', + previewAssetId: 'warpkeep.inner-keep.preview.city-goldworks', + active: true, + policyVersion: INNER_KEEP_POLICY_VERSION, + baseCost: freezeCost({ food: 700n, wood: 1_200n, stone: 1_000n, gold: 500n }), + }), + }); + +const BUILDING_KINDS = Object.freeze([ + 'city-mill', + 'lumber-camp', + 'city-stoneworks', + 'city-goldworks', +] as const satisfies readonly InnerKeepBuildingKind[]); + +const TARGET_LEVELS = Object.freeze([ + Object.freeze({ targetLevel: 1, multiplierBasisPoints: 10_000, durationMicros: 86_400_000_000n }), + Object.freeze({ targetLevel: 2, multiplierBasisPoints: 22_400, durationMicros: 172_800_000_000n }), + Object.freeze({ targetLevel: 3, multiplierBasisPoints: 37_632, durationMicros: 280_800_000_000n }), + Object.freeze({ targetLevel: 4, multiplierBasisPoints: 56_197, durationMicros: 403_200_000_000n }), + Object.freeze({ targetLevel: 5, multiplierBasisPoints: 78_676, durationMicros: 543_600_000_000n }), +]); + +export function canonicalInnerKeepBuildingKinds(): readonly InnerKeepBuildingKind[] { + return BUILDING_KINDS; +} + +export function canonicalInnerKeepBuildingPolicy(kind: string): InnerKeepBuildingPolicy { + if (!BUILDING_KINDS.includes(kind as InnerKeepBuildingKind)) { + fail('INNER_KEEP_BUILDING_KIND_INVALID'); + } + return BUILDING_POLICIES[kind as InnerKeepBuildingKind]; +} + +export const CANONICAL_INNER_KEEP_BUILDING_CATALOG = Object.freeze( + BUILDING_KINDS.map(kind => BUILDING_POLICIES[kind]), +); + +export const CANONICAL_INNER_KEEP_LEVEL_POLICIES = Object.freeze( + BUILDING_KINDS.flatMap(buildingKind => TARGET_LEVELS.map(level => { + const baseCost = BUILDING_POLICIES[buildingKind].baseCost; + return Object.freeze({ + levelKey: `${buildingKind}:${level.targetLevel}`, + buildingKind, + targetLevel: level.targetLevel, + baseFoodCost: baseCost.food, + baseWoodCost: baseCost.wood, + baseStoneCost: baseCost.stone, + baseGoldCost: baseCost.gold, + levelMultiplierBasisPoints: level.multiplierBasisPoints, + durationMicros: level.durationMicros, + policyVersion: INNER_KEEP_POLICY_VERSION, + }); + })), +); + +export function canonicalInnerKeepLevelPolicy( + buildingKind: string, + targetLevel: number, +): InnerKeepLevelPolicy { + canonicalInnerKeepBuildingPolicy(buildingKind); + if (!Number.isSafeInteger(targetLevel) || targetLevel < 1 || targetLevel > INNER_KEEP_MAXIMUM_LEVEL) { + fail('INNER_KEEP_TARGET_LEVEL_INVALID'); + } + const row = CANONICAL_INNER_KEEP_LEVEL_POLICIES.find(candidate => ( + candidate.buildingKind === buildingKind && candidate.targetLevel === targetLevel + )); + if (row === undefined) fail('INNER_KEEP_LEVEL_POLICY_MISSING'); + return row; +} + +function assertU64(value: bigint, code: string): void { + if (value < 0n || value > INNER_KEEP_U64_MAX) fail(code); +} + +function checkedProduct(left: bigint, right: bigint, code: string): bigint { + assertU64(left, code); + assertU64(right, code); + if (left !== 0n && right > INNER_KEEP_U64_MAX / left) fail(code); + return left * right; +} + +function checkedCeilDiv(numerator: bigint, denominator: bigint, code: string): bigint { + assertU64(numerator, code); + if (denominator <= 0n || denominator > INNER_KEEP_U64_MAX) fail(code); + if (numerator === 0n) return 0n; + if (numerator > INNER_KEEP_U64_MAX - (denominator - 1n)) fail(code); + return (numerator + denominator - 1n) / denominator; +} + +/** Round an exact non-negative rational numerator/divisor upward to whole tens. */ +function roundedRationalToTen(numerator: bigint, divisor: bigint): bigint { + const tens = checkedCeilDiv( + numerator, + checkedProduct(divisor, 10n, 'INNER_KEEP_COST_OVERFLOW'), + 'INNER_KEEP_COST_OVERFLOW', + ); + return checkedProduct(tens, 10n, 'INNER_KEEP_COST_OVERFLOW'); +} + +export function innerKeepDiscountBasisPoints(completedLevel: number): number { + if (!Number.isSafeInteger(completedLevel) || completedLevel < 0 || completedLevel > INNER_KEEP_MAXIMUM_LEVEL) { + fail('INNER_KEEP_COMPLETED_LEVEL_INVALID'); + } + return Math.min( + INNER_KEEP_DISCOUNT_CAP_BPS, + completedLevel * INNER_KEEP_DISCOUNT_BPS_PER_LEVEL, + ); +} + +export type InnerKeepActivationRowSnapshot = Readonly<{ + previouslyActivated: boolean; + buildingRows: bigint; + activeProjects: bigint; + receiptRows: bigint; + scheduleRows: bigint; +}>; + +/** + * First activation must begin from zero gameplay. A forward-safe reactivation + * may preserve completed buildings and immutable receipts, but never resumes + * while a project or construction schedule is active. + */ +export function innerKeepActivationRowsAreSafe(snapshot: InnerKeepActivationRowSnapshot): boolean { + if ( + snapshot.buildingRows < 0n + || snapshot.activeProjects < 0n + || snapshot.receiptRows < 0n + || snapshot.scheduleRows < 0n + || snapshot.activeProjects > snapshot.buildingRows + || snapshot.activeProjects !== 0n + || snapshot.scheduleRows !== 0n + ) return false; + return snapshot.previouslyActivated + || (snapshot.buildingRows === 0n && snapshot.receiptRows === 0n); +} + +export type InnerKeepCompletedLevels = Readonly>>; + +export type InnerKeepCostPlan = Readonly<{ + buildingKind: InnerKeepBuildingKind; + targetLevel: number; + durationMicros: bigint; + rawCost: InnerKeepResourceCost; + effectiveCost: InnerKeepResourceCost; + discountBasisPoints: Readonly>; + policyVersion: string; +}>; + +function completedLevelFor( + completedLevels: InnerKeepCompletedLevels, + buildingKind: InnerKeepBuildingKind, +): number { + const value = completedLevels[buildingKind] ?? 0; + if (!Number.isSafeInteger(value) || value < 0 || value > INNER_KEEP_MAXIMUM_LEVEL) { + fail('INNER_KEEP_COMPLETED_LEVEL_INVALID'); + } + return value; +} + +/** + * Compute the exact authoritative cost for one target level. + * Only completed levels are accepted; callers never pass a constructing target. + */ +export function canonicalInnerKeepCost( + buildingKind: string, + targetLevel: number, + completedLevels: InnerKeepCompletedLevels = Object.freeze({}), +): InnerKeepCostPlan { + const building = canonicalInnerKeepBuildingPolicy(buildingKind); + const level = canonicalInnerKeepLevelPolicy(buildingKind, targetLevel); + const rawCost = {} as Record; + const effectiveCost = {} as Record; + const discountBasisPoints = {} as Record; + const discountBuildingByResource: Readonly> = { + food: 'city-mill', + wood: 'lumber-camp', + stone: 'city-stoneworks', + gold: 'city-goldworks', + }; + for (const resource of ['food', 'wood', 'stone', 'gold'] as const) { + const base = building.baseCost[resource]; + const raw = roundedRationalToTen( + checkedProduct(base, BigInt(level.levelMultiplierBasisPoints), 'INNER_KEEP_COST_OVERFLOW'), + BigInt(INNER_KEEP_BASIS_POINTS), + ); + const discount = innerKeepDiscountBasisPoints( + completedLevelFor(completedLevels, discountBuildingByResource[resource]), + ); + const effective = roundedRationalToTen( + checkedProduct(raw, BigInt(INNER_KEEP_BASIS_POINTS - discount), 'INNER_KEEP_COST_OVERFLOW'), + BigInt(INNER_KEEP_BASIS_POINTS), + ); + if (effective > INNER_KEEP_RESOURCE_BALANCE_CAP) fail('INNER_KEEP_COST_EXCEEDS_ACCOUNT_CAP'); + rawCost[resource] = raw; + effectiveCost[resource] = effective; + discountBasisPoints[resource] = discount; + } + return Object.freeze({ + buildingKind: building.buildingKind, + targetLevel, + durationMicros: level.durationMicros, + rawCost: freezeCost(rawCost), + effectiveCost: freezeCost(effectiveCost), + discountBasisPoints: Object.freeze(discountBasisPoints), + policyVersion: INNER_KEEP_POLICY_VERSION, + }); +} + +export type InnerKeepBuildingPolicyRowLike = Readonly<{ + buildingKind: string; + publicLabel: string; + category: string; + footprintClass: string; + maximumLevel: number; + uniquePerCastle: boolean; + matchingDiscountResource: string; + discountBasisPointsPerLevel: number; + discountCapBasisPoints: number; + runtimeAssetId: string; + previewAssetId: string; + active: boolean; + policyVersion: string; +}>; + +export function matchesCanonicalInnerKeepBuildingPolicy( + row: InnerKeepBuildingPolicyRowLike, +): boolean { + try { + const expected = canonicalInnerKeepBuildingPolicy(row.buildingKind); + return row.publicLabel === expected.publicLabel + && row.category === expected.category + && row.footprintClass === expected.footprintClass + && row.maximumLevel === expected.maximumLevel + && row.uniquePerCastle === expected.uniquePerCastle + && row.matchingDiscountResource === expected.matchingDiscountResource + && row.discountBasisPointsPerLevel === expected.discountBasisPointsPerLevel + && row.discountCapBasisPoints === expected.discountCapBasisPoints + && row.runtimeAssetId === expected.runtimeAssetId + && row.previewAssetId === expected.previewAssetId + && row.active === expected.active + && row.policyVersion === expected.policyVersion; + } catch (error) { + if (error instanceof InnerKeepPolicyError) return false; + throw error; + } +} + +export type InnerKeepLevelPolicyRowLike = Readonly<{ + levelKey: string; + buildingKind: string; + targetLevel: number; + baseFoodCost: bigint; + baseWoodCost: bigint; + baseStoneCost: bigint; + baseGoldCost: bigint; + levelMultiplierBasisPoints: number; + durationMicros: bigint; + policyVersion: string; +}>; + +export function matchesCanonicalInnerKeepLevelPolicy(row: InnerKeepLevelPolicyRowLike): boolean { + try { + const expected = canonicalInnerKeepLevelPolicy(row.buildingKind, row.targetLevel); + return row.levelKey === expected.levelKey + && row.baseFoodCost === expected.baseFoodCost + && row.baseWoodCost === expected.baseWoodCost + && row.baseStoneCost === expected.baseStoneCost + && row.baseGoldCost === expected.baseGoldCost + && row.levelMultiplierBasisPoints === expected.levelMultiplierBasisPoints + && row.durationMicros === expected.durationMicros + && row.policyVersion === expected.policyVersion; + } catch (error) { + if (error instanceof InnerKeepPolicyError) return false; + throw error; + } +} + +/** Stable source for the SHA-256 policy digest pinned below. */ +export function canonicalInnerKeepPolicyDigestInput(): string { + return [ + INNER_KEEP_POLICY_VERSION, + INNER_KEEP_MAXIMUM_LEVEL, + INNER_KEEP_DISCOUNT_BPS_PER_LEVEL, + INNER_KEEP_DISCOUNT_CAP_BPS, + INNER_KEEP_RESOURCE_BALANCE_CAP, + ...CANONICAL_INNER_KEEP_BUILDING_CATALOG.flatMap(row => [ + row.buildingKind, + row.publicLabel, + row.category, + row.footprintClass, + row.maximumLevel, + row.uniquePerCastle, + row.matchingDiscountResource, + row.discountBasisPointsPerLevel, + row.discountCapBasisPoints, + row.runtimeAssetId, + row.previewAssetId, + row.active, + row.baseCost.food, + row.baseCost.wood, + row.baseCost.stone, + row.baseCost.gold, + ]), + ...CANONICAL_INNER_KEEP_LEVEL_POLICIES.flatMap(row => [ + row.levelKey, + row.levelMultiplierBasisPoints, + row.durationMicros, + ]), + ].join('|'); +} + +// SHA-256 of canonicalInnerKeepPolicyDigestInput(). This literal keeps the +// deterministic module independent of Node's crypto implementation. +export const INNER_KEEP_POLICY_DIGEST = + 'b3ca0d7ce3a30d3f89e0fe295864dc9c7237fbf5dedc3d8e8c2ed45586d2355e'; diff --git a/spacetimedb/src/reducers/innerKeep.ts b/spacetimedb/src/reducers/innerKeep.ts new file mode 100644 index 00000000..39d78c8a --- /dev/null +++ b/spacetimedb/src/reducers/innerKeep.ts @@ -0,0 +1,415 @@ +import { SenderError, t } from 'spacetimedb/server'; + +import { requireAdmin, requireGameplayPlayerV1 } from '../auth'; +import { + activateInnerKeep, + backfillInnerKeepBuilders, + deactivateInnerKeep, + getMyInnerKeepRequestStatus, + innerKeepEntryErrorCode, + innerKeepErrorCode, + inspectInnerKeep, + planInnerKeepBuilderBackfill, + planInnerKeepCatalogSeed, + projectMyInnerKeepState, + seedInnerKeepCatalog, + startInnerKeepProject, + synchronizeMyInnerKeepEntry, + type InnerKeepActivationAttestation, +} from '../innerKeepAuthority'; +import { + INNER_KEEP_ASSET_CATALOG_DIGEST, + INNER_KEEP_LAYOUT_DIGEST, +} from '../innerKeepLayoutPolicy'; +import { + INNER_KEEP_POLICY_DIGEST, + INNER_KEEP_PROTOCOL_CAPABILITY, +} from '../innerKeepPolicy'; +import warpkeep from '../schema'; + +const myInnerKeepStateV1 = t.object('MyInnerKeepStateV1', { + castleId: t.u64(), + componentActive: t.bool(), + componentReady: t.bool(), + builderPresent: t.bool(), + builderBusy: t.bool(), + activeBuildingKey: t.option(t.string()), + busyUntilMicros: t.option(t.u64()), + builderRevision: t.u64(), + storedFood: t.u64(), + storedWood: t.u64(), + storedStone: t.u64(), + storedGold: t.u64(), + projectedFood: t.u64(), + projectedWood: t.u64(), + projectedStone: t.u64(), + projectedGold: t.u64(), + resourceRevision: t.u64(), + observedAtMicros: t.u64(), + policyVersion: t.string(), + layoutDigest: t.string(), + assetCatalogDigest: t.string(), +}); + +const myInnerKeepRequestStatusV1 = t.object('MyInnerKeepRequestStatusV1', { + found: t.bool(), + castleId: t.option(t.u64()), + buildingKey: t.option(t.string()), + slotId: t.option(t.string()), + buildingKind: t.option(t.string()), + targetLevel: t.option(t.u32()), + deductedFood: t.option(t.u64()), + deductedWood: t.option(t.u64()), + deductedStone: t.option(t.u64()), + deductedGold: t.option(t.u64()), + startedAtMicros: t.option(t.u64()), + policyVersion: t.option(t.string()), +}); + +const adminInnerKeepStatusV1 = t.object('AdminInnerKeepStatusV1', { + layoutRows: t.u64(), + slotRows: t.u64(), + buildingCatalogRows: t.u64(), + levelPolicyRows: t.u64(), + castleRows: t.u64(), + builderRows: t.u64(), + buildingRows: t.u64(), + activeProjects: t.u64(), + receiptRows: t.u64(), + scheduleRows: t.u64(), + missingBuilders: t.u64(), + orphanBuilders: t.u64(), + invalidBuilders: t.u64(), + invalidBuildings: t.u64(), + invalidSchedules: t.u64(), + builderProjectMismatches: t.u64(), + staticCatalogExact: t.bool(), + workerSystemReady: t.bool(), + readyForCatalogSeed: t.bool(), + readyForBuilderBackfill: t.bool(), + readyForActivation: t.bool(), + active: t.bool(), + policyVersion: t.string(), + policyDigest: t.string(), + layoutPolicyVersion: t.string(), + layoutDigest: t.string(), + assetCatalogDigest: t.string(), +}); + +const adminInnerKeepCatalogPlanV1 = t.object('AdminInnerKeepCatalogPlanV1', { + missingLayout: t.u32(), + missingSlots: t.u32(), + missingBuildings: t.u32(), + missingLevels: t.u32(), + ready: t.bool(), +}); + +const adminInnerKeepBuilderPlanV1 = t.object('AdminInnerKeepBuilderPlanV1', { + expectedCastles: t.u32(), + existingBuilders: t.u32(), + missingBuilders: t.u32(), + ready: t.bool(), +}); + +function senderInnerKeepError(error: unknown): never { + const code = innerKeepErrorCode(error); + if (code !== undefined) throw new SenderError(code); + if (error instanceof SenderError) throw error; + throw new SenderError('INNER_KEEP_REQUEST_FAILED'); +} + +function senderInnerKeepEntryError(error: unknown): never { + const code = innerKeepEntryErrorCode(error); + if (code !== undefined) throw new SenderError(code); + if (error instanceof SenderError) throw error; + throw new SenderError('INNER_KEEP_REQUEST_FAILED'); +} + +function audit( + ctx: Parameters[0], + actorSubject: string, + action: string, + note: string, +): void { + ctx.db.adminAudit.insert({ + id: 0n, + action, + targetFid: undefined, + actorSubject, + createdAt: ctx.timestamp, + note, + }); +} + +function requireStaticAttestation(input: Readonly<{ + capability: string; + policyDigest: string; + layoutDigest: string; + assetCatalogDigest: string; +}>): void { + if ( + input.capability !== INNER_KEEP_PROTOCOL_CAPABILITY + || input.policyDigest !== INNER_KEEP_POLICY_DIGEST + || input.layoutDigest !== INNER_KEEP_LAYOUT_DIGEST + || input.assetCatalogDigest !== INNER_KEEP_ASSET_CATALOG_DIGEST + ) throw new SenderError('INNER_KEEP_ADMIN_ATTESTATION_INVALID'); +} + +export const getMyInnerKeepStateV1 = warpkeep.procedure( + { name: 'get_my_inner_keep_state_v1' }, + myInnerKeepStateV1, + ctx => ctx.withTx(tx => { + try { + const { claims, castle } = requireGameplayPlayerV1(tx); + synchronizeMyInnerKeepEntry(tx, castle); + return { ...projectMyInnerKeepState(tx, claims.fid, castle) }; + } catch (error) { + return senderInnerKeepEntryError(error); + } + }), +); + +export const getMyInnerKeepRequestStatusV1 = warpkeep.procedure( + { name: 'get_my_inner_keep_request_status_v1' }, + { requestKey: t.string() }, + myInnerKeepRequestStatusV1, + (ctx, { requestKey }) => ctx.withTx(tx => { + try { + const { claims } = requireGameplayPlayerV1(tx); + const receipt = getMyInnerKeepRequestStatus(tx, claims.fid, requestKey); + if (receipt === undefined) { + return { + found: false, + castleId: undefined, + buildingKey: undefined, + slotId: undefined, + buildingKind: undefined, + targetLevel: undefined, + deductedFood: undefined, + deductedWood: undefined, + deductedStone: undefined, + deductedGold: undefined, + startedAtMicros: undefined, + policyVersion: undefined, + }; + } + return { + found: true, + castleId: receipt.castleId, + buildingKey: receipt.buildingKey, + slotId: receipt.slotId, + buildingKind: receipt.buildingKind, + targetLevel: receipt.targetLevel, + deductedFood: receipt.deductedFood, + deductedWood: receipt.deductedWood, + deductedStone: receipt.deductedStone, + deductedGold: receipt.deductedGold, + startedAtMicros: receipt.startedAt.microsSinceUnixEpoch, + policyVersion: receipt.policyVersion, + }; + } catch (error) { + return senderInnerKeepError(error); + } + }), +); + +export const innerKeepStartProjectV1 = warpkeep.reducer( + { name: 'inner_keep_start_project_v1' }, + { slotId: t.string(), buildingKind: t.string(), requestKey: t.string() }, + (ctx, { slotId, buildingKind, requestKey }) => { + try { + const { claims, castle } = requireGameplayPlayerV1(ctx); + startInnerKeepProject(ctx, { + fid: claims.fid, + castle, + slotId, + buildingKind, + requestKey, + }); + } catch (error) { + return senderInnerKeepError(error); + } + }, +); + +export const adminGetInnerKeepStatusV1 = warpkeep.procedure( + { name: 'admin_get_inner_keep_status_v1' }, + adminInnerKeepStatusV1, + ctx => ctx.withTx(tx => { + requireAdmin(tx); + return { ...inspectInnerKeep(tx) }; + }), +); + +export const adminPlanInnerKeepCatalogV1 = warpkeep.procedure( + { name: 'admin_plan_inner_keep_catalog_v1' }, + adminInnerKeepCatalogPlanV1, + ctx => ctx.withTx(tx => { + try { + requireAdmin(tx); + return { ...planInnerKeepCatalogSeed(tx) }; + } catch (error) { + return senderInnerKeepError(error); + } + }), +); + +export const adminSeedInnerKeepCatalogV1 = warpkeep.reducer( + { name: 'admin_seed_inner_keep_catalog_v1' }, + { + capability: t.string(), + policyDigest: t.string(), + layoutDigest: t.string(), + assetCatalogDigest: t.string(), + expectedMissingLayout: t.u32(), + expectedMissingSlots: t.u32(), + expectedMissingBuildings: t.u32(), + expectedMissingLevels: t.u32(), + }, + (ctx, input) => { + try { + const admin = requireAdmin(ctx); + requireStaticAttestation(input); + const plan = planInnerKeepCatalogSeed(ctx); + if ( + input.expectedMissingLayout !== plan.missingLayout + || input.expectedMissingSlots !== plan.missingSlots + || input.expectedMissingBuildings !== plan.missingBuildings + || input.expectedMissingLevels !== plan.missingLevels + ) throw new SenderError('INNER_KEEP_ADMIN_COUNTS_CHANGED'); + seedInnerKeepCatalog(ctx); + audit(ctx, admin.subject, 'seed_inner_keep_catalog_v1', [ + `layout=${plan.missingLayout}`, + `slots=${plan.missingSlots}`, + `buildings=${plan.missingBuildings}`, + `levels=${plan.missingLevels}`, + `policy=${input.policyDigest}`, + `layout_digest=${input.layoutDigest}`, + `assets=${input.assetCatalogDigest}`, + 'active=false', + 'data_deletion=false', + ].join(';')); + } catch (error) { + return senderInnerKeepError(error); + } + }, +); + +export const adminPlanInnerKeepBuildersV1 = warpkeep.procedure( + { name: 'admin_plan_inner_keep_builders_v1' }, + adminInnerKeepBuilderPlanV1, + ctx => ctx.withTx(tx => { + try { + requireAdmin(tx); + return { ...planInnerKeepBuilderBackfill(tx) }; + } catch (error) { + return senderInnerKeepError(error); + } + }), +); + +export const adminBackfillInnerKeepBuildersV1 = warpkeep.reducer( + { name: 'admin_backfill_inner_keep_builders_v1' }, + { + capability: t.string(), + policyDigest: t.string(), + layoutDigest: t.string(), + assetCatalogDigest: t.string(), + expectedCastles: t.u32(), + expectedExistingBuilders: t.u32(), + expectedMissingBuilders: t.u32(), + }, + (ctx, input) => { + try { + const admin = requireAdmin(ctx); + requireStaticAttestation(input); + const plan = planInnerKeepBuilderBackfill(ctx); + if ( + input.expectedCastles !== plan.expectedCastles + || input.expectedExistingBuilders !== plan.existingBuilders + || input.expectedMissingBuilders !== plan.missingBuilders + ) throw new SenderError('INNER_KEEP_ADMIN_COUNTS_CHANGED'); + backfillInnerKeepBuilders(ctx); + audit(ctx, admin.subject, 'backfill_inner_keep_builders_v1', [ + `castles=${plan.expectedCastles}`, + `existing=${plan.existingBuilders}`, + `inserted=${plan.missingBuilders}`, + 'projects=0', + 'resources_unchanged=true', + 'workers_unchanged=true', + 'marks_unchanged=true', + ].join(';')); + } catch (error) { + return senderInnerKeepError(error); + } + }, +); + +export const adminActivateInnerKeepV1 = warpkeep.reducer( + { name: 'admin_activate_inner_keep_v1' }, + { + capability: t.string(), + policyDigest: t.string(), + layoutDigest: t.string(), + assetCatalogDigest: t.string(), + clientRelease: t.string(), + clientArtifactDigest: t.string(), + moduleArtifactDigest: t.string(), + sourceCommit: t.string(), + expectedCastleCount: t.u32(), + }, + (ctx, input) => { + try { + const admin = requireAdmin(ctx); + const attestation: InnerKeepActivationAttestation = Object.freeze({ ...input }); + activateInnerKeep(ctx, attestation); + audit(ctx, admin.subject, 'activate_inner_keep_v1', [ + `release=${input.clientRelease}`, + `source=${input.sourceCommit}`, + `client=${input.clientArtifactDigest}`, + `module=${input.moduleArtifactDigest}`, + `policy=${input.policyDigest}`, + `layout=${input.layoutDigest}`, + `assets=${input.assetCatalogDigest}`, + `castles=${input.expectedCastleCount}`, + 'data_deletion=false', + ].join(';')); + } catch (error) { + return senderInnerKeepError(error); + } + }, +); + +export const adminDeactivateInnerKeepV1 = warpkeep.reducer( + { name: 'admin_deactivate_inner_keep_v1' }, + { + capability: t.string(), + expectedCastleCount: t.u32(), + expectedActiveProjects: t.u32(), + }, + (ctx, input) => { + try { + const admin = requireAdmin(ctx); + // This is the authoritative compare-and-set boundary. A project start or + // castle change committed after the operator's inspection is visible in + // this transaction and makes the reducer fail before any row is updated. + const aggregate = inspectInnerKeep(ctx); + if ( + !aggregate.active + || aggregate.castleRows !== BigInt(input.expectedCastleCount) + || aggregate.activeProjects !== BigInt(input.expectedActiveProjects) + ) throw new SenderError('INNER_KEEP_ADMIN_COUNTS_CHANGED'); + deactivateInnerKeep(ctx, input.capability); + audit(ctx, admin.subject, 'deactivate_inner_keep_v1', [ + `capability=${input.capability}`, + `castles=${input.expectedCastleCount}`, + `active_projects=${input.expectedActiveProjects}`, + 'rows_preserved=true', + 'refunds=false', + 'data_deletion=false', + ].join(';')); + } catch (error) { + return senderInnerKeepError(error); + } + }, +); diff --git a/spacetimedb/src/schema.ts b/spacetimedb/src/schema.ts index dc82a505..b36e66ba 100644 --- a/spacetimedb/src/schema.ts +++ b/spacetimedb/src/schema.ts @@ -24,6 +24,10 @@ import { dailyMarksErrorCode, runDailyMarkSchedule, } from './dailyMarksAuthority'; +import { + innerKeepErrorCode, + runInnerKeepConstructionSchedule, +} from './innerKeepAuthority'; /** * Private closed-alpha admission list. This table is intentionally omitted @@ -1222,6 +1226,159 @@ export const dailyMarkScheduleV1 = table( }, ); +/** Public singleton/static root for the separately activated Inner Keep. */ +export const innerKeepLayoutV1 = table( + { name: 'inner_keep_layout_v1', public: true }, + { + layoutId: t.string().primaryKey(), + layoutVersion: t.u32(), + policyVersion: t.string(), + slotCount: t.u32(), + mediumSlotCount: t.u32(), + largeSlotCount: t.u32(), + assetCatalogDigest: t.string(), + layoutDigest: t.string(), + active: t.bool(), + createdAt: t.timestamp(), + activatedAt: t.option(t.timestamp()), + }, +); + +/** Public fixed construction-pad catalogue; clients never submit coordinates. */ +export const innerKeepSlotV1 = table( + { name: 'inner_keep_slot_v1', public: true }, + { + slotId: t.string().primaryKey(), + layoutId: t.string().index(), + footprintClass: t.string(), + localXMicrounits: t.i64(), + localZMicrounits: t.i64(), + rotationMilliDegrees: t.u32(), + sortOrder: t.u32(), + active: t.bool(), + }, +); + +/** Public immutable projection of the canonical four-building policy. */ +export const innerKeepBuildingCatalogV1 = table( + { name: 'inner_keep_building_catalog_v1', public: true }, + { + buildingKind: t.string().primaryKey(), + publicLabel: t.string(), + category: t.string(), + footprintClass: t.string(), + maximumLevel: t.u32(), + uniquePerCastle: t.bool(), + matchingDiscountResource: t.string(), + discountBasisPointsPerLevel: t.u32(), + discountCapBasisPoints: t.u32(), + runtimeAssetId: t.string(), + previewAssetId: t.string(), + active: t.bool(), + policyVersion: t.string(), + }, +); + +/** Public exact recipes, multipliers, and durations for target levels 1-5. */ +export const innerKeepBuildLevelV1 = table( + { name: 'inner_keep_build_level_v1', public: true }, + { + levelKey: t.string().primaryKey(), + buildingKind: t.string().index(), + targetLevel: t.u32(), + baseFoodCost: t.u64(), + baseWoodCost: t.u64(), + baseStoneCost: t.u64(), + baseGoldCost: t.u64(), + levelMultiplierBasisPoints: t.u32(), + durationMicros: t.u64(), + policyVersion: t.string(), + }, +); + +/** Public, identity-minimized persistent building/project projection. */ +export const castleInnerKeepBuildingV1 = table( + { + name: 'castle_inner_keep_building_v1', + public: true, + indexes: [{ + accessor: 'byCastle', + algorithm: 'btree', + columns: ['castleId'] as const, + }] as const, + }, + { + buildingKey: t.string().primaryKey(), + castleId: t.u64(), + slotKey: t.string().unique(), + slotId: t.string(), + buildingKind: t.string(), + completedLevel: t.u32(), + targetLevel: t.u32(), + phase: t.string(), + startedAtMicros: t.u64(), + completesAtMicros: t.u64(), + revision: t.u64(), + policyVersion: t.string(), + }, +); + +/** Private one-Builder capacity for each founded castle. */ +export const castleInnerBuilderV1 = table( + { name: 'castle_inner_builder_v1' }, + { + castleId: t.u64().primaryKey(), + fid: t.u64().unique(), + activeBuildingKey: t.option(t.string()), + busyUntilMicros: t.option(t.u64()), + revision: t.u64(), + policyVersion: t.string(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + }, +); + +/** Private immutable exactly-once receipt for an accepted build command. */ +export const castleInnerBuildReceiptV1 = table( + { name: 'castle_inner_build_receipt_v1' }, + { + receiptKey: t.string().primaryKey(), + fid: t.u64().index(), + requestKey: t.string(), + castleId: t.u64(), + buildingKey: t.string(), + slotId: t.string(), + buildingKind: t.string(), + targetLevel: t.u32(), + deductedFood: t.u64(), + deductedWood: t.u64(), + deductedStone: t.u64(), + deductedGold: t.u64(), + startedAt: t.timestamp(), + policyVersion: t.string(), + }, +); + +/** Private minimal construction timer; no FID, balance, cost, or request key. */ +export const castleInnerConstructionScheduleV1 = table( + { + name: 'castle_inner_construction_schedule_v_1', + indexes: [{ + accessor: 'byBuilding', + algorithm: 'btree', + columns: ['buildingKey'] as const, + }] as const, + scheduled: (): any => runInnerKeepConstructionScheduleV1, + }, + { + scheduleId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + buildingKey: t.string(), + expectedRevision: t.u64(), + expectedTargetLevel: t.u32(), + }, +); + const warpkeep = schema({ // Preserve the original production schema prefix exactly. New tables are // append-only so SpacetimeDB can apply this migration without rewriting it. @@ -1281,6 +1438,15 @@ const warpkeep = schema({ accessRequestV1, dailyMarkGrantV1, dailyMarkScheduleV1, + // Additive v15 Inner Keep suffix. Refs 0-55 above remain frozen verbatim. + innerKeepLayoutV1, + innerKeepSlotV1, + innerKeepBuildingCatalogV1, + innerKeepBuildLevelV1, + castleInnerKeepBuildingV1, + castleInnerBuilderV1, + castleInnerBuildReceiptV1, + castleInnerConstructionScheduleV1, }); /** @@ -1384,6 +1550,21 @@ export const runDailyMarkScheduleV1 = warpkeep.reducer( }, ); +/** Scheduler-only completion reducer for one exact Inner Keep project. */ +export const runInnerKeepConstructionScheduleV1 = warpkeep.reducer( + { name: 'run_inner_keep_construction_schedule_v_1' }, + { arg: castleInnerConstructionScheduleV1.rowType }, + (ctx, { arg }) => { + try { + runInnerKeepConstructionSchedule(ctx, arg); + } catch (error) { + const code = innerKeepErrorCode(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 [ @@ -1445,6 +1626,16 @@ for (const name of [ 'admin_get_daily_marks_status_v1', 'admin_backfill_daily_mark_accounts_v1', 'admin_activate_daily_marks_v1', + 'get_my_inner_keep_state_v1', + 'get_my_inner_keep_request_status_v1', + 'inner_keep_start_project_v1', + 'admin_get_inner_keep_status_v1', + 'admin_plan_inner_keep_catalog_v1', + 'admin_seed_inner_keep_catalog_v1', + 'admin_plan_inner_keep_builders_v1', + 'admin_backfill_inner_keep_builders_v1', + 'admin_activate_inner_keep_v1', + 'admin_deactivate_inner_keep_v1', ]) { warpkeep.moduleDef.explicitNames.entries.push({ tag: 'Function', diff --git a/spacetimedb/tests/accessRequestMigrationTooling.test.ts b/spacetimedb/tests/accessRequestMigrationTooling.test.ts index 7b9c7334..5e886702 100644 --- a/spacetimedb/tests/accessRequestMigrationTooling.test.ts +++ b/spacetimedb/tests/accessRequestMigrationTooling.test.ts @@ -16,19 +16,23 @@ function registrations(text: string, marker: string): string[] { .filter(value => /^[A-Za-z][A-Za-z0-9]*$/.test(value)); } -test('v13 remains the exact frozen v12 prefix while v14 appends daily Marks', () => { +test('v13 and v14 remain frozen prefixes before the v15 Inner Keep append', () => { const v12 = source('../migration-fixtures/additive-v12-schema/src/index.ts'); const v13 = source('../migration-fixtures/additive-v13-schema/src/index.ts'); + const v14 = source('../migration-fixtures/additive-v14-schema/src/index.ts'); const candidate = source('../src/schema.ts'); const v12Tables = registrations(v12, 'const db = schema({'); const v13Tables = registrations(v13, 'const db = schema({'); + const v14Tables = registrations(v14, 'const db = schema({'); const candidateTables = registrations(candidate, 'const warpkeep = schema({'); assert.equal(v12Tables.length, 53); assert.deepEqual(v13Tables.slice(0, 53), v12Tables); assert.deepEqual(candidateTables.slice(0, 54), v13Tables); assert.deepEqual(v13Tables.slice(53), ['accessRequestV1']); - assert.deepEqual(candidateTables.slice(54), ['dailyMarkGrantV1', 'dailyMarkScheduleV1']); + assert.deepEqual(v14Tables.slice(0, 54), v13Tables); + assert.deepEqual(v14Tables.slice(54), ['dailyMarkGrantV1', 'dailyMarkScheduleV1']); + assert.deepEqual(candidateTables.slice(0, 56), v14Tables); assert.match(v13, /const accessRequestV1 = table\(\{ name: 'access_request_v1' \}, \{/); assert.match( v13, @@ -40,7 +44,7 @@ test('v13 remains the exact frozen v12 prefix while v14 appends daily Marks', () ); }); -test('general rehearsal binds v14 schema and row preservation with deletion disabled', () => { +test('general rehearsal retains v14 schema and row preservation inside v15', () => { const proof = source('../../scripts/verify-spacetime-additive-migration.mjs'); const receipt = source('../../scripts/spacetime-additive-migration-proof.mjs'); @@ -56,11 +60,13 @@ test('general rehearsal binds v14 schema and row preservation with deletion disa assert.match(proof, /value\.startsWith\('--delete-data='/); assert.doesNotMatch(proof, /--delete-data=(?:always|on-conflict|if-required)/); - assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 14/); + assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 15/); assert.match(receipt, /const V13_TABLE_SCHEMA_RECEIPT_FIELD = 'v13_table_schema_sha256'/); assert.match(receipt, /v13TableSchemaDigest/); assert.match(receipt, /const V14_TABLE_SCHEMA_RECEIPT_FIELD = 'v14_table_schema_sha256'/); assert.match(receipt, /v14TableSchemaDigest/); + assert.match(receipt, /const V15_TABLE_SCHEMA_RECEIPT_FIELD = 'v15_table_schema_sha256'/); + assert.match(receipt, /v15TableSchemaDigest/); }); test('connected rehearsal contains the bounded private request lifecycle', () => { @@ -124,7 +130,7 @@ test('connected rehearsal contains the bounded private request lifecycle', () => assert.match(proof, /final fresh founder access request/); assert.ok(invocation >= 0); - assert.match(finalOwnerRead, /additiveV14SchemaFixture/); + assert.match(finalOwnerRead, /additiveV15SchemaFixture/); assert.match(finalOwnerRead, /tableRowDigests\([\s\S]*deployedV12Tables/); assert.match(finalOwnerRead, /access_request_v1 WHERE fid = \$\{syntheticMissingAccessRequestFid\}/); assert.match(finalOwnerRead, /access_request_v1 WHERE fid = \$\{syntheticSecondAccessRequestFid\}/); @@ -142,7 +148,7 @@ test('dedicated Worker v11-to-v12 proof remains a separate frozen boundary', () assert.doesNotMatch(verifier, /additive-v13-schema|accessRequestV1/); }); -test('workspace metadata includes frozen v13 and current v14 fixtures', () => { +test('workspace metadata includes frozen v13, v14, and current v15 fixtures', () => { assert.match( source('../migration-fixtures/additive-v13-schema/package.json'), /warpkeep-additive-v13-schema-migration-fixture/, @@ -159,4 +165,12 @@ test('workspace metadata includes frozen v13 and current v14 fixtures', () => { source('../pnpm-lock.yaml'), /migration-fixtures\/additive-v14-schema:/, ); + assert.match( + source('../migration-fixtures/additive-v15-schema/package.json'), + /warpkeep-additive-v15-schema-migration-fixture/, + ); + assert.match( + source('../pnpm-lock.yaml'), + /migration-fixtures\/additive-v15-schema:/, + ); }); diff --git a/spacetimedb/tests/accessRequestReducers.test.ts b/spacetimedb/tests/accessRequestReducers.test.ts index 49dbb349..5bddab01 100644 --- a/spacetimedb/tests/accessRequestReducers.test.ts +++ b/spacetimedb/tests/accessRequestReducers.test.ts @@ -41,7 +41,7 @@ test('access requests add one exact private table at the end of the deployed v12 assert.deepEqual(v13Registrations.slice(0, 53), v12Registrations); assert.deepEqual(v13Registrations.slice(53), ['accessRequestV1']); assert.deepEqual(currentRegistrations.slice(0, 54), v13Registrations); - assert.deepEqual(currentRegistrations.slice(54), [ + assert.deepEqual(currentRegistrations.slice(54, 56), [ 'dailyMarkGrantV1', 'dailyMarkScheduleV1', ]); diff --git a/spacetimedb/tests/castleWorkerAuthority.test.ts b/spacetimedb/tests/castleWorkerAuthority.test.ts index 24e76124..4f7ca989 100644 --- a/spacetimedb/tests/castleWorkerAuthority.test.ts +++ b/spacetimedb/tests/castleWorkerAuthority.test.ts @@ -145,7 +145,10 @@ test('worker reads use bounded indexes and public tables omit assignment correla 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/); + assert.doesNotMatch( + section(schema, 'export const workerAssignmentScheduleV1', 'export const accessRequestV1'), + /public: true/, + ); }); test('atomic worker control state uses one caller-bound transaction and one projection clock', () => { diff --git a/spacetimedb/tests/castleWorkerLifecycleStateful.test.ts b/spacetimedb/tests/castleWorkerLifecycleStateful.test.ts index 90335600..0784691c 100644 --- a/spacetimedb/tests/castleWorkerLifecycleStateful.test.ts +++ b/spacetimedb/tests/castleWorkerLifecycleStateful.test.ts @@ -127,6 +127,7 @@ const { recallAllCastleWorkers, recallCastleWorker, runCastleWorkerSchedule, + settleAllWorkerAssignmentsForFid, } = await loadExactProductionModule( new URL('../src/castleWorkerAuthority.ts', import.meta.url), ); @@ -826,6 +827,80 @@ test('four workers share one resource across distinct nodes through replay, sche ); }); +test('same-timestamp settlement preserves an active Worker reservation and materializes it once', () => { + const fixture = makeLifecycleFixture(); + const workerId = [...fixture.workers.keys()].sort()[0]!; + const siteId = fixture.sites[0]!.siteId; + const dispatched = dispatchCastleWorker(fixture.ctx, { + fid: fixture.fid, + castle: fixture.castle, + workerId, + resourceKind: 'gold', + siteId, + idempotencyKey: 'settle-active-reservation-01', + }); + assert.ok(dispatched.assignment); + fixture.runSchedule(fixture.scheduleFor(workerId, 'arrival')); + const gathering = [...fixture.assignments.values()].find( + assignment => assignment.workerId === workerId, + )!; + const observedAtMicros = gathering.arrivesAtMicros + + 2n * REALM_RESOURCE_QUANTUM_MICROS; + assert.ok(observedAtMicros < gathering.gatheringEndsAtMicros); + (fixture.ctx as AnyRow).timestamp = timestamp(observedAtMicros); + + const workersBefore = structuredClone([...fixture.workers.entries()]); + const occupationsBefore = structuredClone([...fixture.occupations.entries()]); + const schedulesBefore = structuredClone([...fixture.schedules.entries()]); + const accountBefore = structuredClone(fixture.account()); + settleAllWorkerAssignmentsForFid( + fixture.ctx, + fixture.fid, + observedAtMicros, + ); + + const materialized = [...fixture.assignments.values()].find( + assignment => assignment.workerId === workerId, + )!; + const goldPolicy = workerResourcePolicy('gold'); + const expectedCredit = ( + (observedAtMicros - gathering.arrivesAtMicros) / goldPolicy.quantumMicros + ) * goldPolicy.ratePerQuantum; + assert.equal(materialized.phase, 'gathering'); + assert.equal(materialized.siteId, siteId); + assert.equal(materialized.resourceKind, 'gold'); + assert.equal(materialized.materializedAmount, expectedCredit); + assert.equal(materialized.accruedAmount, expectedCredit); + assert.deepEqual([...fixture.workers.entries()], workersBefore); + assert.deepEqual([...fixture.occupations.entries()], occupationsBefore); + assert.deepEqual([...fixture.schedules.entries()], schedulesBefore); + assert.equal(fixture.assignments.size, 1); + assert.equal(fixture.occupations.size, 1); + assert.equal(fixture.schedules.size, 1); + assert.equal(fixture.account().revision, accountBefore.revision + 1n); + assert.equal(fixture.account().gold, accountBefore.gold + expectedCredit); + + const afterFirstSettlement = structuredClone({ + account: fixture.account(), + assignments: [...fixture.assignments.entries()], + occupations: [...fixture.occupations.entries()], + schedules: [...fixture.schedules.entries()], + workers: [...fixture.workers.entries()], + }); + settleAllWorkerAssignmentsForFid( + fixture.ctx, + fixture.fid, + observedAtMicros, + ); + assert.deepEqual({ + account: fixture.account(), + assignments: [...fixture.assignments.entries()], + occupations: [...fixture.occupations.entries()], + schedules: [...fixture.schedules.entries()], + workers: [...fixture.workers.entries()], + }, afterFirstSettlement); +}); + test('an exact dispatch retry after normal completion is a terminal idempotent no-op', () => { const fixture = makeLifecycleFixture(); const workerId = [...fixture.workers.keys()].sort()[0]!; diff --git a/spacetimedb/tests/dailyMarksReducers.test.ts b/spacetimedb/tests/dailyMarksReducers.test.ts index 6cc1e6cc..9b9f0421 100644 --- a/spacetimedb/tests/dailyMarksReducers.test.ts +++ b/spacetimedb/tests/dailyMarksReducers.test.ts @@ -27,7 +27,7 @@ test('v14 appends private daily receipt and identity-free schedule tables', () = assert.match(grants, /fid: t\.u64\(\)\.index\(\)/); assert.match(grants, /utcDay: t\.u64\(\)\.index\(\)/); - const schedule = section(schema, 'export const dailyMarkScheduleV1', 'const warpkeep = schema({'); + const schedule = section(schema, 'export const dailyMarkScheduleV1', 'export const innerKeepLayoutV1'); assert.match(schedule, /name: 'daily_mark_schedule_v_1'/); assert.doesNotMatch(schedule, /fid|amountMicros|public:\s*true/); assert.match(schedule, /scheduled: \(\): any => runDailyMarkScheduleV1/); diff --git a/spacetimedb/tests/innerKeepAuthority.test.ts b/spacetimedb/tests/innerKeepAuthority.test.ts new file mode 100644 index 00000000..ff3647fb --- /dev/null +++ b/spacetimedb/tests/innerKeepAuthority.test.ts @@ -0,0 +1,221 @@ +import assert from 'node:assert/strict'; +import { existsSync, 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, startMarker: string, endMarker?: string): string { + const start = text.indexOf(startMarker); + assert.notEqual(start, -1, `missing source section: ${startMarker}`); + if (endMarker === undefined) return text.slice(start); + const end = text.indexOf(endMarker, start + startMarker.length); + assert.notEqual(end, -1, `missing source section terminator: ${endMarker}`); + return text.slice(start, end); +} + +test('project start derives caller, castle, level, cost, and server time', () => { + const reducers = source('../src/reducers/innerKeep.ts'); + const authority = source('../src/innerKeepAuthority.ts'); + const wire = section( + reducers, + 'export const innerKeepStartProjectV1', + 'export const adminGetInnerKeepStatusV1', + ); + const start = section( + authority, + 'export function startInnerKeepProject', + '/** Exact schedule completion', + ); + assert.match(wire, /name: 'inner_keep_start_project_v1'/); + assert.match(wire, /\{ slotId: t\.string\(\), buildingKind: t\.string\(\), requestKey: t\.string\(\) \}/); + assert.doesNotMatch(wire, /fid: t\.|castleId: t\.|targetLevel: t\.|cost|completion/i); + assert.match(wire, /const \{ claims, castle \} = requireGameplayPlayerV1\(ctx\)/); + assert.match(start, /const prior = ctx\.db\.castleInnerBuildReceiptV1\.receiptKey\.find/); + assert.ok(start.indexOf('if (prior !== null)') < start.indexOf('assertInnerKeepComponentActive(ctx)')); + assert.match(start, /targetLevel = existing\.completedLevel \+ 1/); + assert.match(start, /canonicalInnerKeepCost\([\s\S]*completedLevels\(buildings\)/); + assert.match(start, /settleAllWorkerAssignmentsForFid\(ctx, input\.fid, ctx\.timestamp\.microsSinceUnixEpoch\)/); + assert.match(start, /resource\.food - cost\.effectiveCost\.food/); + assert.match(start, /ScheduleAt\.time\(project\.completesAtMicros\)/); + assert.equal((start.match(/castleInnerBuildReceiptV1\.insert\(/g) ?? []).length, 1); + assert.equal((start.match(/castleInnerConstructionScheduleV1\.insert\(/g) ?? []).length, 1); + assert.doesNotMatch(start, /markAccountV1\.(?:insert|update|delete)/); +}); + +test('one private Builder enforces single-flight construction and durable deactivation history', () => { + const authority = source('../src/innerKeepAuthority.ts'); + const builderAuthority = source('../src/innerKeepBuilderAuthority.ts'); + const founding = source('../src/foundingAuthority.ts'); + const start = section( + authority, + 'export function startInnerKeepProject', + '/** Exact schedule completion', + ); + const graph = section( + authority, + 'function assertBuilderProjectGraph', + 'function deleteSchedulesForBuilding', + ); + const activation = section( + authority, + 'export function activateInnerKeep', + 'export function deactivateInnerKeep', + ); + + assert.match(start, /INNER_KEEP_BUILDER_BUSY/); + assert.match(graph, /constructing\.length > 1/); + assert.match(builderAuthority, /innerKeepLifecycleRequiresBuilders\(componentLifecycle\(ctx\)\)/); + assert.match(builderAuthority, /activeBuildingKey: undefined,[\s\S]*busyUntilMicros: undefined,[\s\S]*revision: 0n/); + assert.match(founding, /insertInnerKeepBuilderForNewFounderIfEverActivated\(ctx, castle\)/); + assert.match(founding, /assertInnerKeepBuilderForExistingFounder\(ctx, existingCastle\)/); + assert.match(activation, /activatedAt: layout\.activatedAt \?\? ctx\.timestamp/); +}); + +test('scheduled completion validates exact revision and target before clearing Builder', () => { + const authority = source('../src/innerKeepAuthority.ts'); + const exactSchedule = section( + authority, + 'function scheduleMatchesBuilding', + 'function buildingRowIsConsistent', + ); + const consistency = section( + authority, + 'function buildingRowIsConsistent', + 'function builderForCastle', + ); + const schedule = section( + authority, + 'export function runInnerKeepConstructionSchedule', + 'export type MyInnerKeepState', + ); + + assert.match(exactSchedule, /schedule\.scheduledAt\.tag === 'Time'/); + assert.match(exactSchedule, /scheduledAtMicros === building\.completesAtMicros/); + assert.match(exactSchedule, /schedule\.buildingKey === building\.buildingKey/); + assert.match(exactSchedule, /schedule\.expectedRevision === building\.revision/); + assert.match(exactSchedule, /schedule\.expectedTargetLevel === building\.targetLevel/); + assert.equal((schedule.match(/scheduleMatchesBuilding\(/g) ?? []).length, 2); + assert.match(schedule, /builder\.activeBuildingKey !== building\.buildingKey/); + assert.match(schedule, /completeProject\(ctx, castle, builder, building, ctx\.timestamp\.microsSinceUnixEpoch\)/); + assert.match( + consistency, + /row\.completesAtMicros - row\.startedAtMicros !== levelPolicy\.durationMicros/, + ); + + const completion = section(authority, 'function completeProject', 'function reconcileOverdueProject'); + assert.match(completion, /if \(now < building\.completesAtMicros\) fail\('INNER_KEEP_COMPLETION_EARLY'\)/); + assert.match(completion, /completedLevel: building\.targetLevel/); + assert.match(completion, /phase: 'complete'/); + assert.match(completion, /activeBuildingKey: undefined/); + assert.match(completion, /busyUntilMicros: undefined/); + assert.match(completion, /deleteSchedulesForBuilding/); +}); + +test('zero-input entry synchronizes before projection and closes private graph errors', () => { + const reducers = source('../src/reducers/innerKeep.ts'); + const authority = source('../src/innerKeepAuthority.ts'); + const entry = section( + reducers, + 'export const getMyInnerKeepStateV1', + 'export const getMyInnerKeepRequestStatusV1', + ); + const synchronization = section( + authority, + 'export function synchronizeMyInnerKeepEntry', + 'function completedLevels', + ); + const errorMapping = section( + authority, + 'export function innerKeepEntryErrorCode', + ); + + assert.match(entry, /warpkeep\.procedure\([\s\S]*ctx => ctx\.withTx\(tx =>/); + assert.doesNotMatch(entry, /\{\s*(?:fid|castleId|scheduleId|requestKey)\s*:/); + assert.ok( + entry.indexOf('synchronizeMyInnerKeepEntry(tx, castle)') + < entry.indexOf('projectMyInnerKeepState(tx, claims.fid, castle)'), + ); + assert.match(entry, /senderInnerKeepEntryError\(error\)/); + assert.match(synchronization, /if \(!catalog\.exact \|\| catalog\.layout\?\.active !== true\) return/); + assert.match(synchronization, /assertInnerKeepComponentActive\(ctx\)/); + assert.match(synchronization, /reconcileOverdueProject\(ctx, castle, builder, ctx\.timestamp\.microsSinceUnixEpoch\)/); + assert.match(errorMapping, /'INNER_KEEP_STATE_INTEGRITY'/); +}); + +test('aggregate inspection validates schedule time and both Builder-project directions', () => { + const authority = source('../src/innerKeepAuthority.ts'); + const inspection = section( + authority, + 'export function inspectInnerKeep', + 'export function innerKeepErrorCode', + ); + assert.match(inspection, /scheduleMatchesBuilding\(schedule, building\)/); + assert.match(inspection, /constructingForCastle\.length !== 1/); + assert.match(inspection, /builder\?\.activeBuildingKey === building\.buildingKey/); + assert.match(inspection, /building\.phase === 'constructing'[\s\S]*schedulesForBuilding\(ctx, building\.buildingKey\)\.length === 0/); +}); + +test('public bindings expose projections while Builder, receipt, and schedule stay private', () => { + const bindings = new URL('../../src/spacetime/module_bindings/', import.meta.url); + for (const publicTable of [ + 'inner_keep_layout_v_1_table.ts', + 'inner_keep_slot_v_1_table.ts', + 'inner_keep_building_catalog_v_1_table.ts', + 'inner_keep_build_level_v_1_table.ts', + 'castle_inner_keep_building_v_1_table.ts', + ]) assert.equal(existsSync(new URL(publicTable, bindings)), true); + for (const privateTable of [ + 'castle_inner_builder_v_1_table.ts', + 'castle_inner_build_receipt_v_1_table.ts', + 'castle_inner_construction_schedule_v_1_table.ts', + ]) assert.equal(existsSync(new URL(privateTable, bindings)), false); +}); + +test('admin transitions are separate, attested, counts-bound, and never activate during seed', () => { + const reducers = source('../src/reducers/innerKeep.ts'); + const seed = section( + reducers, + 'export const adminSeedInnerKeepCatalogV1', + 'export const adminPlanInnerKeepBuildersV1', + ); + const backfill = section( + reducers, + 'export const adminBackfillInnerKeepBuildersV1', + 'export const adminActivateInnerKeepV1', + ); + const activate = section( + reducers, + 'export const adminActivateInnerKeepV1', + 'export const adminDeactivateInnerKeepV1', + ); + const deactivate = section( + reducers, + 'export const adminDeactivateInnerKeepV1', + ); + + for (const body of [seed, backfill, activate, deactivate]) { + assert.match(body, /requireAdmin\(ctx\)/); + assert.match(body, /expected/i); + } + assert.match(seed, /requireStaticAttestation\(input\)/); + assert.match(seed, /'active=false'/); + assert.match(backfill, /'resources_unchanged=true'/); + assert.match(backfill, /'workers_unchanged=true'/); + assert.match(backfill, /'marks_unchanged=true'/); + assert.match(activate, /clientArtifactDigest/); + assert.match(activate, /moduleArtifactDigest/); + assert.match(deactivate, /expectedCastleCount: t\.u32\(\)/); + assert.match(deactivate, /expectedActiveProjects: t\.u32\(\)/); + assert.match(deactivate, /const aggregate = inspectInnerKeep\(ctx\)/); + assert.match(deactivate, /!aggregate\.active/); + assert.match(deactivate, /aggregate\.castleRows !== BigInt\(input\.expectedCastleCount\)/); + assert.match(deactivate, /aggregate\.activeProjects !== BigInt\(input\.expectedActiveProjects\)/); + assert.ok( + deactivate.indexOf('const aggregate = inspectInnerKeep(ctx)') + < deactivate.indexOf('deactivateInnerKeep(ctx, input.capability)'), + ); + assert.doesNotMatch(seed, /activateInnerKeep\(/); + assert.doesNotMatch(backfill, /activateInnerKeep\(/); +}); diff --git a/spacetimedb/tests/innerKeepEntrySynchronizationStateful.test.ts b/spacetimedb/tests/innerKeepEntrySynchronizationStateful.test.ts new file mode 100644 index 00000000..fe74cc6c --- /dev/null +++ b/spacetimedb/tests/innerKeepEntrySynchronizationStateful.test.ts @@ -0,0 +1,427 @@ +import assert from 'node:assert/strict'; +import { relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { build, type Plugin } from 'esbuild'; + +import type * as InnerKeepAuthority from '../src/innerKeepAuthority'; +import { + CANONICAL_INNER_KEEP_LAYOUT, + CANONICAL_INNER_KEEP_SLOTS, +} from '../src/innerKeepLayoutPolicy'; +import { + CANONICAL_INNER_KEEP_BUILDING_CATALOG, + CANONICAL_INNER_KEEP_LEVEL_POLICIES, + INNER_KEEP_POLICY_VERSION, +} from '../src/innerKeepPolicy'; + +const sdkRuntimeStub: Plugin = { + name: 'warpkeep-spacetimedb-test-runtime', + setup(buildContext) { + buildContext.onResolve( + { filter: /^spacetimedb(?:\/server)?$/ }, + args => ({ + path: args.path, + namespace: 'warpkeep-spacetimedb-test-runtime', + }), + ); + buildContext.onLoad( + { filter: /.*/, namespace: 'warpkeep-spacetimedb-test-runtime' }, + args => ({ + loader: 'js', + contents: args.path === 'spacetimedb' + ? ` + export const ScheduleAt = Object.freeze({ + time(microsSinceUnixEpoch) { + return Object.freeze({ + tag: 'Time', + value: Object.freeze({ microsSinceUnixEpoch }), + }); + }, + }); + ` + : ` + export class SenderError extends Error { + constructor(message) { + super(message); + this.name = 'SenderError'; + } + } + `, + }), + ); + }, +}; + +async function loadExactProductionModule(sourceUrl: URL): Promise { + const sourcePath = fileURLToPath(sourceUrl); + const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url)); + const result = await build({ + absWorkingDir: repositoryRoot, + bundle: true, + entryPoints: [sourcePath], + format: 'esm', + logLevel: 'silent', + metafile: true, + platform: 'node', + plugins: [sdkRuntimeStub], + target: 'node22', + treeShaking: true, + write: false, + }); + assert.equal(result.outputFiles.length, 1); + const exactInput = relative(repositoryRoot, sourcePath).split(sep).join('/'); + assert.ok(Object.hasOwn(result.metafile.inputs, exactInput)); + const encoded = Buffer.from(result.outputFiles[0]!.contents).toString('base64'); + return import(`data:text/javascript;base64,${encoded}`) as Promise; +} + +const { + InnerKeepAuthorityError, + innerKeepEntryErrorCode, + inspectInnerKeep, + synchronizeMyInnerKeepEntry, +} = await loadExactProductionModule( + new URL('../src/innerKeepAuthority.ts', import.meta.url), +); + +type AnyRow = Record; +type SynchronizationContext = Parameters[0]; +type SynchronizationCastle = Parameters[1]; + +function timestamp(microsSinceUnixEpoch: bigint) { + return { microsSinceUnixEpoch }; +} + +function zeroCountTable() { + return { count: () => 0n }; +} + +type FixtureOptions = Readonly<{ + active?: boolean; + now?: 'on-time' | 'overdue'; + schedule?: 'exact' | 'missing' | 'corrupt' | 'wrong-time'; + builder?: 'canonical' | 'wrong-project'; +}>; + +function makeFixture(options: FixtureOptions = {}) { + const fid = 77_001n; + const castleId = 1n; + const startedAtMicros = 1_900_000_000_000_000n; + const level = CANONICAL_INNER_KEEP_LEVEL_POLICIES.find(row => ( + row.buildingKind === 'city-mill' && row.targetLevel === 1 + ))!; + const completesAtMicros = startedAtMicros + level.durationMicros; + const observedAtMicros = options.now === 'on-time' + ? completesAtMicros - 1n + : completesAtMicros + 1n; + const castle: SynchronizationCastle = { + castleId, + ownerFid: fid, + tileKey: 'fixture-tile', + q: 0, + r: 0, + level: 1, + name: 'Synchronization Keep', + createdAt: timestamp(startedAtMicros), + } as SynchronizationCastle; + const layout = { + ...CANONICAL_INNER_KEEP_LAYOUT, + active: options.active !== false, + createdAt: timestamp(startedAtMicros), + activatedAt: timestamp(startedAtMicros), + }; + const slots = new Map(CANONICAL_INNER_KEEP_SLOTS.map(row => [row.slotId, { ...row }])); + const catalogs = new Map(CANONICAL_INNER_KEEP_BUILDING_CATALOG.map(row => { + const { baseCost: _baseCost, ...stored } = row; + return [stored.buildingKind, { ...stored }] as const; + })); + const levels = new Map(CANONICAL_INNER_KEEP_LEVEL_POLICIES.map(row => [ + row.levelKey, + { ...row }, + ])); + const buildingKey = `${castleId.toString()}:city-mill`; + const buildings = new Map([[buildingKey, { + buildingKey, + castleId, + slotKey: `${castleId.toString()}:inner-keep-slot-m01`, + slotId: 'inner-keep-slot-m01', + buildingKind: 'city-mill', + completedLevel: 0, + targetLevel: 1, + phase: 'constructing', + startedAtMicros, + completesAtMicros, + revision: 0n, + policyVersion: INNER_KEEP_POLICY_VERSION, + }]]); + let builder: AnyRow = { + castleId, + fid, + activeBuildingKey: options.builder === 'wrong-project' ? `${castleId}:lumber-camp` : buildingKey, + busyUntilMicros: completesAtMicros, + revision: 1n, + policyVersion: INNER_KEEP_POLICY_VERSION, + createdAt: timestamp(startedAtMicros), + updatedAt: timestamp(startedAtMicros), + }; + const schedules = new Map(); + if (options.schedule !== 'missing') { + schedules.set(1n, { + scheduleId: 1n, + scheduledAt: { + tag: 'Time', + value: timestamp(options.schedule === 'wrong-time' + ? completesAtMicros + 1n + : completesAtMicros), + }, + buildingKey, + expectedRevision: options.schedule === 'corrupt' ? 9n : 0n, + expectedTargetLevel: 1, + }); + } + const receipts = new Map([['77:entry-sync', { + receiptKey: '77:entry-sync', + deductedFood: 300n, + deductedWood: 900n, + deductedStone: 600n, + deductedGold: 0n, + }]]); + const resources = new Map([[fid, { + food: 700n, + wood: 100n, + stone: 400n, + gold: 1_000n, + revision: 1n, + }]]); + + const ctx = { + timestamp: timestamp(observedAtMicros), + db: { + innerKeepLayoutV1: { + count: () => 1n, + layoutId: { find: (key: string) => key === layout.layoutId ? layout : null }, + }, + innerKeepSlotV1: { + count: () => BigInt(slots.size), + slotId: { find: (key: string) => slots.get(key) ?? null }, + }, + innerKeepBuildingCatalogV1: { + count: () => BigInt(catalogs.size), + buildingKind: { find: (key: string) => catalogs.get(key) ?? null }, + }, + innerKeepBuildLevelV1: { + count: () => BigInt(levels.size), + levelKey: { find: (key: string) => levels.get(key) ?? null }, + }, + castle: { + count: () => 1n, + iter: () => [castle], + castleId: { find: (key: bigint) => key === castleId ? castle : null }, + }, + castleInnerBuilderV1: { + count: () => 1n, + iter: () => [builder], + castleId: { + find: (key: bigint) => key === castleId ? builder : null, + update: (row: AnyRow) => { + builder = { ...row }; + return builder; + }, + }, + fid: { find: (key: bigint) => key === fid ? builder : null }, + }, + castleInnerKeepBuildingV1: { + count: () => BigInt(buildings.size), + iter: () => buildings.values(), + byCastle: { + filter: (key: bigint) => [...buildings.values()].filter(row => row.castleId === key), + }, + buildingKey: { + find: (key: string) => buildings.get(key) ?? null, + update: (row: AnyRow) => { + buildings.set(row.buildingKey, { ...row }); + return row; + }, + }, + }, + castleInnerConstructionScheduleV1: { + count: () => BigInt(schedules.size), + iter: () => schedules.values(), + byBuilding: { + filter: (key: string) => [...schedules.values()].filter(row => row.buildingKey === key), + }, + scheduleId: { + delete: (key: bigint) => schedules.delete(key), + }, + }, + castleInnerBuildReceiptV1: { count: () => BigInt(receipts.size) }, + realmWorkerSystemV1: { + count: () => 1n, + realmId: { + find: (realmId: string) => realmId === 'GENESIS_001' + ? { + mode: 'active', + legacyDrainRequired: false, + expectedCastleCount: 1, + expectedWorkerCount: 4, + } + : null, + }, + }, + castleWorkerV1: { count: () => 4n }, + goldExpeditionV1: zeroCountTable(), + foodExpeditionV1: zeroCountTable(), + woodExpeditionV1: zeroCountTable(), + stoneExpeditionV1: zeroCountTable(), + goldNodeOccupationV1: zeroCountTable(), + foodNodeOccupationV1: zeroCountTable(), + woodNodeOccupationV1: zeroCountTable(), + stoneNodeOccupationV1: zeroCountTable(), + goldExpeditionScheduleV1: zeroCountTable(), + foodExpeditionScheduleV1: zeroCountTable(), + woodExpeditionScheduleV1: zeroCountTable(), + stoneExpeditionScheduleV1: zeroCountTable(), + }, + } as unknown as SynchronizationContext; + + const state = () => structuredClone({ + builder, + buildings: [...buildings.entries()], + schedules: [...schedules.entries()], + receipts: [...receipts.entries()], + resources: [...resources.entries()], + }); + + return { + buildingKey, + castle, + ctx, + getBuilder: () => builder, + getBuilding: () => buildings.get(buildingKey)!, + schedules, + state, + }; +} + +test('on-time entry validates the exact schedule without mutation', () => { + const fixture = makeFixture({ now: 'on-time', schedule: 'exact' }); + const before = fixture.state(); + synchronizeMyInnerKeepEntry(fixture.ctx, fixture.castle); + assert.deepEqual(fixture.state(), before); +}); + +test('overdue entry completes one exact scheduled project', () => { + const fixture = makeFixture({ now: 'overdue', schedule: 'exact' }); + synchronizeMyInnerKeepEntry(fixture.ctx, fixture.castle); + assert.equal(fixture.getBuilding().phase, 'complete'); + assert.equal(fixture.getBuilding().completedLevel, 1); + assert.equal(fixture.getBuilding().revision, 1n); + assert.equal(fixture.getBuilder().activeBuildingKey, undefined); + assert.equal(fixture.getBuilder().busyUntilMicros, undefined); + assert.equal(fixture.getBuilder().revision, 2n); + assert.equal(fixture.schedules.size, 0); +}); + +test('overdue entry recovers a lost schedule once and never refunds or re-receipts', () => { + const fixture = makeFixture({ now: 'overdue', schedule: 'missing' }); + const before = fixture.state(); + synchronizeMyInnerKeepEntry(fixture.ctx, fixture.castle); + const afterFirst = fixture.state(); + assert.equal(fixture.getBuilding().phase, 'complete'); + assert.deepEqual(afterFirst.receipts, before.receipts); + assert.deepEqual(afterFirst.resources, before.resources); + + synchronizeMyInnerKeepEntry(fixture.ctx, fixture.castle); + assert.deepEqual(fixture.state(), afterFirst); +}); + +test('a missing on-time schedule fails without partial mutation', () => { + const fixture = makeFixture({ now: 'on-time', schedule: 'missing' }); + const before = fixture.state(); + assert.throws( + () => synchronizeMyInnerKeepEntry(fixture.ctx, fixture.castle), + /INNER_KEEP_SCHEDULE_INTEGRITY/, + ); + assert.deepEqual(fixture.state(), before); +}); + +test('a corrupt overdue schedule fails without being erased or completing', () => { + const fixture = makeFixture({ now: 'overdue', schedule: 'corrupt' }); + const before = fixture.state(); + assert.throws( + () => synchronizeMyInnerKeepEntry(fixture.ctx, fixture.castle), + /INNER_KEEP_SCHEDULE_INTEGRITY/, + ); + assert.deepEqual(fixture.state(), before); +}); + +test('a schedule at the wrong server time fails the shared exact predicate', () => { + const fixture = makeFixture({ now: 'overdue', schedule: 'wrong-time' }); + const before = fixture.state(); + assert.throws( + () => synchronizeMyInnerKeepEntry(fixture.ctx, fixture.castle), + /INNER_KEEP_SCHEDULE_INTEGRITY/, + ); + assert.deepEqual(fixture.state(), before); +}); + +test('a corrupt Builder/project edge fails without partial mutation', () => { + const fixture = makeFixture({ + builder: 'wrong-project', + now: 'overdue', + schedule: 'missing', + }); + const before = fixture.state(); + assert.throws( + () => synchronizeMyInnerKeepEntry(fixture.ctx, fixture.castle), + /INNER_KEEP_BUILDER_INTEGRITY/, + ); + assert.deepEqual(fixture.state(), before); +}); + +test('inactive entry is explicitly projection-only even when a project is overdue', () => { + const fixture = makeFixture({ active: false, now: 'overdue', schedule: 'missing' }); + const before = fixture.state(); + synchronizeMyInnerKeepEntry(fixture.ctx, fixture.castle); + assert.deepEqual(fixture.state(), before); + assert.equal(fixture.getBuilding().phase, 'constructing'); +}); + +test('aggregate inspection catches missing schedules and project-to-Builder mismatches', () => { + const missingSchedule = makeFixture({ now: 'on-time', schedule: 'missing' }); + const missingScheduleStatus = inspectInnerKeep(missingSchedule.ctx); + assert.equal(missingScheduleStatus.invalidSchedules, 1n); + assert.equal(missingScheduleStatus.builderProjectMismatches, 0n); + + const wrongBuilder = makeFixture({ + builder: 'wrong-project', + now: 'on-time', + schedule: 'exact', + }); + const wrongBuilderStatus = inspectInnerKeep(wrongBuilder.ctx); + assert.ok(wrongBuilderStatus.builderProjectMismatches >= 1n); + + const wrongTime = makeFixture({ now: 'on-time', schedule: 'wrong-time' }); + assert.equal(inspectInnerKeep(wrongTime.ctx).invalidSchedules, 1n); +}); + +test('entry error classification exposes availability but closes private graph failures', () => { + assert.equal( + innerKeepEntryErrorCode(new InnerKeepAuthorityError('INNER_KEEP_UNAVAILABLE')), + 'INNER_KEEP_UNAVAILABLE', + ); + assert.equal( + innerKeepEntryErrorCode(new InnerKeepAuthorityError('INNER_KEEP_BACKEND_SYNCHRONIZING')), + 'INNER_KEEP_BACKEND_SYNCHRONIZING', + ); + assert.equal( + innerKeepEntryErrorCode(new InnerKeepAuthorityError('INNER_KEEP_SCHEDULE_INTEGRITY')), + 'INNER_KEEP_STATE_INTEGRITY', + ); + assert.equal( + innerKeepEntryErrorCode(new InnerKeepAuthorityError('INNER_KEEP_BUILDER_INTEGRITY')), + 'INNER_KEEP_STATE_INTEGRITY', + ); + assert.equal(innerKeepEntryErrorCode(new Error('private detail')), undefined); +}); diff --git a/spacetimedb/tests/innerKeepMigrationTooling.test.ts b/spacetimedb/tests/innerKeepMigrationTooling.test.ts new file mode 100644 index 00000000..fe7ce71d --- /dev/null +++ b/spacetimedb/tests/innerKeepMigrationTooling.test.ts @@ -0,0 +1,98 @@ +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 registrations(text: string, marker: string): string[] { + const start = text.indexOf(marker); + const end = text.indexOf('\n});', start); + assert.ok(start >= 0 && end > start, `missing schema marker: ${marker}`); + return text.slice(start + marker.length, end) + .split(/[,\n]/) + .map(value => value.trim()) + .filter(value => /^[A-Za-z][A-Za-z0-9]*$/.test(value)); +} + +const innerKeepRegistrations = [ + 'innerKeepLayoutV1', + 'innerKeepSlotV1', + 'innerKeepBuildingCatalogV1', + 'innerKeepBuildLevelV1', + 'castleInnerKeepBuildingV1', + 'castleInnerBuilderV1', + 'castleInnerBuildReceiptV1', + 'castleInnerConstructionScheduleV1', +] as const; + +test('v15 is the exact v14 prefix plus Inner Keep refs 56-63', () => { + const v14 = source('../migration-fixtures/additive-v14-schema/src/index.ts'); + const v15 = source('../migration-fixtures/additive-v15-schema/src/index.ts'); + const candidate = source('../src/schema.ts'); + const v14Tables = registrations(v14, 'const db = schema({'); + const v15Tables = registrations(v15, 'const db = schema({'); + const candidateTables = registrations(candidate, 'const warpkeep = schema({'); + + assert.equal(v14Tables.length, 56); + assert.equal(v15Tables.length, 64); + assert.deepEqual(v15Tables.slice(0, 56), v14Tables); + assert.deepEqual(v15Tables.slice(56), innerKeepRegistrations); + assert.deepEqual(candidateTables, v15Tables); +}); + +test('v15 fixture pins public projection, private authority, and all-table sentinel', () => { + const fixture = source('../migration-fixtures/additive-v15-schema/src/index.ts'); + const packageJson = source('../migration-fixtures/additive-v15-schema/package.json'); + const lock = source('../pnpm-lock.yaml'); + + for (const name of [ + 'inner_keep_layout_v1', + 'inner_keep_slot_v1', + 'inner_keep_building_catalog_v1', + 'inner_keep_build_level_v1', + 'castle_inner_keep_building_v1', + ]) { + assert.match(fixture, new RegExp(`name: '${name}'[^\n]*public: true`)); + } + for (const name of [ + 'castle_inner_builder_v1', + 'castle_inner_build_receipt_v1', + 'castle_inner_construction_schedule_v_1', + ]) { + const declaration = fixture.slice( + fixture.indexOf(`name: '${name}'`) - 80, + fixture.indexOf(`name: '${name}'`) + 240, + ); + assert.doesNotMatch(declaration, /public:\s*true/); + } + assert.match(fixture, /name: 'fixture_seed_inner_keep_sentinel_v15'/); + for (const accessor of innerKeepRegistrations) { + assert.match( + fixture.slice(fixture.indexOf("name: 'fixture_seed_inner_keep_sentinel_v15'")), + new RegExp(`ctx\\.db\\.${accessor}\\.insert`), + ); + } + assert.match(packageJson, /warpkeep-additive-v15-schema-migration-fixture/); + assert.match(lock, /migration-fixtures\/additive-v15-schema:/); +}); + +test('general rehearsal proves populated v14 to v15 preservation and downgrade refusal', () => { + const verifier = source('../../scripts/verify-spacetime-additive-migration.mjs'); + const receipt = source('../../scripts/spacetime-additive-migration-proof.mjs'); + + assert.match(verifier, /function assertAdditiveV15Schema\(before, after\)/); + assert.match(verifier, /assertAdditiveV15Schema\(emptyV14, emptyV15\)/); + assert.match(verifier, /fixture_seed_daily_marks_sentinel_v14/); + assert.match(verifier, /dailyMarksPopulatedV14Rows/); + assert.match(verifier, /fixture_seed_inner_keep_sentinel_v15/); + assert.match(verifier, /dailyMarksPopulatedV15Rows/); + assert.match(verifier, /additiveV14SchemaFixture,[\s\S]{0,120}dailyMarksMigrationDatabase,[\s\S]{0,40}false/); + assert.match(verifier, /The v15 boundary must refuse its immediate predecessor/); + assert.match(verifier, /inner_keep_layout_v1: 56/); + assert.match(verifier, /castle_inner_construction_schedule_v_1: 63/); + assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 15/); + assert.match(receipt, /const V15_TABLE_SCHEMA_RECEIPT_FIELD = 'v15_table_schema_sha256'/); + assert.match(receipt, /v15TableSchemaDigest/); +}); diff --git a/spacetimedb/tests/innerKeepPolicy.test.ts b/spacetimedb/tests/innerKeepPolicy.test.ts new file mode 100644 index 00000000..076dc109 --- /dev/null +++ b/spacetimedb/tests/innerKeepPolicy.test.ts @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { + CANONICAL_INNER_KEEP_BUILDING_CATALOG, + CANONICAL_INNER_KEEP_LEVEL_POLICIES, + INNER_KEEP_DISCOUNT_CAP_BPS, + INNER_KEEP_MAXIMUM_LEVEL, + INNER_KEEP_POLICY_DIGEST, + INNER_KEEP_POLICY_VERSION, + InnerKeepPolicyError, + canonicalInnerKeepBuildingKinds, + canonicalInnerKeepCost, + canonicalInnerKeepPolicyDigestInput, + innerKeepDiscountBasisPoints, + innerKeepActivationRowsAreSafe, + matchesCanonicalInnerKeepBuildingPolicy, + matchesCanonicalInnerKeepLevelPolicy, +} from '../src/innerKeepPolicy'; +import { + CANONICAL_INNER_KEEP_LAYOUT, + CANONICAL_INNER_KEEP_SLOTS, + INNER_KEEP_ASSET_CATALOG_DIGEST, + INNER_KEEP_LARGE_SLOT_COUNT, + INNER_KEEP_LAYOUT_DIGEST, + INNER_KEEP_MEDIUM_SLOT_COUNT, + INNER_KEEP_SLOT_COUNT, + canonicalInnerKeepLayoutDigestInput, + innerKeepActivationLifecycle, + innerKeepLifecycleRequiresBuilders, + matchesCanonicalInnerKeepLayout, + matchesCanonicalInnerKeepSlot, +} from '../src/innerKeepLayoutPolicy'; + +test('Inner Keep pins four unique economy buildings and twenty exact level rows', () => { + assert.deepEqual(canonicalInnerKeepBuildingKinds(), [ + 'city-mill', + 'lumber-camp', + 'city-stoneworks', + 'city-goldworks', + ]); + assert.equal(CANONICAL_INNER_KEEP_BUILDING_CATALOG.length, 4); + assert.equal(new Set(CANONICAL_INNER_KEEP_BUILDING_CATALOG.map(row => row.publicLabel)).size, 4); + assert.equal(new Set(CANONICAL_INNER_KEEP_BUILDING_CATALOG.map(row => row.runtimeAssetId)).size, 4); + assert.equal(CANONICAL_INNER_KEEP_LEVEL_POLICIES.length, 20); + assert.equal(new Set(CANONICAL_INNER_KEEP_LEVEL_POLICIES.map(row => row.levelKey)).size, 20); + assert.equal(Object.isFrozen(CANONICAL_INNER_KEEP_BUILDING_CATALOG), true); + assert.equal(Object.isFrozen(CANONICAL_INNER_KEEP_LEVEL_POLICIES), true); + for (const building of CANONICAL_INNER_KEEP_BUILDING_CATALOG) { + assert.equal(building.maximumLevel, INNER_KEEP_MAXIMUM_LEVEL); + assert.equal(building.footprintClass, 'medium'); + assert.equal(building.uniquePerCastle, true); + assert.equal(building.active, true); + assert.equal(building.policyVersion, INNER_KEEP_POLICY_VERSION); + const { baseCost, ...publicRow } = building; + assert.equal(matchesCanonicalInnerKeepBuildingPolicy(publicRow), true); + assert.equal(Object.isFrozen(baseCost), true); + } + for (const level of CANONICAL_INNER_KEEP_LEVEL_POLICIES) { + assert.equal(matchesCanonicalInnerKeepLevelPolicy(level), true); + } +}); + +test('Inner Keep base recipes, multipliers, and durations are exact', () => { + const expectedBase = { + 'city-mill': { food: 300n, wood: 900n, stone: 600n, gold: 0n }, + 'lumber-camp': { food: 500n, wood: 700n, stone: 650n, gold: 0n }, + 'city-stoneworks': { food: 500n, wood: 900n, stone: 450n, gold: 0n }, + 'city-goldworks': { food: 700n, wood: 1_200n, stone: 1_000n, gold: 500n }, + } as const; + const expectedMultipliers = [10_000, 22_400, 37_632, 56_197, 78_676]; + const expectedDurations = [ + 86_400_000_000n, + 172_800_000_000n, + 280_800_000_000n, + 403_200_000_000n, + 543_600_000_000n, + ]; + for (const building of CANONICAL_INNER_KEEP_BUILDING_CATALOG) { + assert.deepEqual(building.baseCost, expectedBase[building.buildingKind]); + const levels = CANONICAL_INNER_KEEP_LEVEL_POLICIES.filter( + level => level.buildingKind === building.buildingKind, + ); + assert.deepEqual(levels.map(level => level.targetLevel), [1, 2, 3, 4, 5]); + assert.deepEqual(levels.map(level => level.levelMultiplierBasisPoints), expectedMultipliers); + assert.deepEqual(levels.map(level => level.durationMicros), expectedDurations); + } +}); + +test('authoritative cost arithmetic rounds each rational upward to tens', () => { + assert.deepEqual(canonicalInnerKeepCost('city-mill', 1).effectiveCost, { + food: 300n, + wood: 900n, + stone: 600n, + gold: 0n, + }); + assert.deepEqual(canonicalInnerKeepCost('city-mill', 2).effectiveCost, { + food: 680n, + wood: 2_020n, + stone: 1_350n, + gold: 0n, + }); + assert.deepEqual(canonicalInnerKeepCost('city-goldworks', 5).effectiveCost, { + food: 5_510n, + wood: 9_450n, + stone: 7_870n, + gold: 3_940n, + }); + for (const building of canonicalInnerKeepBuildingKinds()) { + for (let targetLevel = 1; targetLevel <= 5; targetLevel += 1) { + const plan = canonicalInnerKeepCost(building, targetLevel); + for (const amount of Object.values(plan.effectiveCost)) { + assert.equal(amount % 10n, 0n); + assert.equal(amount <= 1_000_000n, true); + } + } + } +}); + +test('only completed levels discount the matching future resource, including own upgrade', () => { + const undiscounted = canonicalInnerKeepCost('lumber-camp', 2); + const oneCompleted = canonicalInnerKeepCost('lumber-camp', 2, { 'lumber-camp': 1 }); + assert.equal(oneCompleted.discountBasisPoints.wood, 500); + assert.equal(oneCompleted.effectiveCost.wood, 1_500n); + assert.equal(undiscounted.effectiveCost.wood, 1_570n); + assert.equal(oneCompleted.effectiveCost.food, undiscounted.effectiveCost.food); + assert.equal(oneCompleted.effectiveCost.stone, undiscounted.effectiveCost.stone); + assert.equal(oneCompleted.effectiveCost.gold, 0n); + + const capped = canonicalInnerKeepCost('city-goldworks', 5, { + 'city-mill': 5, + 'lumber-camp': 5, + 'city-stoneworks': 5, + 'city-goldworks': 5, + }); + assert.deepEqual(capped.discountBasisPoints, { + food: INNER_KEEP_DISCOUNT_CAP_BPS, + wood: INNER_KEEP_DISCOUNT_CAP_BPS, + stone: INNER_KEEP_DISCOUNT_CAP_BPS, + gold: INNER_KEEP_DISCOUNT_CAP_BPS, + }); + assert.equal(innerKeepDiscountBasisPoints(5), INNER_KEEP_DISCOUNT_CAP_BPS); +}); + +test('invalid levels, kinds, and overflowing inputs fail closed', () => { + assert.throws(() => canonicalInnerKeepCost('barracks', 1), (error: unknown) => ( + error instanceof InnerKeepPolicyError && error.code === 'INNER_KEEP_BUILDING_KIND_INVALID' + )); + assert.throws(() => canonicalInnerKeepCost('city-mill', 0), (error: unknown) => ( + error instanceof InnerKeepPolicyError && error.code === 'INNER_KEEP_TARGET_LEVEL_INVALID' + )); + assert.throws(() => canonicalInnerKeepCost('city-mill', 6), (error: unknown) => ( + error instanceof InnerKeepPolicyError && error.code === 'INNER_KEEP_TARGET_LEVEL_INVALID' + )); + assert.throws(() => canonicalInnerKeepCost('city-mill', 2, { 'city-mill': 6 }), (error: unknown) => ( + error instanceof InnerKeepPolicyError && error.code === 'INNER_KEEP_COMPLETED_LEVEL_INVALID' + )); +}); + +test('first activation is zero-state while reactivation preserves completed history', () => { + assert.equal(innerKeepActivationRowsAreSafe({ + previouslyActivated: false, + buildingRows: 0n, + activeProjects: 0n, + receiptRows: 0n, + scheduleRows: 0n, + }), true); + assert.equal(innerKeepActivationRowsAreSafe({ + previouslyActivated: false, + buildingRows: 1n, + activeProjects: 0n, + receiptRows: 1n, + scheduleRows: 0n, + }), false); + assert.equal(innerKeepActivationRowsAreSafe({ + previouslyActivated: true, + buildingRows: 4n, + activeProjects: 0n, + receiptRows: 20n, + scheduleRows: 0n, + }), true); + assert.equal(innerKeepActivationRowsAreSafe({ + previouslyActivated: true, + buildingRows: 4n, + activeProjects: 1n, + receiptRows: 20n, + scheduleRows: 1n, + }), false); +}); + +test('deactivation preserves the ever-activated lifecycle for founder Builder coverage', () => { + assert.equal(innerKeepActivationLifecycle({ + active: false, + activatedAt: undefined, + }), 'never-activated'); + const firstActivation = { microsSinceUnixEpoch: 123n }; + assert.equal(innerKeepActivationLifecycle({ + active: true, + activatedAt: firstActivation, + }), 'active'); + assert.equal(innerKeepActivationLifecycle({ + active: false, + activatedAt: firstActivation, + }), 'inactive-after-activation'); + assert.equal(innerKeepLifecycleRequiresBuilders('never-activated'), false); + assert.equal(innerKeepLifecycleRequiresBuilders('active'), true); + assert.equal(innerKeepLifecycleRequiresBuilders('inactive-after-activation'), true); + assert.equal(innerKeepLifecycleRequiresBuilders('invalid'), false); + assert.equal(innerKeepActivationLifecycle({ + active: true, + activatedAt: undefined, + }), 'invalid'); +}); + +test('Inner Keep policy and layout digests detect checked-in drift', () => { + assert.equal( + createHash('sha256').update(canonicalInnerKeepPolicyDigestInput()).digest('hex'), + INNER_KEEP_POLICY_DIGEST, + ); + assert.equal( + createHash('sha256').update(canonicalInnerKeepLayoutDigestInput()).digest('hex'), + INNER_KEEP_LAYOUT_DIGEST, + ); + assert.match(INNER_KEEP_ASSET_CATALOG_DIGEST, /^[0-9a-f]{64}$/); + assert.equal(matchesCanonicalInnerKeepLayout(CANONICAL_INNER_KEEP_LAYOUT), true); + assert.equal(matchesCanonicalInnerKeepLayout({ + ...CANONICAL_INNER_KEEP_LAYOUT, + layoutVersion: 2, + }), false); +}); + +test('layout contains eight active medium and four reserved large slots', () => { + assert.equal(CANONICAL_INNER_KEEP_SLOTS.length, INNER_KEEP_SLOT_COUNT); + assert.equal(new Set(CANONICAL_INNER_KEEP_SLOTS.map(slot => slot.slotId)).size, 12); + assert.equal(new Set(CANONICAL_INNER_KEEP_SLOTS.map(slot => slot.sortOrder)).size, 12); + assert.equal( + CANONICAL_INNER_KEEP_SLOTS.filter(slot => slot.footprintClass === 'medium').length, + INNER_KEEP_MEDIUM_SLOT_COUNT, + ); + assert.equal( + CANONICAL_INNER_KEEP_SLOTS.filter(slot => slot.footprintClass === 'large').length, + INNER_KEEP_LARGE_SLOT_COUNT, + ); + for (const slot of CANONICAL_INNER_KEEP_SLOTS) { + assert.equal(matchesCanonicalInnerKeepSlot(slot), true); + assert.equal(slot.active, slot.footprintClass === 'medium'); + } +}); diff --git a/spacetimedb/tests/innerKeepProjectLifecycleStateful.test.ts b/spacetimedb/tests/innerKeepProjectLifecycleStateful.test.ts new file mode 100644 index 00000000..1f1bb3ee --- /dev/null +++ b/spacetimedb/tests/innerKeepProjectLifecycleStateful.test.ts @@ -0,0 +1,703 @@ +import assert from 'node:assert/strict'; +import { relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { build, type Plugin } from 'esbuild'; + +import type * as InnerKeepAuthority from '../src/innerKeepAuthority'; +import { + CANONICAL_INNER_KEEP_LAYOUT, + CANONICAL_INNER_KEEP_SLOTS, +} from '../src/innerKeepLayoutPolicy'; +import { + CANONICAL_INNER_KEEP_BUILDING_CATALOG, + CANONICAL_INNER_KEEP_LEVEL_POLICIES, + canonicalInnerKeepCost, + INNER_KEEP_POLICY_VERSION, + INNER_KEEP_RESOURCE_BALANCE_CAP, + type InnerKeepBuildingKind, +} from '../src/innerKeepPolicy'; + +const productionDependencyHarness: Plugin = { + name: 'warpkeep-inner-keep-stateful-runtime', + setup(buildContext) { + buildContext.onResolve( + { filter: /^spacetimedb(?:\/server)?$/ }, + args => ({ path: args.path, namespace: 'warpkeep-inner-keep-sdk' }), + ); + buildContext.onLoad( + { filter: /.*/, namespace: 'warpkeep-inner-keep-sdk' }, + args => ({ + loader: 'js', + contents: args.path === 'spacetimedb' + ? ` + export const ScheduleAt = Object.freeze({ + time(microsSinceUnixEpoch) { + return Object.freeze({ + tag: 'Time', + value: Object.freeze({ microsSinceUnixEpoch }), + }); + }, + }); + ` + : ` + export class SenderError extends Error { + constructor(message) { + super(message); + this.name = 'SenderError'; + } + } + `, + }), + ); + buildContext.onResolve( + { filter: /castleWorkerAuthority$/ }, + () => ({ path: 'castle-worker-authority', namespace: 'warpkeep-inner-keep-dependency' }), + ); + buildContext.onResolve( + { filter: /resourceAuthority$/ }, + () => ({ path: 'resource-authority', namespace: 'warpkeep-inner-keep-dependency' }), + ); + buildContext.onLoad( + { filter: /.*/, namespace: 'warpkeep-inner-keep-dependency' }, + args => ({ + loader: 'js', + contents: args.path === 'castle-worker-authority' + ? ` + export function settleAllWorkerAssignmentsForFid(ctx, fid, now) { + ctx.__innerKeepTestSettle(fid, now); + } + export function projectMyWorkerState(ctx, fid, now) { + return ctx.__innerKeepTestProject(fid, now); + } + ` + : ` + export function assertGenesisResourceForFid(ctx, fid) { + return { account: ctx.__innerKeepTestResource(fid) }; + } + `, + }), + ); + }, +}; + +async function loadExactProductionModule(sourceUrl: URL): Promise { + const sourcePath = fileURLToPath(sourceUrl); + const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url)); + const result = await build({ + absWorkingDir: repositoryRoot, + bundle: true, + entryPoints: [sourcePath], + format: 'esm', + logLevel: 'silent', + metafile: true, + platform: 'node', + plugins: [productionDependencyHarness], + target: 'node22', + treeShaking: true, + write: false, + }); + assert.equal(result.outputFiles.length, 1); + const exactInput = relative(repositoryRoot, sourcePath).split(sep).join('/'); + assert.ok(Object.hasOwn(result.metafile.inputs, exactInput)); + const encoded = Buffer.from(result.outputFiles[0]!.contents).toString('base64'); + return import(`data:text/javascript;base64,${encoded}`) as Promise; +} + +const { + runInnerKeepConstructionSchedule, + startInnerKeepProject, +} = await loadExactProductionModule( + new URL('../src/innerKeepAuthority.ts', import.meta.url), +); + +type AnyRow = Record; +type StartContext = Parameters[0]; +type StartCastle = Parameters[1]['castle']; +type ScheduleInput = Parameters[1]; + +const STARTED_AT_MICROS = 1_900_000_000_000_000n; +const FID = 77_001n; +const CASTLE_ID = 1n; + +function timestamp(microsSinceUnixEpoch: bigint) { + return { microsSinceUnixEpoch }; +} + +function zeroCountTable() { + return { count: () => 0n }; +} + +function requestKey(label: string, ordinal = 0): string { + return `inner-keep-${label}-${ordinal.toString().padStart(3, '0')}`; +} + +function replaceMap(map: Map, entries: readonly (readonly [Key, Value])[]): void { + map.clear(); + for (const [key, value] of entries) map.set(key, structuredClone(value)); +} + +type FixtureOptions = Readonly<{ + balance?: bigint; + settlementAward?: bigint; +}>; + +function makeFixture(options: FixtureOptions = {}) { + const layout = { + ...CANONICAL_INNER_KEEP_LAYOUT, + active: true, + createdAt: timestamp(STARTED_AT_MICROS - 1_000_000n), + activatedAt: timestamp(STARTED_AT_MICROS - 500_000n), + }; + const slots = new Map(CANONICAL_INNER_KEEP_SLOTS.map(row => [row.slotId, { ...row }])); + const catalogs = new Map(CANONICAL_INNER_KEEP_BUILDING_CATALOG.map(row => { + const { baseCost: _baseCost, ...stored } = row; + return [stored.buildingKind, { ...stored }] as const; + })); + const levels = new Map(CANONICAL_INNER_KEEP_LEVEL_POLICIES.map(row => [row.levelKey, { ...row }])); + const castle: StartCastle = { + castleId: CASTLE_ID, + ownerFid: FID, + tileKey: '0,0', + q: 0, + r: 0, + level: 1, + name: 'Stateful Keep', + createdAt: timestamp(STARTED_AT_MICROS - 2_000_000n), + } as StartCastle; + let builder: AnyRow = { + castleId: CASTLE_ID, + fid: FID, + activeBuildingKey: undefined, + busyUntilMicros: undefined, + revision: 0n, + policyVersion: INNER_KEEP_POLICY_VERSION, + createdAt: timestamp(STARTED_AT_MICROS - 500_000n), + updatedAt: timestamp(STARTED_AT_MICROS - 500_000n), + }; + let resource: AnyRow = { + fid: FID, + castleId: CASTLE_ID, + realmId: 'GENESIS_001', + food: options.balance ?? 10_000n, + wood: options.balance ?? 10_000n, + stone: options.balance ?? 10_000n, + gold: options.balance ?? 10_000n, + settledThroughMicros: STARTED_AT_MICROS - 60_000_000n, + revision: 0n, + policyVersion: 'genesis-001-resources-v1', + createdAt: timestamp(STARTED_AT_MICROS - 1_000_000n), + updatedAt: timestamp(STARTED_AT_MICROS - 1_000_000n), + }; + const buildings = new Map(); + const receipts = new Map(); + const schedules = new Map(); + let nextScheduleId = 1n; + let now = STARTED_AT_MICROS; + let corruptCompletionWrite = false; + const settlementCalls: Array> = []; + const snapshot = () => structuredClone({ + builder, + resource, + buildings: [...buildings.entries()], + receipts: [...receipts.entries()], + schedules: [...schedules.entries()], + nextScheduleId, + }); + + const restore = (state: ReturnType) => { + builder = structuredClone(state.builder); + resource = structuredClone(state.resource); + replaceMap(buildings, state.buildings); + replaceMap(receipts, state.receipts); + replaceMap(schedules, state.schedules); + nextScheduleId = state.nextScheduleId; + }; + + const ctx = { + get timestamp() { + return timestamp(now); + }, + __innerKeepTestSettle(fid: bigint, observedAtMicros: bigint) { + assert.equal(fid, FID); + settlementCalls.push(Object.freeze({ fid, now: observedAtMicros })); + if (observedAtMicros <= resource.settledThroughMicros) return; + const award = options.settlementAward ?? 0n; + if (award > 0n) { + resource = { + ...resource, + food: resource.food + award > INNER_KEEP_RESOURCE_BALANCE_CAP + ? INNER_KEEP_RESOURCE_BALANCE_CAP + : resource.food + award, + wood: resource.wood + award > INNER_KEEP_RESOURCE_BALANCE_CAP + ? INNER_KEEP_RESOURCE_BALANCE_CAP + : resource.wood + award, + stone: resource.stone + award > INNER_KEEP_RESOURCE_BALANCE_CAP + ? INNER_KEEP_RESOURCE_BALANCE_CAP + : resource.stone + award, + gold: resource.gold + award > INNER_KEEP_RESOURCE_BALANCE_CAP + ? INNER_KEEP_RESOURCE_BALANCE_CAP + : resource.gold + award, + settledThroughMicros: observedAtMicros, + revision: resource.revision + 1n, + updatedAt: timestamp(observedAtMicros), + }; + } + }, + __innerKeepTestProject(fid: bigint) { + assert.equal(fid, FID); + return { balances: resource }; + }, + __innerKeepTestResource(fid: bigint) { + assert.equal(fid, FID); + return resource; + }, + db: { + innerKeepLayoutV1: { + count: () => 1n, + layoutId: { find: (key: string) => key === layout.layoutId ? layout : null }, + }, + innerKeepSlotV1: { + count: () => BigInt(slots.size), + slotId: { find: (key: string) => slots.get(key) ?? null }, + }, + innerKeepBuildingCatalogV1: { + count: () => BigInt(catalogs.size), + buildingKind: { find: (key: string) => catalogs.get(key) ?? null }, + }, + innerKeepBuildLevelV1: { + count: () => BigInt(levels.size), + levelKey: { find: (key: string) => levels.get(key) ?? null }, + }, + castle: { + count: () => 1n, + castleId: { find: (key: bigint) => key === CASTLE_ID ? castle : null }, + }, + castleInnerBuilderV1: { + castleId: { + find: (key: bigint) => key === CASTLE_ID ? builder : null, + update: (row: AnyRow) => { + builder = corruptCompletionWrite + && row.activeBuildingKey === undefined + && builder.activeBuildingKey !== undefined + ? { ...row, activeBuildingKey: builder.activeBuildingKey, busyUntilMicros: builder.busyUntilMicros } + : { ...row }; + return builder; + }, + }, + fid: { find: (key: bigint) => key === FID ? builder : null }, + }, + castleInnerKeepBuildingV1: { + count: () => BigInt(buildings.size), + byCastle: { + filter: (key: bigint) => [...buildings.values()].filter(row => row.castleId === key), + }, + slotKey: { + find: (key: string) => [...buildings.values()].find(row => row.slotKey === key) ?? null, + }, + buildingKey: { + find: (key: string) => buildings.get(key) ?? null, + update: (row: AnyRow) => { + if (!buildings.has(row.buildingKey)) throw new Error('TEST_BUILDING_UPDATE_MISSING'); + buildings.set(row.buildingKey, { ...row }); + return row; + }, + }, + insert: (row: AnyRow) => { + if (buildings.has(row.buildingKey)) throw new Error('TEST_BUILDING_DUPLICATE'); + if ([...buildings.values()].some(existing => existing.slotKey === row.slotKey)) { + throw new Error('TEST_SLOT_DUPLICATE'); + } + buildings.set(row.buildingKey, { ...row }); + return row; + }, + }, + castleInnerBuildReceiptV1: { + count: () => BigInt(receipts.size), + receiptKey: { find: (key: string) => receipts.get(key) ?? null }, + insert: (row: AnyRow) => { + if (receipts.has(row.receiptKey)) throw new Error('TEST_RECEIPT_DUPLICATE'); + receipts.set(row.receiptKey, { ...row }); + return row; + }, + }, + castleInnerConstructionScheduleV1: { + count: () => BigInt(schedules.size), + byBuilding: { + filter: (key: string) => [...schedules.values()].filter(row => row.buildingKey === key), + }, + scheduleId: { + find: (key: bigint) => schedules.get(key) ?? null, + delete: (key: bigint) => schedules.delete(key), + }, + insert: (row: AnyRow) => { + const inserted = { ...row, scheduleId: nextScheduleId }; + schedules.set(nextScheduleId, inserted); + nextScheduleId += 1n; + return inserted; + }, + }, + resourceAccountV1: { + fid: { + find: (key: bigint) => key === FID ? resource : null, + update: (row: AnyRow) => { + resource = { ...row }; + return resource; + }, + }, + }, + realmWorkerSystemV1: { + count: () => 1n, + realmId: { + find: (key: string) => key === 'GENESIS_001' + ? { + mode: 'active', + legacyDrainRequired: false, + expectedCastleCount: 1, + expectedWorkerCount: 4, + } + : null, + }, + }, + castleWorkerV1: { count: () => 4n }, + goldExpeditionV1: zeroCountTable(), + foodExpeditionV1: zeroCountTable(), + woodExpeditionV1: zeroCountTable(), + stoneExpeditionV1: zeroCountTable(), + goldNodeOccupationV1: zeroCountTable(), + foodNodeOccupationV1: zeroCountTable(), + woodNodeOccupationV1: zeroCountTable(), + stoneNodeOccupationV1: zeroCountTable(), + goldExpeditionScheduleV1: zeroCountTable(), + foodExpeditionScheduleV1: zeroCountTable(), + woodExpeditionScheduleV1: zeroCountTable(), + stoneExpeditionScheduleV1: zeroCountTable(), + }, + } as unknown as StartContext; + + function transaction(work: () => Result): Result { + const before = snapshot(); + try { + return work(); + } catch (error) { + restore(before); + throw error; + } + } + + function start( + buildingKind: InnerKeepBuildingKind, + key: string, + slotId = 'inner-keep-slot-m01', + ) { + return transaction(() => startInnerKeepProject(ctx, { + fid: FID, + castle, + slotId, + buildingKind, + requestKey: key, + })); + } + + return { + builder: () => builder, + buildings, + castle, + ctx, + receipts, + resource: () => resource, + schedules, + settlementCalls, + snapshot, + start, + transaction, + setBalance(kind: 'food' | 'wood' | 'stone' | 'gold', value: bigint) { + resource = { ...resource, [kind]: value }; + }, + setNow(value: bigint) { + now = value; + }, + setCorruptCompletionWrite(value: boolean) { + corruptCompletionWrite = value; + }, + mutateBuilder(mutator: (row: AnyRow) => AnyRow) { + builder = mutator(structuredClone(builder)); + }, + }; +} + +function onlySchedule(fixture: ReturnType): AnyRow { + assert.equal(fixture.schedules.size, 1); + return structuredClone([...fixture.schedules.values()][0]!); +} + +test('a serialized twenty-request distinct-key burst accepts one project exactly once', () => { + const fixture = makeFixture(); + const beforeResource = fixture.resource(); + const first = fixture.start('city-mill', requestKey('tab-a')); + assert.equal(first.idempotent, false); + + assert.throws( + () => fixture.start('lumber-camp', requestKey('tab-b'), 'inner-keep-slot-m02'), + /INNER_KEEP_BUILDER_BUSY/, + ); + for (let index = 0; index < 18; index += 1) { + assert.throws( + () => fixture.start('city-stoneworks', requestKey('rapid', index), 'inner-keep-slot-m03'), + /INNER_KEEP_BUILDER_BUSY/, + ); + } + + assert.equal(fixture.buildings.size, 1); + assert.equal(fixture.schedules.size, 1); + assert.equal(fixture.receipts.size, 1); + assert.equal(fixture.resource().revision, beforeResource.revision + 1n); + assert.deepEqual( + { + food: fixture.resource().food, + wood: fixture.resource().wood, + stone: fixture.resource().stone, + gold: fixture.resource().gold, + }, + { food: 9_700n, wood: 9_100n, stone: 9_400n, gold: 10_000n }, + ); + assert.equal(fixture.builder().activeBuildingKey, `${CASTLE_ID}:city-mill`); +}); + +test('twenty serialized same-key retries reuse one receipt', () => { + const fixture = makeFixture(); + const key = requestKey('same-command'); + const outcomes = [fixture.start('city-mill', key)]; + for (let index = 1; index < 20; index += 1) { + outcomes.push(fixture.start('city-mill', key)); + } + assert.equal(outcomes.filter(outcome => !outcome.idempotent).length, 1); + assert.equal(outcomes.filter(outcome => outcome.idempotent).length, 19); + assert.equal(fixture.buildings.size, 1); + assert.equal(fixture.schedules.size, 1); + assert.equal(fixture.receipts.size, 1); + assert.equal(fixture.resource().revision, 1n); + assert.equal(fixture.settlementCalls.length, 1); +}); + +test('harnessed same-timestamp settlement feeds the stored account before exact deduction', () => { + const fixture = makeFixture({ settlementAward: 25n }); + const result = fixture.start('city-mill', requestKey('settlement')); + + assert.equal(fixture.settlementCalls.length, 1); + assert.deepEqual(fixture.settlementCalls[0], { fid: FID, now: STARTED_AT_MICROS }); + assert.equal(fixture.resource().settledThroughMicros, STARTED_AT_MICROS); + assert.equal(fixture.resource().revision, 2n); + assert.deepEqual( + { + food: fixture.resource().food, + wood: fixture.resource().wood, + stone: fixture.resource().stone, + gold: fixture.resource().gold, + }, + { food: 9_725n, wood: 9_125n, stone: 9_425n, gold: 10_025n }, + ); + assert.deepEqual( + { + food: result.receipt.deductedFood, + wood: result.receipt.deductedWood, + stone: result.receipt.deductedStone, + gold: result.receipt.deductedGold, + }, + { food: 300n, wood: 900n, stone: 600n, gold: 0n }, + ); +}); + +test('each insufficient resource rolls settlement and every project write back atomically', () => { + const cost = canonicalInnerKeepCost('city-goldworks', 1, {}).effectiveCost; + for (const [kind, code] of [ + ['food', 'INNER_KEEP_INSUFFICIENT_FOOD'], + ['wood', 'INNER_KEEP_INSUFFICIENT_WOOD'], + ['stone', 'INNER_KEEP_INSUFFICIENT_STONE'], + ['gold', 'INNER_KEEP_INSUFFICIENT_GOLD'], + ] as const) { + const fixture = makeFixture({ settlementAward: 25n }); + fixture.setBalance(kind, cost[kind] - 26n); + const before = fixture.snapshot(); + assert.throws( + () => fixture.start('city-goldworks', requestKey(`insufficient-${kind}`)), + new RegExp(code), + ); + assert.deepEqual(fixture.snapshot(), before); + assert.equal(fixture.settlementCalls.length, 1); + assert.equal(fixture.buildings.size, 0); + assert.equal(fixture.schedules.size, 0); + assert.equal(fixture.receipts.size, 0); + assert.equal(fixture.builder().activeBuildingKey, undefined); + } +}); + +test('only the harnessed stored account can satisfy a project', () => { + const insufficient = makeFixture(); + insufficient.setBalance('gold', 499n); + assert.throws( + () => insufficient.start('city-goldworks', requestKey('pending-not-spendable')), + /INNER_KEEP_INSUFFICIENT_GOLD/, + ); + assert.equal(insufficient.buildings.size, 0); +}); + +test('early, tampered, persisted-drift, and Builder-mismatch callbacks fail without mutation', () => { + const cases = [ + { + label: 'early', + prepare(fixture: ReturnType, schedule: AnyRow) { + fixture.setNow(schedule.scheduledAt.value.microsSinceUnixEpoch - 1n); + return schedule; + }, + code: /INNER_KEEP_COMPLETION_EARLY/, + }, + { + label: 'tampered-revision', + prepare(fixture: ReturnType, schedule: AnyRow) { + fixture.setNow(schedule.scheduledAt.value.microsSinceUnixEpoch); + return { ...schedule, expectedRevision: schedule.expectedRevision + 1n }; + }, + code: /INNER_KEEP_SCHEDULE_INTEGRITY/, + }, + { + label: 'tampered-target', + prepare(fixture: ReturnType, schedule: AnyRow) { + fixture.setNow(schedule.scheduledAt.value.microsSinceUnixEpoch); + return { ...schedule, expectedTargetLevel: schedule.expectedTargetLevel + 1 }; + }, + code: /INNER_KEEP_SCHEDULE_INTEGRITY/, + }, + { + label: 'persisted-revision-drift', + prepare(fixture: ReturnType, schedule: AnyRow) { + fixture.setNow(schedule.scheduledAt.value.microsSinceUnixEpoch); + fixture.schedules.set(schedule.scheduleId, { + ...schedule, + expectedRevision: schedule.expectedRevision + 1n, + }); + return schedule; + }, + code: /INNER_KEEP_SCHEDULE_INTEGRITY/, + }, + { + label: 'persisted-target-drift', + prepare(fixture: ReturnType, schedule: AnyRow) { + fixture.setNow(schedule.scheduledAt.value.microsSinceUnixEpoch); + fixture.schedules.set(schedule.scheduleId, { + ...schedule, + expectedTargetLevel: schedule.expectedTargetLevel + 1, + }); + return schedule; + }, + code: /INNER_KEEP_SCHEDULE_INTEGRITY/, + }, + { + label: 'builder-mismatch', + prepare(fixture: ReturnType, schedule: AnyRow) { + fixture.setNow(schedule.scheduledAt.value.microsSinceUnixEpoch); + fixture.mutateBuilder(row => ({ ...row, activeBuildingKey: `${CASTLE_ID}:lumber-camp` })); + return schedule; + }, + code: /INNER_KEEP_SCHEDULE_INTEGRITY/, + }, + ] as const; + + for (const scenario of cases) { + const fixture = makeFixture(); + fixture.start('city-mill', requestKey(`completion-${scenario.label}`)); + const callback = scenario.prepare(fixture, onlySchedule(fixture)); + const before = fixture.snapshot(); + assert.throws( + () => fixture.transaction(() => runInnerKeepConstructionSchedule( + fixture.ctx, + callback as ScheduleInput, + )), + scenario.code, + ); + assert.deepEqual(fixture.snapshot(), before); + } +}); + +test('exact and late callbacks complete without a claim, refund, or duplicate effect', () => { + for (const lateness of [0n, 60_000_000n]) { + const fixture = makeFixture(); + fixture.start('city-mill', requestKey(`complete-${lateness.toString()}`)); + const callback = onlySchedule(fixture); + const resourceBefore = structuredClone(fixture.resource()); + const receiptsBefore = structuredClone([...fixture.receipts.entries()]); + fixture.setNow(callback.scheduledAt.value.microsSinceUnixEpoch + lateness); + fixture.transaction(() => runInnerKeepConstructionSchedule( + fixture.ctx, + structuredClone(callback) as ScheduleInput, + )); + + const building = [...fixture.buildings.values()][0]!; + assert.equal(building.phase, 'complete'); + assert.equal(building.completedLevel, 1); + assert.equal(building.targetLevel, 1); + assert.equal(fixture.schedules.size, 0); + assert.equal(fixture.builder().activeBuildingKey, undefined); + assert.equal(fixture.builder().busyUntilMicros, undefined); + assert.deepEqual(fixture.resource(), resourceBefore); + assert.deepEqual([...fixture.receipts.entries()], receiptsBefore); + + const completed = fixture.snapshot(); + fixture.transaction(() => runInnerKeepConstructionSchedule( + fixture.ctx, + structuredClone(callback) as ScheduleInput, + )); + assert.deepEqual(fixture.snapshot(), completed); + } +}); + +test('post-write integrity failure rolls the schedule, building, and Builder back together', () => { + const fixture = makeFixture(); + fixture.start('city-mill', requestKey('completion-rollback')); + const callback = onlySchedule(fixture); + fixture.setNow(callback.scheduledAt.value.microsSinceUnixEpoch); + fixture.setCorruptCompletionWrite(true); + const before = fixture.snapshot(); + assert.throws( + () => fixture.transaction(() => runInnerKeepConstructionSchedule( + fixture.ctx, + callback as ScheduleInput, + )), + /INNER_KEEP_COMPLETION_INTEGRITY/, + ); + assert.deepEqual(fixture.snapshot(), before); +}); + +test('completed levels affect the next authoritative upgrade only after completion', () => { + const fixture = makeFixture(); + fixture.start('city-mill', requestKey('level-one')); + assert.throws( + () => fixture.start('city-mill', requestKey('upgrade-too-early')), + /INNER_KEEP_BUILDER_BUSY/, + ); + const callback = onlySchedule(fixture); + fixture.setNow(callback.scheduledAt.value.microsSinceUnixEpoch); + fixture.transaction(() => runInnerKeepConstructionSchedule( + fixture.ctx, + callback as ScheduleInput, + )); + + const expected = canonicalInnerKeepCost('city-mill', 2, { 'city-mill': 1 }); + const upgrade = fixture.start('city-mill', requestKey('level-two')); + assert.equal(upgrade.building.completedLevel, 1); + assert.equal(upgrade.building.targetLevel, 2); + assert.equal(upgrade.building.phase, 'constructing'); + assert.deepEqual( + { + food: upgrade.receipt.deductedFood, + wood: upgrade.receipt.deductedWood, + stone: upgrade.receipt.deductedStone, + gold: upgrade.receipt.deductedGold, + }, + expected.effectiveCost, + ); + assert.deepEqual( + expected.discountBasisPoints, + { food: 500, wood: 0, stone: 0, gold: 0 }, + ); +}); diff --git a/spacetimedb/tests/playerIdentityPrivacy.test.ts b/spacetimedb/tests/playerIdentityPrivacy.test.ts index f6b5d7b1..45af34d6 100644 --- a/spacetimedb/tests/playerIdentityPrivacy.test.ts +++ b/spacetimedb/tests/playerIdentityPrivacy.test.ts @@ -126,6 +126,7 @@ test('generated bindings contain the public projections and omit every private e assert.equal(existsSync(new URL('player_ownership_v_2_table.ts', bindingsRoot)), false); const publicTableFiles = [ + 'castle_inner_keep_building_v_1_table.ts', 'castle_slot_v_1_table.ts', 'castle_table.ts', 'castle_worker_v_1_table.ts', @@ -135,6 +136,10 @@ test('generated bindings contain the public projections and omit every private e 'gold_expedition_schedule_v_1_table.ts', 'gold_node_occupation_v_1_table.ts', 'gold_site_v_1_table.ts', + 'inner_keep_build_level_v_1_table.ts', + 'inner_keep_building_catalog_v_1_table.ts', + 'inner_keep_layout_v_1_table.ts', + 'inner_keep_slot_v_1_table.ts', 'player_table.ts', 'player_v_2_table.ts', 'realm_environment_v_1_table.ts', @@ -215,6 +220,9 @@ test('generated bindings contain the public projections and omit every private e 'admin_audit', 'allowed_fid', 'alpha_terms_acceptance_v_1', + 'castle_inner_build_receipt_v_1', + 'castle_inner_builder_v_1', + 'castle_inner_construction_schedule_v_1', 'castle_slot_claim_v_1', 'fid_wallet_attribution_v_1', 'food_expedition_idempotency_v_1', diff --git a/spacetimedb/tests/resourceReducers.test.ts b/spacetimedb/tests/resourceReducers.test.ts index 5a523e4c..1ef0705f 100644 --- a/spacetimedb/tests/resourceReducers.test.ts +++ b/spacetimedb/tests/resourceReducers.test.ts @@ -87,6 +87,14 @@ test('resource and Gold prefixes remain intact through later additive suffixes', 'accessRequestV1', 'dailyMarkGrantV1', 'dailyMarkScheduleV1', + 'innerKeepLayoutV1', + 'innerKeepSlotV1', + 'innerKeepBuildingCatalogV1', + 'innerKeepBuildLevelV1', + 'castleInnerKeepBuildingV1', + 'castleInnerBuilderV1', + 'castleInnerBuildReceiptV1', + 'castleInnerConstructionScheduleV1', ]); const account = tableDefinition(schema, 'resourceAccountV1'); diff --git a/spacetimedb/tests/waterRevisionAuthority.test.ts b/spacetimedb/tests/waterRevisionAuthority.test.ts index 4d412baa..dc87abeb 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\s*realmWorkerSystemV1,\n\s*castleWorkerV1,\n\s*workerAssignmentV1,\n\s*workerNodeOccupationV1,\n\s*workerCommandIdempotencyV1,\n\s*workerAssignmentScheduleV1,\n\s*accessRequestV1,\n\s*dailyMarkGrantV1,\n\s*dailyMarkScheduleV1,\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\s*accessRequestV1,\n\s*dailyMarkGrantV1,\n\s*dailyMarkScheduleV1,\n\s*\/\/ Additive v15 Inner Keep suffix\.[\s\S]*\}\);/); }); diff --git a/spacetimedb/tests/waterRevisionMigrationTooling.test.ts b/spacetimedb/tests/waterRevisionMigrationTooling.test.ts index 2efe2a86..98125382 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 retains the populated v10 to v11 proof inside protocol v14', () => { +test('the migration verifier retains the populated v10 to v11 proof inside protocol v15', () => { 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 = 14/); + assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 15/); 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 retains the populated v10 to v11 proof inside proto 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, /v14 boundary must refuse its immediate predecessor/); + assert.match(verifier, /v15 boundary must refuse its immediate predecessor/); assert.match(verifier, /deployedV11Tables/); - assert.match(verifier, /populated Water\/Stone\/Water-revision fixtures remained preserved through v14/); + assert.match(verifier, /populated Water\/Stone\/Water-revision fixtures remained preserved through v15/); 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 retains the populated v10 to v11 proof inside proto assert.ok(inspectionFixtures.length >= 4); assert.deepEqual( new Set(inspectionFixtures), - new Set(['additiveV14SchemaFixture']), + new Set(['additiveV15SchemaFixture']), ); }); diff --git a/src/components/WarpkeepExperience.tsx b/src/components/WarpkeepExperience.tsx index 7bf35cab..7f5f979c 100644 --- a/src/components/WarpkeepExperience.tsx +++ b/src/components/WarpkeepExperience.tsx @@ -1595,6 +1595,22 @@ export function WarpkeepExperience() { workerProjection={backend.state.workerProjection} workerRoster={backend.state.workerRoster} workerResourceState={backend.state.workerResourceState} + innerKeep={backend.state.innerKeep} + onStartInnerKeepProject={ + backend.state.phase === 'ready' + && backend.state.admission === 'ready' + && backendMutationAuthorityCurrent + && backend.state.innerKeep?.phase === 'ready' + && backend.state.innerKeep.commandsEnabled + ? backend.startInnerKeepProject + : undefined} + onRequestInnerKeepSync={ + backend.state.phase === 'ready' + && backend.state.admission === 'ready' + && backendMutationAuthorityCurrent + && backend.state.innerKeep !== undefined + ? backend.retryInnerKeepSync + : undefined} workerPrivateSync={backend.workerPrivateSync} onRetryWorkerPrivateSync={backend.retryWorkerPrivateSync} onDispatchWorker={ @@ -1688,7 +1704,7 @@ export function WarpkeepExperience() { suspended={miniAppEntryGateActive} /> - + ); } diff --git a/src/components/audio/WarpkeepHapticsDirector.tsx b/src/components/audio/WarpkeepHapticsDirector.tsx index c1cac685..7a5be502 100644 --- a/src/components/audio/WarpkeepHapticsDirector.tsx +++ b/src/components/audio/WarpkeepHapticsDirector.tsx @@ -41,12 +41,19 @@ export function resolveWarpkeepHapticCue( event.kind === 'worker-dispatch-confirmed' || event.kind === 'worker-recall-confirmed' || event.kind === 'access-request-confirmed' + || event.kind === 'inner-keep-project-completed' ))) { return Object.freeze({ kind: 'notification', type: 'success' }); } if (events.some(event => SELECTION_EVENT_KINDS.has(event.kind))) { return Object.freeze({ kind: 'selection' }); } + if (events.some(event => event.kind === 'inner-keep-project-confirmed')) { + return Object.freeze({ kind: 'impact', type: 'soft' }); + } + if (events.some(event => event.kind === 'inner-keep-menu-opened')) { + return Object.freeze({ kind: 'impact', type: 'light' }); + } if (events.some(event => event.kind === 'ui-open')) { return Object.freeze({ kind: 'impact', type: 'soft' }); } @@ -64,11 +71,14 @@ export function resolveWarpkeepHapticCue( * Ordinary browsers resolve every optional host call to false and keep the * complete visual and accessible feedback path unchanged. */ -export function WarpkeepHapticsDirector() { +export function WarpkeepHapticsDirector({ muted = false }: Readonly<{ + muted?: boolean; +}>) { const { haptics } = useMiniAppHost(); useEffect(() => { const unsubscribe = subscribeWarpkeepSfx((events) => { + if (muted) return; const cue = resolveWarpkeepHapticCue(events); if (!cue) return; if (cue.kind === 'selection') { @@ -82,7 +92,7 @@ export function WarpkeepHapticsDirector() { return () => { unsubscribe(); }; - }, [haptics]); + }, [haptics, muted]); return null; } diff --git a/src/components/audio/proceduralSfxEngine.ts b/src/components/audio/proceduralSfxEngine.ts index a8d86413..60661c5d 100644 --- a/src/components/audio/proceduralSfxEngine.ts +++ b/src/components/audio/proceduralSfxEngine.ts @@ -121,6 +121,9 @@ const EVENT_COOLDOWN_MILLISECONDS: Readonly) | Readonly<{ kind: 'access-request-confirmed' }> + | Readonly<{ + kind: + | 'inner-keep-menu-opened' + | 'inner-keep-project-confirmed' + | 'inner-keep-project-completed'; + }> | Readonly<{ kind: 'command-failed' }> | (ScreenPosition & Readonly<{ kind: 'river-focus-entered' }>) | Readonly<{ kind: 'river-focus-left' }>; @@ -58,6 +64,9 @@ export const WARPKEEP_SFX_EVENT_KINDS = Object.freeze([ 'worker-arrived', 'worker-returned', 'access-request-confirmed', + 'inner-keep-menu-opened', + 'inner-keep-project-confirmed', + 'inner-keep-project-completed', 'command-failed', 'river-focus-entered', 'river-focus-left' diff --git a/src/components/inner-keep/InnerKeepScreen.css b/src/components/inner-keep/InnerKeepScreen.css new file mode 100644 index 00000000..6a7a316d --- /dev/null +++ b/src/components/inner-keep/InnerKeepScreen.css @@ -0,0 +1,1191 @@ +.inner-keep { + --inner-panel: rgb(10 9 15 / 96%); + --inner-border: rgb(225 190 105 / 42%); + --inner-gold: #f0d58c; + --inner-ivory: #fff7e5; + --inner-muted: #c9bfd0; + position: fixed; + z-index: 40; + inset: 0; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + min-width: 0; + min-height: 100vh; + min-height: 100dvh; + overflow: hidden; + color: var(--inner-ivory); + background: + radial-gradient(circle at 50% 32%, rgb(146 163 104 / 18%), transparent 45%), + linear-gradient(152deg, #172116, #252c1d 54%, #11140f); + isolation: isolate; +} + +.inner-keep button { + min-width: 44px; + min-height: 44px; + border: 1px solid var(--inner-border); + border-radius: 0.48rem; + background: linear-gradient(145deg, rgb(77 42 93 / 96%), rgb(39 23 48 / 96%)); + color: var(--inner-ivory); + font: 760 0.72rem/1.2 Inter, ui-sans-serif, system-ui, sans-serif; + letter-spacing: 0.045em; + cursor: pointer; +} + +.inner-keep button:hover:not(:disabled), +.inner-keep button:focus-visible { + border-color: var(--inner-gold); + outline: 2px solid var(--inner-gold); + outline-offset: 2px; +} + +.inner-keep button:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.inner-keep__header { + position: relative; + z-index: 8; + display: grid; + grid-template-columns: minmax(4.5rem, auto) minmax(0, 1fr) minmax(7rem, auto); + gap: 0.6rem; + align-items: center; + min-width: 0; + padding: + max(0.55rem, var(--realm-safe-top, env(safe-area-inset-top))) + max(0.65rem, var(--realm-safe-right, env(safe-area-inset-right))) + 0.48rem + max(0.65rem, var(--realm-safe-left, env(safe-area-inset-left))); + border-bottom: 1px solid rgb(225 190 105 / 22%); + background: rgb(8 7 12 / 90%); + box-shadow: 0 0.5rem 1.8rem rgb(0 0 0 / 24%); + backdrop-filter: blur(12px); +} + +.inner-keep__header > div { + min-width: 0; + text-align: center; +} + +.inner-keep__header p, +.inner-keep-panel header p, +.inner-keep-catalogue p:first-of-type, +.inner-keep-active-project > p:first-of-type, +.inner-keep-building-detail > p:first-of-type { + margin: 0; + color: #cba8ed; + font-size: 0.62rem; + font-weight: 820; + letter-spacing: 0.14em; +} + +.inner-keep__header h1 { + margin: 0.08rem 0 0; + overflow: hidden; + color: var(--inner-gold); + font: 500 clamp(1.05rem, 3.5vw, 1.55rem)/1 Georgia, "Times New Roman", serif; + letter-spacing: 0.08em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inner-keep__header button:first-child { + display: inline-flex; + gap: 0.28rem; + align-items: center; + justify-content: center; +} + +.inner-keep__header button:first-child span { + font: 400 1.25rem/0.7 Georgia, "Times New Roman", serif; +} + +.inner-keep__resources { + position: relative; + z-index: 7; + padding: 0.38rem max(0.6rem, var(--realm-safe-right, 0px)) 0.38rem max(0.6rem, var(--realm-safe-left, 0px)); + border-bottom: 1px solid rgb(225 190 105 / 16%); + background: rgb(12 12 14 / 76%); + backdrop-filter: blur(10px); +} + +.inner-keep__resources ul { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.35rem; + width: min(42rem, 100%); + margin: 0 auto; + padding: 0; + list-style: none; +} + +.inner-keep__authority-status { + display: flex; + gap: 0.55rem; + align-items: center; + justify-content: center; + width: min(42rem, 100%); + margin: 0.38rem auto 0; + padding-top: 0.38rem; + border-top: 1px solid rgb(225 190 105 / 18%); +} + +.inner-keep__authority-status p { + min-width: 0; + margin: 0; + color: var(--inner-muted); + font-size: 0.72rem; + line-height: 1.35; +} + +.inner-keep__authority-status button { + flex: 0 0 auto; +} + +@media (max-width: 34rem) { + .inner-keep__authority-status { + align-items: stretch; + flex-direction: column; + } +} + +.inner-keep__resources li { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0 0.3rem; + align-items: center; + min-width: 0; +} + +.inner-keep__resources img { + grid-row: 1 / span 3; + width: 1.9rem; + height: 1.9rem; + object-fit: contain; + filter: drop-shadow(0 0.18rem 0.22rem rgb(0 0 0 / 58%)); +} + +.inner-keep__resources span, +.inner-keep__resources small { + overflow: hidden; + color: var(--inner-muted); + font-size: 0.58rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inner-keep__resources strong { + overflow: hidden; + color: var(--inner-gold); + font-size: 0.84rem; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inner-keep__stage { + position: relative; + z-index: 1; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.inner-keep-map { + position: absolute; + inset: 0; + display: grid; + padding: clamp(0.5rem, 2vw, 1.3rem); + place-items: center; + overflow: auto; + overscroll-behavior: contain; + background: + radial-gradient(ellipse at 50% 44%, rgb(178 178 115 / 17%), transparent 43%), + repeating-linear-gradient(34deg, transparent 0 32px, rgb(255 255 255 / 1.2%) 33px 34px), + linear-gradient(148deg, #40513a, #657052 48%, #343e30); +} + +.inner-keep-map::before, +.inner-keep-map::after { + position: absolute; + inset: 0; + pointer-events: none; + content: ""; +} + +.inner-keep-map::before { + background: + radial-gradient(circle at 8% 12%, #1c3423 0 4%, transparent 4.2%), + radial-gradient(circle at 92% 16%, #193121 0 5%, transparent 5.2%), + radial-gradient(circle at 4% 80%, #1a3020 0 6%, transparent 6.2%), + radial-gradient(circle at 96% 78%, #203523 0 7%, transparent 7.2%); + opacity: 0.92; +} + +.inner-keep-map::after { + box-shadow: inset 0 0 5rem rgb(3 7 4 / 54%); +} + +.inner-keep-map__wall { + position: absolute; + z-index: 0; + inset: 4%; + border: clamp(0.42rem, 1vw, 0.72rem) ridge #6e5838; + border-radius: 12% 8% 13% 7%; + box-shadow: + inset 0 0 0 2px rgb(31 20 11 / 65%), + 0 0.7rem 1.3rem rgb(0 0 0 / 22%); + pointer-events: none; +} + +.inner-keep-map__road { + position: absolute; + z-index: 0; + border: 1px solid rgb(104 83 51 / 44%); + background: + repeating-linear-gradient(90deg, #937d5a 0 14px, #8a7351 14px 27px); + box-shadow: inset 0 0 0.7rem rgb(60 40 24 / 28%); + opacity: 0.78; + pointer-events: none; +} + +.inner-keep-map__road--vertical { + top: 7%; + bottom: 4%; + left: calc(50% - 1.45rem); + width: 2.9rem; + transform: perspective(20rem) rotateX(8deg); +} + +.inner-keep-map__road--horizontal { + top: calc(50% - 1.1rem); + right: 6%; + left: 6%; + height: 2.2rem; +} + +.inner-keep-map__keep { + position: absolute; + z-index: 2; + top: 50%; + left: 50%; + display: grid; + width: min(28%, 13rem); + min-width: 7.5rem; + min-height: 6.2rem; + box-sizing: border-box; + padding: 0.75rem; + place-items: center; + border: 2px solid #a58c57; + border-radius: 0.6rem 0.6rem 1.1rem 1.1rem; + background: + linear-gradient(90deg, transparent 47%, #332a27 48% 52%, transparent 53%), + linear-gradient(140deg, #9a8b70, #5c5143); + box-shadow: 0 1rem 1.2rem rgb(0 0 0 / 38%); + text-align: center; + pointer-events: none; + transform: translate(-50%, -50%); +} + +.inner-keep-map__keep > span { + width: 65%; + height: 2.8rem; + border: 0.26rem solid #504435; + border-bottom: 0; + background: + linear-gradient(90deg, #72614d 0 19%, transparent 20% 40%, #72614d 41% 59%, transparent 60% 80%, #72614d 81%); +} + +.inner-keep-map__keep strong { + color: #fff0c3; + font: 500 0.8rem/1.1 Georgia, "Times New Roman", serif; + letter-spacing: 0.06em; +} + +.inner-keep-map__keep small { + color: #e2d5b8; + font-size: 0.6rem; +} + +.inner-keep-map__slots { + position: relative; + z-index: 3; + display: grid; + grid-template-columns: repeat(4, minmax(5.6rem, 1fr)); + grid-template-rows: repeat(4, minmax(5.2rem, 1fr)); + gap: clamp(0.35rem, 1.2vw, 0.78rem); + width: min(64rem, 92%); + min-width: min(34rem, 92vw); + height: min(42rem, 96%); + min-height: 24rem; + margin: auto; + padding: 0; + list-style: none; +} + +.inner-keep-map__slots li:nth-child(1) { grid-area: 1 / 1; } +.inner-keep-map__slots li:nth-child(2) { grid-area: 1 / 2; } +.inner-keep-map__slots li:nth-child(3) { grid-area: 1 / 3; } +.inner-keep-map__slots li:nth-child(4) { grid-area: 1 / 4; } +.inner-keep-map__slots li:nth-child(5) { grid-area: 2 / 1; } +.inner-keep-map__slots li:nth-child(6) { grid-area: 2 / 4; } +.inner-keep-map__slots li:nth-child(7) { grid-area: 3 / 1; } +.inner-keep-map__slots li:nth-child(8) { grid-area: 3 / 4; } +.inner-keep-map__slots li:nth-child(9) { grid-area: 4 / 1; } +.inner-keep-map__slots li:nth-child(10) { grid-area: 4 / 2; } +.inner-keep-map__slots li:nth-child(11) { grid-area: 4 / 3; } +.inner-keep-map__slots li:nth-child(12) { grid-area: 4 / 4; } + +.inner-keep-map__slots li, +.inner-keep-map__slots button { + min-width: 0; + min-height: 0; +} + +.inner-keep-map__slots button { + position: relative; + display: grid; + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 0.3rem; + place-items: center; + overflow: hidden; + border: 2px solid rgb(207 190 137 / 52%); + background: + radial-gradient(ellipse at center, rgb(205 192 143 / 22%), transparent 65%), + repeating-linear-gradient(25deg, #695f49 0 7px, #625943 7px 14px); + box-shadow: + inset 0 0 0 2px rgb(45 37 25 / 38%), + 0 0.45rem 0.8rem rgb(0 0 0 / 22%); + color: #fff2c8; + letter-spacing: normal; +} + +.inner-keep-map__slots button[aria-pressed="true"] { + border-color: #ffe39b; + box-shadow: + inset 0 0 0 2px rgb(255 227 155 / 42%), + 0 0 1.1rem rgb(255 218 119 / 45%); +} + +.inner-keep-map__slots li[data-footprint="large"] button { + background: + radial-gradient(ellipse at center, rgb(214 196 135 / 25%), transparent 67%), + repeating-linear-gradient(-25deg, #76694f 0 8px, #695e48 8px 16px); +} + +.inner-keep-map__slots li[data-active="false"] button { + border-style: dashed; + filter: saturate(0.55); +} + +/* The procedural view is another scene on the Realm's existing renderer. + * The canvas owns pointer and touch input. The offscreen native site index + * remains available to keyboards and assistive technology, and reveals + * itself when one of its controls receives focus. */ +.inner-keep[data-inner-keep-renderer="webgl"] { + pointer-events: none; + background: + radial-gradient(circle at 50% 42%, transparent 40%, rgb(4 8 4 / 30%) 100%), + linear-gradient(rgb(7 9 7 / 8%), rgb(7 9 7 / 15%)); +} + +.inner-keep[data-inner-keep-renderer="webgl"] + :is( + .inner-keep__header, + .inner-keep__resources, + .inner-keep-builder, + .inner-keep-panel, + .inner-keep__unavailable + ) { + pointer-events: auto; +} + +.inner-keep[data-inner-keep-renderer="webgl"] .inner-keep-map { + pointer-events: none; + background: transparent; +} + +.inner-keep[data-inner-keep-renderer="webgl"] .inner-keep-map::before, +.inner-keep[data-inner-keep-renderer="webgl"] .inner-keep-map::after, +.inner-keep[data-inner-keep-renderer="webgl"] + :is(.inner-keep-map__wall, .inner-keep-map__road, .inner-keep-map__keep) { + display: none; +} + +.inner-keep[data-inner-keep-renderer="webgl"] .inner-keep-map__slots { + position: fixed; + z-index: 24; + right: auto; + bottom: calc(max(0.55rem, var(--realm-safe-bottom, 0px)) + 4.7rem); + left: -200vw; + display: grid; + grid-template-columns: repeat(4, minmax(44px, 1fr)); + grid-template-rows: repeat(3, minmax(44px, auto)); + gap: 0.25rem; + width: min(22rem, calc(100vw - 1rem)); + min-width: 0; + height: auto; + min-height: 0; + margin: 0; + padding: 0.38rem; + border: 1px solid rgb(225 190 105 / 42%); + border-radius: 0.55rem; + background: rgb(8 7 12 / 94%); + box-shadow: 0 0.75rem 2rem rgb(0 0 0 / 48%); + pointer-events: none; +} + +.inner-keep[data-inner-keep-renderer="webgl"] .inner-keep-map__slots:focus-within { + left: max(0.5rem, var(--realm-safe-left, 0px)); +} + +.inner-keep[data-inner-keep-renderer="webgl"] .inner-keep-map__slots li { + position: relative; + grid-area: auto; +} + +.inner-keep[data-inner-keep-renderer="webgl"] .inner-keep-map__slots button { + position: relative; + width: 100%; + height: 100%; + border-color: rgb(238 211 143 / 28%); + background: rgb(31 24 37 / 96%); + box-shadow: inset 0 0 1.4rem rgb(255 221 137 / 5%); + pointer-events: none; + touch-action: none; +} + +.inner-keep[data-inner-keep-renderer="webgl"] + .inner-keep-map__slots button[aria-pressed="true"] { + border-color: #ffe39b; + background: rgb(55 37 62 / 22%); + box-shadow: 0 0 1.15rem rgb(255 218 119 / 44%); +} + +.inner-keep[data-inner-keep-renderer="webgl"] + .inner-keep-map__slots button + :is( + .inner-keep-slot__empty-mark, + .inner-keep-building-art, + .inner-keep-building-art-fallback, + .inner-keep-worksite + ) { + display: none; +} + +.inner-keep[data-inner-keep-renderer="webgl"] + .inner-keep-map__slots button :is(strong, small) { + display: none; +} + +.inner-keep[data-inner-keep-renderer="webgl"] .inner-keep-slot__label { + position: static; + padding: 0.12rem; + background: transparent; + font-size: 0.56rem; + white-space: normal; +} + +.inner-keep-slot__label { + position: absolute; + z-index: 5; + top: 0.18rem; + right: 0.18rem; + left: 0.18rem; + overflow: hidden; + padding: 0.17rem 0.22rem; + border-radius: 0.22rem; + background: rgb(9 8 11 / 72%); + color: #f4e3b4; + font-size: 0.55rem; + font-weight: 760; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inner-keep-map__slots button > strong { + z-index: 4; + align-self: end; + max-width: 100%; + overflow: hidden; + padding: 0.12rem 0.25rem; + border-radius: 0.2rem; + background: rgb(8 8 9 / 70%); + font-size: 0.6rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inner-keep-map__slots button > small { + z-index: 4; + max-width: 100%; + overflow: hidden; + color: #e0d5bd; + font-size: 0.52rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inner-keep-slot__empty-mark { + display: grid; + width: 2.2rem; + height: 2.2rem; + margin-top: 0.65rem; + place-items: center; + border: 1px dashed rgb(255 232 169 / 72%); + border-radius: 50%; + background: rgb(42 34 25 / 48%); + color: #ffeab0; + font-size: 1.25rem; +} + +.inner-keep-building-art, +.inner-keep-building-art-fallback { + display: block; + width: min(100%, 7rem); + height: 4.1rem; + margin: 0.45rem auto 0; + object-fit: contain; + filter: drop-shadow(0 0.45rem 0.36rem rgb(0 0 0 / 52%)); +} + +.inner-keep-building-art-fallback { + position: relative; + border-bottom: 0.7rem solid #40362c; +} + +.inner-keep-building-art-fallback span { + position: absolute; + bottom: 0; + width: 27%; + border: 2px solid #4f4336; + background: linear-gradient(#9f917b, #6e6251); +} + +.inner-keep-building-art-fallback span:nth-child(1) { + left: 6%; + height: 54%; +} + +.inner-keep-building-art-fallback span:nth-child(2) { + left: 36%; + height: 82%; +} + +.inner-keep-building-art-fallback span:nth-child(3) { + right: 6%; + height: 62%; +} + +.inner-keep-worksite { + position: relative; + display: block; + width: min(100%, 6rem); + height: 3.8rem; + margin-top: 0.55rem; + isolation: isolate; +} + +.inner-keep-worksite__foundation, +.inner-keep-worksite__scaffold, +.inner-keep-worksite__smoke, +.inner-keep-worksite__dust { + position: absolute; + display: block; + pointer-events: none; +} + +.inner-keep-worksite__foundation { + right: 6%; + bottom: 0; + left: 6%; + height: 27%; + border: 2px solid #51483c; + background: repeating-linear-gradient(90deg, #8a8272 0 14px, #70695d 14px 27px); +} + +.inner-keep-worksite__scaffold { + z-index: 1; + inset: 12% 18% 12%; + border: 0.18rem solid #744a25; + border-top-width: 0.28rem; + background: + linear-gradient(62deg, transparent 46%, #744a25 47% 52%, transparent 53%), + linear-gradient(-62deg, transparent 46%, #744a25 47% 52%, transparent 53%); +} + +.inner-keep-worksite__smoke { + z-index: 2; + width: 2.4rem; + height: 2.4rem; + border-radius: 50%; + background: radial-gradient(circle at 45% 45%, rgb(190 183 165 / 88%), rgb(88 83 75 / 65%) 48%, transparent 72%); + filter: blur(1px); + animation: inner-keep-smoke 4.8s ease-in-out infinite; +} + +.inner-keep-worksite__smoke--one { + bottom: 13%; + left: 16%; +} + +.inner-keep-worksite__smoke--two { + right: 13%; + bottom: 18%; + animation-delay: -1.7s; + animation-duration: 5.4s; +} + +.inner-keep-worksite__smoke--three { + bottom: 25%; + left: 37%; + animation-delay: -3.1s; + animation-duration: 5.8s; +} + +.inner-keep-worksite__dust { + z-index: 3; + right: 3%; + bottom: -2%; + left: 3%; + height: 1.1rem; + border-radius: 50%; + background: radial-gradient(ellipse, rgb(150 128 96 / 66%), transparent 70%); +} + +@keyframes inner-keep-smoke { + 0%, 100% { opacity: 0.68; transform: translate3d(0, 0, 0) scale(0.88); } + 44% { opacity: 0.88; transform: translate3d(0.22rem, -0.5rem, 0) scale(1.12); } + 72% { opacity: 0.64; transform: translate3d(-0.16rem, -0.72rem, 0) scale(1.22); } +} + +.inner-keep-builder { + position: relative; + z-index: 12; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.62rem; + align-items: center; + width: min(42rem, calc(100% - 1.2rem)); + min-height: 4rem; + box-sizing: border-box; + margin: 0.45rem auto max(0.45rem, var(--realm-safe-bottom, env(safe-area-inset-bottom))); + padding: 0.5rem 0.72rem; + border-color: rgb(239 211 139 / 62%); + background: linear-gradient(135deg, rgb(18 15 23 / 98%), rgb(51 31 60 / 98%)); + box-shadow: 0 0.65rem 2rem rgb(0 0 0 / 45%); + text-align: left; +} + +.inner-keep-builder__crest { + display: grid; + width: 2.6rem; + height: 2.6rem; + place-items: center; + border: 1px solid rgb(236 205 126 / 62%); + border-radius: 50%; + background: #2b1835; + color: var(--inner-gold); + font-size: 1.2rem; +} + +.inner-keep-builder > span:last-child { + display: grid; + min-width: 0; + gap: 0.1rem; +} + +.inner-keep-builder small { + color: #cba8ed; + font-size: 0.62rem; + letter-spacing: 0.11em; +} + +.inner-keep-builder strong, +.inner-keep-builder time { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inner-keep-builder strong { + color: var(--inner-ivory); + font-size: 0.78rem; +} + +.inner-keep-builder time { + color: var(--inner-gold); + font-size: 0.68rem; + font-variant-numeric: tabular-nums; +} + +.inner-keep-panel { + position: fixed; + z-index: 45; + right: 0; + bottom: 0; + left: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + max-height: min(68vh, 40rem); + max-height: min(68dvh, 40rem); + box-sizing: border-box; + padding-bottom: var(--realm-safe-bottom, env(safe-area-inset-bottom)); + overflow: hidden; + border-top: 1px solid var(--inner-border); + border-radius: 1rem 1rem 0 0; + background: var(--inner-panel); + box-shadow: 0 -1rem 3rem rgb(0 0 0 / 52%); + backdrop-filter: blur(16px); +} + +.inner-keep-panel > header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.7rem; + align-items: center; + padding: 0.72rem max(0.8rem, var(--realm-safe-right, 0px)) 0.6rem max(0.8rem, var(--realm-safe-left, 0px)); + border-bottom: 1px solid rgb(225 190 105 / 18%); +} + +.inner-keep-panel h2 { + margin: 0.12rem 0 0; + color: var(--inner-gold); + font: 500 1.2rem/1.1 Georgia, "Times New Roman", serif; +} + +.inner-keep-panel__body { + min-height: 0; + padding: 0.8rem max(0.85rem, var(--realm-safe-right, 0px)) 1rem max(0.85rem, var(--realm-safe-left, 0px)); + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +.inner-keep-panel__empty, +.inner-keep__unavailable { + color: var(--inner-muted); + line-height: 1.55; +} + +.inner-keep__unavailable { + position: absolute; + z-index: 4; + top: 50%; + left: 50%; + display: grid; + gap: 0.7rem; + width: min(26rem, calc(100% - 2rem)); + box-sizing: border-box; + padding: 1rem; + border: 1px solid var(--inner-border); + border-radius: 0.7rem; + background: var(--inner-panel); + text-align: center; + transform: translate(-50%, -50%); +} + +.inner-keep__unavailable h2, +.inner-keep__unavailable p { + margin: 0; +} + +.inner-keep-catalogue { + display: grid; + gap: 0.72rem; + margin: 0; + padding: 0; + list-style: none; +} + +.inner-keep-catalogue > li { + display: grid; + grid-template-columns: 5.7rem minmax(0, 1fr); + gap: 0.48rem 0.7rem; + padding: 0.68rem; + border: 1px solid rgb(225 190 105 / 25%); + border-radius: 0.55rem; + background: rgb(255 255 255 / 2.5%); +} + +.inner-keep-catalogue .inner-keep-building-art, +.inner-keep-catalogue .inner-keep-building-art-fallback { + grid-row: 1 / span 4; + width: 5.7rem; + margin-top: 0; +} + +.inner-keep-catalogue h3, +.inner-keep-catalogue p, +.inner-keep-catalogue small, +.inner-keep-catalogue span { + margin: 0; +} + +.inner-keep-catalogue h3 { + margin-top: 0.12rem; + color: var(--inner-ivory); + font: 500 1.05rem/1.1 Georgia, "Times New Roman", serif; +} + +.inner-keep-catalogue p:not(:first-of-type), +.inner-keep-catalogue small, +.inner-keep-catalogue span { + color: var(--inner-muted); + font-size: 0.7rem; + line-height: 1.4; +} + +.inner-keep-catalogue__timing, +.inner-keep-building-detail__next { + display: grid; + gap: 0.3rem; + margin: 0; +} + +.inner-keep-catalogue__timing > div, +.inner-keep-building-detail__next > div { + display: flex; + justify-content: space-between; + gap: 0.7rem; + padding-bottom: 0.25rem; + border-bottom: 1px solid rgb(225 190 105 / 16%); +} + +.inner-keep-catalogue__timing dt, +.inner-keep-building-detail__next dt { + color: var(--inner-muted); + font-size: 0.68rem; +} + +.inner-keep-catalogue__timing dd, +.inner-keep-building-detail__next dd { + margin: 0; + color: var(--inner-gold); + font-size: 0.7rem; + font-weight: 700; + text-align: right; +} + +.inner-keep-project-cost { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.3rem; + margin: 0; +} + +.inner-keep-project-cost > div { + display: grid; + gap: 0.12rem; + padding: 0.34rem; + border: 1px solid rgb(225 190 105 / 17%); + border-radius: 0.3rem; + background: rgb(0 0 0 / 18%); +} + +.inner-keep-project-cost dt { + color: var(--inner-muted); + font-size: 0.57rem; +} + +.inner-keep-project-cost dd { + margin: 0; + overflow: hidden; + color: var(--inner-gold); + font-size: 0.72rem; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; +} + +.inner-keep-confirmation, +.inner-keep-active-project, +.inner-keep-building-detail { + display: grid; + gap: 0.72rem; +} + +.inner-keep-confirmation > *, +.inner-keep-active-project > *, +.inner-keep-building-detail > * { + margin: 0; +} + +.inner-keep-confirmation > .inner-keep-building-art, +.inner-keep-confirmation > .inner-keep-building-art-fallback, +.inner-keep-building-detail > .inner-keep-building-art, +.inner-keep-building-detail > .inner-keep-building-art-fallback { + height: 7rem; +} + +.inner-keep-confirmation__prompt, +.inner-keep-active-project h3, +.inner-keep-building-detail h3 { + color: var(--inner-gold); + font: 500 1.28rem/1.18 Georgia, "Times New Roman", serif; + text-align: center; +} + +.inner-keep-confirmation > dl { + display: grid; + gap: 0.35rem; + margin: 0; +} + +.inner-keep-confirmation > dl div { + display: flex; + justify-content: space-between; + gap: 1rem; + padding-bottom: 0.3rem; + border-bottom: 1px solid rgb(225 190 105 / 16%); +} + +.inner-keep-confirmation dt { + color: var(--inner-muted); +} + +.inner-keep-confirmation dd { + margin: 0; + color: var(--inner-gold); + font-weight: 700; + text-align: right; +} + +.inner-keep-confirmation__rule, +.inner-keep-confirmation__blocked, +.inner-keep-active-project p, +.inner-keep-building-detail p { + color: var(--inner-muted); + font-size: 0.76rem; + line-height: 1.5; +} + +.inner-keep-confirmation__blocked { + padding: 0.55rem; + border: 1px solid rgb(230 159 126 / 32%); + border-radius: 0.4rem; + background: rgb(92 38 35 / 24%); +} + +.inner-keep-active-project .inner-keep-worksite { + width: min(100%, 14rem); + height: 8rem; + margin: 0 auto; +} + +.inner-keep-active-project > strong, +.inner-keep-active-project > time, +.inner-keep-building-detail > strong { + color: var(--inner-gold); + font-variant-numeric: tabular-nums; + text-align: center; +} + +@media (min-width: 900px) and (min-height: 620px) { + .inner-keep[data-inner-keep-valid="true"] .inner-keep__stage { + margin-right: 0; + } + + .inner-keep:has(.inner-keep-panel) .inner-keep__stage, + .inner-keep:has(.inner-keep-panel) .inner-keep-builder { + margin-right: 22rem; + } + + .inner-keep:has(.inner-keep-panel) .inner-keep-builder { + width: min(42rem, calc(100% - 23.2rem)); + } + + .inner-keep-panel { + top: calc(var(--realm-safe-top, 0px) + 0.5rem); + right: max(0.55rem, var(--realm-safe-right, 0px)); + bottom: max(0.55rem, var(--realm-safe-bottom, 0px)); + left: auto; + width: 21.5rem; + max-height: none; + padding-bottom: 0; + border: 1px solid var(--inner-border); + border-radius: 0.72rem; + box-shadow: -1rem 0 3rem rgb(0 0 0 / 43%); + } + + .inner-keep-catalogue > li { + grid-template-columns: 1fr; + } + + .inner-keep-catalogue .inner-keep-building-art, + .inner-keep-catalogue .inner-keep-building-art-fallback { + grid-row: auto; + width: 100%; + height: 7rem; + } +} + +@media (max-width: 680px), (max-height: 600px) { + .inner-keep__header { + grid-template-columns: auto minmax(0, 1fr) auto; + padding-top: max(0.42rem, var(--realm-safe-top, 0px)); + } + + .inner-keep__header button { + padding: 0 0.48rem; + font-size: 0.6rem; + } + + .inner-keep__header button:last-child { + width: 5.3rem; + } + + .inner-keep__resources { + padding-block: 0.28rem; + } + + .inner-keep__resources li { + grid-template-columns: auto minmax(0, 1fr); + } + + .inner-keep__resources img { + width: 1.55rem; + height: 1.55rem; + } + + .inner-keep__resources span { + display: none; + } + + .inner-keep__resources strong { + font-size: 0.76rem; + } + + .inner-keep__resources small { + grid-column: 1 / -1; + font-size: 0.48rem; + text-align: center; + } + + .inner-keep-map { + padding: 0.35rem; + } + + .inner-keep-map__wall { + inset: 2%; + } + + .inner-keep-map__slots { + grid-template-columns: repeat(4, minmax(4.3rem, 1fr)); + grid-template-rows: repeat(4, minmax(4rem, 1fr)); + width: 98%; + min-width: 19rem; + height: 98%; + min-height: 18rem; + gap: 0.24rem; + } + + .inner-keep-map__keep { + width: 34%; + min-width: 6.2rem; + min-height: 5.2rem; + padding: 0.45rem; + } + + .inner-keep-map__keep strong { + font-size: 0.64rem; + } + + .inner-keep-slot__label { + font-size: 0.48rem; + } + + .inner-keep-map__slots button > strong { + font-size: 0.52rem; + } + + .inner-keep-map__slots button > small { + display: none; + } + + .inner-keep-building-art, + .inner-keep-building-art-fallback, + .inner-keep-worksite { + height: 2.8rem; + } + + .inner-keep-builder { + min-height: 3.5rem; + margin-top: 0.3rem; + padding-block: 0.35rem; + } + + .inner-keep-builder__crest { + width: 2.2rem; + height: 2.2rem; + } +} + +@media (max-height: 600px) and (min-width: 581px) { + .inner-keep { + grid-template-rows: auto minmax(0, 1fr) auto; + } + + .inner-keep__resources { + position: absolute; + z-index: 13; + top: max(0.4rem, var(--realm-safe-top, 0px)); + right: 7.6rem; + width: min(24rem, 45vw); + border: 1px solid rgb(225 190 105 / 18%); + border-radius: 0.5rem; + } + + .inner-keep__stage { + grid-row: 2; + } + + .inner-keep-builder { + grid-row: 3; + width: min(34rem, calc(100% - 1rem)); + min-height: 3.1rem; + margin-block: 0.25rem max(0.25rem, var(--realm-safe-bottom, 0px)); + } + + .inner-keep-panel { + top: max(0.35rem, var(--realm-safe-top, 0px)); + right: max(0.35rem, var(--realm-safe-right, 0px)); + bottom: max(0.35rem, var(--realm-safe-bottom, 0px)); + left: auto; + width: min(21rem, 46vw); + max-height: none; + padding-bottom: 0; + border: 1px solid var(--inner-border); + border-radius: 0.65rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .inner-keep *, + .inner-keep *::before, + .inner-keep *::after { + scroll-behavior: auto !important; + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + } + + .inner-keep-worksite__smoke { + opacity: 0.86; + transform: none; + } +} + +@media (forced-colors: active) { + .inner-keep, + .inner-keep-panel, + .inner-keep__header, + .inner-keep__resources { + forced-color-adjust: auto; + } + + .inner-keep-map__slots button[aria-pressed="true"] { + outline: 3px solid Highlight; + } +} diff --git a/src/components/inner-keep/InnerKeepScreen.tsx b/src/components/inner-keep/InnerKeepScreen.tsx new file mode 100644 index 00000000..2f0b6744 --- /dev/null +++ b/src/components/inner-keep/InnerKeepScreen.tsx @@ -0,0 +1,923 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react'; + +import { + INNER_KEEP_RESOURCE_ORDER, + innerKeepPresentationIntegrity, + isInnerKeepProjectNoCommitError, + innerKeepQuoteAffordable, + innerKeepQuoteBlockedReason, + type InnerKeepBuildingKind, + type InnerKeepBuildingPresentation, + type InnerKeepCatalogueEntry, + type InnerKeepPresentation, + type InnerKeepProjectQuote, + type InnerKeepResource, + type InnerKeepSlotPresentation, + type StartInnerKeepProject +} from './innerKeepPresentation'; +import { emitWarpkeepSfx } from '../audio/sfxEvents'; +import './InnerKeepScreen.css'; + +const RESOURCE_COPY: Readonly>> = Object.freeze({ + food: Object.freeze({ + label: 'Food', + icon: 'images/resources/hegemony-food-5c012a7e939f8796.webp' + }), + wood: Object.freeze({ + label: 'Wood', + icon: 'images/resources/hegemony-wood-add35506da245240.webp' + }), + stone: Object.freeze({ + label: 'Stone', + icon: 'images/resources/hegemony-stone-ac50a538fc202d15.webp' + }), + gold: Object.freeze({ + label: 'Gold', + icon: 'images/resources/hegemony-gold-522eb5b1f40b5d51.webp' + }) +}); + +type SubmissionState = Readonly<{ + key: string; + authoritySignature: string; + phase: 'submitting' | 'awaiting-authority' | 'uncertain'; +}>; + +export type InnerKeepScreenProps = Readonly<{ + presentation: InnerKeepPresentation; + selectedSlotId?: string; + selectedBuildingKind?: InnerKeepBuildingKind; + renderMode?: 'webgl' | 'fallback'; + onBack: () => void; + onCloseToRealm: () => void; + onOpenSlot: (slotId: string) => void; + onReviewBuilding: (buildingKind: InnerKeepBuildingKind) => void; + onStartProject?: StartInnerKeepProject; + onRequestSync?: () => void; +}>; + +function publicAssetUrl(path: string) { + const base = import.meta.env.BASE_URL || '/'; + return `${base.endsWith('/') ? base : `${base}/`}${path.replace(/^\/+/, '')}`; +} + +function compactQuantity(value: bigint) { + const absolute = value < 0n ? -value : value; + const sign = value < 0n ? '-' : ''; + if (absolute < 1_000n) return `${value}`; + if (absolute < 1_000_000n) { + const whole = absolute / 1_000n; + const decimal = (absolute % 1_000n) / 100n; + return `${sign}${whole}${decimal > 0n ? `.${decimal}` : ''}k`; + } + const whole = absolute / 1_000_000n; + const decimal = (absolute % 1_000_000n) / 100_000n; + return `${sign}${whole}${decimal > 0n ? `.${decimal}` : ''}m`; +} + +function exactQuantity(value: bigint) { + return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +function durationCopy(durationMicros: bigint) { + const totalMinutes = (durationMicros + 59_999_999n) / 60_000_000n; + const days = totalMinutes / (24n * 60n); + const hours = (totalMinutes % (24n * 60n)) / 60n; + const minutes = totalMinutes % 60n; + const parts: string[] = []; + if (days > 0n) parts.push(`${days} ${days === 1n ? 'day' : 'days'}`); + if (hours > 0n) parts.push(`${hours} ${hours === 1n ? 'hour' : 'hours'}`); + if (minutes > 0n && days === 0n) { + parts.push(`${minutes} ${minutes === 1n ? 'minute' : 'minutes'}`); + } + return parts.join(' ') || 'Less than one minute'; +} + +function basisPointsPercentCopy(basisPoints: number) { + const whole = Math.floor(basisPoints / 100); + const fraction = basisPoints % 100; + return fraction === 0 + ? `${whole}%` + : `${whole}.${String(fraction).padStart(2, '0').replace(/0+$/, '')}%`; +} + +function completedEffectCopy(entry: InnerKeepCatalogueEntry, completedLevel: number) { + const basisPoints = Math.min( + entry.discountCapBasisPoints, + entry.discountBasisPointsPerLevel * completedLevel + ); + return `${RESOURCE_COPY[entry.matchingDiscountResource].label} construction costs -${basisPointsPercentCopy(basisPoints)}.`; +} + +function remainingCopy(completesAtMicros: bigint, nowMilliseconds: number) { + const nowMicros = BigInt(Math.max(0, Math.trunc(nowMilliseconds))) * 1_000n; + if (completesAtMicros <= nowMicros) return 'FINALIZING WITH THE REALM…'; + const remaining = completesAtMicros - nowMicros; + const minutes = (remaining + 59_999_999n) / 60_000_000n; + const days = minutes / (24n * 60n); + const hours = (minutes % (24n * 60n)) / 60n; + const minuteRemainder = minutes % 60n; + if (days > 0n) return `${days}d ${hours}h remaining`; + if (hours > 0n) return `${hours}h ${minuteRemainder}m remaining`; + return `${minuteRemainder}m remaining`; +} + +function dateTimeForMicros(value: bigint) { + const milliseconds = value / 1_000n; + if (milliseconds < 0n || milliseconds > BigInt(Number.MAX_SAFE_INTEGER)) return undefined; + const date = new Date(Number(milliseconds)); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + +function useCoarseClock(active: boolean) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!active) return undefined; + const timer = window.setInterval(() => setNow(Date.now()), 30_000); + return () => window.clearInterval(timer); + }, [active]); + return now; +} + +function buildingLabel( + catalogue: readonly InnerKeepCatalogueEntry[], + buildingKind: InnerKeepBuildingKind +) { + return catalogue.find((entry) => entry.buildingKind === buildingKind)?.label + ?? 'Inner Keep building'; +} + +function projectAuthoritySignature(presentation: InnerKeepPresentation) { + return [ + presentation.castleId, + presentation.layoutId, + presentation.layoutVersion, + presentation.projectRevision.toString(), + presentation.phase, + presentation.builder.state, + presentation.builder.state === 'busy' + ? `${presentation.builder.slotId}:${presentation.builder.buildingKind}:` + + `${presentation.builder.targetLevel}:${presentation.builder.completesAtMicros}` + : 'idle' + ].join('|'); +} + +function SmokeWorksite({ reduced = false }: Readonly<{ reduced?: boolean }>) { + return ( +