diff --git a/ASSETS-LICENSE.md b/ASSETS-LICENSE.md index 2c1b59d3..efedcd26 100644 --- a/ASSETS-LICENSE.md +++ b/ASSETS-LICENSE.md @@ -461,6 +461,106 @@ 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. +## Inner Keep 3D runtime 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. + +On 2026-08-04, Ael instructed the project to use Warpkeep-Assets 3D objects in +the official Inner Keep and to make the Grand Covenant Cathedral its main +building. Warpkeep records that owner-authorized exact subset as 114 GLBs and +six PNG previews at +selection digest +`00304c5dbf819cec6cb656996c1105f64efcf36acf8099c431f5b04b822679f0`. +The exact content-addressed outputs are authorized for the public +`ael-dev3/Warpkeep` repository and official `warpkeep.com` client. The Grand +Covenant Cathedral is the permanent northern civic and visual anchor; the +Hegemony Shieldcourt Barracks is the western garrison anchor. + +This is exact runtime-use authorization only. The subset remains +`LicenseRef-Warpkeep-Provenance-Required`; committing and serving these files +does not establish ownership, relicense the source archive, grant a general +derivative or redistribution right, authorize an unlisted or substitute file, +grant trademark or canonical-identity rights, or approve activation, merge, or +deployment. Mixed source terms and provenance continue to apply. + +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, 38 asset records, 114 +model hashes, six preview hashes, exclusions, audit procedure, and exact owner +decision are in the dated +[Inner Keep asset selection record](docs/reference/assets/2026-08-02-inner-keep-3d-library/). + +## Inner Keep citizens, mounts, and patrol units + +On 2026-08-04, Ael instructed the project to use characters, mounts, and army +units from Warpkeep-Assets in the official Inner Keep runtime, including +animated civic routines and patrols. The exact selection contains eight +citizens and twelve army units. Six of the twenty characters are mounted. The +runtime set contains one Balanced rigged GLB and one Compact static GLB per +character, for 40 content-addressed files and selection digest +`79237fbe85a4db7a0592eb0c27cc00f8e72e85e58be867bec4dd35992f0b87f7`. + +The files come from the public Warpkeep-Assets releases +[`hegemony-citizens-keep-services-2026-08-03`](https://github.com/ael-dev3/Warpkeep-Assets/releases/tag/hegemony-citizens-keep-services-2026-08-03) +and +[`hegemony-unit-corps-2026-08-03`](https://github.com/ael-dev3/Warpkeep-Assets/releases/tag/hegemony-unit-corps-2026-08-03). +The citizen archive has SHA-256 +`14a8bda3848ede5b398a7fc4ec10e56d98ae06584c42f4683cb2635c5bbb6aeb`. +The Infantry, Ranged, and Cavalry archives have SHA-256 +`953fcca256324f2a73b3f13e2ca04911349a0f69934337e6ac7161041a2d4ba0`, +`310b0feaf21f0619f1d10f154facd64f98ce3ad1658cf80bb4dd68f5847a0391`, +and `9d411ee308abacfd7ca93b1e96594cc4f9e8af613f6d18aca3128b49dfb9a48b`. + +This is exact official-runtime use authorization, not a new public license. +The files remain `LicenseRef-Warpkeep-Provenance-Required`. Their repository +presence does not establish ownership, relicense either source release, grant +general redistribution or derivative rights, authorize substitute files, or +approve activation, merge, or deployment. The ambient actors are client-only +presentation. Their routes, conversations, models, and animation clips never +claim authority over units, movement, collision, chat, identity, resources, +rewards, or combat. + +The complete actor list, archive and manifest pins, per-file hashes, LODs, +triangle counts, clip sets, installation policy, and exact authorization are in +the dated [Inner Keep population selection record](docs/reference/assets/2026-08-04-inner-keep-population/). + +## Inner Keep Lowlands Rabbit wildlife + +The public Warpkeep-Assets release +[`rabbit-runtime-ui-bundle-2026-07-30`](https://github.com/ael-dev3/Warpkeep-Assets/releases/tag/rabbit-runtime-ui-bundle-2026-07-30) +contains the Lowlands Rabbit runtime family. Its 2,717,585-byte outer ZIP has +SHA-256 +`cf40e6c7149635a8cf6439e618e951219770491a7f364e7776b8af128461a3a9`. +The nested privacy-sanitized source package is 2,710,713 bytes with SHA-256 +`1ab9a02a39e68a3ddadcec4ac1824aafaf252f9cb068511e6341d2377f256dc3`. +The release records public archival distribution as authorized by Ael, with +no separate open-license grant asserted. Its source status is +`public-archive-authorized-no-separate-open-license`. + +On 2026-08-04, Ael instructed Warpkeep to use the official Lowlands Rabbit +outside the Inner Keep so the surrounding world feels alive. That instruction +authorizes the exact High, Balanced, and Compact content-addressed GLBs under +`public/models/hegemony/inner-keep/wildlife/rabbit/` for the public +`ael-dev3/Warpkeep` repository and official `warpkeep.com` client. It does not +relicense the source archive, authorize unrelated reuse or substitute files, +or approve activation, merge, or deployment. The files remain +`LicenseRef-Warpkeep-Provenance-Required`. + +Rabbit models and animations are optional client presentation only. They do +not define animal population, collision, pathing, resources, rewards, +ownership, or any other gameplay authority. High and Balanced scene quality +use the Balanced rig by default; reduced quality and reduced motion use the +Compact static LOD. The High rig is retained only as an explicit close-detail +option. The complete release pins, source-member hashes, animation contract, +three model digests, exclusions, and reproducible audit procedure are in the +dated [Lowlands Rabbit runtime selection record](docs/reference/assets/2026-07-30-lowlands-rabbit/). + ## 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..ba1b6754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ 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, runtime asset verification, 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. +- Installed the owner-authorized Inner Keep selection at immutable paths: 38 + environment assets across 114 GLBs, six catalog previews, and 20 ambient + actors across 40 GLBs. This does not activate, merge, or deploy the feature. +- Built a living presentation around the Grand Covenant Cathedral and western + Barracks with a larger 1,454-square-meter walled city, roomier build sites, + dirt wall aprons, district lanes, staggered authored trees, mounted patrols, + civic conversations, dense wind-swept grass, deterministic clearances, + bounded animation, and atomic fail-safe asset loading. +- Extended that presentation beyond the walls with nine named landforms, one + connected headwater-to-lake course, a rounded terrain shoulder across a + 68 x 76 meter estate, quality-scaled fields and six-species woodland, scenic + resource structures and a trade wagon, outer patrols, and rabbits. Exact + optional wildlife can fall back independently to bounded procedural animals; + every exterior element remains visual-only and grounded by the same + deterministic terrain sampler. - 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..c86b3a18 --- /dev/null +++ b/docs/design/inner-keep-construction.md @@ -0,0 +1,300 @@ +# 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, runtime asset +verification, 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 presentation uses the Grand Covenant Cathedral as its permanent +northern main-building anchor, with the Barracks to the west, 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 palisade encloses a 40.4 x 36 meter compound, about 1,454 square meters. +The re-spaced build pads retain at least 0.2 meters of proved separation for +the largest Level-5 economy-building footprints. A dirt apron, district lanes, +staggered perimeter planting, and a rounded terrain shoulder soften the wall +edge without adding collision or gameplay authority. + +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 38 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 +`0a976765d6f6e740eb6282fca90f59b412ecbd7ed382f001da89a0b7abeca756`. + +The server's compact slot-policy digest includes that presentation digest, so +the client and activation tooling pin the combined layout digest +`dec272175dc96085b26d2bc96125e77c6433331c698f150d80dfbbb4881ee3d7`. +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. + +Portrait framing uses a front-facing fit that keeps every authored building +and the full east drain on screen at the 390 x 844 reference viewport, with at +least 10 CSS pixels of measured edge clearance. After the player pans or +zooms, resize and orientation changes preserve that choice instead of snapping +the camera back to its initial pose. + +## Living presentation and budgets + +The exact static selection renders 76 fixed authored placements around the +twelve canonical slot pads. The Cathedral remains the northern focal point and +the Barracks remains the western garrison. High quality presents all 20 +selected ambient actors: eight citizens and twelve patrol units, including six +mounted characters. Balanced and Reduced retain smaller deterministic casts. +Civic routines use only Greet, Idle, Walk, and Work. Patrols use Idle and Walk; +combat clips are excluded. Conversation bubbles and every ambient identity are +synthetic presentation, never player chat or server state. + +The ecology layer now continues across a 68 x 76 meter estate with nine named +topographic features. The complete wall footprint remains on an exact level, +rounded plateau. Its five-meter corners and 5.5-meter feather blend into +ridges, stone shelves, meadows, woodland margins, and a south-eastern lake. +One strictly downhill watercourse runs from its north-eastern headwater, along +the east-wall rill, and into that lake. It is still rendered as two logical +water surfaces, so water detail stays bounded. + +Grass, actors, trees, wildlife, resource scenery, roads, and the wagon all use +the same deterministic terrain-height sampler. Grass clears fixed authored +footprints, reserved slots, water banks, unsafe slopes, central and district +roads, routes, and the four leveled scenic resource pads. The outer patrol road +detours around both the headwater and lake, keeping its complete surface dry. +It carries existing mounted and foot characters beyond the walls without +creating another population or claiming unit authority. + +| Quality | Actors (mounted / patrol) | Compound trees | Grass | Max active fps | Scene graph max (draws / triangles) | Renderer evidence max (draws / triangles) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| High | 20 (6 / 12) | 18 | 2,400 | 30 | 390 / 420,000 | 750 / 720,000 | +| Balanced | 12 (4 / 6) | 12 | 1,400 | 24 | 310 / 215,000 | 380 / 260,000 | +| Reduced | 8 (2 / 4) | 6 | 480 | 18 | 235 / 110,000 | 260 / 130,000 | + +The exterior has its own quality budget: + +| Quality | Authored trees across six species | Scenic resource structures | Rabbits | Trade wagons | +| --- | ---: | ---: | ---: | ---: | +| High | 72 | 8 | 10 | 1 | +| Balanced | 44 | 6 | 7 | 1 | +| Reduced | 22 | 4 | 4 | 1 | + +The four resource families and supply wagon are presentation-only uses of the +reviewed Warpkeep-Assets runtime models. High and Balanced use their Balanced +outer-estate LODs so the compound keeps the close-detail budget; Reduced uses +Compact. These models do not create gathering sites, balances, assignments, +collision, or production. Rabbits prefer the exact optional three-LOD +[`rabbit-runtime-ui-bundle-2026-07-30`](https://github.com/ael-dev3/Warpkeep-Assets/releases/tag/rabbit-runtime-ui-bundle-2026-07-30) +selection and use a bounded procedural rabbit fallback when it is unavailable. +That fallback preserves the same 10 / 7 / 4 count and deterministic anchors; +the [exact selection record](../reference/assets/2026-07-30-lowlands-rabbit/) +retains its narrow authorization and license boundary. + +Reduced quality uses static population and ecology presentation. Explicit +reduced motion sets the animation cap to zero. Fixed static instancing is +budgeted at 91 / 90 / 81 draws and 204,592 / 105,632 / 52,013 triangles for +High / Balanced / Reduced. Population models are separately capped at +207 / 131 / 78 draws and 65,000 / 40,000 / 16,000 triangles before the +combined scene ceilings above are enforced. + +Runtime loading uses one globally bounded, static-first queue. Each asset gets +one transient retry. Abort drains and disposes every request, and a weaker +retry cannot replace a stronger settled bundle. The authored static scene +appears only with complete exact static coverage, so procedural and authored +landmarks never mix. Population may retain the strongest verified partial +coverage. A terminal missing completed-building prefab falls back to the +bounded procedural completed model rather than leaving a permanent scaffold. +The exterior keeps separate terrain, tree, resource, wagon, and wildlife +status. One optional family can degrade to its own bounded fallback without +removing the compound or another healthy family. Quality budgets cap each +layer independently before the combined scene ceiling is enforced. + +## 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 38 selected assets across six families, 114 +High/Balanced/Compact GLBs, and six previews. Its language-neutral parsed-JSON digest is +`00304c5dbf819cec6cb656996c1105f64efcf36acf8099c431f5b04b822679f0`. +The bounded installer verifies the attachment and extracts only that allowlist +into content-addressed runtime paths. + +The exact owner authorization recorded on 2026-08-04 permits those files in the +public Warpkeep repository and official runtime, with the Grand Covenant +Cathedral as the main northern visual anchor and the Barracks as the western +garrison. The presentation manifest pins their installed paths and transforms; +the current procedural scene remains the fail-closed visual fallback while an +asset loads or if WebGL initialization fails. + +This exact-use authorization does not relicense the mixed-source archive, +grant general derivative or redistribution rights, authorize substitutes, or +approve activation, merge, or deployment. + +The separately reviewed population selection contains 20 actors and 40 +content-addressed Balanced/Compact GLBs at digest +`79237fbe85a4db7a0592eb0c27cc00f8e72e85e58be867bec4dd35992f0b87f7`. +It is sourced from the 2026-08-03 Citizens and Unit Corps releases and is +authorized only for this public repository and official client. The complete +archive pins, per-file hashes, animation contracts, and unchanged license +limits live in the +[dated population record](../reference/assets/2026-08-04-inner-keep-population/). + +## Release gates + +`npm run qa:inner-keep` runs the dedicated local-only synthetic presentation +matrix. Its 18 cases add a High living compound and an active civic +conversation to the empty compound, completed Levels 1–5, construction at +1/50/99 percent, authoritative completion reveal, occupied Builder, +insufficient-resource, compact-quality, reduced-motion, missing-asset, and +functional 2D-fallback cases. 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 live renderer, one context, one animation +owner, twelve scene pads, twelve native controls, the exact living-scene +counts, and both scene-graph and post-render GPU counters under the tabled +ceilings. The 2D case requires zero 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. Backend module publication, catalog seed, Builder backfill, +activation, and production smoke testing 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/legal/license-inventory.md b/docs/legal/license-inventory.md index b34d5351..2efdc796 100644 --- a/docs/legal/license-inventory.md +++ b/docs/legal/license-inventory.md @@ -43,6 +43,8 @@ public license or expand the recorded reuse and redistribution terms. | `/public/models/hegemony/gathering-nodes/stone-quarry/hegemony-stone-quarry-*.glb` | Active Hegemony Stone Quarry visual runtime LODs | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-18, the project owner supplied the exact Stone Quarry runtime delivery for an isolated draft asset-integration PR and an eventual official `warpkeep.com` Pages runtime only after separately approved deployment; this is not publication, Stone-site seed, economic, or deployment approval | The dated Stone Quarry runtime record pins the delivered manifest facts, exact output hashes, orientation, LOD guidance, collision guidance, and visual-only boundary | Preserve immutable hash-bearing paths; do not infer authoritative placement, collision, routing, occupation, ownership, Stone balance/reward, scheduler behavior, public relicensing, or general redistribution authority | | `/public/models/hegemony/hegemony-supply-wagon-*.glb` | Active Hegemony Supply Wagon animated runtime LODs | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-18, the project owner authorized the named reviewed outputs for the Gold Wagon integration in this public Warpkeep GitHub repository and an eventual official `warpkeep.com` Pages runtime after separately approved deployment; this is not deployment approval | The dated Supply Wagon runtime record pins public-release archive/source hashes, deterministic LOD outputs, 47-joint/six-clip contract, toolchain, budgets, and visual-only boundary | Preserve immutable hash-bearing paths; do not infer server routing, ownership, dispatch, Gold/Marks balance, reward, settlement, public relicensing, or general redistribution authority | | `/public/models/hegemony/environment/trees/hegemony-tree-*.glb` | Active Hegemony environment-tree High/Balanced/Compact runtime family | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-18, the project owner supplied the exact tree bundle and authorized its named outputs for a draft public-repository integration plus a future official `warpkeep.com` Pages runtime only after separately approved deployment; this is not merge or deployment approval | The dated tree record pins the archive, source manifests, all 66 output hashes, GLB structure, LOD/biome contract, and visual-only boundary; the separately authored shared forest projection may select asset IDs and fixed visual transforms but never derives them from GLB geometry | Preserve immutable hash-bearing paths; do not infer authoritative collision, gameplay terrain placement, ownership, resources, rewards, pathing, public relicensing, or general redistribution authority | +| `/public/models/hegemony/inner-keep/{buildings,landmarks,palisade,stone,town-items,trees}/**`
`/public/images/inner-keep/catalog/**` | Installed Inner Keep environment models and catalog previews; dormant pending separate feature activation | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-08-04, the project owner authorized the exact 114 GLBs and six PNGs at static selection digest `00304c5dbf819cec6cb656996c1105f64efcf36acf8099c431f5b04b822679f0` for this public repository and official `warpkeep.com` client, with Cathedral and Barracks anchor instructions | The dated Inner Keep environment record pins the source release, complete 38-asset allowlist, all paths and hashes, exclusions, installation policy, and exact authorization boundary | Preserve immutable paths and exact-use scope; do not infer public relicensing, general derivatives or redistribution, substitution, activation, merge, deployment, or gameplay authority | +| `/public/models/hegemony/inner-keep/population/**` | Installed Inner Keep citizens, mounts, and patrol models; presentation-only and dormant pending separate feature activation | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-08-04, the project owner authorized the exact 40 GLBs at population selection digest `79237fbe85a4db7a0592eb0c27cc00f8e72e85e58be867bec4dd35992f0b87f7` for this public repository and official `warpkeep.com` client | The dated population record pins both source releases, four archives, 20 actors, per-file hashes, LOD and clip contracts, bounded installer, and presentation-only boundary | Preserve immutable paths and exact-use scope; do not infer player or Worker identity, unit movement, collision, chat, combat, resources, rewards, ownership, public relicensing, substitution, activation, merge, or deployment | | `/public/models/hegemony/hegemony-frontier-keep-*.glb` | Historical Frontier Keep runtime derivatives | Historical repository policy was applied to claimed project-owned material | Original/external terms remain unresolved | GLB source was Ael-supplied; generated derivative authority was not independently established | Historical source/derivative hashes and pipeline are preserved in the 2026-07-11 castle manifest | Do not restore, redistribute, or add Apache/CC-BY headers without rights confirmation | | `/docs/**` outside reference archives | Product, design, release, operations, and architecture docs | `CC0-1.0` for project-owned documentation | `CC-BY-4.0` for new/modified project-owned documentation | Project-authored content is generally confirmed; quoted/external material remains separate | Release and auth docs may describe third-party systems and must not relicense them | Add documentation SPDX metadata and preserve quoted-source terms | | `/docs/reference/**` | Source archives, visual/audio records, manifests, and provenance | Historical policy was applied where the repository claimed project ownership | Original/external terms unless authority is confirmed; new project-authored metadata may be `CC-BY-4.0` | Several records say Ael supplied the file but do not establish ownership or upstream terms | See unresolved list below and each dated README/manifest | Correct overbroad CC0 wording and retain technical evidence | @@ -64,9 +66,10 @@ The audit found no basis to assign a new Warpkeep license to these categories wi 3. The active Hegemony Main Castle GameReady package beyond its recorded official Warpkeep scope. Supply and Warpkeep integration authorization do not establish a separate public open license, general third-party derivative permission, redistribution permission, trademark right, or canonical-identity right; the 2026-07-16 owner authorization covers only the exact three inputs, their use in this public Warpkeep GitHub repository and official `warpkeep.com` Pages runtime, and bounded deterministic metadata correction. The retained Alpha 0.3.4 compatibility binaries and their 2026-07-15 public-source archive keep their separate historical no-separate-open-license boundary. 4. The active Hegemony Castle Landscape Base GameReady package beyond its recorded official Warpkeep PR #40 scope. The owner's instruction authorizes exact integration into this public Warpkeep GitHub repository and official `warpkeep.com` Pages runtime plus the bounded Balanced/Compact metadata correction, not a separate public license, general third-party derivative or redistribution permission, trademark/canonical-identity right, or same-named-file substitution. Preserve `LicenseRef-Warpkeep-Provenance-Required` and the exact same-parent-transform contract. 5. The 18 July Gold Mine review candidates, promoted Gold Mine outputs, provenance-restricted Wheat Farm, Logging Camp, and Stone Quarry outputs, Supply Wagon outputs, Gold Mine, Wheat Farm, and Logging Camp inspection-art derivatives, and Hegemony environment-tree family beyond their recorded narrow scopes. Their detailed provenance and any separately recorded project-owner Pages-runtime authorization do not establish a public license, general derivative/redistribution permission, trademark/canonical-identity right, or authority to make Gold, Food, Wood, Stone, terrain placement, or trees authoritative outside reviewed application/server code. -6. Older castle reference JPEGs and PixelLab-generated sprite candidates. Their archive READMEs identify references or generated output, but do not establish complete upstream licensing terms. -7. The removed historical `public/images/warpkeep-cover.png`, which lacks a dedicated provenance manifest. Its bytes remain recoverable from Git history but are not part of the current tree or runtime. -8. Generated SpacetimeDB bindings, where the repository owns the module schema but the output also reflects upstream generator/SDK terms. The generated header and reproducibility check must remain intact. +6. The installed Inner Keep environment, preview, citizen, mount, and army selections beyond their recorded exact official Warpkeep scope. The 2026-08-04 authorization permits the named digest-pinned files in this public repository and official client; it does not establish a public license, general derivative or redistribution permission, substitution right, activation, merge, deployment, or any gameplay authority. +7. Older castle reference JPEGs and PixelLab-generated sprite candidates. Their archive READMEs identify references or generated output, but do not establish complete upstream licensing terms. +8. The removed historical `public/images/warpkeep-cover.png`, which lacks a dedicated provenance manifest. Its bytes remain recoverable from Git history but are not part of the current tree or runtime. +9. Generated SpacetimeDB bindings, where the repository owns the module schema but the output also reflects upstream generator/SDK terms. The generated header and reproducibility check must remain intact. Historical repository license statements are retained as historical statements and are not silently rewritten into a retroactive revocation. Until authority is confirmed, future modifications to these items must retain their file-specific/original terms and must not be labeled Apache-2.0 or CC-BY-4.0 by assumption. diff --git a/docs/operations/inner-keep-activation.md b/docs/operations/inner-keep-activation.md new file mode 100644 index 00000000..a5a776e3 --- /dev/null +++ b/docs/operations/inner-keep-activation.md @@ -0,0 +1,208 @@ +# 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 static, population, and rabbit runtime-use authorizations plus the + combined 163-path installed registry; +- 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 both exact runtime-use authorizations are recorded, all selected static +and population files form the exact combined 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..52550c98 100644 --- a/docs/operations/reconstruction/asset-pipeline.md +++ b/docs/operations/reconstruction/asset-pipeline.md @@ -58,6 +58,51 @@ Then verify checksums, manifest entry parity, path safety, and every GLB: npx --yes @gltf-transform/cli@4.4.1 validate ``` +## Authorized Inner Keep runtime selections + +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 +owner-authorized allowlist of 38 assets: 114 High/Balanced/Compact GLBs and six +catalog PNGs. The complete 234,962,670-byte archive, unselected media, editable +sources, and inspection models remain outside this repository. Only the 120 +content-addressed outputs are installed under `public/`. + +The separately authorized population selection adds eight citizens and twelve +army units from the 2026-08-03 Citizens and Unit Corps releases. Its 40 local, +content-addressed GLBs provide one rigged Balanced model and one Compact static +fallback per actor. Release archives remain outside Git history and are never +used as browser URLs. + +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 + +WARPKEEP_INNER_KEEP_POPULATION_ARCHIVE_ROOT=/trusted/offline/inner-keep-population-cache \ + npm run assets:audit:inner-keep-population +npm run verify:inner-keep-population-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. + +The two bounded installers accept only the exact trusted archives and manifests +recorded by their selection contracts. They stage a complete allowlist, verify +every hash and GLB invariant, and replace only the expected outputs. Repository +and production verifiers reject missing, changed, unexpected, symlinked, or +non-content-addressed files. Ordinary builds use no asset network access. + +The exact authorization and license limits remain in the dated +[environment selection](../../reference/assets/2026-08-02-inner-keep-3d-library/) +and [population selection](../../reference/assets/2026-08-04-inner-keep-population/). +Those records authorize only the named official runtime use. They do not +relicense either source release or approve activation, merge, or deployment. + ## Active runtime models | File | Bytes | SHA-256 | diff --git a/docs/reference/assets/2026-07-30-lowlands-rabbit/README.md b/docs/reference/assets/2026-07-30-lowlands-rabbit/README.md new file mode 100644 index 00000000..87299b43 --- /dev/null +++ b/docs/reference/assets/2026-07-30-lowlands-rabbit/README.md @@ -0,0 +1,52 @@ +# Lowlands Rabbit runtime selection + +This record pins the three official Lowlands Rabbit runtime LODs from the +Warpkeep-Assets release +[`rabbit-runtime-ui-bundle-2026-07-30`](https://github.com/ael-dev3/Warpkeep-Assets/releases/tag/rabbit-runtime-ui-bundle-2026-07-30). +The models are optional, visual-only wildlife for the land outside the Inner +Keep. They do not define collision, population, resources, ownership, rewards, +or any other gameplay authority. + +## Exact source + +| Item | Exact pin | +| --- | --- | +| Release tag commit | `d8c35bb01c399ecde711274ef43880d8d304ae44` | +| Trusted release manifest | 3,300 bytes; SHA-256 `c1009554053a793da979a8a1aae7558aade09bfbefcc53fcf2dae7a98a80a705` | +| Outer release ZIP | 2,717,585 bytes; SHA-256 `cf40e6c7149635a8cf6439e618e951219770491a7f364e7776b8af128461a3a9` | +| Nested privacy-sanitized ZIP | 2,710,713 bytes; SHA-256 `1ab9a02a39e68a3ddadcec4ac1824aafaf252f9cb068511e6341d2377f256dc3` | + +The source release records public archival distribution as authorized by Ael +and makes no separate open-license grant. On 2026-08-04, Ael separately +authorized these exact three files for official Warpkeep repository/runtime +use while asking for animals outside the Inner Keep. That narrow integration +authorization does not relicense the archive, authorize unrelated reuse, or +approve activation, merge, or deployment. + +## Runtime policy + +High and Balanced scene quality use the Balanced rig by default. It has the +`Alert`, `Idle`, `Nibble`, and `Walk` clips. Reduced quality and reduced-motion +presentation use the Compact static LOD with no animation mixer. The High rig +remains available as an explicit close-detail option. All three models use one +embedded vertex-color material and have no external texture dependency. + +The 2048px UI image is intentionally excluded. The exterior scene needs only +the three runtime GLBs, so unused UI media is not copied into Warpkeep. + +## Reproduce and verify locally + +Place the exact trusted release manifest at +`.cache/warpkeep-assets/rabbit-runtime-ui-bundle-2026-07-30/manifest.json` and +the exact release ZIP beside it. Then run: + +```sh +npm run assets:audit:inner-keep-rabbit +npm run prepare:inner-keep-rabbit-assets +npm run verify:inner-keep-rabbit-assets +``` + +The audit validates the trusted release manifest, outer ZIP, nested sanitized +package, both nested manifests, complete member sets, and selected GLBs before +installation. Ordinary builds use only local content-addressed files and never +read the archive or access the network. diff --git a/docs/reference/assets/2026-07-30-lowlands-rabbit/manifest.json b/docs/reference/assets/2026-07-30-lowlands-rabbit/manifest.json new file mode 100644 index 00000000..4f0c32b4 --- /dev/null +++ b/docs/reference/assets/2026-07-30-lowlands-rabbit/manifest.json @@ -0,0 +1,176 @@ +{ + "schema": "warpkeep.inner-keep-rabbit-runtime-selection.v1", + "selectionId": "inner-keep-lowlands-rabbit-v1", + "recordedAt": "2026-08-04", + "selectionDigestAlgorithm": "sha256-ecmascript-canonical-json-v1", + "selectionDigestSha256": "58cab83f4c4e1773012d2b099da5b05ab3fa857d6e8395710e60cd4db337b958", + "authorization": { + "archiveDistributionAuthorized": true, + "officialRepositoryRuntimeUseAuthorized": true, + "status": "authorized-owner-runtime-use", + "recordedAt": "2026-08-04", + "instruction": "looks cool so far, make sure there is lots of topography, water, trees, animals, people, rss nodes outside inner keep so it feels well done and alive like a leaving and breathing world", + "integrationDecision": "Use the exact official Warpkeep-Assets Lowlands Rabbit runtime family for the requested animals outside the Inner Keep.", + "scopeBoundary": "Runtime use in the public ael-dev3/Warpkeep repository and official warpkeep.com client is authorized for the exact selected files. This does not relicense the source archive, authorize unrelated reuse, give decorative wildlife gameplay authority, approve activation, approve merge, or approve deployment." + }, + "licenseBoundary": { + "sourceStatus": "public-archive-authorized-no-separate-open-license", + "integrationGrant": "owner-authorized-official-warpkeep-runtime-use-only", + "redistributionOrRelicensingGranted": false + }, + "runtimePolicy": { + "highQualityTier": "LOD1_Balanced", + "balancedQualityTier": "LOD1_Balanced", + "reducedQualityTier": "LOD2_Compact", + "optionalHighDetailTier": "LOD0_High", + "animatedClips": [ + "Alert", + "Idle", + "Nibble", + "Walk" + ], + "ordinaryBuildMayReadArchive": false, + "ordinaryBuildMayUseNetwork": false, + "clientPresentationOnly": true, + "gameplayAuthorityClaimed": false, + "renderMeshCollision": false + }, + "source": { + "repository": "ael-dev3/Warpkeep-Assets", + "releaseCommit": "d8c35bb01c399ecde711274ef43880d8d304ae44", + "tag": "rabbit-runtime-ui-bundle-2026-07-30", + "trustedReleaseManifest": { + "repositoryPath": "releases/rabbit-runtime-ui-bundle-2026-07-30/manifest.json", + "bytes": 3300, + "sha256": "c1009554053a793da979a8a1aae7558aade09bfbefcc53fcf2dae7a98a80a705" + }, + "attachment": { + "name": "rabbit-runtime-ui-bundle-2026-07-30-v1.zip", + "bytes": 2717585, + "sha256": "cf40e6c7149635a8cf6439e618e951219770491a7f364e7776b8af128461a3a9", + "entries": 4, + "packageRoot": "rabbit-runtime-ui-bundle-2026-07-30-v1" + }, + "outerEntries": [ + { + "path": "rabbit-runtime-ui-bundle-2026-07-30-v1/README.md", + "bytes": 2126, + "sha256": "3d6909181d31011dbb0b09935dee07d2e7d36147123ec30371f8b293c31643dd" + }, + { + "path": "rabbit-runtime-ui-bundle-2026-07-30-v1/Warpkeep_Rabbit_Runtime_UI_Bundle_2026-07-30.zip", + "bytes": 2710713, + "sha256": "1ab9a02a39e68a3ddadcec4ac1824aafaf252f9cb068511e6341d2377f256dc3" + }, + { + "path": "rabbit-runtime-ui-bundle-2026-07-30-v1/manifest.json", + "bytes": 3745, + "sha256": "2174b34ee5f0d025dc0a46e35cb981ce6074a500d7cdbe6f153753155f5b6772" + }, + { + "path": "rabbit-runtime-ui-bundle-2026-07-30-v1/SHA256SUMS.txt", + "bytes": 195, + "sha256": "a3d5f92a30549a12c50dba3e6ba81474e2bc46dddff5a78d8ab3062a06a3d387" + } + ], + "nestedPackage": { + "path": "rabbit-runtime-ui-bundle-2026-07-30-v1/Warpkeep_Rabbit_Runtime_UI_Bundle_2026-07-30.zip", + "bytes": 2710713, + "sha256": "1ab9a02a39e68a3ddadcec4ac1824aafaf252f9cb068511e6341d2377f256dc3", + "entries": 13, + "payloadEntries": 8, + "packageRoot": "Warpkeep_Rabbit_Runtime_UI_Bundle", + "privacySanitized": true, + "decodedPixelsPreserved": true + }, + "runtimeManifest": { + "path": "Warpkeep_Rabbit_Runtime_UI_Bundle/Runtime/Environment/Wildlife/Rabbit/runtime-manifest.json", + "bytes": 5453, + "sha256": "89e8ed79f1672a98d5bb6cab6c0741f66ba97ca63a7e25758f052375afb3e005" + }, + "bundleManifest": { + "path": "Warpkeep_Rabbit_Runtime_UI_Bundle/bundle-manifest.json", + "bytes": 2161, + "sha256": "35a98b5a81a824634030635da9a57494a47543cf414d760d0884e560255fcd87" + }, + "excludedUiImage": { + "path": "Warpkeep_Rabbit_Runtime_UI_Bundle/Warpkeep_Rabbit_UI_Transparent_2048.png", + "bytes": 2468019, + "sha256": "572e67902dd6db561bcce494ac5049463baf939494f74af3a0b1f2459f3b2c3e", + "reason": "The exterior wildlife scene needs only the runtime GLBs; this selection does not authorize or install unused UI media." + } + }, + "models": [ + { + "profile": "high", + "tier": "LOD0_High", + "mode": "animated", + "sourcePath": "Warpkeep_Rabbit_Runtime_UI_Bundle/Runtime/Environment/Wildlife/Rabbit/Warpkeep_Rabbit_LOD0_High_Rigged_Runtime.glb", + "destinationPath": "public/models/hegemony/inner-keep/wildlife/rabbit/inner-keep-lowlands-rabbit-high-735f7d72457acfb2.glb", + "bytes": 129388, + "sha256": "735f7d72457acfb20581cfcacbd8908eab3ef36d5640b63ecb134bbaa7a10b1d", + "triangles": 726, + "uploadedVertices": 1640, + "drawCalls": 1, + "boundsMeters": [ + 0.199311, + 0.265033, + 0.335667 + ], + "animations": [ + "Alert", + "Idle", + "Nibble", + "Walk" + ] + }, + { + "profile": "balanced", + "tier": "LOD1_Balanced", + "mode": "animated", + "sourcePath": "Warpkeep_Rabbit_Runtime_UI_Bundle/Runtime/Environment/Wildlife/Rabbit/Warpkeep_Rabbit_LOD1_Balanced_Rigged_Runtime.glb", + "destinationPath": "public/models/hegemony/inner-keep/wildlife/rabbit/inner-keep-lowlands-rabbit-balanced-daeb493a827ecbd6.glb", + "bytes": 86340, + "sha256": "daeb493a827ecbd605c6ad25d83b84050e6a0ad29e96619c9d86c57c37be1f6a", + "triangles": 350, + "uploadedVertices": 856, + "drawCalls": 1, + "boundsMeters": [ + 0.192403, + 0.265033, + 0.342784 + ], + "animations": [ + "Alert", + "Idle", + "Nibble", + "Walk" + ] + }, + { + "profile": "compact", + "tier": "LOD2_Compact", + "mode": "static", + "sourcePath": "Warpkeep_Rabbit_Runtime_UI_Bundle/Runtime/Environment/Wildlife/Rabbit/Warpkeep_Rabbit_LOD2_Compact_Static_Runtime.glb", + "destinationPath": "public/models/hegemony/inner-keep/wildlife/rabbit/inner-keep-lowlands-rabbit-compact-2ecc7b1adf4c1d79.glb", + "bytes": 14808, + "sha256": "2ecc7b1adf4c1d79b7ca2d5ea9a6727ed3f6d9072047466082bb912d34ea930c", + "triangles": 146, + "uploadedVertices": 384, + "drawCalls": 1, + "boundsMeters": [ + 0.162326, + 0.265102, + 0.338002 + ], + "animations": [] + } + ], + "counts": { + "selectedModels": 3, + "selectedBytes": 230536, + "selectedTriangles": 1222, + "animatedModels": 2, + "staticModels": 1 + } +} 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..0184c818 --- /dev/null +++ b/docs/reference/assets/2026-08-02-inner-keep-3d-library/README.md @@ -0,0 +1,100 @@ +# Inner Keep 3D asset selection record + +This directory records the exact reviewed subset of the Warpkeep Inner-Keep +3D Asset Library authorized for the public Warpkeep repository and official +runtime. It is a file-by-file runtime-use record, not a blanket license for the +source archive, any unlisted asset, or unrelated redistribution. + +## Pinned source + +| Coordinate | Pinned value | +| --- | --- | +| Repository | [`ael-dev3/Warpkeep-Assets`](https://github.com/ael-dev3/Warpkeep-Assets) | +| Repository main commit | `10c84fbcc339f143ee6f25dfe7a0682660e0e458` | +| 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 | `00304c5dbf819cec6cb656996c1105f64efcf36acf8099c431f5b04b822679f0` | + +The [machine-readable selection](manifest.json) pins every selected source +member, byte count, SHA-256 digest, triangle count, bound, and +content-addressed destination. It also preserves the exact owner instruction +that authorized this bounded runtime use on 2026-08-04. + +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 runtime subset + +| Family | Assets | +| --- | ---: | +| Economy buildings | 4 | +| Permanent landmarks | 2 | +| Palisade pieces | 6 | +| Town items | 19 | +| Stone and ruin pieces | 4 | +| New tree assets | 3 | +| Existing authorized tree families reused | 3 | + +The selected subset contains 114 GLBs: High, Balanced, and Compact for each of +38 assets. Six 320×320 previews cover the four economy buildings plus the +Grand Covenant Cathedral and Hegemony Shieldcourt Barracks. The Cathedral is +the Inner Keep's permanent main-building and northern visual anchor. The +Barracks is its western garrison anchor. + +| Profile | Bytes | Triangles | +| --- | ---: | ---: | +| High | 19,570,592 | 159,914 | +| Balanced | 10,074,464 | 85,284 | +| Compact | 5,991,748 | 42,671 | + +All 114 models total 35,636,804 bytes. The six selected previews total 928,374 +bytes. `LOD3_Map`, inspection and catalogue models, editable sources, tools, +and every unlisted asset remain excluded. + +## Exact authorization boundary + +On 2026-08-04, Ael instructed the project to use Warpkeep-Assets 3D objects in +the official Inner Keep and to make the Grand Covenant Cathedral its main +building. That instruction authorizes the exact 114 GLBs and six PNG previews +at selection digest +`00304c5dbf819cec6cb656996c1105f64efcf36acf8099c431f5b04b822679f0` +for the public `ael-dev3/Warpkeep` repository and official `warpkeep.com` +client. + +This is runtime-use authorization only. It does not relicense the source +archive, authorize unlisted or substitute files, grant general derivative or +redistribution rights, or approve activation, merge, or deployment. The +selected media remains `LicenseRef-Warpkeep-Provenance-Required`; mixed source +terms and provenance continue to apply. + +Three separately authorized Realm tree families may be reused under their own +dated record. This selection does not duplicate those files or broaden their +permission. + +## Local audit and installation + +Place the exact archive and trusted release manifest in a private local cache, +or point the commands 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 + +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 prepare:inner-keep-assets +``` + +The audit validates the full ZIP member set and extracts only allowlisted +members into a temporary private directory. Installation writes only the 120 +recorded content-addressed outputs after exact byte, hash, and structure +verification. Ordinary builds never read the source archive or use the +network; they verify the installed files already in the repository. 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..343445f0 --- /dev/null +++ b/docs/reference/assets/2026-08-02-inner-keep-3d-library/manifest.json @@ -0,0 +1,2376 @@ +{ + "schema": "warpkeep.inner-keep-asset-selection.v1", + "selectionId": "inner-keep-v1", + "selectionDigestSha256": "00304c5dbf819cec6cb656996c1105f64efcf36acf8099c431f5b04b822679f0", + "selectionDigestAlgorithm": "sha256-ecmascript-canonical-json-v1", + "recordedAt": "2026-08-04", + "sourceRelease": { + "repository": "ael-dev3/Warpkeep-Assets", + "repositoryMainCommit": "10c84fbcc339f143ee6f25dfe7a0682660e0e458", + "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": true, + "status": "authorized-owner-runtime-use", + "recordedAt": "2026-08-04", + "instruction": "Use Warpkeep-Assets 3D objects to populate the official Inner Keep runtime, with the Grand Covenant Cathedral as the main building.", + "scopeBoundary": "Runtime use in the public ael-dev3/Warpkeep repository and official warpkeep.com client is authorized for the exact selected files. This does not relicense the source archives, authorize unrelated reuse, approve activation, approve merge, or approve deployment." + }, + "selectionPolicy": { + "profiles": [ + "high", + "balanced", + "compact" + ], + "sourceAssetCount": 38, + "sourceModelCount": 114, + "sourcePreviewCount": 6, + "excludedProfiles": [ + "LOD3_Map" + ], + "excludedPackages": [], + "inspectionCataloguesAllowed": false, + "editableSourcesAllowed": false, + "ordinaryBuildMayReadArchive": false, + "ordinaryBuildMayUseNetwork": false + }, + "qualityTotals": { + "high": { + "bytes": 19570592, + "triangles": 159914 + }, + "balanced": { + "bytes": 10074464, + "triangles": 85284 + }, + "compact": { + "bytes": 5991748, + "triangles": 42671 + } + }, + "selectedModelBytesAllProfiles": 35636804, + "selectedPreviewBytes": 928374, + "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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 3.2, + 4 + ], + "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, + 3.2, + 4 + ], + "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, + 3.2, + 4 + ], + "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, + 3.2, + 4 + ], + "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, + 5, + 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, + 5, + 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, + 5, + 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, + 5, + 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, + 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, + 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, + 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, + 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.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.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.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.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.12, + 4 + ], + "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.12, + 4 + ], + "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.12, + 4 + ], + "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.12, + 4 + ], + "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.12, + 4 + ], + "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.12, + 4 + ], + "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.12, + 4 + ], + "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.12, + 4 + ], + "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.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.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.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.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.1625, + 6 + ], + "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.1625, + 6 + ], + "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.1625, + 6 + ], + "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.1625, + 6 + ], + "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.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.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.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.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, + 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, + 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, + 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, + 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.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.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.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.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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 2.8, + 1.1 + ], + "destinationPath": "public/models/hegemony/inner-keep/stone/inner-keep-breached-keep-wall-compact-95131380fca529e6.glb" + } + ] + }, + { + "id": "grand-covenant-cathedral", + "family": "landmarks", + "sourcePackage": "Warpkeep_GrandCovenantCathedral_GameReady", + "sourceAssetId": "warpkeep.city-buildings.grand-covenant-cathedral", + "displayName": "Grand Covenant Cathedral", + "intendedUse": "Permanent northern civic and ceremonial anchor", + "boundsMeters": [ + 34, + 31.5, + 29.02 + ], + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_GrandCovenantCathedral_GameReady/Runtime/runtime-manifest.json", + "bytes": 9661, + "sha256": "29ad96e073f09f645598eb69f27bd55ef4b486ebb182cba98819f1ec507c47a1" + }, + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_GrandCovenantCathedral_GameReady/Runtime/Warpkeep_GrandCovenantCathedral_LOD0_High_Runtime.glb", + "bytes": 12507408, + "sha256": "9bf438bdf020d2743478d6041ffaecd324b78fe2429ec52c580a1c43f7b32a6c", + "triangles": 82994, + "boundsMeters": [ + 34, + 31.5, + 29.02 + ], + "destinationPath": "public/models/hegemony/inner-keep/landmarks/inner-keep-grand-covenant-cathedral-high-9bf438bdf020d274.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_GrandCovenantCathedral_GameReady/Runtime/Warpkeep_GrandCovenantCathedral_LOD1_Balanced_Runtime.glb", + "bytes": 5810260, + "sha256": "c90cf49f6b90325b27161bf00e3682f59a6cc730d468417c53a3b17197ec78d2", + "triangles": 40578, + "boundsMeters": [ + 34, + 31.5, + 29.02 + ], + "destinationPath": "public/models/hegemony/inner-keep/landmarks/inner-keep-grand-covenant-cathedral-balanced-c90cf49f6b90325b.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_GrandCovenantCathedral_GameReady/Runtime/Warpkeep_GrandCovenantCathedral_LOD2_Compact_Runtime.glb", + "bytes": 3697276, + "sha256": "cea4eb9e4de9c32343a1f0135d58749ddfc44de3adb6f4927d6f3cab85c1b575", + "triangles": 21738, + "boundsMeters": [ + 34, + 31.5, + 29.02 + ], + "destinationPath": "public/models/hegemony/inner-keep/landmarks/inner-keep-grand-covenant-cathedral-compact-cea4eb9e4de9c323.glb" + } + ], + "preview": { + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_GrandCovenantCathedral_GameReady/Previews/Warpkeep_GrandCovenantCathedral_MobilePreview_320.png", + "bytes": 142878, + "sha256": "cc1eb98b0c3e811e2e559bc81537617493939ff38e7117f5defd9ad05907462f", + "width": 320, + "height": 320, + "destinationPath": "public/images/inner-keep/catalog/grand-covenant-cathedral-cc1eb98b0c3e811e.png" + } + }, + { + "id": "city-barracks", + "family": "landmarks", + "sourcePackage": "Warpkeep_CityBarracks_GameReady", + "sourceAssetId": "warpkeep.city-buildings.city-barracks", + "displayName": "Hegemony Shieldcourt Barracks", + "intendedUse": "Permanent western garrison and patrol origin", + "boundsMeters": [ + 16, + 10.1, + 13 + ], + "sourceManifest": { + "path": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityBarracks_GameReady/Runtime/runtime-manifest.json", + "bytes": 4506, + "sha256": "f81c2f9bc30c53c47a9bf2d2c6e6cb839398d7c8e4d66b938d44fbb234533df9" + }, + "models": [ + { + "profile": "high", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityBarracks_GameReady/Runtime/Warpkeep_CityBarracks_LOD0_High_Runtime.glb", + "bytes": 1172080, + "sha256": "21b4c204adbde086aa7a078b5f12838e579664bb33809b9fe39884a6adabd2a3", + "triangles": 13886, + "boundsMeters": [ + 16, + 10.1, + 13 + ], + "destinationPath": "public/models/hegemony/inner-keep/landmarks/inner-keep-city-barracks-high-21b4c204adbde086.glb" + }, + { + "profile": "balanced", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityBarracks_GameReady/Runtime/Warpkeep_CityBarracks_LOD1_Balanced_Runtime.glb", + "bytes": 629352, + "sha256": "3a22b6e910d4dd1b6f105eae9bd0234eaf65821e0dcd65ff59e6311f54638201", + "triangles": 7550, + "boundsMeters": [ + 16, + 10.1, + 13 + ], + "destinationPath": "public/models/hegemony/inner-keep/landmarks/inner-keep-city-barracks-balanced-3a22b6e910d4dd1b.glb" + }, + { + "profile": "compact", + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityBarracks_GameReady/Runtime/Warpkeep_CityBarracks_LOD2_Compact_Runtime.glb", + "bytes": 504080, + "sha256": "7f90bd96932b7fea1e2541c103657b248bf86f0a4a9bd38c9b03e3b5bd487404", + "triangles": 5488, + "boundsMeters": [ + 16, + 10.1, + 13 + ], + "destinationPath": "public/models/hegemony/inner-keep/landmarks/inner-keep-city-barracks-compact-7f90bd96932b7fea.glb" + } + ], + "preview": { + "sourcePath": "inner-keep-3d-asset-library-2026-08-02-v1/3d/Warpkeep_CityBarracks_GameReady/Previews/Warpkeep_CityBarracks_MobilePreview_320.png", + "bytes": 150811, + "sha256": "7aa4f0901fa752a6a86afa99c74ccf97c879aca9818d22aae40d4197f5491175", + "width": 320, + "height": 320, + "destinationPath": "public/images/inner-keep/catalog/city-barracks-7aa4f0901fa752a6.png" + } + } + ] +} diff --git a/docs/reference/assets/2026-08-04-inner-keep-population/README.md b/docs/reference/assets/2026-08-04-inner-keep-population/README.md new file mode 100644 index 00000000..10608be5 --- /dev/null +++ b/docs/reference/assets/2026-08-04-inner-keep-population/README.md @@ -0,0 +1,74 @@ +# Inner Keep population runtime selection + +This record pins the exact citizens, mounts, infantry, ranged units, and +cavalry authorized for Warpkeep's official Inner Keep presentation. It does +not turn ambient motion into game state and does not grant a new public license +for the model files. + +## What is selected + +- Eight citizens, including the mounted Emberfoot Courier and Shellback Shrine + Tender. +- Four infantry units and four ranged units for foot patrols. +- Four mounted cavalry units for the garrison patrol. +- One rigged Balanced model and one static Compact fallback per actor. +- 40 content-addressed GLBs, 8,705,628 bytes across both profiles. +- Selection digest + `79237fbe85a4db7a0592eb0c27cc00f8e72e85e58be867bec4dd35992f0b87f7`. + +The full machine-readable allowlist is [`manifest.json`](manifest.json). + +## Source coordinates + +The selected files come from two public Warpkeep-Assets releases dated +2026-08-03: + +- [`hegemony-citizens-keep-services-2026-08-03`](https://github.com/ael-dev3/Warpkeep-Assets/releases/tag/hegemony-citizens-keep-services-2026-08-03) +- [`hegemony-unit-corps-2026-08-03`](https://github.com/ael-dev3/Warpkeep-Assets/releases/tag/hegemony-unit-corps-2026-08-03) + +The manifest pins all four release archives, both trusted release manifests, +their repository commits, every source runtime manifest, and every selected +GLB by exact bytes and SHA-256. + +## Authorization and license boundary + +On 2026-08-04, Ael instructed Warpkeep to use these characters, mounts, and +army units in the official Inner Keep runtime, including animated civic +routines and patrols. That instruction authorizes only the exact selected +files in the public `ael-dev3/Warpkeep` repository and official +`warpkeep.com` client. + +The source releases provide public archive distribution but no separate +blanket open-content license for these models. The selection therefore remains +`LicenseRef-Warpkeep-Provenance-Required`. The authorization does not relicense +the releases, grant unrelated redistribution or derivative rights, authorize +substitutions, or approve activation, merge, or deployment. + +## Runtime boundary + +The browser loads only installed, local, content-addressed files. Ordinary +builds never fetch GitHub releases or read the archive cache. Balanced models +provide deterministic Idle, Walk, and civic Greet/Work animation where +attested. Compact models are static fallbacks for reduced quality or reduced +motion. + +All inhabitants are presentation-only. Their path sampling is deterministic +and closed-form. No ambient actor accepts player coordinates, writes server +state, impersonates a player or Worker, emits chat, determines collision, or +grants combat, resource, reward, identity, or ownership authority. + +## Reproduce and verify locally + +With the exact four release archives already present in the ignored local +asset cache: + +```sh +npm run assets:audit:inner-keep-population +npm run prepare:inner-keep-population-assets +npm run verify:inner-keep-population-assets +``` + +The audit verifies the release manifests and archives without installing. +Installation extracts only the 40 allowlisted GLBs. Repository and production +verification reject missing, unexpected, changed, symlinked, malformed, or +non-content-addressed outputs. diff --git a/docs/reference/assets/2026-08-04-inner-keep-population/manifest.json b/docs/reference/assets/2026-08-04-inner-keep-population/manifest.json new file mode 100644 index 00000000..19f6208f --- /dev/null +++ b/docs/reference/assets/2026-08-04-inner-keep-population/manifest.json @@ -0,0 +1,1178 @@ +{ + "schema": "warpkeep.inner-keep-population-selection.v1", + "selectionId": "inner-keep-population-v1", + "recordedAt": "2026-08-04", + "selectionDigestAlgorithm": "sha256-ecmascript-canonical-json-v1", + "selectionDigestSha256": "79237fbe85a4db7a0592eb0c27cc00f8e72e85e58be867bec4dd35992f0b87f7", + "authorization": { + "archiveDistributionAuthorized": true, + "officialRepositoryRuntimeUseAuthorized": true, + "status": "authorized-owner-runtime-use", + "recordedAt": "2026-08-04", + "instruction": "Use characters, mounts, and army units from Warpkeep-Assets in the official Inner Keep runtime, including animated civic routines and patrols.", + "scopeBoundary": "Runtime use in the public ael-dev3/Warpkeep repository and official warpkeep.com client is authorized for the exact selected files. This does not relicense the source archives, authorize unrelated reuse, represent ambient patrols as gameplay authority, approve activation, approve merge, or approve deployment." + }, + "licenseBoundary": { + "sourceStatus": "public-archive-authorized-no-separate-open-license", + "integrationGrant": "owner-authorized-official-warpkeep-runtime-use-only", + "redistributionOrRelicensingGranted": false + }, + "runtimePolicy": { + "animatedTier": "LOD1_Balanced", + "staticFallbackTier": "LOD2_Compact", + "ordinaryBuildMayReadArchive": false, + "ordinaryBuildMayUseNetwork": false, + "clientPresentationOnly": true, + "gameplayAuthorityClaimed": false + }, + "sources": [ + { + "repository": "ael-dev3/Warpkeep-Assets", + "repositoryMainCommit": "10c84fbcc339f143ee6f25dfe7a0682660e0e458", + "releaseCommit": "b074ffb6317ff9a581f5b7fc7f0a0760e721a9b6", + "tag": "hegemony-unit-corps-2026-08-03", + "trustedReleaseManifest": { + "repositoryPath": "releases/hegemony-unit-corps-2026-08-03/manifest.json", + "bytes": 54417, + "sha256": "983c7dbede5220a0b020a73ea7cf51f8cdd72fbe235417d890b34eabae3acaaf" + }, + "attachments": [ + { + "name": "warpkeep-hegemony-infantry-game-ready-2026-08-03-v1.zip", + "bytes": 16877451, + "sha256": "953fcca256324f2a73b3f13e2ca04911349a0f69934337e6ac7161041a2d4ba0", + "entries": 43, + "packageRoot": "Warpkeep_HegemonyInfantry_GameReady" + }, + { + "name": "warpkeep-hegemony-ranged-game-ready-2026-08-03-v1.zip", + "bytes": 16349955, + "sha256": "310b0feaf21f0619f1d10f154facd64f98ce3ad1658cf80bb4dd68f5847a0391", + "entries": 43, + "packageRoot": "Warpkeep_HegemonyRanged_GameReady" + }, + { + "name": "warpkeep-hegemony-cavalry-game-ready-2026-08-03-v1.zip", + "bytes": 19651045, + "sha256": "9d411ee308abacfd7ca93b1e96594cc4f9e8af613f6d18aca3128b49dfb9a48b", + "entries": 43, + "packageRoot": "Warpkeep_HegemonyCavalry_GameReady" + } + ] + }, + { + "repository": "ael-dev3/Warpkeep-Assets", + "repositoryMainCommit": "10c84fbcc339f143ee6f25dfe7a0682660e0e458", + "releaseCommit": "9f99751f691581ce4f33a31dd56c818cfa455b61", + "tag": "hegemony-citizens-keep-services-2026-08-03", + "trustedReleaseManifest": { + "repositoryPath": "releases/hegemony-citizens-keep-services-2026-08-03/manifest.json", + "bytes": 33008, + "sha256": "2472aea6dc43780f6ee11692b65fe138a2355373a9e72d96156bc0f9290cb34b" + }, + "attachments": [ + { + "name": "warpkeep-hegemony-citizens-keep-services-game-ready-2026-08-03-v1.zip", + "bytes": 24825077, + "sha256": "14a8bda3848ede5b398a7fc4ec10e56d98ae06584c42f4683cb2635c5bbb6aeb", + "entries": 75, + "packageRoot": "Warpkeep_HegemonyCitizens_Set2_GameReady" + } + ] + } + ], + "counts": { + "actors": 20, + "selectedModels": 40, + "mountedActors": 6, + "families": { + "citizen": 8, + "infantry": 4, + "ranged": 4, + "cavalry": 4 + }, + "selectedBytes": 8705628, + "selectedTriangles": { + "balanced": 58792, + "compact": 37506 + } + }, + "actors": [ + { + "id": "astral-lancer", + "family": "cavalry", + "sourceReleaseTag": "hegemony-unit-corps-2026-08-03", + "sourceArchive": "warpkeep-hegemony-cavalry-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.cavalry.astral-lancer", + "displayName": "Hegemony Astral Lancer", + "mounted": true, + "presentationRole": "ceremonial-patrol", + "sourceManifest": { + "path": "Warpkeep_HegemonyCavalry_GameReady/Runtime/Units/Hegemony/Cavalry/AstralLancer/runtime-manifest.json", + "bytes": 8404, + "sha256": "b60ad2798759a93b2273519f62f2ebc31d99d5e22c7a0ca47458d34e7a272aa7" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyCavalry_GameReady/Runtime/Units/Hegemony/Cavalry/AstralLancer/Warpkeep_Hegemony_AstralLancer_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 566296, + "sha256": "bff3bd13e876cce8023ee856800ba392c65d04ed840efcb00f1dc3d429b669b4", + "triangles": 5884, + "animations": [ + "Attack", + "Idle", + "Special", + "Walk" + ], + "boundsMeters": [ + 1.560603, + 3.07, + 4.066258 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/cavalry/inner-keep-astral-lancer-balanced-bff3bd13e876cce8.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyCavalry_GameReady/Runtime/Units/Hegemony/Cavalry/AstralLancer/Warpkeep_Hegemony_AstralLancer_LOD2_Compact_Static_Runtime.glb", + "bytes": 202664, + "sha256": "fea4558e4365cba0e5c984393519c25ed1beedced55cc88eeb384e76c02957ff", + "triangles": 3710, + "animations": [], + "boundsMeters": [ + 1.564349, + 2.87, + 4.066258 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/cavalry/inner-keep-astral-lancer-compact-fea4558e4365cba0.glb" + } + ] + }, + { + "id": "dusk-outrider", + "family": "cavalry", + "sourceReleaseTag": "hegemony-unit-corps-2026-08-03", + "sourceArchive": "warpkeep-hegemony-cavalry-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.cavalry.dusk-outrider", + "displayName": "Hegemony Dusk Outrider", + "mounted": true, + "presentationRole": "ceremonial-patrol", + "sourceManifest": { + "path": "Warpkeep_HegemonyCavalry_GameReady/Runtime/Units/Hegemony/Cavalry/DuskOutrider/runtime-manifest.json", + "bytes": 8388, + "sha256": "c7375e85a7d5ebb1c58a6a718ccd98db15ae4bdbf6a086253bd9f68466f22901" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyCavalry_GameReady/Runtime/Units/Hegemony/Cavalry/DuskOutrider/Warpkeep_Hegemony_DuskOutrider_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 529884, + "sha256": "b956a70d9c99541e9ab94e4a61edb3b8747297d478295c3128e05031259cb47f", + "triangles": 5508, + "animations": [ + "Attack", + "Idle", + "Special", + "Walk" + ], + "boundsMeters": [ + 1.81, + 2.93, + 3.451225 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/cavalry/inner-keep-dusk-outrider-balanced-b956a70d9c99541e.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyCavalry_GameReady/Runtime/Units/Hegemony/Cavalry/DuskOutrider/Warpkeep_Hegemony_DuskOutrider_LOD2_Compact_Static_Runtime.glb", + "bytes": 183164, + "sha256": "f9233e5018551a5aab51a95b8a8b081f7622e4484d6800733cdc67deb6eaf11a", + "triangles": 3434, + "animations": [], + "boundsMeters": [ + 1.81, + 2.93, + 3.4421 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/cavalry/inner-keep-dusk-outrider-compact-f9233e5018551a5a.glb" + } + ] + }, + { + "id": "horseguard", + "family": "cavalry", + "sourceReleaseTag": "hegemony-unit-corps-2026-08-03", + "sourceArchive": "warpkeep-hegemony-cavalry-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.cavalry.horseguard", + "displayName": "Hegemony Horseguard", + "mounted": true, + "presentationRole": "ceremonial-patrol", + "sourceManifest": { + "path": "Warpkeep_HegemonyCavalry_GameReady/Runtime/Units/Hegemony/Cavalry/Horseguard/runtime-manifest.json", + "bytes": 8447, + "sha256": "805ff45fe4c8bba4a06f1e5b3b8772bc0a9a8c0bf80d7ad9b75021cb3f7fd9d2" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyCavalry_GameReady/Runtime/Units/Hegemony/Cavalry/Horseguard/Warpkeep_Hegemony_Horseguard_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 587332, + "sha256": "2e15034a96229eb543c6528c9abee1140dbc78cf4acd173c074051309142e2bf", + "triangles": 6046, + "animations": [ + "Attack", + "Idle", + "Special", + "Walk" + ], + "boundsMeters": [ + 1.560668, + 3.047646, + 3.451225 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/cavalry/inner-keep-horseguard-balanced-2e15034a96229eb5.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyCavalry_GameReady/Runtime/Units/Hegemony/Cavalry/Horseguard/Warpkeep_Hegemony_Horseguard_LOD2_Compact_Static_Runtime.glb", + "bytes": 215128, + "sha256": "26ec537f8a0dc677a40ad102c5073ff43c661c3e94fe1ccffee366867cb16866", + "triangles": 3860, + "animations": [], + "boundsMeters": [ + 1.560668, + 3.006159, + 3.4421 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/cavalry/inner-keep-horseguard-compact-26ec537f8a0dc677.glb" + } + ] + }, + { + "id": "imperial-cataphract", + "family": "cavalry", + "sourceReleaseTag": "hegemony-unit-corps-2026-08-03", + "sourceArchive": "warpkeep-hegemony-cavalry-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.cavalry.imperial-cataphract", + "displayName": "Hegemony Imperial Cataphract", + "mounted": true, + "presentationRole": "ceremonial-patrol", + "sourceManifest": { + "path": "Warpkeep_HegemonyCavalry_GameReady/Runtime/Units/Hegemony/Cavalry/ImperialCataphract/runtime-manifest.json", + "bytes": 8433, + "sha256": "17923aca73f423a77465bf22d14bccb321e23a2de38d8618d2263726a29f47d5" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyCavalry_GameReady/Runtime/Units/Hegemony/Cavalry/ImperialCataphract/Warpkeep_Hegemony_ImperialCataphract_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 622796, + "sha256": "9f2f98369c4d7af8c237ea8d8d742cf5b320b7e1f9731a57209ba5928e14da45", + "triangles": 6416, + "animations": [ + "Attack", + "Idle", + "Special", + "Walk" + ], + "boundsMeters": [ + 1.98, + 3.12, + 3.451225 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/cavalry/inner-keep-imperial-cataphract-balanced-9f2f98369c4d7af8.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyCavalry_GameReady/Runtime/Units/Hegemony/Cavalry/ImperialCataphract/Warpkeep_Hegemony_ImperialCataphract_LOD2_Compact_Static_Runtime.glb", + "bytes": 232544, + "sha256": "6f892da1bec76a66d06469a14e16e132694612ae686deb5803c8847931db9c1c", + "triangles": 4098, + "animations": [], + "boundsMeters": [ + 1.98, + 2.93, + 3.4421 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/cavalry/inner-keep-imperial-cataphract-compact-6f892da1bec76a66.glb" + } + ] + }, + { + "id": "basilica-warden", + "family": "citizen", + "sourceReleaseTag": "hegemony-citizens-keep-services-2026-08-03", + "sourceArchive": "warpkeep-hegemony-citizens-keep-services-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.citizens-set2.basilica-warden", + "displayName": "Hegemony Basilica Warden", + "mounted": false, + "presentationRole": "civic-routine", + "sourceManifest": { + "path": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/BasilicaWarden/runtime-manifest.json", + "bytes": 6564, + "sha256": "c524bf7df16cfc3d67b79fad48a4de13a628884786555b251cd690976860338a" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/BasilicaWarden/Warpkeep_Hegemony_BasilicaWarden_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 137068, + "sha256": "797df2ac1595c1caf02178660944276773fe06e4314b1564d7b070aba9ac5502", + "triangles": 1452, + "animations": [ + "Greet", + "Idle", + "Walk", + "Work" + ], + "boundsMeters": [ + 1.210697, + 2.445267, + 0.633327 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-basilica-warden-balanced-797df2ac1595c1ca.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/BasilicaWarden/Warpkeep_Hegemony_BasilicaWarden_LOD2_Compact_Static_Runtime.glb", + "bytes": 46684, + "sha256": "901fcf144078ea310c11f9c6491ac455a112d3c8d02aebd1f5f9574b306a5a62", + "triangles": 884, + "animations": [], + "boundsMeters": [ + 1.21072, + 2.445267, + 0.63154 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-basilica-warden-compact-901fcf144078ea31.glb" + } + ] + }, + { + "id": "bell-herald", + "family": "citizen", + "sourceReleaseTag": "hegemony-citizens-keep-services-2026-08-03", + "sourceArchive": "warpkeep-hegemony-citizens-keep-services-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.citizens-set2.bell-herald", + "displayName": "Hegemony Bell Herald", + "mounted": false, + "presentationRole": "civic-routine", + "sourceManifest": { + "path": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/BellHerald/runtime-manifest.json", + "bytes": 6489, + "sha256": "84d49853485092e89db7c0c3dcab25a67d542def523e39299b66046e08488f8d" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/BellHerald/Warpkeep_Hegemony_BellHerald_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 139012, + "sha256": "9db372f360e5567474612c3c57564a34ac98dbe5f062e1fb3529c3787118d94f", + "triangles": 1512, + "animations": [ + "Greet", + "Idle", + "Walk", + "Work" + ], + "boundsMeters": [ + 1.267, + 2.085, + 0.758613 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-bell-herald-balanced-9db372f360e55674.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/BellHerald/Warpkeep_Hegemony_BellHerald_LOD2_Compact_Static_Runtime.glb", + "bytes": 47484, + "sha256": "8906c8b6513dbe24cca09a1e8653af425fbee22eb3591910b5f4ff937026a88b", + "triangles": 908, + "animations": [], + "boundsMeters": [ + 1.247, + 2.085, + 0.7817 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-bell-herald-compact-8906c8b6513dbe24.glb" + } + ] + }, + { + "id": "chirurgeon-apothecary", + "family": "citizen", + "sourceReleaseTag": "hegemony-citizens-keep-services-2026-08-03", + "sourceArchive": "warpkeep-hegemony-citizens-keep-services-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.citizens-set2.chirurgeon-apothecary", + "displayName": "Hegemony Chirurgeon-Apothecary", + "mounted": false, + "presentationRole": "civic-routine", + "sourceManifest": { + "path": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/ChirurgeonApothecary/runtime-manifest.json", + "bytes": 6598, + "sha256": "dff1e406a6f1d01eabd6a118a8338f146c6915180173fd1bd0d96f1d2cbd0079" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/ChirurgeonApothecary/Warpkeep_Hegemony_ChirurgeonApothecary_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 161932, + "sha256": "68c52942e9673098a510f4e02efd596479716341bc3ad7046d58d90c6ceb45c7", + "triangles": 1824, + "animations": [ + "Greet", + "Idle", + "Walk", + "Work" + ], + "boundsMeters": [ + 0.977991, + 2.076911, + 0.675 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-chirurgeon-apothecary-balanced-68c52942e9673098.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/ChirurgeonApothecary/Warpkeep_Hegemony_ChirurgeonApothecary_LOD2_Compact_Static_Runtime.glb", + "bytes": 54348, + "sha256": "5060e336435c66e2a9895f17b852e8f58bee8154b30f7738f73b14cbedef79bf", + "triangles": 984, + "animations": [], + "boundsMeters": [ + 0.976906, + 2.076911, + 0.668971 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-chirurgeon-apothecary-compact-5060e336435c66e2.glb" + } + ] + }, + { + "id": "cistern-warden", + "family": "citizen", + "sourceReleaseTag": "hegemony-citizens-keep-services-2026-08-03", + "sourceArchive": "warpkeep-hegemony-citizens-keep-services-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.citizens-set2.cistern-warden", + "displayName": "Hegemony Cistern Warden", + "mounted": false, + "presentationRole": "civic-routine", + "sourceManifest": { + "path": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/CisternWarden/runtime-manifest.json", + "bytes": 6514, + "sha256": "d74f2bc3a2b1b693771161c2670d82a4b35faef202b38914f0f2cb8374f46c3d" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/CisternWarden/Warpkeep_Hegemony_CisternWarden_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 143716, + "sha256": "e6baa9401689e30a285615889d91c76aeba23efcc0e7cb9b9e6eaffa88958c75", + "triangles": 1640, + "animations": [ + "Greet", + "Idle", + "Walk", + "Work" + ], + "boundsMeters": [ + 1.476, + 2.005, + 0.657579 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-cistern-warden-balanced-e6baa9401689e30a.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/CisternWarden/Warpkeep_Hegemony_CisternWarden_LOD2_Compact_Static_Runtime.glb", + "bytes": 50548, + "sha256": "78dfc68d6f10e194e558bd17103395735393e8b6dec7ba8da5b54e153b0312ff", + "triangles": 1016, + "animations": [], + "boundsMeters": [ + 1.476, + 2.005, + 0.643006 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-cistern-warden-compact-78dfc68d6f10e194.glb" + } + ] + }, + { + "id": "ember-lamplighter", + "family": "citizen", + "sourceReleaseTag": "hegemony-citizens-keep-services-2026-08-03", + "sourceArchive": "warpkeep-hegemony-citizens-keep-services-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.citizens-set2.ember-lamplighter", + "displayName": "Hegemony Ember Lamplighter", + "mounted": false, + "presentationRole": "civic-routine", + "sourceManifest": { + "path": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/EmberLamplighter/runtime-manifest.json", + "bytes": 6572, + "sha256": "00ed670f00b360f0b6b98941e1703cf8acb0bca3ba8cf51101e2ad54b4dd5b3b" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/EmberLamplighter/Warpkeep_Hegemony_EmberLamplighter_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 157472, + "sha256": "599200017f3e3a3d992d9b848ee90e789157e6c8770bfc16b93dbe54184c4535", + "triangles": 1696, + "animations": [ + "Greet", + "Idle", + "Walk", + "Work" + ], + "boundsMeters": [ + 1.276807, + 2.395823, + 0.613925 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-ember-lamplighter-balanced-599200017f3e3a3d.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/EmberLamplighter/Warpkeep_Hegemony_EmberLamplighter_LOD2_Compact_Static_Runtime.glb", + "bytes": 58524, + "sha256": "1bb943eba0ec8114ecf1c17ec66adc797bea4f0b96d69d400ef71460a75aab2c", + "triangles": 1056, + "animations": [], + "boundsMeters": [ + 1.277643, + 2.395823, + 0.62225 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-ember-lamplighter-compact-1bb943eba0ec8114.glb" + } + ] + }, + { + "id": "emberfoot-courier", + "family": "citizen", + "sourceReleaseTag": "hegemony-citizens-keep-services-2026-08-03", + "sourceArchive": "warpkeep-hegemony-citizens-keep-services-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.citizens-set2.emberfoot-courier", + "displayName": "Hegemony Emberfoot Courier", + "mounted": true, + "presentationRole": "civic-routine", + "sourceManifest": { + "path": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/EmberfootCourier/runtime-manifest.json", + "bytes": 6719, + "sha256": "eba95466103537910d3ede7aba810d79a46bebc4cf73918f657928dc18fc2c51" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/EmberfootCourier/Warpkeep_Hegemony_EmberfootCourier_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 248820, + "sha256": "f7d399621be2dbd2c57104b8d95e6cbdb83586474e8bfd3c2d29c6a3c18ae0a1", + "triangles": 3020, + "animations": [ + "Greet", + "Idle", + "Walk", + "Work" + ], + "boundsMeters": [ + 1.28, + 2.52, + 2.23 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-emberfoot-courier-balanced-f7d399621be2dbd2.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/EmberfootCourier/Warpkeep_Hegemony_EmberfootCourier_LOD2_Compact_Static_Runtime.glb", + "bytes": 95168, + "sha256": "ff89e129ad744cdf95a7f6a3b4bc234aa94c9e9d69efb193c9645d22744e23b1", + "triangles": 1732, + "animations": [], + "boundsMeters": [ + 1.28, + 2.52, + 2.23 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-emberfoot-courier-compact-ff89e129ad744cdf.glb" + } + ] + }, + { + "id": "shellback-shrine-tender", + "family": "citizen", + "sourceReleaseTag": "hegemony-citizens-keep-services-2026-08-03", + "sourceArchive": "warpkeep-hegemony-citizens-keep-services-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.citizens-set2.shellback-shrine-tender", + "displayName": "Hegemony Shellback Shrine Tender", + "mounted": true, + "presentationRole": "civic-routine", + "sourceManifest": { + "path": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/ShellbackShrineTender/runtime-manifest.json", + "bytes": 6766, + "sha256": "9c038a0bc1ec5ab467b1a88468eb84b1dcadb777ff589add273808a21791198d" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/ShellbackShrineTender/Warpkeep_Hegemony_ShellbackShrineTender_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 231248, + "sha256": "65f276dfe3947f94b9b5cd14242f69d9cf3f25f23ee3eb738d0a1c2a480abe26", + "triangles": 2856, + "animations": [ + "Greet", + "Idle", + "Walk", + "Work" + ], + "boundsMeters": [ + 1.56, + 2.905, + 2.5 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-shellback-shrine-tender-balanced-65f276dfe3947f94.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/ShellbackShrineTender/Warpkeep_Hegemony_ShellbackShrineTender_LOD2_Compact_Static_Runtime.glb", + "bytes": 82084, + "sha256": "8c0c01a5bb0ea918ebe32369fcc145e7c1af5745598f8b99d07e6e5a43200e21", + "triangles": 1588, + "animations": [], + "boundsMeters": [ + 1.56, + 2.905, + 2.5 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-shellback-shrine-tender-compact-8c0c01a5bb0ea918.glb" + } + ] + }, + { + "id": "ward-peacekeeper", + "family": "citizen", + "sourceReleaseTag": "hegemony-citizens-keep-services-2026-08-03", + "sourceArchive": "warpkeep-hegemony-citizens-keep-services-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.citizens-set2.ward-peacekeeper", + "displayName": "Hegemony Ward Peacekeeper", + "mounted": false, + "presentationRole": "civic-routine", + "sourceManifest": { + "path": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/WardPeacekeeper/runtime-manifest.json", + "bytes": 6567, + "sha256": "abe268a83bffe565a88c2e2529690164c3f34126cebb66d36632085e02c22f48" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/WardPeacekeeper/Warpkeep_Hegemony_WardPeacekeeper_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 234140, + "sha256": "78f3dde74069cdfcf8c844716da454981303bf4f88292151d0508da5ba0698b0", + "triangles": 1594, + "animations": [ + "Greet", + "Idle", + "Walk", + "Work" + ], + "boundsMeters": [ + 1.27, + 2.025, + 0.692153 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-ward-peacekeeper-balanced-78f3dde74069cdfc.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyCitizens_Set2_GameReady/Runtime/Units/Hegemony/CitizensSet2/WardPeacekeeper/Warpkeep_Hegemony_WardPeacekeeper_LOD2_Compact_Static_Runtime.glb", + "bytes": 56256, + "sha256": "d6d867fad68548c1259b3aa64ad78a24a8ae9a594b9e54c3abd2f6241b0fd0df", + "triangles": 996, + "animations": [], + "boundsMeters": [ + 1.258635, + 2.025, + 0.7136 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/citizen/inner-keep-ward-peacekeeper-compact-d6d867fad68548c1.glb" + } + ] + }, + { + "id": "bulwark", + "family": "infantry", + "sourceReleaseTag": "hegemony-unit-corps-2026-08-03", + "sourceArchive": "warpkeep-hegemony-infantry-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.infantry.bulwark", + "displayName": "Hegemony Bulwark", + "mounted": false, + "presentationRole": "ceremonial-patrol", + "sourceManifest": { + "path": "Warpkeep_HegemonyInfantry_GameReady/Runtime/Units/Hegemony/Infantry/Bulwark/runtime-manifest.json", + "bytes": 5783, + "sha256": "56b28598c07b6e29dc6aa9d939dc9c667bc8fab86159d83c6d7201cdad85f0dc" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyInfantry_GameReady/Runtime/Units/Hegemony/Infantry/Bulwark/Warpkeep_Hegemony_Bulwark_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 303004, + "sha256": "4a0e1fd2470259ea60f4428c7ad2e5f7f1469c7272ccdfa65fc4b04acda669d4", + "triangles": 2362, + "animations": [ + "Attack", + "Idle", + "Walk" + ], + "boundsMeters": [ + 1.431042, + 2.03, + 0.65202 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/infantry/inner-keep-bulwark-balanced-4a0e1fd2470259ea.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyInfantry_GameReady/Runtime/Units/Hegemony/Infantry/Bulwark/Warpkeep_Hegemony_Bulwark_LOD2_Compact_Static_Runtime.glb", + "bytes": 188336, + "sha256": "1cc92a6d96a4ffc2a8b8b9651419517662aaa56244513521c870ff527e4a601e", + "triangles": 1690, + "animations": [], + "boundsMeters": [ + 1.431042, + 2.03, + 0.666752 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/infantry/inner-keep-bulwark-compact-1cc92a6d96a4ffc2.glb" + } + ] + }, + { + "id": "honor-guard", + "family": "infantry", + "sourceReleaseTag": "hegemony-unit-corps-2026-08-03", + "sourceArchive": "warpkeep-hegemony-infantry-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.infantry.honor-guard", + "displayName": "Hegemony Honor Guard", + "mounted": false, + "presentationRole": "ceremonial-patrol", + "sourceManifest": { + "path": "Warpkeep_HegemonyInfantry_GameReady/Runtime/Units/Hegemony/Infantry/HonorGuard/runtime-manifest.json", + "bytes": 5823, + "sha256": "68fd38e7599dc528b31450d596831945b64df066d4a2ce372f6b208dfbe195a7" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyInfantry_GameReady/Runtime/Units/Hegemony/Infantry/HonorGuard/Warpkeep_Hegemony_HonorGuard_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 320440, + "sha256": "66f157c4dc9bcd4ffaec17081f5019a168f9b69009293518f89eb366864a7c67", + "triangles": 2586, + "animations": [ + "Attack", + "Idle", + "Walk" + ], + "boundsMeters": [ + 1.411042, + 2.28, + 0.65202 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/infantry/inner-keep-honor-guard-balanced-66f157c4dc9bcd4f.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyInfantry_GameReady/Runtime/Units/Hegemony/Infantry/HonorGuard/Warpkeep_Hegemony_HonorGuard_LOD2_Compact_Static_Runtime.glb", + "bytes": 194620, + "sha256": "105926a9ac1be2fac529109367cecbfcdc409f5812b2d7eea2f4925c8e63156f", + "triangles": 1770, + "animations": [], + "boundsMeters": [ + 1.411042, + 2.109529, + 0.666752 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/infantry/inner-keep-honor-guard-compact-105926a9ac1be2fa.glb" + } + ] + }, + { + "id": "legionary", + "family": "infantry", + "sourceReleaseTag": "hegemony-unit-corps-2026-08-03", + "sourceArchive": "warpkeep-hegemony-infantry-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.infantry.legionary", + "displayName": "Hegemony Legionary", + "mounted": false, + "presentationRole": "ceremonial-patrol", + "sourceManifest": { + "path": "Warpkeep_HegemonyInfantry_GameReady/Runtime/Units/Hegemony/Infantry/Legionary/runtime-manifest.json", + "bytes": 5786, + "sha256": "bc2949de95c9fbf06aef3880b942741f9ff8902d103007a347a9d6098b3122c2" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyInfantry_GameReady/Runtime/Units/Hegemony/Infantry/Legionary/Warpkeep_Hegemony_Legionary_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 306080, + "sha256": "e7361bc20051055d40b90e6415ddee117883dda5d7b1b38317a76e99bb704faa", + "triangles": 2410, + "animations": [ + "Attack", + "Idle", + "Walk" + ], + "boundsMeters": [ + 1.484765, + 2.23, + 0.653768 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/infantry/inner-keep-legionary-balanced-e7361bc20051055d.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyInfantry_GameReady/Runtime/Units/Hegemony/Infantry/Legionary/Warpkeep_Hegemony_Legionary_LOD2_Compact_Static_Runtime.glb", + "bytes": 188676, + "sha256": "abb4a2ef93b80cb05212906860911ee9d54eb0cbe11a92a812b8510753e8a03a", + "triangles": 1690, + "animations": [], + "boundsMeters": [ + 1.484765, + 2.03, + 0.6685 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/infantry/inner-keep-legionary-compact-abb4a2ef93b80cb0.glb" + } + ] + }, + { + "id": "vanguard", + "family": "infantry", + "sourceReleaseTag": "hegemony-unit-corps-2026-08-03", + "sourceArchive": "warpkeep-hegemony-infantry-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.infantry.vanguard", + "displayName": "Hegemony Vanguard", + "mounted": false, + "presentationRole": "ceremonial-patrol", + "sourceManifest": { + "path": "Warpkeep_HegemonyInfantry_GameReady/Runtime/Units/Hegemony/Infantry/Vanguard/runtime-manifest.json", + "bytes": 5810, + "sha256": "65916d394f1e30121dad43e787be3a0f07fdf67389a1933b62f6e66d027061fa" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyInfantry_GameReady/Runtime/Units/Hegemony/Infantry/Vanguard/Warpkeep_Hegemony_Vanguard_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 305928, + "sha256": "e62df99856c02f9964ac4303600abcf7c9f40879bd52bbabbce75a6330538176", + "triangles": 2402, + "animations": [ + "Attack", + "Idle", + "Walk" + ], + "boundsMeters": [ + 1.514723, + 2.207646, + 0.69597 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/infantry/inner-keep-vanguard-balanced-e62df99856c02f99.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyInfantry_GameReady/Runtime/Units/Hegemony/Infantry/Vanguard/Warpkeep_Hegemony_Vanguard_LOD2_Compact_Static_Runtime.glb", + "bytes": 188192, + "sha256": "ee76a1bc2cbb528576cd6706a18c169fa8af0783cc5bb81e0b8f9294ac19b266", + "triangles": 1682, + "animations": [], + "boundsMeters": [ + 1.514723, + 2.03, + 0.710702 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/infantry/inner-keep-vanguard-compact-ee76a1bc2cbb5285.glb" + } + ] + }, + { + "id": "astral-magister", + "family": "ranged", + "sourceReleaseTag": "hegemony-unit-corps-2026-08-03", + "sourceArchive": "warpkeep-hegemony-ranged-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.ranged.astral-magister", + "displayName": "Hegemony Astral Magister", + "mounted": false, + "presentationRole": "ceremonial-patrol", + "sourceManifest": { + "path": "Warpkeep_HegemonyRanged_GameReady/Runtime/Units/Hegemony/Ranged/AstralMagister/runtime-manifest.json", + "bytes": 6459, + "sha256": "aa9f069c8c1fb05c24bd6155aa6032bdc2cb63a9e70ae1cfb4126ef5048f5cff" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyRanged_GameReady/Runtime/Units/Hegemony/Ranged/AstralMagister/Warpkeep_Hegemony_AstralMagister_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 277348, + "sha256": "f7ebf6dd805946574838b64c2c886f959ea4309a296fce6b66ec8d9a804f3a1f", + "triangles": 1958, + "animations": [ + "Attack", + "Idle", + "Special", + "Walk" + ], + "boundsMeters": [ + 1.413046, + 2.396841, + 0.793268 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/ranged/inner-keep-astral-magister-balanced-f7ebf6dd80594657.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyRanged_GameReady/Runtime/Units/Hegemony/Ranged/AstralMagister/Warpkeep_Hegemony_AstralMagister_LOD2_Compact_Static_Runtime.glb", + "bytes": 79828, + "sha256": "2b1b9985793ec5435678fcaefef68770da54fff084d5e5af2b6c55a3658c4a20", + "triangles": 1336, + "animations": [], + "boundsMeters": [ + 1.413046, + 2.396841, + 0.799962 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/ranged/inner-keep-astral-magister-compact-2b1b9985793ec543.glb" + } + ] + }, + { + "id": "dusk-ranger", + "family": "ranged", + "sourceReleaseTag": "hegemony-unit-corps-2026-08-03", + "sourceArchive": "warpkeep-hegemony-ranged-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.ranged.dusk-ranger", + "displayName": "Hegemony Dusk Ranger", + "mounted": false, + "presentationRole": "ceremonial-patrol", + "sourceManifest": { + "path": "Warpkeep_HegemonyRanged_GameReady/Runtime/Units/Hegemony/Ranged/DuskRanger/runtime-manifest.json", + "bytes": 6335, + "sha256": "d2f851723da013dcd9efd15fd627e3138336f86b0d49545612deb5c70b098cc1" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyRanged_GameReady/Runtime/Units/Hegemony/Ranged/DuskRanger/Warpkeep_Hegemony_DuskRanger_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 310084, + "sha256": "24020097cc18b2488c65219485b7386773c9781ae619f6ebf27f1b2a8ea246ea", + "triangles": 2370, + "animations": [ + "Attack", + "Idle", + "Special", + "Walk" + ], + "boundsMeters": [ + 1.095, + 2.015, + 1.336268 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/ranged/inner-keep-dusk-ranger-balanced-24020097cc18b248.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyRanged_GameReady/Runtime/Units/Hegemony/Ranged/DuskRanger/Warpkeep_Hegemony_DuskRanger_LOD2_Compact_Static_Runtime.glb", + "bytes": 92004, + "sha256": "c707df9a6fcf23a1dbd9560e759b4cf494bc27b8f3e0ec6621c240a179522d85", + "triangles": 1552, + "animations": [], + "boundsMeters": [ + 1.060886, + 2.015, + 1.351 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/ranged/inner-keep-dusk-ranger-compact-c707df9a6fcf23a1.glb" + } + ] + }, + { + "id": "longbow-warden", + "family": "ranged", + "sourceReleaseTag": "hegemony-unit-corps-2026-08-03", + "sourceArchive": "warpkeep-hegemony-ranged-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.ranged.longbow-warden", + "displayName": "Hegemony Longbow Warden", + "mounted": false, + "presentationRole": "ceremonial-patrol", + "sourceManifest": { + "path": "Warpkeep_HegemonyRanged_GameReady/Runtime/Units/Hegemony/Ranged/LongbowWarden/runtime-manifest.json", + "bytes": 6345, + "sha256": "a1e55d9ce54d358e86b64fcada90af99193f17f5cdbd4e240e11bc646bd4db13" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyRanged_GameReady/Runtime/Units/Hegemony/Ranged/LongbowWarden/Warpkeep_Hegemony_LongbowWarden_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 331228, + "sha256": "496ac58752dfd060239a8687a2efe2bee50340f948a1c0221f3e6b750bea4f9c", + "triangles": 2698, + "animations": [ + "Attack", + "Idle", + "Special", + "Walk" + ], + "boundsMeters": [ + 1.17954, + 2.23, + 1.416268 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/ranged/inner-keep-longbow-warden-balanced-496ac58752dfd060.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyRanged_GameReady/Runtime/Units/Hegemony/Ranged/LongbowWarden/Warpkeep_Hegemony_LongbowWarden_LOD2_Compact_Static_Runtime.glb", + "bytes": 104560, + "sha256": "63d2d79b4b1f573663aac88ff343dbee1bd985755888d4ef1fb579180e7861c6", + "triangles": 1788, + "animations": [], + "boundsMeters": [ + 1.16254, + 2.03, + 1.431 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/ranged/inner-keep-longbow-warden-compact-63d2d79b4b1f5736.glb" + } + ] + }, + { + "id": "rift-battlemage", + "family": "ranged", + "sourceReleaseTag": "hegemony-unit-corps-2026-08-03", + "sourceArchive": "warpkeep-hegemony-ranged-game-ready-2026-08-03-v1.zip", + "sourceAssetId": "warpkeep.units.hegemony.ranged.rift-battlemage", + "displayName": "Hegemony Rift Battlemage", + "mounted": false, + "presentationRole": "ceremonial-patrol", + "sourceManifest": { + "path": "Warpkeep_HegemonyRanged_GameReady/Runtime/Units/Hegemony/Ranged/RiftBattlemage/runtime-manifest.json", + "bytes": 6426, + "sha256": "06144d0313ac07d5d79a111065f7aa12ee6c1b2f99d28c69e468901396725b65" + }, + "models": [ + { + "mode": "animated", + "profile": "balanced", + "tier": "LOD1_Balanced", + "sourcePath": "Warpkeep_HegemonyRanged_GameReady/Runtime/Units/Hegemony/Ranged/RiftBattlemage/Warpkeep_Hegemony_RiftBattlemage_LOD1_Balanced_Rigged_Runtime.glb", + "bytes": 327152, + "sha256": "91061c1f8a40d4b109eb4dd3554120c3222ec44f7189856417a34bfa211f4cac", + "triangles": 2558, + "animations": [ + "Attack", + "Idle", + "Special", + "Walk" + ], + "boundsMeters": [ + 1.485489, + 2.25, + 0.771368 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/ranged/inner-keep-rift-battlemage-balanced-91061c1f8a40d4b1.glb" + }, + { + "mode": "static", + "profile": "compact", + "tier": "LOD2_Compact", + "sourcePath": "Warpkeep_HegemonyRanged_GameReady/Runtime/Units/Hegemony/Ranged/RiftBattlemage/Warpkeep_Hegemony_RiftBattlemage_LOD2_Compact_Static_Runtime.glb", + "bytes": 103836, + "sha256": "5a90ae4a8def1909ca52443395b16ea0808a1068f540d6308a9852160ae83e97", + "triangles": 1732, + "animations": [], + "boundsMeters": [ + 1.390589, + 2.25, + 0.7861 + ], + "destinationPath": "public/models/hegemony/inner-keep/population/ranged/inner-keep-rift-battlemage-compact-5a90ae4a8def1909.glb" + } + ] + } + ] +} diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 7121cdbd..c404a620 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,13 @@ 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 installers accept only selections covered by explicit exact + runtime-use authorization and enforce the reviewed release identifier, + attachment checksum, content allowlist, and per-file hashes. Possessing or + packaging an archive alone does not authorize publication. Before credentials + or network access, the activation operator independently fails closed unless + the static, population, and rabbit authorization records and all three + complete installed registries verify. ## Principal risks and treatment @@ -193,6 +207,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 | Checksum-pinned allowlists, exact per-file checks, and owner authorization records block unknown, altered, 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..fe31e793 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,18 @@ 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, +runtime asset verification, 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 +98,49 @@ 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. + +The installed Inner Keep presentation uses the Grand Covenant Cathedral as its +northern anchor and the Barracks as its western garrison. Fixed authored +scenery, quality-bounded grass and water, and deterministic ambient citizens +and patrols remain client-only presentation. They never provide server +coordinates, collision, unit, chat, resource, reward, or ownership authority. +Static placement is atomic, loading is bounded and retry-safe, and a procedural +compound remains available until exact coverage is safe to reveal. + +The 40.4 x 36 meter palisade and re-spaced slot transforms are part of the +canonical layout attestation. Presentation-only dirt aprons and district roads +make the larger footprint readable, while native slot controls and server +commands remain keyed only by the twelve fixed slot identities. + +The surrounding-estate policy is a separate client contract and does not +change the canonical Inner Keep layout digest. One finite deterministic height +sampler grounds its nine topographic features, connected headwater-rill-lake, +outer patrol road, grass, trees, animals, trade wagon, and scenic resource +structures. The compound footprint samples to exactly zero; terrain feathers +through a rounded 5.5-meter shoulder only beyond the walls. The estate spans +68 x 76 meters. Quality caps grass at 2,400 / 1,400 / 480 blades and six +Warpkeep-Assets tree species at 72 / 44 / 22 instances. + +The same outer policy caps presentation-only resource structures at 8 / 6 / 4 +and rabbits at 10 / 7 / 4, with one trade wagon in every tier. The optional +exact rabbit layer replaces matching procedural animals only after its local, +content-addressed model is ready. Terrain, trees, resources, wagon, and +wildlife report and fail independently, so an optional asset failure cannot +remove healthy scenery or affect gameplay state. None of these anchors are +resource nodes or server coordinates, and none can create production, +balances, Workers, collision, ownership, or rewards. + 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. The three Inner Keep installers accept only the exact content-addressed +static, population, and Lowlands Rabbit selections authorized for runtime use; +archive publication alone would not have been enough. See +[`ASSETS-LICENSE.md`](../ASSETS-LICENSE.md) before changing media. ## Security and privacy diff --git a/package.json b/package.json index c0d24252..f5a4110e 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/generate-inner-keep-browser-asset-catalog.mjs --check && node scripts/verify-runtime-assets.mjs && node scripts/verify-inner-keep-runtime-assets.mjs && node scripts/verify-inner-keep-population-assets.mjs && node scripts/verify-inner-keep-rabbit-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-inner-keep-runtime-assets.mjs --production-dist && node scripts/verify-inner-keep-population-assets.mjs --production-dist && node scripts/verify-inner-keep-rabbit-assets.mjs --production-dist && 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,28 @@ "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", + "assets:audit:inner-keep-population": "node scripts/install-inner-keep-population-assets.mjs --audit-only", + "assets:audit:inner-keep-rabbit": "node scripts/install-inner-keep-rabbit-assets.mjs --audit-only", + "prepare:inner-keep-assets": "node scripts/install-inner-keep-runtime-assets.mjs --install", + "prepare:inner-keep-population-assets": "node scripts/install-inner-keep-population-assets.mjs --install", + "prepare:inner-keep-rabbit-assets": "node scripts/install-inner-keep-rabbit-assets.mjs --install", + "generate:inner-keep-browser-assets": "node scripts/generate-inner-keep-browser-asset-catalog.mjs", "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 && node scripts/verify-inner-keep-population-assets.mjs && node scripts/verify-inner-keep-rabbit-assets.mjs", + "verify:inner-keep-assets": "node scripts/verify-inner-keep-runtime-assets.mjs && node scripts/verify-inner-keep-population-assets.mjs && node scripts/verify-inner-keep-rabbit-assets.mjs", + "verify:inner-keep-population-assets": "node scripts/verify-inner-keep-population-assets.mjs", + "verify:inner-keep-rabbit-assets": "node scripts/verify-inner-keep-rabbit-assets.mjs", + "verify:inner-keep-browser-assets": "node scripts/generate-inner-keep-browser-asset-catalog.mjs --check", "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 +86,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 +115,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/public/images/inner-keep/catalog/city-barracks-7aa4f0901fa752a6.png b/public/images/inner-keep/catalog/city-barracks-7aa4f0901fa752a6.png new file mode 100644 index 00000000..21cc03d4 Binary files /dev/null and b/public/images/inner-keep/catalog/city-barracks-7aa4f0901fa752a6.png differ diff --git a/public/images/inner-keep/catalog/city-goldworks-4b0caa06c4ffb1e8.png b/public/images/inner-keep/catalog/city-goldworks-4b0caa06c4ffb1e8.png new file mode 100644 index 00000000..09bcfe3c Binary files /dev/null and b/public/images/inner-keep/catalog/city-goldworks-4b0caa06c4ffb1e8.png differ diff --git a/public/images/inner-keep/catalog/city-mill-717cf59d3bba1485.png b/public/images/inner-keep/catalog/city-mill-717cf59d3bba1485.png new file mode 100644 index 00000000..ddf9a9d4 Binary files /dev/null and b/public/images/inner-keep/catalog/city-mill-717cf59d3bba1485.png differ diff --git a/public/images/inner-keep/catalog/city-stoneworks-e4c562fc94705fc7.png b/public/images/inner-keep/catalog/city-stoneworks-e4c562fc94705fc7.png new file mode 100644 index 00000000..f6147042 Binary files /dev/null and b/public/images/inner-keep/catalog/city-stoneworks-e4c562fc94705fc7.png differ diff --git a/public/images/inner-keep/catalog/grand-covenant-cathedral-cc1eb98b0c3e811e.png b/public/images/inner-keep/catalog/grand-covenant-cathedral-cc1eb98b0c3e811e.png new file mode 100644 index 00000000..153821dc Binary files /dev/null and b/public/images/inner-keep/catalog/grand-covenant-cathedral-cc1eb98b0c3e811e.png differ diff --git a/public/images/inner-keep/catalog/lumber-camp-d180ce53c9573f7f.png b/public/images/inner-keep/catalog/lumber-camp-d180ce53c9573f7f.png new file mode 100644 index 00000000..6db15059 Binary files /dev/null and b/public/images/inner-keep/catalog/lumber-camp-d180ce53c9573f7f.png differ diff --git a/public/models/hegemony/inner-keep/buildings/inner-keep-city-goldworks-balanced-662fa7a5983818cf.glb b/public/models/hegemony/inner-keep/buildings/inner-keep-city-goldworks-balanced-662fa7a5983818cf.glb new file mode 100644 index 00000000..a1da3575 Binary files /dev/null and b/public/models/hegemony/inner-keep/buildings/inner-keep-city-goldworks-balanced-662fa7a5983818cf.glb differ diff --git a/public/models/hegemony/inner-keep/buildings/inner-keep-city-goldworks-compact-ed47ade5517e78da.glb b/public/models/hegemony/inner-keep/buildings/inner-keep-city-goldworks-compact-ed47ade5517e78da.glb new file mode 100644 index 00000000..3f1b07d1 Binary files /dev/null and b/public/models/hegemony/inner-keep/buildings/inner-keep-city-goldworks-compact-ed47ade5517e78da.glb differ diff --git a/public/models/hegemony/inner-keep/buildings/inner-keep-city-goldworks-high-9cd4a9851ce291a9.glb b/public/models/hegemony/inner-keep/buildings/inner-keep-city-goldworks-high-9cd4a9851ce291a9.glb new file mode 100644 index 00000000..89c13f89 Binary files /dev/null and b/public/models/hegemony/inner-keep/buildings/inner-keep-city-goldworks-high-9cd4a9851ce291a9.glb differ diff --git a/public/models/hegemony/inner-keep/buildings/inner-keep-city-mill-balanced-0963ee142eb0d8ea.glb b/public/models/hegemony/inner-keep/buildings/inner-keep-city-mill-balanced-0963ee142eb0d8ea.glb new file mode 100644 index 00000000..a17287c6 Binary files /dev/null and b/public/models/hegemony/inner-keep/buildings/inner-keep-city-mill-balanced-0963ee142eb0d8ea.glb differ diff --git a/public/models/hegemony/inner-keep/buildings/inner-keep-city-mill-compact-80419c9566ea5ab5.glb b/public/models/hegemony/inner-keep/buildings/inner-keep-city-mill-compact-80419c9566ea5ab5.glb new file mode 100644 index 00000000..d5055eb3 Binary files /dev/null and b/public/models/hegemony/inner-keep/buildings/inner-keep-city-mill-compact-80419c9566ea5ab5.glb differ diff --git a/public/models/hegemony/inner-keep/buildings/inner-keep-city-mill-high-8613faf8ac5a61f5.glb b/public/models/hegemony/inner-keep/buildings/inner-keep-city-mill-high-8613faf8ac5a61f5.glb new file mode 100644 index 00000000..4ea9e3d5 Binary files /dev/null and b/public/models/hegemony/inner-keep/buildings/inner-keep-city-mill-high-8613faf8ac5a61f5.glb differ diff --git a/public/models/hegemony/inner-keep/buildings/inner-keep-city-stoneworks-balanced-81f8818339900c2f.glb b/public/models/hegemony/inner-keep/buildings/inner-keep-city-stoneworks-balanced-81f8818339900c2f.glb new file mode 100644 index 00000000..a6df48b9 Binary files /dev/null and b/public/models/hegemony/inner-keep/buildings/inner-keep-city-stoneworks-balanced-81f8818339900c2f.glb differ diff --git a/public/models/hegemony/inner-keep/buildings/inner-keep-city-stoneworks-compact-174fd2f984065937.glb b/public/models/hegemony/inner-keep/buildings/inner-keep-city-stoneworks-compact-174fd2f984065937.glb new file mode 100644 index 00000000..304dd136 Binary files /dev/null and b/public/models/hegemony/inner-keep/buildings/inner-keep-city-stoneworks-compact-174fd2f984065937.glb differ diff --git a/public/models/hegemony/inner-keep/buildings/inner-keep-city-stoneworks-high-01df9557bccaed14.glb b/public/models/hegemony/inner-keep/buildings/inner-keep-city-stoneworks-high-01df9557bccaed14.glb new file mode 100644 index 00000000..76e8416f Binary files /dev/null and b/public/models/hegemony/inner-keep/buildings/inner-keep-city-stoneworks-high-01df9557bccaed14.glb differ diff --git a/public/models/hegemony/inner-keep/buildings/inner-keep-lumber-camp-balanced-d8f33e487545f44a.glb b/public/models/hegemony/inner-keep/buildings/inner-keep-lumber-camp-balanced-d8f33e487545f44a.glb new file mode 100644 index 00000000..f3f1b74a Binary files /dev/null and b/public/models/hegemony/inner-keep/buildings/inner-keep-lumber-camp-balanced-d8f33e487545f44a.glb differ diff --git a/public/models/hegemony/inner-keep/buildings/inner-keep-lumber-camp-compact-1be188b3bb6a90b5.glb b/public/models/hegemony/inner-keep/buildings/inner-keep-lumber-camp-compact-1be188b3bb6a90b5.glb new file mode 100644 index 00000000..390e8f06 Binary files /dev/null and b/public/models/hegemony/inner-keep/buildings/inner-keep-lumber-camp-compact-1be188b3bb6a90b5.glb differ diff --git a/public/models/hegemony/inner-keep/buildings/inner-keep-lumber-camp-high-a4f6831dd6ddcb09.glb b/public/models/hegemony/inner-keep/buildings/inner-keep-lumber-camp-high-a4f6831dd6ddcb09.glb new file mode 100644 index 00000000..5a0c51b7 Binary files /dev/null and b/public/models/hegemony/inner-keep/buildings/inner-keep-lumber-camp-high-a4f6831dd6ddcb09.glb differ diff --git a/public/models/hegemony/inner-keep/landmarks/inner-keep-city-barracks-balanced-3a22b6e910d4dd1b.glb b/public/models/hegemony/inner-keep/landmarks/inner-keep-city-barracks-balanced-3a22b6e910d4dd1b.glb new file mode 100644 index 00000000..2b21bd17 Binary files /dev/null and b/public/models/hegemony/inner-keep/landmarks/inner-keep-city-barracks-balanced-3a22b6e910d4dd1b.glb differ diff --git a/public/models/hegemony/inner-keep/landmarks/inner-keep-city-barracks-compact-7f90bd96932b7fea.glb b/public/models/hegemony/inner-keep/landmarks/inner-keep-city-barracks-compact-7f90bd96932b7fea.glb new file mode 100644 index 00000000..46e768ae Binary files /dev/null and b/public/models/hegemony/inner-keep/landmarks/inner-keep-city-barracks-compact-7f90bd96932b7fea.glb differ diff --git a/public/models/hegemony/inner-keep/landmarks/inner-keep-city-barracks-high-21b4c204adbde086.glb b/public/models/hegemony/inner-keep/landmarks/inner-keep-city-barracks-high-21b4c204adbde086.glb new file mode 100644 index 00000000..d7304b53 Binary files /dev/null and b/public/models/hegemony/inner-keep/landmarks/inner-keep-city-barracks-high-21b4c204adbde086.glb differ diff --git a/public/models/hegemony/inner-keep/landmarks/inner-keep-grand-covenant-cathedral-balanced-c90cf49f6b90325b.glb b/public/models/hegemony/inner-keep/landmarks/inner-keep-grand-covenant-cathedral-balanced-c90cf49f6b90325b.glb new file mode 100644 index 00000000..ebbc24a1 Binary files /dev/null and b/public/models/hegemony/inner-keep/landmarks/inner-keep-grand-covenant-cathedral-balanced-c90cf49f6b90325b.glb differ diff --git a/public/models/hegemony/inner-keep/landmarks/inner-keep-grand-covenant-cathedral-compact-cea4eb9e4de9c323.glb b/public/models/hegemony/inner-keep/landmarks/inner-keep-grand-covenant-cathedral-compact-cea4eb9e4de9c323.glb new file mode 100644 index 00000000..b953454b Binary files /dev/null and b/public/models/hegemony/inner-keep/landmarks/inner-keep-grand-covenant-cathedral-compact-cea4eb9e4de9c323.glb differ diff --git a/public/models/hegemony/inner-keep/landmarks/inner-keep-grand-covenant-cathedral-high-9bf438bdf020d274.glb b/public/models/hegemony/inner-keep/landmarks/inner-keep-grand-covenant-cathedral-high-9bf438bdf020d274.glb new file mode 100644 index 00000000..1aa234d0 Binary files /dev/null and b/public/models/hegemony/inner-keep/landmarks/inner-keep-grand-covenant-cathedral-high-9bf438bdf020d274.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-frame-6m-balanced-af67ba3dc8085d80.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-frame-6m-balanced-af67ba3dc8085d80.glb new file mode 100644 index 00000000..05c20c42 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-frame-6m-balanced-af67ba3dc8085d80.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-frame-6m-compact-c144a11a4f496e4b.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-frame-6m-compact-c144a11a4f496e4b.glb new file mode 100644 index 00000000..19c1174d Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-frame-6m-compact-c144a11a4f496e4b.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-frame-6m-high-d4f580ae04a3b94e.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-frame-6m-high-d4f580ae04a3b94e.glb new file mode 100644 index 00000000..fa49f3ec Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-frame-6m-high-d4f580ae04a3b94e.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-left-balanced-8b7009dccabeb38b.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-left-balanced-8b7009dccabeb38b.glb new file mode 100644 index 00000000..e815c8d8 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-left-balanced-8b7009dccabeb38b.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-left-compact-6653afb68006acb2.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-left-compact-6653afb68006acb2.glb new file mode 100644 index 00000000..744f0417 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-left-compact-6653afb68006acb2.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-left-high-a3e3e5eb7b13568e.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-left-high-a3e3e5eb7b13568e.glb new file mode 100644 index 00000000..76201872 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-left-high-a3e3e5eb7b13568e.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-right-balanced-e59ec83f925c9741.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-right-balanced-e59ec83f925c9741.glb new file mode 100644 index 00000000..936cad95 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-right-balanced-e59ec83f925c9741.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-right-compact-5b7e4895ecf8f2d5.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-right-compact-5b7e4895ecf8f2d5.glb new file mode 100644 index 00000000..3521c125 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-right-compact-5b7e4895ecf8f2d5.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-right-high-0b397bb750ba886e.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-right-high-0b397bb750ba886e.glb new file mode 100644 index 00000000..4909baf2 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-gate-leaf-right-high-0b397bb750ba886e.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-corner-90-balanced-c138e93283e0daf6.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-corner-90-balanced-c138e93283e0daf6.glb new file mode 100644 index 00000000..0dcb0c90 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-corner-90-balanced-c138e93283e0daf6.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-corner-90-compact-7a9f51fa1355fc6c.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-corner-90-compact-7a9f51fa1355fc6c.glb new file mode 100644 index 00000000..0af33d36 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-corner-90-compact-7a9f51fa1355fc6c.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-corner-90-high-6641c3b823412e2f.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-corner-90-high-6641c3b823412e2f.glb new file mode 100644 index 00000000..3d2f85f3 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-corner-90-high-6641c3b823412e2f.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-4m-balanced-c16ecc1c5ea8adba.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-4m-balanced-c16ecc1c5ea8adba.glb new file mode 100644 index 00000000..46d78ce7 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-4m-balanced-c16ecc1c5ea8adba.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-4m-compact-d0d33e6da38ed1f7.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-4m-compact-d0d33e6da38ed1f7.glb new file mode 100644 index 00000000..d204c09d Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-4m-compact-d0d33e6da38ed1f7.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-4m-high-00cdf8624e12b594.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-4m-high-00cdf8624e12b594.glb new file mode 100644 index 00000000..2af2f122 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-4m-high-00cdf8624e12b594.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-8m-balanced-d3f7ad3299cfc932.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-8m-balanced-d3f7ad3299cfc932.glb new file mode 100644 index 00000000..3d98bf29 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-8m-balanced-d3f7ad3299cfc932.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-8m-compact-70aa97418582b41b.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-8m-compact-70aa97418582b41b.glb new file mode 100644 index 00000000..f657bf25 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-8m-compact-70aa97418582b41b.glb differ diff --git a/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-8m-high-84d5d73d4b56baa9.glb b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-8m-high-84d5d73d4b56baa9.glb new file mode 100644 index 00000000..2b0a2fb0 Binary files /dev/null and b/public/models/hegemony/inner-keep/palisade/inner-keep-palisade-wall-straight-8m-high-84d5d73d4b56baa9.glb differ diff --git a/public/models/hegemony/inner-keep/population/cavalry/inner-keep-astral-lancer-balanced-bff3bd13e876cce8.glb b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-astral-lancer-balanced-bff3bd13e876cce8.glb new file mode 100644 index 00000000..c6f5131a Binary files /dev/null and b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-astral-lancer-balanced-bff3bd13e876cce8.glb differ diff --git a/public/models/hegemony/inner-keep/population/cavalry/inner-keep-astral-lancer-compact-fea4558e4365cba0.glb b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-astral-lancer-compact-fea4558e4365cba0.glb new file mode 100644 index 00000000..27dcff1b Binary files /dev/null and b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-astral-lancer-compact-fea4558e4365cba0.glb differ diff --git a/public/models/hegemony/inner-keep/population/cavalry/inner-keep-dusk-outrider-balanced-b956a70d9c99541e.glb b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-dusk-outrider-balanced-b956a70d9c99541e.glb new file mode 100644 index 00000000..c47d032b Binary files /dev/null and b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-dusk-outrider-balanced-b956a70d9c99541e.glb differ diff --git a/public/models/hegemony/inner-keep/population/cavalry/inner-keep-dusk-outrider-compact-f9233e5018551a5a.glb b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-dusk-outrider-compact-f9233e5018551a5a.glb new file mode 100644 index 00000000..6eb5e97c Binary files /dev/null and b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-dusk-outrider-compact-f9233e5018551a5a.glb differ diff --git a/public/models/hegemony/inner-keep/population/cavalry/inner-keep-horseguard-balanced-2e15034a96229eb5.glb b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-horseguard-balanced-2e15034a96229eb5.glb new file mode 100644 index 00000000..e4144835 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-horseguard-balanced-2e15034a96229eb5.glb differ diff --git a/public/models/hegemony/inner-keep/population/cavalry/inner-keep-horseguard-compact-26ec537f8a0dc677.glb b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-horseguard-compact-26ec537f8a0dc677.glb new file mode 100644 index 00000000..6e3067a4 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-horseguard-compact-26ec537f8a0dc677.glb differ diff --git a/public/models/hegemony/inner-keep/population/cavalry/inner-keep-imperial-cataphract-balanced-9f2f98369c4d7af8.glb b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-imperial-cataphract-balanced-9f2f98369c4d7af8.glb new file mode 100644 index 00000000..eaaab26a Binary files /dev/null and b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-imperial-cataphract-balanced-9f2f98369c4d7af8.glb differ diff --git a/public/models/hegemony/inner-keep/population/cavalry/inner-keep-imperial-cataphract-compact-6f892da1bec76a66.glb b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-imperial-cataphract-compact-6f892da1bec76a66.glb new file mode 100644 index 00000000..ffd7024b Binary files /dev/null and b/public/models/hegemony/inner-keep/population/cavalry/inner-keep-imperial-cataphract-compact-6f892da1bec76a66.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-basilica-warden-balanced-797df2ac1595c1ca.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-basilica-warden-balanced-797df2ac1595c1ca.glb new file mode 100644 index 00000000..482f944c Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-basilica-warden-balanced-797df2ac1595c1ca.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-basilica-warden-compact-901fcf144078ea31.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-basilica-warden-compact-901fcf144078ea31.glb new file mode 100644 index 00000000..04e713e8 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-basilica-warden-compact-901fcf144078ea31.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-bell-herald-balanced-9db372f360e55674.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-bell-herald-balanced-9db372f360e55674.glb new file mode 100644 index 00000000..dcd97e94 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-bell-herald-balanced-9db372f360e55674.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-bell-herald-compact-8906c8b6513dbe24.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-bell-herald-compact-8906c8b6513dbe24.glb new file mode 100644 index 00000000..a7ea1f7f Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-bell-herald-compact-8906c8b6513dbe24.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-chirurgeon-apothecary-balanced-68c52942e9673098.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-chirurgeon-apothecary-balanced-68c52942e9673098.glb new file mode 100644 index 00000000..653298c0 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-chirurgeon-apothecary-balanced-68c52942e9673098.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-chirurgeon-apothecary-compact-5060e336435c66e2.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-chirurgeon-apothecary-compact-5060e336435c66e2.glb new file mode 100644 index 00000000..b9882a86 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-chirurgeon-apothecary-compact-5060e336435c66e2.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-cistern-warden-balanced-e6baa9401689e30a.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-cistern-warden-balanced-e6baa9401689e30a.glb new file mode 100644 index 00000000..4dcb8197 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-cistern-warden-balanced-e6baa9401689e30a.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-cistern-warden-compact-78dfc68d6f10e194.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-cistern-warden-compact-78dfc68d6f10e194.glb new file mode 100644 index 00000000..594dbfeb Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-cistern-warden-compact-78dfc68d6f10e194.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-ember-lamplighter-balanced-599200017f3e3a3d.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-ember-lamplighter-balanced-599200017f3e3a3d.glb new file mode 100644 index 00000000..0c5b5beb Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-ember-lamplighter-balanced-599200017f3e3a3d.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-ember-lamplighter-compact-1bb943eba0ec8114.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-ember-lamplighter-compact-1bb943eba0ec8114.glb new file mode 100644 index 00000000..57621ec6 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-ember-lamplighter-compact-1bb943eba0ec8114.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-emberfoot-courier-balanced-f7d399621be2dbd2.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-emberfoot-courier-balanced-f7d399621be2dbd2.glb new file mode 100644 index 00000000..d5ccff84 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-emberfoot-courier-balanced-f7d399621be2dbd2.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-emberfoot-courier-compact-ff89e129ad744cdf.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-emberfoot-courier-compact-ff89e129ad744cdf.glb new file mode 100644 index 00000000..93f0a0ab Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-emberfoot-courier-compact-ff89e129ad744cdf.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-shellback-shrine-tender-balanced-65f276dfe3947f94.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-shellback-shrine-tender-balanced-65f276dfe3947f94.glb new file mode 100644 index 00000000..8d6b380a Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-shellback-shrine-tender-balanced-65f276dfe3947f94.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-shellback-shrine-tender-compact-8c0c01a5bb0ea918.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-shellback-shrine-tender-compact-8c0c01a5bb0ea918.glb new file mode 100644 index 00000000..83c08483 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-shellback-shrine-tender-compact-8c0c01a5bb0ea918.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-ward-peacekeeper-balanced-78f3dde74069cdfc.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-ward-peacekeeper-balanced-78f3dde74069cdfc.glb new file mode 100644 index 00000000..69a6896d Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-ward-peacekeeper-balanced-78f3dde74069cdfc.glb differ diff --git a/public/models/hegemony/inner-keep/population/citizen/inner-keep-ward-peacekeeper-compact-d6d867fad68548c1.glb b/public/models/hegemony/inner-keep/population/citizen/inner-keep-ward-peacekeeper-compact-d6d867fad68548c1.glb new file mode 100644 index 00000000..e0663603 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/citizen/inner-keep-ward-peacekeeper-compact-d6d867fad68548c1.glb differ diff --git a/public/models/hegemony/inner-keep/population/infantry/inner-keep-bulwark-balanced-4a0e1fd2470259ea.glb b/public/models/hegemony/inner-keep/population/infantry/inner-keep-bulwark-balanced-4a0e1fd2470259ea.glb new file mode 100644 index 00000000..c747bdb6 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/infantry/inner-keep-bulwark-balanced-4a0e1fd2470259ea.glb differ diff --git a/public/models/hegemony/inner-keep/population/infantry/inner-keep-bulwark-compact-1cc92a6d96a4ffc2.glb b/public/models/hegemony/inner-keep/population/infantry/inner-keep-bulwark-compact-1cc92a6d96a4ffc2.glb new file mode 100644 index 00000000..61cbe8a5 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/infantry/inner-keep-bulwark-compact-1cc92a6d96a4ffc2.glb differ diff --git a/public/models/hegemony/inner-keep/population/infantry/inner-keep-honor-guard-balanced-66f157c4dc9bcd4f.glb b/public/models/hegemony/inner-keep/population/infantry/inner-keep-honor-guard-balanced-66f157c4dc9bcd4f.glb new file mode 100644 index 00000000..00449b40 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/infantry/inner-keep-honor-guard-balanced-66f157c4dc9bcd4f.glb differ diff --git a/public/models/hegemony/inner-keep/population/infantry/inner-keep-honor-guard-compact-105926a9ac1be2fa.glb b/public/models/hegemony/inner-keep/population/infantry/inner-keep-honor-guard-compact-105926a9ac1be2fa.glb new file mode 100644 index 00000000..d1daa2fd Binary files /dev/null and b/public/models/hegemony/inner-keep/population/infantry/inner-keep-honor-guard-compact-105926a9ac1be2fa.glb differ diff --git a/public/models/hegemony/inner-keep/population/infantry/inner-keep-legionary-balanced-e7361bc20051055d.glb b/public/models/hegemony/inner-keep/population/infantry/inner-keep-legionary-balanced-e7361bc20051055d.glb new file mode 100644 index 00000000..bbbacd43 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/infantry/inner-keep-legionary-balanced-e7361bc20051055d.glb differ diff --git a/public/models/hegemony/inner-keep/population/infantry/inner-keep-legionary-compact-abb4a2ef93b80cb0.glb b/public/models/hegemony/inner-keep/population/infantry/inner-keep-legionary-compact-abb4a2ef93b80cb0.glb new file mode 100644 index 00000000..a1c89400 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/infantry/inner-keep-legionary-compact-abb4a2ef93b80cb0.glb differ diff --git a/public/models/hegemony/inner-keep/population/infantry/inner-keep-vanguard-balanced-e62df99856c02f99.glb b/public/models/hegemony/inner-keep/population/infantry/inner-keep-vanguard-balanced-e62df99856c02f99.glb new file mode 100644 index 00000000..b63f3007 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/infantry/inner-keep-vanguard-balanced-e62df99856c02f99.glb differ diff --git a/public/models/hegemony/inner-keep/population/infantry/inner-keep-vanguard-compact-ee76a1bc2cbb5285.glb b/public/models/hegemony/inner-keep/population/infantry/inner-keep-vanguard-compact-ee76a1bc2cbb5285.glb new file mode 100644 index 00000000..ec82dcf8 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/infantry/inner-keep-vanguard-compact-ee76a1bc2cbb5285.glb differ diff --git a/public/models/hegemony/inner-keep/population/ranged/inner-keep-astral-magister-balanced-f7ebf6dd80594657.glb b/public/models/hegemony/inner-keep/population/ranged/inner-keep-astral-magister-balanced-f7ebf6dd80594657.glb new file mode 100644 index 00000000..4db16dbd Binary files /dev/null and b/public/models/hegemony/inner-keep/population/ranged/inner-keep-astral-magister-balanced-f7ebf6dd80594657.glb differ diff --git a/public/models/hegemony/inner-keep/population/ranged/inner-keep-astral-magister-compact-2b1b9985793ec543.glb b/public/models/hegemony/inner-keep/population/ranged/inner-keep-astral-magister-compact-2b1b9985793ec543.glb new file mode 100644 index 00000000..0514e9f3 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/ranged/inner-keep-astral-magister-compact-2b1b9985793ec543.glb differ diff --git a/public/models/hegemony/inner-keep/population/ranged/inner-keep-dusk-ranger-balanced-24020097cc18b248.glb b/public/models/hegemony/inner-keep/population/ranged/inner-keep-dusk-ranger-balanced-24020097cc18b248.glb new file mode 100644 index 00000000..077bf0c0 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/ranged/inner-keep-dusk-ranger-balanced-24020097cc18b248.glb differ diff --git a/public/models/hegemony/inner-keep/population/ranged/inner-keep-dusk-ranger-compact-c707df9a6fcf23a1.glb b/public/models/hegemony/inner-keep/population/ranged/inner-keep-dusk-ranger-compact-c707df9a6fcf23a1.glb new file mode 100644 index 00000000..d5a45896 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/ranged/inner-keep-dusk-ranger-compact-c707df9a6fcf23a1.glb differ diff --git a/public/models/hegemony/inner-keep/population/ranged/inner-keep-longbow-warden-balanced-496ac58752dfd060.glb b/public/models/hegemony/inner-keep/population/ranged/inner-keep-longbow-warden-balanced-496ac58752dfd060.glb new file mode 100644 index 00000000..9334d0b1 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/ranged/inner-keep-longbow-warden-balanced-496ac58752dfd060.glb differ diff --git a/public/models/hegemony/inner-keep/population/ranged/inner-keep-longbow-warden-compact-63d2d79b4b1f5736.glb b/public/models/hegemony/inner-keep/population/ranged/inner-keep-longbow-warden-compact-63d2d79b4b1f5736.glb new file mode 100644 index 00000000..5a85a4c3 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/ranged/inner-keep-longbow-warden-compact-63d2d79b4b1f5736.glb differ diff --git a/public/models/hegemony/inner-keep/population/ranged/inner-keep-rift-battlemage-balanced-91061c1f8a40d4b1.glb b/public/models/hegemony/inner-keep/population/ranged/inner-keep-rift-battlemage-balanced-91061c1f8a40d4b1.glb new file mode 100644 index 00000000..83e0b6f7 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/ranged/inner-keep-rift-battlemage-balanced-91061c1f8a40d4b1.glb differ diff --git a/public/models/hegemony/inner-keep/population/ranged/inner-keep-rift-battlemage-compact-5a90ae4a8def1909.glb b/public/models/hegemony/inner-keep/population/ranged/inner-keep-rift-battlemage-compact-5a90ae4a8def1909.glb new file mode 100644 index 00000000..446e2d76 Binary files /dev/null and b/public/models/hegemony/inner-keep/population/ranged/inner-keep-rift-battlemage-compact-5a90ae4a8def1909.glb differ diff --git a/public/models/hegemony/inner-keep/stone/inner-keep-breached-keep-wall-balanced-914de93f1e8928c8.glb b/public/models/hegemony/inner-keep/stone/inner-keep-breached-keep-wall-balanced-914de93f1e8928c8.glb new file mode 100644 index 00000000..432ba026 Binary files /dev/null and b/public/models/hegemony/inner-keep/stone/inner-keep-breached-keep-wall-balanced-914de93f1e8928c8.glb differ diff --git a/public/models/hegemony/inner-keep/stone/inner-keep-breached-keep-wall-compact-95131380fca529e6.glb b/public/models/hegemony/inner-keep/stone/inner-keep-breached-keep-wall-compact-95131380fca529e6.glb new file mode 100644 index 00000000..7e5f8c2c Binary files /dev/null and b/public/models/hegemony/inner-keep/stone/inner-keep-breached-keep-wall-compact-95131380fca529e6.glb differ diff --git a/public/models/hegemony/inner-keep/stone/inner-keep-breached-keep-wall-high-802c1017e987e03b.glb b/public/models/hegemony/inner-keep/stone/inner-keep-breached-keep-wall-high-802c1017e987e03b.glb new file mode 100644 index 00000000..d8ad4532 Binary files /dev/null and b/public/models/hegemony/inner-keep/stone/inner-keep-breached-keep-wall-high-802c1017e987e03b.glb differ diff --git a/public/models/hegemony/inner-keep/stone/inner-keep-canopied-keep-well-balanced-750ea19f085dd660.glb b/public/models/hegemony/inner-keep/stone/inner-keep-canopied-keep-well-balanced-750ea19f085dd660.glb new file mode 100644 index 00000000..52b4843b Binary files /dev/null and b/public/models/hegemony/inner-keep/stone/inner-keep-canopied-keep-well-balanced-750ea19f085dd660.glb differ diff --git a/public/models/hegemony/inner-keep/stone/inner-keep-canopied-keep-well-compact-8628434c2c8c3fde.glb b/public/models/hegemony/inner-keep/stone/inner-keep-canopied-keep-well-compact-8628434c2c8c3fde.glb new file mode 100644 index 00000000..139d0c65 Binary files /dev/null and b/public/models/hegemony/inner-keep/stone/inner-keep-canopied-keep-well-compact-8628434c2c8c3fde.glb differ diff --git a/public/models/hegemony/inner-keep/stone/inner-keep-canopied-keep-well-high-fa7b15b156fc13cb.glb b/public/models/hegemony/inner-keep/stone/inner-keep-canopied-keep-well-high-fa7b15b156fc13cb.glb new file mode 100644 index 00000000..8b1bcc66 Binary files /dev/null and b/public/models/hegemony/inner-keep/stone/inner-keep-canopied-keep-well-high-fa7b15b156fc13cb.glb differ diff --git a/public/models/hegemony/inner-keep/stone/inner-keep-collapsed-courtyard-arch-balanced-db433981ff318419.glb b/public/models/hegemony/inner-keep/stone/inner-keep-collapsed-courtyard-arch-balanced-db433981ff318419.glb new file mode 100644 index 00000000..fd2927fe Binary files /dev/null and b/public/models/hegemony/inner-keep/stone/inner-keep-collapsed-courtyard-arch-balanced-db433981ff318419.glb differ diff --git a/public/models/hegemony/inner-keep/stone/inner-keep-collapsed-courtyard-arch-compact-bb8b41dbe991314c.glb b/public/models/hegemony/inner-keep/stone/inner-keep-collapsed-courtyard-arch-compact-bb8b41dbe991314c.glb new file mode 100644 index 00000000..1e44a15e Binary files /dev/null and b/public/models/hegemony/inner-keep/stone/inner-keep-collapsed-courtyard-arch-compact-bb8b41dbe991314c.glb differ diff --git a/public/models/hegemony/inner-keep/stone/inner-keep-collapsed-courtyard-arch-high-087f2557b6595d60.glb b/public/models/hegemony/inner-keep/stone/inner-keep-collapsed-courtyard-arch-high-087f2557b6595d60.glb new file mode 100644 index 00000000..3d64d345 Binary files /dev/null and b/public/models/hegemony/inner-keep/stone/inner-keep-collapsed-courtyard-arch-high-087f2557b6595d60.glb differ diff --git a/public/models/hegemony/inner-keep/stone/inner-keep-masonry-rubble-cluster-balanced-33f632b032467856.glb b/public/models/hegemony/inner-keep/stone/inner-keep-masonry-rubble-cluster-balanced-33f632b032467856.glb new file mode 100644 index 00000000..7f6c27b5 Binary files /dev/null and b/public/models/hegemony/inner-keep/stone/inner-keep-masonry-rubble-cluster-balanced-33f632b032467856.glb differ diff --git a/public/models/hegemony/inner-keep/stone/inner-keep-masonry-rubble-cluster-compact-fbcc413bfd0f0cee.glb b/public/models/hegemony/inner-keep/stone/inner-keep-masonry-rubble-cluster-compact-fbcc413bfd0f0cee.glb new file mode 100644 index 00000000..5392f29f Binary files /dev/null and b/public/models/hegemony/inner-keep/stone/inner-keep-masonry-rubble-cluster-compact-fbcc413bfd0f0cee.glb differ diff --git a/public/models/hegemony/inner-keep/stone/inner-keep-masonry-rubble-cluster-high-9bed961f4df79a58.glb b/public/models/hegemony/inner-keep/stone/inner-keep-masonry-rubble-cluster-high-9bed961f4df79a58.glb new file mode 100644 index 00000000..35a779ca Binary files /dev/null and b/public/models/hegemony/inner-keep/stone/inner-keep-masonry-rubble-cluster-high-9bed961f4df79a58.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-boulder-cluster-3m-balanced-90235d94748ba8f9.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-boulder-cluster-3m-balanced-90235d94748ba8f9.glb new file mode 100644 index 00000000..3a13d66c Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-boulder-cluster-3m-balanced-90235d94748ba8f9.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-boulder-cluster-3m-compact-06849c7703cc1e17.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-boulder-cluster-3m-compact-06849c7703cc1e17.glb new file mode 100644 index 00000000..64e947e3 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-boulder-cluster-3m-compact-06849c7703cc1e17.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-boulder-cluster-3m-high-a1859faf45ecf03e.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-boulder-cluster-3m-high-a1859faf45ecf03e.glb new file mode 100644 index 00000000..e170eb83 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-boulder-cluster-3m-high-a1859faf45ecf03e.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-clipped-boxwood-mound-balanced-6e447757d3a205d9.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-clipped-boxwood-mound-balanced-6e447757d3a205d9.glb new file mode 100644 index 00000000..f44bc466 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-clipped-boxwood-mound-balanced-6e447757d3a205d9.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-clipped-boxwood-mound-compact-c01e8db707adf31b.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-clipped-boxwood-mound-compact-c01e8db707adf31b.glb new file mode 100644 index 00000000..bee23fae Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-clipped-boxwood-mound-compact-c01e8db707adf31b.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-clipped-boxwood-mound-high-71bd32a43e6f81dd.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-clipped-boxwood-mound-high-71bd32a43e6f81dd.glb new file mode 100644 index 00000000..061bac1e Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-clipped-boxwood-mound-high-71bd32a43e6f81dd.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-plaza-6m-balanced-965d83a7e208b704.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-plaza-6m-balanced-965d83a7e208b704.glb new file mode 100644 index 00000000..bbff8a47 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-plaza-6m-balanced-965d83a7e208b704.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-plaza-6m-compact-767384a66fae7938.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-plaza-6m-compact-767384a66fae7938.glb new file mode 100644 index 00000000..71577d3a Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-plaza-6m-compact-767384a66fae7938.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-plaza-6m-high-85662cda60bedecf.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-plaza-6m-high-85662cda60bedecf.glb new file mode 100644 index 00000000..a83220b0 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-plaza-6m-high-85662cda60bedecf.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-road-straight-4m-balanced-54b23ca15974c825.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-road-straight-4m-balanced-54b23ca15974c825.glb new file mode 100644 index 00000000..eba06d90 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-road-straight-4m-balanced-54b23ca15974c825.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-road-straight-4m-compact-6d838a7ea65cf313.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-road-straight-4m-compact-6d838a7ea65cf313.glb new file mode 100644 index 00000000..53555a3b Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-road-straight-4m-compact-6d838a7ea65cf313.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-road-straight-4m-high-2066fd90088760b6.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-road-straight-4m-high-2066fd90088760b6.glb new file mode 100644 index 00000000..feb26058 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-cobble-road-straight-4m-high-2066fd90088760b6.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-compact-processional-standard-balanced-9cee7a39ec36ffb0.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-compact-processional-standard-balanced-9cee7a39ec36ffb0.glb new file mode 100644 index 00000000..313168ee Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-compact-processional-standard-balanced-9cee7a39ec36ffb0.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-compact-processional-standard-compact-10ddd768469d4f4f.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-compact-processional-standard-compact-10ddd768469d4f4f.glb new file mode 100644 index 00000000..204e480c Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-compact-processional-standard-compact-10ddd768469d4f4f.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-compact-processional-standard-high-2b8b1a6b5c735374.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-compact-processional-standard-high-2b8b1a6b5c735374.glb new file mode 100644 index 00000000..445cf4ee Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-compact-processional-standard-high-2b8b1a6b5c735374.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-directional-signpost-balanced-1e41b951b18a6395.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-directional-signpost-balanced-1e41b951b18a6395.glb new file mode 100644 index 00000000..827af7e7 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-directional-signpost-balanced-1e41b951b18a6395.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-directional-signpost-compact-8afe0112a0de6af8.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-directional-signpost-compact-8afe0112a0de6af8.glb new file mode 100644 index 00000000..bae30a4a Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-directional-signpost-compact-8afe0112a0de6af8.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-directional-signpost-high-8ab4ca08af77c67b.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-directional-signpost-high-8ab4ca08af77c67b.glb new file mode 100644 index 00000000..18504bdb Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-directional-signpost-high-8ab4ca08af77c67b.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-curve-90-4m-balanced-e3c0221f0b8a5338.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-curve-90-4m-balanced-e3c0221f0b8a5338.glb new file mode 100644 index 00000000..1d842971 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-curve-90-4m-balanced-e3c0221f0b8a5338.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-curve-90-4m-compact-8434a330607f9993.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-curve-90-4m-compact-8434a330607f9993.glb new file mode 100644 index 00000000..5d13b7da Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-curve-90-4m-compact-8434a330607f9993.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-curve-90-4m-high-b93f180794c8de5c.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-curve-90-4m-high-b93f180794c8de5c.glb new file mode 100644 index 00000000..49b5f91c Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-curve-90-4m-high-b93f180794c8de5c.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-straight-4m-balanced-cec2db4515a24d8f.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-straight-4m-balanced-cec2db4515a24d8f.glb new file mode 100644 index 00000000..6870a96d Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-straight-4m-balanced-cec2db4515a24d8f.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-straight-4m-compact-88065d72da8b6ceb.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-straight-4m-compact-88065d72da8b6ceb.glb new file mode 100644 index 00000000..2136f4b6 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-straight-4m-compact-88065d72da8b6ceb.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-straight-4m-high-d572f8bc4e9d38d4.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-straight-4m-high-d572f8bc4e9d38d4.glb new file mode 100644 index 00000000..95ccb8b0 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-straight-4m-high-d572f8bc4e9d38d4.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-t-junction-4m-balanced-0278db0236891174.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-t-junction-4m-balanced-0278db0236891174.glb new file mode 100644 index 00000000..a9b9c364 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-t-junction-4m-balanced-0278db0236891174.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-t-junction-4m-compact-07c1f95ab8841bb0.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-t-junction-4m-compact-07c1f95ab8841bb0.glb new file mode 100644 index 00000000..a6efe12d Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-t-junction-4m-compact-07c1f95ab8841bb0.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-t-junction-4m-high-8df463a83ecdf544.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-t-junction-4m-high-8df463a83ecdf544.glb new file mode 100644 index 00000000..15ccb18b Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-dirt-road-t-junction-4m-high-8df463a83ecdf544.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-corner-balanced-088946fbca5f5b86.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-corner-balanced-088946fbca5f5b86.glb new file mode 100644 index 00000000..1ae1b88a Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-corner-balanced-088946fbca5f5b86.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-corner-compact-0074e6825468a2c8.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-corner-compact-0074e6825468a2c8.glb new file mode 100644 index 00000000..ed48c904 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-corner-compact-0074e6825468a2c8.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-corner-high-e87710f79704b117.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-corner-high-e87710f79704b117.glb new file mode 100644 index 00000000..ab468c6c Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-corner-high-e87710f79704b117.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-straight-balanced-d619e7c26a496ba5.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-straight-balanced-d619e7c26a496ba5.glb new file mode 100644 index 00000000..3fc9b431 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-straight-balanced-d619e7c26a496ba5.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-straight-compact-3724b124e4504f98.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-straight-compact-3724b124e4504f98.glb new file mode 100644 index 00000000..541e0fec Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-straight-compact-3724b124e4504f98.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-straight-high-ba22ccdbef21b42c.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-straight-high-ba22ccdbef21b42c.glb new file mode 100644 index 00000000..d480e02c Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-formal-hedge-straight-high-ba22ccdbef21b42c.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-masonry-rubble-2m-balanced-02f591b8bf2362c4.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-masonry-rubble-2m-balanced-02f591b8bf2362c4.glb new file mode 100644 index 00000000..a66f889f Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-masonry-rubble-2m-balanced-02f591b8bf2362c4.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-masonry-rubble-2m-compact-06a6458760fcc83f.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-masonry-rubble-2m-compact-06a6458760fcc83f.glb new file mode 100644 index 00000000..dc4b90df Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-masonry-rubble-2m-compact-06a6458760fcc83f.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-masonry-rubble-2m-high-ff69c805c0321fbf.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-masonry-rubble-2m-high-ff69c805c0321fbf.glb new file mode 100644 index 00000000..a1a31723 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-masonry-rubble-2m-high-ff69c805c0321fbf.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-picket-fence-4m-balanced-63f09802eac8c246.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-picket-fence-4m-balanced-63f09802eac8c246.glb new file mode 100644 index 00000000..ebfd383b Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-picket-fence-4m-balanced-63f09802eac8c246.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-picket-fence-4m-compact-8d62f006b21fd721.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-picket-fence-4m-compact-8d62f006b21fd721.glb new file mode 100644 index 00000000..cd7131a0 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-picket-fence-4m-compact-8d62f006b21fd721.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-picket-fence-4m-high-a3a5044e87569151.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-picket-fence-4m-high-a3a5044e87569151.glb new file mode 100644 index 00000000..23c8a886 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-picket-fence-4m-high-a3a5044e87569151.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-roofed-noticeboard-balanced-91976a7e7e6ec54f.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-roofed-noticeboard-balanced-91976a7e7e6ec54f.glb new file mode 100644 index 00000000..a2636f58 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-roofed-noticeboard-balanced-91976a7e7e6ec54f.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-roofed-noticeboard-compact-dfd35c9c36f2f4da.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-roofed-noticeboard-compact-dfd35c9c36f2f4da.glb new file mode 100644 index 00000000..89fbfdf0 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-roofed-noticeboard-compact-dfd35c9c36f2f4da.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-roofed-noticeboard-high-8d45890e63829407.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-roofed-noticeboard-high-8d45890e63829407.glb new file mode 100644 index 00000000..32555ce3 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-roofed-noticeboard-high-8d45890e63829407.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-stone-curb-2m-balanced-6a34e355098ba968.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-stone-curb-2m-balanced-6a34e355098ba968.glb new file mode 100644 index 00000000..3202b50f Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-stone-curb-2m-balanced-6a34e355098ba968.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-stone-curb-2m-compact-ad9a56588710e3e9.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-stone-curb-2m-compact-ad9a56588710e3e9.glb new file mode 100644 index 00000000..5adb6c03 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-stone-curb-2m-compact-ad9a56588710e3e9.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-stone-curb-2m-high-46cbb80d3b92d3f3.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-stone-curb-2m-high-46cbb80d3b92d3f3.glb new file mode 100644 index 00000000..677f24ea Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-stone-curb-2m-high-46cbb80d3b92d3f3.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-stone-pedestal-brazier-balanced-6e9b6f425e081277.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-stone-pedestal-brazier-balanced-6e9b6f425e081277.glb new file mode 100644 index 00000000..aab369fa Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-stone-pedestal-brazier-balanced-6e9b6f425e081277.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-stone-pedestal-brazier-compact-35a276eee41bbb72.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-stone-pedestal-brazier-compact-35a276eee41bbb72.glb new file mode 100644 index 00000000..ac14428e Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-stone-pedestal-brazier-compact-35a276eee41bbb72.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-stone-pedestal-brazier-high-c6d2485ca58328e1.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-stone-pedestal-brazier-high-c6d2485ca58328e1.glb new file mode 100644 index 00000000..b8cf1085 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-stone-pedestal-brazier-high-c6d2485ca58328e1.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-timber-bench-balanced-625b24648f9fbdae.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-bench-balanced-625b24648f9fbdae.glb new file mode 100644 index 00000000..051bdb6f Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-bench-balanced-625b24648f9fbdae.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-timber-bench-compact-b77c20806cdf61e2.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-bench-compact-b77c20806cdf61e2.glb new file mode 100644 index 00000000..262a7e17 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-bench-compact-b77c20806cdf61e2.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-timber-bench-high-e96e0add043678ad.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-bench-high-e96e0add043678ad.glb new file mode 100644 index 00000000..983f24c8 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-bench-high-e96e0add043678ad.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-timber-post-lamp-balanced-381b847971a6cc54.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-post-lamp-balanced-381b847971a6cc54.glb new file mode 100644 index 00000000..ae60369d Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-post-lamp-balanced-381b847971a6cc54.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-timber-post-lamp-compact-5f35fa874a95c04b.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-post-lamp-compact-5f35fa874a95c04b.glb new file mode 100644 index 00000000..7ae1e344 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-post-lamp-compact-5f35fa874a95c04b.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-timber-post-lamp-high-bf0d7f23315858a8.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-post-lamp-high-bf0d7f23315858a8.glb new file mode 100644 index 00000000..deb0469b Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-post-lamp-high-bf0d7f23315858a8.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-timber-water-trough-balanced-6425e366ed4276e8.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-water-trough-balanced-6425e366ed4276e8.glb new file mode 100644 index 00000000..412db4e3 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-water-trough-balanced-6425e366ed4276e8.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-timber-water-trough-compact-76408969d1a42d95.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-water-trough-compact-76408969d1a42d95.glb new file mode 100644 index 00000000..709a31f3 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-water-trough-compact-76408969d1a42d95.glb differ diff --git a/public/models/hegemony/inner-keep/town-items/inner-keep-timber-water-trough-high-66a56aae3d4a762c.glb b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-water-trough-high-66a56aae3d4a762c.glb new file mode 100644 index 00000000..2901a5a1 Binary files /dev/null and b/public/models/hegemony/inner-keep/town-items/inner-keep-timber-water-trough-high-66a56aae3d4a762c.glb differ diff --git a/public/models/hegemony/inner-keep/trees/inner-keep-courtyard-linden-teardrop-balanced-70e50e8f3cc5de23.glb b/public/models/hegemony/inner-keep/trees/inner-keep-courtyard-linden-teardrop-balanced-70e50e8f3cc5de23.glb new file mode 100644 index 00000000..1281d76b Binary files /dev/null and b/public/models/hegemony/inner-keep/trees/inner-keep-courtyard-linden-teardrop-balanced-70e50e8f3cc5de23.glb differ diff --git a/public/models/hegemony/inner-keep/trees/inner-keep-courtyard-linden-teardrop-compact-556a89c1ad3f6a31.glb b/public/models/hegemony/inner-keep/trees/inner-keep-courtyard-linden-teardrop-compact-556a89c1ad3f6a31.glb new file mode 100644 index 00000000..80541841 Binary files /dev/null and b/public/models/hegemony/inner-keep/trees/inner-keep-courtyard-linden-teardrop-compact-556a89c1ad3f6a31.glb differ diff --git a/public/models/hegemony/inner-keep/trees/inner-keep-courtyard-linden-teardrop-high-e1f548983c081308.glb b/public/models/hegemony/inner-keep/trees/inner-keep-courtyard-linden-teardrop-high-e1f548983c081308.glb new file mode 100644 index 00000000..38024adf Binary files /dev/null and b/public/models/hegemony/inner-keep/trees/inner-keep-courtyard-linden-teardrop-high-e1f548983c081308.glb differ diff --git a/public/models/hegemony/inner-keep/trees/inner-keep-giant-ancient-cedar-balanced-6563723d5f37cb00.glb b/public/models/hegemony/inner-keep/trees/inner-keep-giant-ancient-cedar-balanced-6563723d5f37cb00.glb new file mode 100644 index 00000000..65bf4450 Binary files /dev/null and b/public/models/hegemony/inner-keep/trees/inner-keep-giant-ancient-cedar-balanced-6563723d5f37cb00.glb differ diff --git a/public/models/hegemony/inner-keep/trees/inner-keep-giant-ancient-cedar-compact-48d82b31a418db32.glb b/public/models/hegemony/inner-keep/trees/inner-keep-giant-ancient-cedar-compact-48d82b31a418db32.glb new file mode 100644 index 00000000..4777bd5a Binary files /dev/null and b/public/models/hegemony/inner-keep/trees/inner-keep-giant-ancient-cedar-compact-48d82b31a418db32.glb differ diff --git a/public/models/hegemony/inner-keep/trees/inner-keep-giant-ancient-cedar-high-a27d4410172bfba2.glb b/public/models/hegemony/inner-keep/trees/inner-keep-giant-ancient-cedar-high-a27d4410172bfba2.glb new file mode 100644 index 00000000..0ffc3b41 Binary files /dev/null and b/public/models/hegemony/inner-keep/trees/inner-keep-giant-ancient-cedar-high-a27d4410172bfba2.glb differ diff --git a/public/models/hegemony/inner-keep/trees/inner-keep-pruned-ornamental-three-tier-balanced-72bdcd76ef4bf9f4.glb b/public/models/hegemony/inner-keep/trees/inner-keep-pruned-ornamental-three-tier-balanced-72bdcd76ef4bf9f4.glb new file mode 100644 index 00000000..a83e1888 Binary files /dev/null and b/public/models/hegemony/inner-keep/trees/inner-keep-pruned-ornamental-three-tier-balanced-72bdcd76ef4bf9f4.glb differ diff --git a/public/models/hegemony/inner-keep/trees/inner-keep-pruned-ornamental-three-tier-compact-6995d4e430b3b1ba.glb b/public/models/hegemony/inner-keep/trees/inner-keep-pruned-ornamental-three-tier-compact-6995d4e430b3b1ba.glb new file mode 100644 index 00000000..8fea6450 Binary files /dev/null and b/public/models/hegemony/inner-keep/trees/inner-keep-pruned-ornamental-three-tier-compact-6995d4e430b3b1ba.glb differ diff --git a/public/models/hegemony/inner-keep/trees/inner-keep-pruned-ornamental-three-tier-high-8bbe6ec99f51822b.glb b/public/models/hegemony/inner-keep/trees/inner-keep-pruned-ornamental-three-tier-high-8bbe6ec99f51822b.glb new file mode 100644 index 00000000..2991c762 Binary files /dev/null and b/public/models/hegemony/inner-keep/trees/inner-keep-pruned-ornamental-three-tier-high-8bbe6ec99f51822b.glb differ diff --git a/public/models/hegemony/inner-keep/wildlife/rabbit/inner-keep-lowlands-rabbit-balanced-daeb493a827ecbd6.glb b/public/models/hegemony/inner-keep/wildlife/rabbit/inner-keep-lowlands-rabbit-balanced-daeb493a827ecbd6.glb new file mode 100644 index 00000000..44ef463a Binary files /dev/null and b/public/models/hegemony/inner-keep/wildlife/rabbit/inner-keep-lowlands-rabbit-balanced-daeb493a827ecbd6.glb differ diff --git a/public/models/hegemony/inner-keep/wildlife/rabbit/inner-keep-lowlands-rabbit-compact-2ecc7b1adf4c1d79.glb b/public/models/hegemony/inner-keep/wildlife/rabbit/inner-keep-lowlands-rabbit-compact-2ecc7b1adf4c1d79.glb new file mode 100644 index 00000000..19115cdc Binary files /dev/null and b/public/models/hegemony/inner-keep/wildlife/rabbit/inner-keep-lowlands-rabbit-compact-2ecc7b1adf4c1d79.glb differ diff --git a/public/models/hegemony/inner-keep/wildlife/rabbit/inner-keep-lowlands-rabbit-high-735f7d72457acfb2.glb b/public/models/hegemony/inner-keep/wildlife/rabbit/inner-keep-lowlands-rabbit-high-735f7d72457acfb2.glb new file mode 100644 index 00000000..079ba497 Binary files /dev/null and b/public/models/hegemony/inner-keep/wildlife/rabbit/inner-keep-lowlands-rabbit-high-735f7d72457acfb2.glb differ diff --git a/scripts/generate-inner-keep-browser-asset-catalog.mjs b/scripts/generate-inner-keep-browser-asset-catalog.mjs new file mode 100644 index 00000000..fabf4227 --- /dev/null +++ b/scripts/generate-inner-keep-browser-asset-catalog.mjs @@ -0,0 +1,185 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const repositoryRoot = resolve(import.meta.dirname, '..'); +const staticManifestPath = resolve( + repositoryRoot, + 'docs/reference/assets/2026-08-02-inner-keep-3d-library/manifest.json', +); +const populationManifestPath = resolve( + repositoryRoot, + 'docs/reference/assets/2026-08-04-inner-keep-population/manifest.json', +); +const outputPath = resolve( + repositoryRoot, + 'src/components/inner-keep/innerKeepRuntimeAssetCatalog.generated.ts', +); + +const STATIC_SELECTION_DIGEST = + '00304c5dbf819cec6cb656996c1105f64efcf36acf8099c431f5b04b822679f0'; +const POPULATION_SELECTION_DIGEST = + '79237fbe85a4db7a0592eb0c27cc00f8e72e85e58be867bec4dd35992f0b87f7'; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; + +function fail(message) { + throw new Error(`Inner Keep browser asset catalog: ${message}`); +} + +function localRuntimePath(destinationPath, sha256) { + if ( + typeof destinationPath !== 'string' + || !destinationPath.startsWith('public/models/hegemony/inner-keep/') + || destinationPath.includes('\\') + || destinationPath.split('/').includes('..') + || !SHA256_PATTERN.test(sha256) + || !destinationPath.endsWith(`-${sha256.slice(0, 16)}.glb`) + ) fail(`unsafe or non-content-addressed destination ${String(destinationPath)}`); + return destinationPath.slice('public/'.length); +} + +async function exactGlbDrawCalls(destinationPath) { + const bytes = await readFile(resolve(repositoryRoot, destinationPath)); + if (bytes.length < 20 || bytes.toString('ascii', 0, 4) !== 'glTF') { + fail(`invalid GLB container ${destinationPath}`); + } + let offset = 12; + while (offset + 8 <= bytes.length) { + const chunkLength = bytes.readUInt32LE(offset); + const chunkType = bytes.readUInt32LE(offset + 4); + const chunkEnd = offset + 8 + chunkLength; + if (chunkEnd > bytes.length) fail(`truncated GLB chunk ${destinationPath}`); + if (chunkType === 0x4e4f534a) { + const gltf = JSON.parse(bytes.toString('utf8', offset + 8, chunkEnd)); + const drawCalls = (gltf.meshes ?? []).reduce((total, mesh) => ( + total + (Array.isArray(mesh.primitives) ? mesh.primitives.length : 0) + ), 0); + if (!Number.isSafeInteger(drawCalls) || drawCalls <= 0) { + fail(`GLB has no render primitives ${destinationPath}`); + } + return drawCalls; + } + offset = chunkEnd; + } + fail(`GLB has no JSON chunk ${destinationPath}`); +} + +async function exactModel(model) { + if ( + !Number.isSafeInteger(model.bytes) + || model.bytes <= 0 + || !Number.isSafeInteger(model.triangles) + || model.triangles <= 0 + || !SHA256_PATTERN.test(model.sha256) + ) fail(`invalid model coordinate ${String(model.destinationPath)}`); + return { + path: localRuntimePath(model.destinationPath, model.sha256), + bytes: model.bytes, + sha256: model.sha256, + triangles: model.triangles, + drawCalls: await exactGlbDrawCalls(model.destinationPath), + }; +} + +function exactBounds(bounds, label) { + if ( + !Array.isArray(bounds) + || bounds.length !== 3 + || bounds.some((value) => !Number.isFinite(value) || value <= 0) + ) fail(`invalid bounds for ${label}`); + return bounds; +} + +function stableLiteral(value) { + return JSON.stringify(value, null, 2) + .replace(/"([^"\\]+)":/g, '$1:') + .replace(/\n/g, '\n '); +} + +async function main() { + const [staticManifest, populationManifest] = await Promise.all([ + readFile(staticManifestPath, 'utf8').then(JSON.parse), + readFile(populationManifestPath, 'utf8').then(JSON.parse), + ]); + if ( + staticManifest.selectionDigestSha256 !== STATIC_SELECTION_DIGEST + || staticManifest.authorization?.officialRepositoryRuntimeUseAuthorized !== true + || staticManifest.authorization?.status !== 'authorized-owner-runtime-use' + ) fail('static selection or authorization drift'); + if ( + populationManifest.selectionDigestSha256 !== POPULATION_SELECTION_DIGEST + || populationManifest.authorization?.officialRepositoryRuntimeUseAuthorized !== true + || populationManifest.authorization?.status !== 'authorized-owner-runtime-use' + || populationManifest.runtimePolicy?.clientPresentationOnly !== true + || populationManifest.runtimePolicy?.gameplayAuthorityClaimed !== false + ) fail('population selection, authorization, or authority boundary drift'); + + const staticAssets = await Promise.all(staticManifest.assets.map(async (asset) => { + const models = Object.fromEntries(await Promise.all(asset.models.map(async (model) => [ + model.profile, + await exactModel(model), + ]))); + if (!models.high || !models.balanced || !models.compact) { + fail(`incomplete static LOD family ${asset.id}`); + } + return { + id: asset.id, + family: asset.family, + displayName: asset.displayName, + boundsMeters: exactBounds(asset.boundsMeters, asset.id), + models, + }; + })); + const actors = await Promise.all(populationManifest.actors.map(async (actor) => { + const models = Object.fromEntries(await Promise.all(actor.models.map(async (model) => [ + model.profile, + { + ...await exactModel(model), + mode: model.mode, + animations: model.animations, + boundsMeters: exactBounds(model.boundsMeters, `${actor.id}:${model.profile}`), + }, + ]))); + if (!models.balanced || !models.compact) fail(`incomplete actor LOD family ${actor.id}`); + return { + id: actor.id, + family: actor.family, + displayName: actor.displayName, + mounted: actor.mounted, + presentationRole: actor.presentationRole, + models, + }; + })); + if (staticAssets.length !== 38 || actors.length !== 20) fail('catalog count drift'); + + const source = `/* eslint-disable */\n` + + `// Generated by scripts/generate-inner-keep-browser-asset-catalog.mjs.\n` + + `// Do not edit by hand; exact owner-authorized selections are the source of truth.\n\n` + + `export const INNER_KEEP_STATIC_RUNTIME_SELECTION_DIGEST = '${STATIC_SELECTION_DIGEST}';\n` + + `export const INNER_KEEP_POPULATION_RUNTIME_SELECTION_DIGEST = '${POPULATION_SELECTION_DIGEST}';\n\n` + + `export const INNER_KEEP_STATIC_RUNTIME_ASSETS = Object.freeze(${stableLiteral(staticAssets)}) as readonly InnerKeepStaticRuntimeAsset[];\n\n` + + `export const INNER_KEEP_POPULATION_RUNTIME_ACTORS = Object.freeze(${stableLiteral(actors)}) as readonly InnerKeepPopulationRuntimeActor[];\n\n` + + `export type InnerKeepStaticRuntimeProfile = 'high' | 'balanced' | 'compact';\n` + + `export type InnerKeepPopulationRuntimeProfile = 'balanced' | 'compact';\n` + + `export type InnerKeepRuntimeModel = Readonly<{ path: string; bytes: number; sha256: string; triangles: number; drawCalls: number }>;\n` + + `export type InnerKeepStaticRuntimeAsset = Readonly<{\n` + + ` id: string; family: 'buildings' | 'landmarks' | 'palisade' | 'stone' | 'town-items' | 'trees';\n` + + ` displayName: string; boundsMeters: readonly [number, number, number];\n` + + ` models: Readonly>;\n` + + `}>;\n` + + `export type InnerKeepPopulationRuntimeModel = InnerKeepRuntimeModel & Readonly<{\n` + + ` mode: 'animated' | 'static'; animations: readonly string[]; boundsMeters: readonly [number, number, number];\n` + + `}>;\n` + + `export type InnerKeepPopulationRuntimeActor = Readonly<{\n` + + ` id: string; family: 'citizen' | 'infantry' | 'ranged' | 'cavalry'; displayName: string; mounted: boolean;\n` + + ` presentationRole: 'civic-routine' | 'ceremonial-patrol';\n` + + ` models: Readonly>;\n` + + `}>;\n`; + if (process.argv.includes('--check')) { + const observed = await readFile(outputPath, 'utf8').catch(() => ''); + if (observed !== source) fail('generated TypeScript catalog is stale'); + return; + } + await writeFile(outputPath, source, 'utf8'); +} + +await main(); diff --git a/scripts/generate-inner-keep-population-selection.mjs b/scripts/generate-inner-keep-population-selection.mjs new file mode 100644 index 00000000..bf97d801 --- /dev/null +++ b/scripts/generate-inner-keep-population-selection.mjs @@ -0,0 +1,312 @@ +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +const ROOT = resolve(import.meta.dirname, '..'); +const ASSET_REPO = resolve(ROOT, '../Warpkeep-Assets-main'); +const OUTPUT = resolve( + ROOT, + 'docs/reference/assets/2026-08-04-inner-keep-population/manifest.json' +); +const RELEASES = Object.freeze([ + Object.freeze({ + tag: 'hegemony-unit-corps-2026-08-03', + releaseCommit: 'b074ffb6317ff9a581f5b7fc7f0a0760e721a9b6', + manifestPath: resolve( + ASSET_REPO, + 'releases/hegemony-unit-corps-2026-08-03/manifest.json' + ), + archiveDirectory: resolve( + ROOT, + '.cache/warpkeep-assets/hegemony-unit-corps-2026-08-03' + ) + }), + Object.freeze({ + tag: 'hegemony-citizens-keep-services-2026-08-03', + releaseCommit: '9f99751f691581ce4f33a31dd56c818cfa455b61', + manifestPath: resolve( + ASSET_REPO, + 'releases/hegemony-citizens-keep-services-2026-08-03/manifest.json' + ), + archiveDirectory: resolve( + ROOT, + '.cache/warpkeep-assets/hegemony-citizens-keep-services-2026-08-03' + ) + }) +]); +const SELECTED_TIERS = Object.freeze([ + Object.freeze({ mode: 'animated', profile: 'balanced', tier: 'LOD1_Balanced' }), + Object.freeze({ mode: 'static', profile: 'compact', tier: 'LOD2_Compact' }) +]); + +function fail(detail) { + throw new Error(`Inner Keep population selection generator: ${detail}`); +} + +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; +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +function listZipMembers(archivePath) { + const result = spawnSync('/usr/bin/unzip', ['-Z1', archivePath], { + cwd: ROOT, + encoding: 'utf8', + maxBuffer: 8 * 1024 * 1024 + }); + if (result.error || result.status !== 0) fail(`cannot list ${archivePath}`); + return result.stdout.split(/\r?\n/u).filter(Boolean); +} + +function readZipMember(archivePath, member) { + const result = spawnSync('/usr/bin/unzip', ['-p', archivePath, member], { + cwd: ROOT, + encoding: null, + maxBuffer: 32 * 1024 * 1024 + }); + if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout)) { + fail(`cannot read ${member} from ${archivePath}`); + } + return result.stdout; +} + +function familyForPackage(packageRoot) { + if (packageRoot === 'Warpkeep_HegemonyCitizens_Set2_GameReady') return 'citizen'; + if (packageRoot === 'Warpkeep_HegemonyInfantry_GameReady') return 'infantry'; + if (packageRoot === 'Warpkeep_HegemonyRanged_GameReady') return 'ranged'; + if (packageRoot === 'Warpkeep_HegemonyCavalry_GameReady') return 'cavalry'; + fail(`unexpected package ${packageRoot}`); +} + +function sourceEntry(entryMap, path) { + const entry = entryMap.get(path); + if ( + !entry + || !Number.isSafeInteger(entry.bytes) + || entry.bytes <= 0 + || typeof entry.sha256 !== 'string' + ) fail(`trusted manifest does not attest ${path}`); + return entry; +} + +function actorId(assetId) { + const id = assetId.split('.').at(-1); + if (!id || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id)) { + fail(`unsafe actor ID in ${assetId}`); + } + return id; +} + +function runtimeDestination(family, id, model) { + return `public/models/hegemony/inner-keep/population/${family}/inner-keep-${id}-` + + `${model.profile}-${model.sha256.slice(0, 16)}.glb`; +} + +function populationActor({ + archive, + attachment, + entryMap, + manifestPath, + releaseTag +}) { + const manifestEntry = sourceEntry(entryMap, manifestPath); + const manifestBytes = readZipMember(archive, manifestPath); + if ( + manifestBytes.byteLength !== manifestEntry.bytes + || sha256(manifestBytes) !== manifestEntry.sha256 + ) fail(`${manifestPath} bytes changed`); + const manifest = JSON.parse(manifestBytes.toString('utf8')); + const id = actorId(manifest.assetId); + const family = familyForPackage(attachment.packageRoot); + const models = SELECTED_TIERS.map((selection) => { + const lod = manifest.lods?.find((entry) => entry.tier === selection.tier); + if (!lod) fail(`${manifest.assetId} does not declare ${selection.tier}`); + const sourcePath = `${dirname(manifestPath)}/${lod.file}`; + const entry = sourceEntry(entryMap, sourcePath); + if ( + entry.bytes !== lod.bytes + || entry.sha256 !== lod.sha256 + || !Number.isSafeInteger(lod.triangles) + || lod.triangles <= 0 + || lod.rigged !== (selection.mode === 'animated') + || !Array.isArray(lod.boundsBlender?.size) + || lod.boundsBlender.size.length !== 3 + ) fail(`${manifest.assetId} ${selection.tier} contract changed`); + const model = { + ...selection, + sourcePath, + bytes: entry.bytes, + sha256: entry.sha256, + triangles: lod.triangles, + animations: [...lod.animations].sort(), + boundsMeters: [ + lod.boundsBlender.size[0], + lod.boundsBlender.size[2], + lod.boundsBlender.size[1] + ] + }; + return { + ...model, + destinationPath: runtimeDestination(family, id, model) + }; + }); + const mounted = family === 'cavalry' || manifest.roleProfile?.mounted === true; + return { + id, + family, + sourceReleaseTag: releaseTag, + sourceArchive: attachment.name, + sourceAssetId: manifest.assetId, + displayName: manifest.name, + mounted, + presentationRole: family === 'citizen' ? 'civic-routine' : 'ceremonial-patrol', + sourceManifest: { + path: manifestPath, + bytes: manifestEntry.bytes, + sha256: manifestEntry.sha256 + }, + models + }; +} + +const actors = []; +const sources = []; +for (const release of RELEASES) { + const manifestBytes = readFileSync(release.manifestPath); + const manifest = JSON.parse(manifestBytes.toString('utf8')); + if ( + manifest.tag !== release.tag + || manifest.repository !== 'ael-dev3/Warpkeep-Assets' + || manifest.license?.status !== 'public-archive-authorized-no-separate-open-license' + || !Array.isArray(manifest.attachments) + ) fail(`${release.tag} release manifest identity changed`); + const source = { + repository: 'ael-dev3/Warpkeep-Assets', + repositoryMainCommit: '10c84fbcc339f143ee6f25dfe7a0682660e0e458', + releaseCommit: release.releaseCommit, + tag: release.tag, + trustedReleaseManifest: { + repositoryPath: `releases/${release.tag}/manifest.json`, + bytes: manifestBytes.byteLength, + sha256: sha256(manifestBytes) + }, + attachments: [] + }; + for (const attachment of manifest.attachments) { + const archive = resolve(release.archiveDirectory, attachment.name); + const archiveBytes = readFileSync(archive); + if ( + archiveBytes.byteLength !== attachment.bytes + || sha256(archiveBytes) !== attachment.sha256 + ) fail(`${attachment.name} archive bytes changed`); + const members = listZipMembers(archive); + if (members.length !== attachment.entries.length) { + fail(`${attachment.name} archive membership changed`); + } + const entryMap = new Map(attachment.entries.map((entry) => [entry.path, entry])); + const manifests = members.filter((path) => path.endsWith('/runtime-manifest.json')); + for (const manifestPath of manifests) { + actors.push(populationActor({ + archive, + attachment, + entryMap, + manifestPath, + releaseTag: release.tag + })); + } + source.attachments.push({ + name: attachment.name, + bytes: attachment.bytes, + sha256: attachment.sha256, + entries: attachment.entries.length, + packageRoot: attachment.packageRoot + }); + } + sources.push(source); +} + +actors.sort((left, right) => ( + left.family.localeCompare(right.family) || left.id.localeCompare(right.id) +)); +const familyCounts = Object.fromEntries( + ['citizen', 'infantry', 'ranged', 'cavalry'].map((family) => [ + family, + actors.filter((actor) => actor.family === family).length + ]) +); +const record = { + schema: 'warpkeep.inner-keep-population-selection.v1', + selectionId: 'inner-keep-population-v1', + recordedAt: '2026-08-04', + selectionDigestAlgorithm: 'sha256-ecmascript-canonical-json-v1', + selectionDigestSha256: '', + authorization: { + archiveDistributionAuthorized: true, + officialRepositoryRuntimeUseAuthorized: true, + status: 'authorized-owner-runtime-use', + recordedAt: '2026-08-04', + instruction: 'Use characters, mounts, and army units from Warpkeep-Assets in the official Inner Keep runtime, including animated civic routines and patrols.', + scopeBoundary: 'Runtime use in the public ael-dev3/Warpkeep repository and official warpkeep.com client is authorized for the exact selected files. This does not relicense the source archives, authorize unrelated reuse, represent ambient patrols as gameplay authority, approve activation, approve merge, or approve deployment.' + }, + licenseBoundary: { + sourceStatus: 'public-archive-authorized-no-separate-open-license', + integrationGrant: 'owner-authorized-official-warpkeep-runtime-use-only', + redistributionOrRelicensingGranted: false + }, + runtimePolicy: { + animatedTier: 'LOD1_Balanced', + staticFallbackTier: 'LOD2_Compact', + ordinaryBuildMayReadArchive: false, + ordinaryBuildMayUseNetwork: false, + clientPresentationOnly: true, + gameplayAuthorityClaimed: false + }, + sources, + counts: { + actors: actors.length, + selectedModels: actors.reduce((total, actor) => total + actor.models.length, 0), + mountedActors: actors.filter((actor) => actor.mounted).length, + families: familyCounts, + selectedBytes: actors.flatMap((actor) => actor.models) + .reduce((total, model) => total + model.bytes, 0), + selectedTriangles: Object.fromEntries(SELECTED_TIERS.map(({ profile }) => [ + profile, + actors.flatMap((actor) => actor.models) + .filter((model) => model.profile === profile) + .reduce((total, model) => total + model.triangles, 0) + ])) + }, + actors +}; +record.selectionDigestSha256 = sha256(Buffer.from(JSON.stringify(canonicalize({ + selectionId: record.selectionId, + sources: record.sources, + runtimePolicy: record.runtimePolicy, + actors: record.actors +})), 'utf8')); + +const write = process.argv.slice(2).includes('--write'); +if (write) { + mkdirSync(dirname(OUTPUT), { recursive: true }); + writeFileSync(OUTPUT, `${JSON.stringify(record, null, 2)}\n`, 'utf8'); +} +console.log(JSON.stringify({ + write, + output: OUTPUT, + digest: record.selectionDigestSha256, + counts: record.counts, + trustedReleaseManifests: sources.map((source) => ({ + tag: source.tag, + ...source.trustedReleaseManifest + })) +}, null, 2)); diff --git a/scripts/generate-inner-keep-static-selection.mjs b/scripts/generate-inner-keep-static-selection.mjs new file mode 100644 index 00000000..2cbd30bc --- /dev/null +++ b/scripts/generate-inner-keep-static-selection.mjs @@ -0,0 +1,245 @@ +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { basename, resolve } from 'node:path'; + +const ROOT = resolve(import.meta.dirname, '..'); +const RECORD_PATH = resolve( + ROOT, + 'docs/reference/assets/2026-08-02-inner-keep-3d-library/manifest.json' +); +const DEFAULT_ARCHIVE = resolve( + ROOT, + '.cache/warpkeep-assets/inner-keep-3d-asset-library-2026-08-02/', + 'inner-keep-3d-asset-library-2026-08-02-v1.zip' +); +const DEFAULT_RELEASE_MANIFEST = resolve( + ROOT, + '../Warpkeep-Assets-main/releases/inner-keep-3d-asset-library-2026-08-02/manifest.json' +); +const PROFILES = Object.freeze([ + Object.freeze({ profile: 'high', tier: 'LOD0_High' }), + Object.freeze({ profile: 'balanced', tier: 'LOD1_Balanced' }), + Object.freeze({ profile: 'compact', tier: 'LOD2_Compact' }) +]); +const LANDMARKS = Object.freeze([ + Object.freeze({ + id: 'grand-covenant-cathedral', + package: 'Warpkeep_GrandCovenantCathedral_GameReady', + displayName: 'Grand Covenant Cathedral', + intendedUse: 'Permanent northern civic and ceremonial anchor' + }), + Object.freeze({ + id: 'city-barracks', + package: 'Warpkeep_CityBarracks_GameReady', + displayName: 'Hegemony Shieldcourt Barracks', + intendedUse: 'Permanent western garrison and patrol origin' + }) +]); + +function fail(detail) { + throw new Error(`Inner Keep static selection generator: ${detail}`); +} + +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; +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +function selectionDigest(record) { + return sha256(Buffer.from(JSON.stringify(canonicalize({ + selectionId: record.selectionId, + sourceRelease: record.sourceRelease, + profiles: record.selectionPolicy.profiles, + assets: record.assets, + existingRuntimeReuse: record.existingRuntimeReuse + })), 'utf8')); +} + +function readZipMember(archivePath, member) { + const result = spawnSync('/usr/bin/unzip', ['-p', archivePath, member], { + cwd: ROOT, + encoding: null, + maxBuffer: 16 * 1024 * 1024 + }); + if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout)) { + fail(`cannot read ${member} from ${archivePath}`); + } + return result.stdout; +} + +function exactEntry(entryMap, path) { + const entry = entryMap.get(path); + if ( + !entry + || !Number.isSafeInteger(entry.bytes) + || entry.bytes <= 0 + || typeof entry.sha256 !== 'string' + ) fail(`trusted release manifest does not attest ${path}`); + return entry; +} + +function destinationPath(id, profile, digest) { + return `public/models/hegemony/inner-keep/landmarks/inner-keep-${id}-${profile}-` + + `${digest.slice(0, 16)}.glb`; +} + +function makeLandmark(source, entryMap, archivePath, packageRoot) { + const runtimeRoot = `${packageRoot}/3d/${source.package}/Runtime`; + const manifestPath = `${runtimeRoot}/runtime-manifest.json`; + const manifestEntry = exactEntry(entryMap, manifestPath); + const manifestBytes = readZipMember(archivePath, manifestPath); + if ( + manifestBytes.byteLength !== manifestEntry.bytes + || sha256(manifestBytes) !== manifestEntry.sha256 + ) fail(`${source.package} runtime manifest bytes changed`); + const runtimeManifest = JSON.parse(manifestBytes.toString('utf8')); + const blenderSize = runtimeManifest?.runtimeContract?.boundsBlender?.size; + if ( + typeof runtimeManifest?.assetId !== 'string' + || !Array.isArray(blenderSize) + || blenderSize.length !== 3 + || blenderSize.some((entry) => !Number.isFinite(entry) || entry <= 0) + ) fail(`${source.package} runtime manifest shape changed`); + const boundsMeters = [blenderSize[0], blenderSize[2], blenderSize[1]]; + const models = PROFILES.map(({ profile, tier }) => { + const lod = runtimeManifest.lods.find((entry) => entry.tier === tier); + if (!lod) fail(`${source.package} does not declare ${tier}`); + const sourcePath = `${runtimeRoot}/${lod.file}`; + const entry = exactEntry(entryMap, sourcePath); + if ( + entry.bytes !== lod.bytes + || entry.sha256 !== lod.sha256 + || !Number.isSafeInteger(lod.triangles) + || lod.triangles <= 0 + ) fail(`${source.package} ${tier} release attestation changed`); + return { + profile, + sourcePath, + bytes: entry.bytes, + sha256: entry.sha256, + triangles: lod.triangles, + boundsMeters, + destinationPath: destinationPath(source.id, profile, entry.sha256) + }; + }); + const previewPath = `${packageRoot}/3d/${source.package}/Previews/` + + `${source.package.replace('_GameReady', '')}_MobilePreview_320.png`; + const previewEntry = exactEntry(entryMap, previewPath); + return { + id: source.id, + family: 'landmarks', + sourcePackage: source.package, + sourceAssetId: runtimeManifest.assetId, + displayName: source.displayName, + intendedUse: source.intendedUse, + boundsMeters, + sourceManifest: { + path: manifestPath, + bytes: manifestEntry.bytes, + sha256: manifestEntry.sha256 + }, + models, + preview: { + sourcePath: previewPath, + bytes: previewEntry.bytes, + sha256: previewEntry.sha256, + width: 320, + height: 320, + destinationPath: `public/images/inner-keep/catalog/${source.id}-` + + `${previewEntry.sha256.slice(0, 16)}.png` + } + }; +} + +const write = process.argv.slice(2).includes('--write'); +const archivePath = resolve(process.env.WARPKEEP_INNER_KEEP_ARCHIVE || DEFAULT_ARCHIVE); +const releaseManifestPath = resolve( + process.env.WARPKEEP_INNER_KEEP_RELEASE_MANIFEST || DEFAULT_RELEASE_MANIFEST +); +const record = JSON.parse(readFileSync(RECORD_PATH, 'utf8')); +const releaseManifestBytes = readFileSync(releaseManifestPath); +const releaseManifest = JSON.parse(releaseManifestBytes.toString('utf8')); +const attachment = releaseManifest.attachments?.[0]; +if ( + releaseManifest.tag !== record.sourceRelease.tag + || attachment?.name !== record.sourceRelease.attachment.name + || attachment?.sha256 !== record.sourceRelease.attachment.sha256 + || !Array.isArray(attachment?.entries) +) fail('trusted release identity changed'); +const entryMap = new Map(attachment.entries.map((entry) => [entry.path, entry])); +const landmarks = LANDMARKS.map((source) => makeLandmark( + source, + entryMap, + archivePath, + attachment.packageRoot +)); +const assets = [ + ...record.assets.filter((asset) => asset.family !== 'landmarks'), + ...landmarks +]; +const updated = { + ...record, + recordedAt: '2026-08-04', + sourceRelease: { + ...record.sourceRelease, + repositoryMainCommit: '10c84fbcc339f143ee6f25dfe7a0682660e0e458' + }, + authorization: { + archiveDistributionAuthorized: true, + officialRepositoryRuntimeUseAuthorized: true, + status: 'authorized-owner-runtime-use', + recordedAt: '2026-08-04', + instruction: 'Use Warpkeep-Assets 3D objects to populate the official Inner Keep runtime, with the Grand Covenant Cathedral as the main building.', + scopeBoundary: 'Runtime use in the public ael-dev3/Warpkeep repository and official warpkeep.com client is authorized for the exact selected files. This does not relicense the source archives, authorize unrelated reuse, approve activation, approve merge, or approve deployment.' + }, + selectionPolicy: { + ...record.selectionPolicy, + sourceAssetCount: assets.length, + sourceModelCount: assets.reduce((total, asset) => total + asset.models.length, 0), + sourcePreviewCount: assets.reduce((total, asset) => total + (asset.preview ? 1 : 0), 0), + excludedPackages: [] + }, + assets +}; +updated.qualityTotals = Object.fromEntries(PROFILES.map(({ profile }) => { + const models = assets.flatMap((asset) => asset.models) + .filter((model) => model.profile === profile); + return [profile, { + bytes: models.reduce((total, model) => total + model.bytes, 0), + triangles: models.reduce((total, model) => total + model.triangles, 0) + }]; +})); +updated.selectedModelBytesAllProfiles = assets + .flatMap((asset) => asset.models) + .reduce((total, model) => total + model.bytes, 0); +updated.selectedPreviewBytes = assets + .reduce((total, asset) => total + (asset.preview?.bytes ?? 0), 0); +updated.selectionDigestSha256 = selectionDigest(updated); + +const output = `${JSON.stringify(updated, null, 2)}\n`; +if (write) writeFileSync(RECORD_PATH, output, 'utf8'); +console.log(JSON.stringify({ + write, + record: basename(RECORD_PATH), + selectionDigestSha256: updated.selectionDigestSha256, + sourceAssetCount: updated.selectionPolicy.sourceAssetCount, + sourceModelCount: updated.selectionPolicy.sourceModelCount, + sourcePreviewCount: updated.selectionPolicy.sourcePreviewCount, + qualityTotals: updated.qualityTotals, + selectedModelBytesAllProfiles: updated.selectedModelBytesAllProfiles, + selectedPreviewBytes: updated.selectedPreviewBytes, + trustedReleaseManifest: { + bytes: releaseManifestBytes.byteLength, + sha256: sha256(releaseManifestBytes) + } +}, null, 2)); 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..0b0c85fe --- /dev/null +++ b/scripts/inner-keep-operator.ts @@ -0,0 +1,840 @@ +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 { + INNER_KEEP_POPULATION_MODELS, + INNER_KEEP_POPULATION_RUNTIME_PATHS, + INNER_KEEP_POPULATION_SELECTION, + assertInnerKeepPopulationRuntimeUseAuthorized, +} from './inner-keep-population-runtime-contract.mjs'; +import { + INNER_KEEP_RABBIT_MODELS, + INNER_KEEP_RABBIT_RUNTIME_PATHS, + INNER_KEEP_RABBIT_SELECTION, + assertInnerKeepRabbitRuntimeUseAuthorized, +} from './inner-keep-rabbit-runtime-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 INNER_KEEP_AUTHORIZED_RUNTIME_PATHS = Object.freeze([ + ...INNER_KEEP_PLANNED_RUNTIME_PATHS, + ...INNER_KEEP_POPULATION_RUNTIME_PATHS, + ...INNER_KEEP_RABBIT_RUNTIME_PATHS, +].sort()); +const INNER_KEEP_AUTHORIZED_RUNTIME_FILES = Object.freeze([ + ...INNER_KEEP_SELECTED_MODELS, + ...INNER_KEEP_SELECTED_PREVIEWS, + ...INNER_KEEP_POPULATION_MODELS, + ...INNER_KEEP_RABBIT_MODELS, +]); +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; +}>; + +type InnerKeepRuntimeStatus = Readonly<{ + dev: number; + ino: number; + size: number; + mtimeMs: number; + ctimeMs: number; + isDirectory: () => boolean; + isFile: () => boolean; + isSymbolicLink: () => boolean; +}>; + +type InnerKeepRuntimeDirectoryEntry = Readonly<{ + name: string; +}>; + +type InnerKeepRuntimeFilesystem = Readonly<{ + close: (descriptor: number) => void; + fstat: (descriptor: number) => InnerKeepRuntimeStatus; + lstat: (path: string) => InnerKeepRuntimeStatus; + open: (path: string, flags: number) => number; + readFile: (descriptor: number) => Buffer; + readdir: (path: string) => readonly InnerKeepRuntimeDirectoryEntry[]; + realpath: (path: string) => string; +}>; + +const INNER_KEEP_RUNTIME_FILESYSTEM: InnerKeepRuntimeFilesystem = Object.freeze({ + close: (descriptor) => closeSync(descriptor), + fstat: (descriptor) => fstatSync(descriptor), + lstat: (path) => lstatSync(path), + open: (path, flags) => openSync(path, flags), + readFile: (descriptor) => readFileSync(descriptor), + readdir: (path) => readdirSync(path, { withFileTypes: true }), + realpath: realpathSync, +}); + +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 sameRuntimeNode( + left: InnerKeepRuntimeStatus, + right: InnerKeepRuntimeStatus, +): boolean { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs; +} + +export function collectInnerKeepRuntimeFiles( + relativeRoot: string, + filesystem: InnerKeepRuntimeFilesystem = INNER_KEEP_RUNTIME_FILESYSTEM, +): readonly string[] { + const absoluteRoot = resolve(REPOSITORY_ROOT, relativeRoot); + if (!absoluteRoot.startsWith(`${REPOSITORY_ROOT}${sep}`)) { + fail('Inner Keep runtime registry path escaped the repository.'); + } + let status; + try { + status = filesystem.lstat(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, + expectedStatus?: InnerKeepRuntimeStatus, + ): void => { + let descriptor: number | undefined; + try { + descriptor = filesystem.open( + absoluteDirectory, + constants.O_RDONLY + | (constants.O_DIRECTORY ?? 0) + | (constants.O_NOFOLLOW ?? 0), + ); + const openedBefore = filesystem.fstat(descriptor); + const pathBefore = filesystem.lstat(absoluteDirectory); + const canonicalBefore = filesystem.realpath(absoluteDirectory); + if ( + !openedBefore.isDirectory() + || openedBefore.isSymbolicLink() + || !pathBefore.isDirectory() + || pathBefore.isSymbolicLink() + || canonicalBefore !== absoluteDirectory + || !sameRuntimeNode(openedBefore, pathBefore) + || (expectedStatus !== undefined && !sameRuntimeNode(expectedStatus, pathBefore)) + ) fail('Inner Keep runtime registry directory changed while it was inspected.'); + for (const entry of filesystem.readdir(absoluteDirectory)) { + 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); + if (!childAbsolute.startsWith(`${absoluteDirectory}${sep}`)) { + fail('Inner Keep runtime registry contains an unsafe path.'); + } + const childRelative = `${relativeDirectory}/${entry.name}`; + const childStatus = filesystem.lstat(childAbsolute); + if (childStatus.isSymbolicLink()) { + fail('Inner Keep runtime registry contains a symbolic link.'); + } + if (childStatus.isDirectory()) { + visit(childAbsolute, childRelative, childStatus); + } else if (childStatus.isFile()) { + files.push(childRelative); + } else { + fail('Inner Keep runtime registry contains an unsupported entry.'); + } + } + const openedAfter = filesystem.fstat(descriptor); + const pathAfter = filesystem.lstat(absoluteDirectory); + const canonicalAfter = filesystem.realpath(absoluteDirectory); + if ( + !openedAfter.isDirectory() + || openedAfter.isSymbolicLink() + || !pathAfter.isDirectory() + || pathAfter.isSymbolicLink() + || canonicalAfter !== absoluteDirectory + || !sameRuntimeNode(openedBefore, openedAfter) + || !sameRuntimeNode(openedAfter, pathAfter) + ) fail('Inner Keep runtime registry directory changed while it was inspected.'); + } catch (error) { + if (error instanceof InnerKeepOperatorError) throw error; + fail('Inner Keep runtime registry cannot be inspected safely.'); + } finally { + if (descriptor !== undefined) filesystem.close(descriptor); + } + }; + visit(absoluteRoot, relativeRoot, status); + return Object.freeze(files.sort()); +} + +type InnerKeepRuntimeFileObservation = Readonly<{ + bytes: number; + sha256: string; +}>; + +type InnerKeepRuntimeRegistryPreflight = Readonly<{ + staticSelection: unknown; + populationSelection: unknown; + rabbitSelection: unknown; + observedPaths: readonly string[]; + inspectRuntimeFile: (relativePath: string) => InnerKeepRuntimeFileObservation; +}>; + +export function inspectExactInnerKeepRuntimeFile( + relativePath: string, + filesystem: InnerKeepRuntimeFilesystem = INNER_KEEP_RUNTIME_FILESYSTEM, +): InnerKeepRuntimeFileObservation { + const absolutePath = resolve(REPOSITORY_ROOT, relativePath); + if (!absolutePath.startsWith(`${REPOSITORY_ROOT}${sep}`)) { + fail('Inner Keep runtime registry path escaped the repository.'); + } + let descriptor: number | undefined; + try { + descriptor = filesystem.open( + absolutePath, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + const before = filesystem.fstat(descriptor); + const pathBefore = filesystem.lstat(absolutePath); + const canonicalBefore = filesystem.realpath(absolutePath); + if ( + !before.isFile() + || before.isSymbolicLink() + || !pathBefore.isFile() + || pathBefore.isSymbolicLink() + || canonicalBefore !== absolutePath + || !sameRuntimeNode(before, pathBefore) + ) fail('Inner Keep runtime registry does not match the authorized selection.'); + const bytes = filesystem.readFile(descriptor); + const after = filesystem.fstat(descriptor); + const pathAfter = filesystem.lstat(absolutePath); + const canonicalAfter = filesystem.realpath(absolutePath); + if ( + !after.isFile() + || after.isSymbolicLink() + || !pathAfter.isFile() + || pathAfter.isSymbolicLink() + || canonicalAfter !== absolutePath + || !sameRuntimeNode(before, after) + || !sameRuntimeNode(after, pathAfter) + || bytes.byteLength !== before.size + ) fail('Inner Keep runtime registry does not match the authorized selection.'); + return Object.freeze({ + bytes: bytes.byteLength, + sha256: sha256(bytes), + }); + } catch (error) { + if (error instanceof InnerKeepOperatorError) throw error; + return fail('Inner Keep runtime registry is incomplete.'); + } finally { + if (descriptor !== undefined) filesystem.close(descriptor); + } +} + +export function verifyInnerKeepRuntimeRegistryPreflight( + preflight: InnerKeepRuntimeRegistryPreflight, +): void { + try { + assertInnerKeepRuntimeUseAuthorized(preflight.staticSelection); + } catch { + fail( + 'Inner Keep activation is blocked: owner runtime-use authorization for the selected ' + + 'static asset registry is not recorded.', + ); + } + try { + assertInnerKeepPopulationRuntimeUseAuthorized(preflight.populationSelection); + } catch { + fail( + 'Inner Keep activation is blocked: owner runtime-use authorization for the selected ' + + 'population asset registry is not recorded.', + ); + } + try { + assertInnerKeepRabbitRuntimeUseAuthorized(preflight.rabbitSelection); + } catch { + fail( + 'Inner Keep activation is blocked: owner runtime-use authorization for the selected ' + + 'rabbit asset registry is not recorded.', + ); + } + const observedPaths = [...preflight.observedPaths].sort(); + if ( + observedPaths.length !== INNER_KEEP_AUTHORIZED_RUNTIME_PATHS.length + || observedPaths.some((path, index) => path !== INNER_KEEP_AUTHORIZED_RUNTIME_PATHS[index]) + ) fail('Inner Keep runtime registry paths do not match all authorized selections.'); + for (const expected of INNER_KEEP_AUTHORIZED_RUNTIME_FILES) { + const observed = preflight.inspectRuntimeFile(expected.destinationPath); + if ( + observed.bytes !== expected.bytes + || observed.sha256 !== expected.sha256 + ) fail('Inner Keep runtime registry does not match the authorized selection.'); + } +} + +export function verifyAuthorizedInnerKeepRuntimeRegistry(): void { + verifyInnerKeepRuntimeRegistryPreflight(Object.freeze({ + staticSelection: INNER_KEEP_ASSET_SELECTION, + populationSelection: INNER_KEEP_POPULATION_SELECTION, + rabbitSelection: INNER_KEEP_RABBIT_SELECTION, + observedPaths: INNER_KEEP_RUNTIME_ROOTS.flatMap((root) => ( + collectInnerKeepRuntimeFiles(root) + )), + inspectRuntimeFile: inspectExactInnerKeepRuntimeFile, + })); +} + +function innerKeepRuntimeRegistryReady(): boolean { + try { + verifyAuthorizedInnerKeepRuntimeRegistry(); + return true; + } catch { + return false; + } +} + +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, + populationRuntimeAssetAuthorization: + INNER_KEEP_POPULATION_SELECTION.authorization.status, + rabbitRuntimeAssetAuthorization: + INNER_KEEP_RABBIT_SELECTION.authorization.status, + readyToConfirm: innerKeepRuntimeRegistryReady(), + } : {}), + })); + 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-population-runtime-contract.d.mts b/scripts/inner-keep-population-runtime-contract.d.mts new file mode 100644 index 00000000..adb4f2a9 --- /dev/null +++ b/scripts/inner-keep-population-runtime-contract.d.mts @@ -0,0 +1,22 @@ +export const INNER_KEEP_POPULATION_SELECTION_RECORD: string; +export const INNER_KEEP_POPULATION_SELECTION_DIGEST: string; +export const INNER_KEEP_POPULATION_SELECTION: Readonly>; +export const INNER_KEEP_POPULATION_ACTORS: readonly Readonly>[]; +export const INNER_KEEP_POPULATION_MODELS: readonly Readonly>[]; +export const INNER_KEEP_POPULATION_RUNTIME_PATHS: readonly string[]; + +export function innerKeepPopulationSha256(bytes: Uint8Array): string; +export function calculateInnerKeepPopulationSelectionDigest( + record: Readonly> +): string; +export function assertInnerKeepPopulationSelectionRecord(record: unknown): void; +export function assertInnerKeepPopulationRuntimeUseAuthorized(record?: unknown): void; +export function readInnerKeepPopulationGlbJson( + bytes: Buffer, + label: string +): Record; +export function verifyInnerKeepPopulationGlb( + bytes: Buffer, + model: Readonly>, + label: string +): Record; diff --git a/scripts/inner-keep-population-runtime-contract.mjs b/scripts/inner-keep-population-runtime-contract.mjs new file mode 100644 index 00000000..8c22d4cd --- /dev/null +++ b/scripts/inner-keep-population-runtime-contract.mjs @@ -0,0 +1,389 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +export const INNER_KEEP_POPULATION_SELECTION_RECORD = + 'docs/reference/assets/2026-08-04-inner-keep-population/manifest.json'; +export const INNER_KEEP_POPULATION_SELECTION_DIGEST = + '79237fbe85a4db7a0592eb0c27cc00f8e72e85e58be867bec4dd35992f0b87f7'; + +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({ + citizen: 8, + infantry: 4, + ranged: 4, + cavalry: 4 +}); +const EXPECTED_SOURCES = Object.freeze({ + 'hegemony-unit-corps-2026-08-03': Object.freeze({ + releaseCommit: 'b074ffb6317ff9a581f5b7fc7f0a0760e721a9b6', + manifestBytes: 54_417, + manifestSha256: '983c7dbede5220a0b020a73ea7cf51f8cdd72fbe235417d890b34eabae3acaaf', + attachments: Object.freeze([ + Object.freeze({ + name: 'warpkeep-hegemony-infantry-game-ready-2026-08-03-v1.zip', + bytes: 16_877_451, + sha256: '953fcca256324f2a73b3f13e2ca04911349a0f69934337e6ac7161041a2d4ba0', + entries: 43, + packageRoot: 'Warpkeep_HegemonyInfantry_GameReady' + }), + Object.freeze({ + name: 'warpkeep-hegemony-ranged-game-ready-2026-08-03-v1.zip', + bytes: 16_349_955, + sha256: '310b0feaf21f0619f1d10f154facd64f98ce3ad1658cf80bb4dd68f5847a0391', + entries: 43, + packageRoot: 'Warpkeep_HegemonyRanged_GameReady' + }), + Object.freeze({ + name: 'warpkeep-hegemony-cavalry-game-ready-2026-08-03-v1.zip', + bytes: 19_651_045, + sha256: '9d411ee308abacfd7ca93b1e96594cc4f9e8af613f6d18aca3128b49dfb9a48b', + entries: 43, + packageRoot: 'Warpkeep_HegemonyCavalry_GameReady' + }) + ]) + }), + 'hegemony-citizens-keep-services-2026-08-03': Object.freeze({ + releaseCommit: '9f99751f691581ce4f33a31dd56c818cfa455b61', + manifestBytes: 33_008, + manifestSha256: '2472aea6dc43780f6ee11692b65fe138a2355373a9e72d96156bc0f9290cb34b', + attachments: Object.freeze([ + Object.freeze({ + name: 'warpkeep-hegemony-citizens-keep-services-game-ready-2026-08-03-v1.zip', + bytes: 24_825_077, + sha256: '14a8bda3848ede5b398a7fc4ec10e56d98ae06584c42f4683cb2635c5bbb6aeb', + entries: 75, + packageRoot: 'Warpkeep_HegemonyCitizens_Set2_GameReady' + }) + ]) + }) +}); +const EXPECTED_ANIMATIONS = Object.freeze({ + citizen: Object.freeze(['Greet', 'Idle', 'Walk', 'Work']), + infantry: Object.freeze(['Attack', 'Idle', 'Walk']), + ranged: Object.freeze(['Attack', 'Idle', 'Special', 'Walk']), + cavalry: Object.freeze(['Attack', 'Idle', 'Special', 'Walk']) +}); +const EXPECTED_MOUNTED_CITIZENS = Object.freeze(new Set([ + 'emberfoot-courier', + 'shellback-shrine-tender' +])); +const EXPECTED_AUTHORIZATION_INSTRUCTION = + 'Use characters, mounts, and army units from Warpkeep-Assets in the official Inner Keep runtime, including animated civic routines and patrols.'; +const EXPECTED_AUTHORIZATION_SCOPE = + 'Runtime use in the public ael-dev3/Warpkeep repository and official warpkeep.com client is authorized for the exact selected files. This does not relicense the source archives, authorize unrelated reuse, represent ambient patrols as gameplay authority, approve activation, approve merge, or approve deployment.'; + +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 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; +} + +function exactArray(actual, expected) { + return Array.isArray(actual) + && actual.length === expected.length + && actual.every((entry, index) => entry === expected[index]); +} + +function safePath(path) { + return typeof path === 'string' + && path.length > 0 + && !path.startsWith('/') + && !path.includes('\\') + && !path.includes('\0') + && path === path.normalize('NFC') + && path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'); +} + +function positiveVector3(value) { + return Array.isArray(value) + && value.length === 3 + && value.every((entry) => Number.isFinite(entry) && entry > 0); +} + +export function innerKeepPopulationSha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +export function calculateInnerKeepPopulationSelectionDigest(record) { + return innerKeepPopulationSha256(Buffer.from(JSON.stringify(canonicalize({ + selectionId: record?.selectionId, + sources: record?.sources, + runtimePolicy: record?.runtimePolicy, + actors: record?.actors + })), 'utf8')); +} + +function assertSource(source, seenArchives) { + const expected = EXPECTED_SOURCES[source?.tag]; + const label = `Inner Keep population source ${source?.tag ?? ''}`; + if ( + !expected + || source.repository !== 'ael-dev3/Warpkeep-Assets' + || source.repositoryMainCommit !== '10c84fbcc339f143ee6f25dfe7a0682660e0e458' + || source.releaseCommit !== expected.releaseCommit + || source.trustedReleaseManifest?.repositoryPath + !== `releases/${source.tag}/manifest.json` + || source.trustedReleaseManifest?.bytes !== expected.manifestBytes + || source.trustedReleaseManifest?.sha256 !== expected.manifestSha256 + || !Array.isArray(source.attachments) + || source.attachments.length !== expected.attachments.length + ) fail(label, 'release coordinate changed.'); + source.attachments.forEach((attachment, index) => { + const expectedAttachment = expected.attachments[index]; + if ( + attachment?.name !== expectedAttachment.name + || attachment.bytes !== expectedAttachment.bytes + || attachment.sha256 !== expectedAttachment.sha256 + || attachment.entries !== expectedAttachment.entries + || attachment.packageRoot !== expectedAttachment.packageRoot + || seenArchives.has(attachment.name) + ) fail(label, 'attachment coordinate is unsafe or duplicated.'); + seenArchives.add(attachment.name); + }); +} + +function assertActor(actor, sourceByTag, archiveByName, ids, destinations) { + const label = `Inner Keep population actor ${actor?.id ?? ''}`; + if ( + !actor + || typeof actor.id !== 'string' + || !SAFE_ID_PATTERN.test(actor.id) + || !Object.hasOwn(EXPECTED_FAMILY_COUNTS, actor.family) + || actor.sourceAssetId !== `warpkeep.units.hegemony.${ + actor.family === 'citizen' ? 'citizens-set2' : actor.family + }.${actor.id}` + || typeof actor.displayName !== 'string' + || actor.displayName.length === 0 + || typeof actor.mounted !== 'boolean' + || actor.presentationRole !== (actor.family === 'citizen' + ? 'civic-routine' + : 'ceremonial-patrol') + || !sourceByTag.has(actor.sourceReleaseTag) + || !archiveByName.has(actor.sourceArchive) + || archiveByName.get(actor.sourceArchive).tag !== actor.sourceReleaseTag + || ids.has(actor.id) + || !safePath(actor.sourceManifest?.path) + || !actor.sourceManifest.path.endsWith('/runtime-manifest.json') + || !Number.isSafeInteger(actor.sourceManifest?.bytes) + || actor.sourceManifest.bytes <= 0 + || !SHA256_PATTERN.test(actor.sourceManifest?.sha256) + || !Array.isArray(actor.models) + || actor.models.length !== 2 + ) fail(label, 'selection shape changed.'); + if ( + actor.mounted !== ( + actor.family === 'cavalry' + || (actor.family === 'citizen' && EXPECTED_MOUNTED_CITIZENS.has(actor.id)) + ) + ) fail(label, 'mounted presentation identity changed.'); + ids.add(actor.id); + const expectedProfiles = [ + ['animated', 'balanced', 'LOD1_Balanced'], + ['static', 'compact', 'LOD2_Compact'] + ]; + actor.models.forEach((model, index) => { + const [mode, profile, tier] = expectedProfiles[index]; + if ( + model?.mode !== mode + || model?.profile !== profile + || model?.tier !== tier + || !safePath(model?.sourcePath) + || !model.sourcePath.endsWith('.glb') + || dirname(model.sourcePath) !== dirname(actor.sourceManifest.path) + || !Number.isSafeInteger(model.bytes) + || model.bytes <= 0 + || !SHA256_PATTERN.test(model.sha256) + || !Number.isSafeInteger(model.triangles) + || model.triangles <= 0 + || !exactArray( + model.animations, + mode === 'animated' ? EXPECTED_ANIMATIONS[actor.family] : [] + ) + || !positiveVector3(model.boundsMeters) + || model.destinationPath !== `public/models/hegemony/inner-keep/population/` + + `${actor.family}/inner-keep-${actor.id}-${profile}-${model.sha256.slice(0, 16)}.glb` + || !safePath(model.destinationPath) + || destinations.has(model.destinationPath) + ) fail(label, `${profile} model changed.`); + destinations.add(model.destinationPath); + }); +} + +export function assertInnerKeepPopulationSelectionRecord(record) { + const label = 'Inner Keep population selection record'; + if ( + record?.schema !== 'warpkeep.inner-keep-population-selection.v1' + || record.selectionId !== 'inner-keep-population-v1' + || record.recordedAt !== '2026-08-04' + || record.selectionDigestAlgorithm !== 'sha256-ecmascript-canonical-json-v1' + || record.selectionDigestSha256 !== INNER_KEEP_POPULATION_SELECTION_DIGEST + || record.authorization?.archiveDistributionAuthorized !== true + || record.authorization?.officialRepositoryRuntimeUseAuthorized !== true + || record.authorization?.status !== 'authorized-owner-runtime-use' + || record.authorization?.recordedAt !== '2026-08-04' + || record.authorization?.instruction !== EXPECTED_AUTHORIZATION_INSTRUCTION + || record.authorization?.scopeBoundary !== EXPECTED_AUTHORIZATION_SCOPE + || record.licenseBoundary?.sourceStatus + !== 'public-archive-authorized-no-separate-open-license' + || record.licenseBoundary?.integrationGrant + !== 'owner-authorized-official-warpkeep-runtime-use-only' + || record.licenseBoundary?.redistributionOrRelicensingGranted !== false + || record.runtimePolicy?.animatedTier !== 'LOD1_Balanced' + || record.runtimePolicy?.staticFallbackTier !== 'LOD2_Compact' + || record.runtimePolicy?.ordinaryBuildMayReadArchive !== false + || record.runtimePolicy?.ordinaryBuildMayUseNetwork !== false + || record.runtimePolicy?.clientPresentationOnly !== true + || record.runtimePolicy?.gameplayAuthorityClaimed !== false + || !Array.isArray(record.sources) + || record.sources.length !== 2 + || !Array.isArray(record.actors) + || record.actors.length !== 20 + ) fail(label, 'top-level authorization, source, or runtime policy changed.'); + const seenArchives = new Set(); + record.sources.forEach((source) => assertSource(source, seenArchives)); + if ( + new Set(record.sources.map((source) => source.tag)).size !== 2 + || seenArchives.size !== 4 + ) fail(label, 'source or archive identity was duplicated.'); + const sourceByTag = new Map(record.sources.map((source) => [source.tag, source])); + const archiveByName = new Map(record.sources.flatMap((source) => ( + source.attachments.map((attachment) => [attachment.name, { ...attachment, tag: source.tag }]) + ))); + const ids = new Set(); + const destinations = new Set(); + record.actors.forEach((actor) => assertActor( + actor, + sourceByTag, + archiveByName, + ids, + destinations + )); + const familyCounts = Object.fromEntries(Object.keys(EXPECTED_FAMILY_COUNTS).map((family) => [ + family, + record.actors.filter((actor) => actor.family === family).length + ])); + if ( + JSON.stringify(familyCounts) !== JSON.stringify(EXPECTED_FAMILY_COUNTS) + || record.counts?.actors !== 20 + || record.counts?.selectedModels !== 40 + || record.counts?.mountedActors !== 6 + || JSON.stringify(record.counts?.families) !== JSON.stringify(EXPECTED_FAMILY_COUNTS) + || record.counts?.selectedBytes !== 8_705_628 + || record.counts?.selectedTriangles?.balanced !== 58_792 + || record.counts?.selectedTriangles?.compact !== 37_506 + ) fail(label, 'bounded selection totals changed.'); + const digest = calculateInnerKeepPopulationSelectionDigest(record); + if (digest !== INNER_KEEP_POPULATION_SELECTION_DIGEST) { + fail(label, `selection digest changed to ${digest}.`); + } +} + +function readRecord() { + const record = JSON.parse(readFileSync( + resolve(ROOT, INNER_KEEP_POPULATION_SELECTION_RECORD), + 'utf8' + )); + assertInnerKeepPopulationSelectionRecord(record); + return deeplyFreeze(record); +} + +export const INNER_KEEP_POPULATION_SELECTION = readRecord(); +export const INNER_KEEP_POPULATION_ACTORS = INNER_KEEP_POPULATION_SELECTION.actors; +export const INNER_KEEP_POPULATION_MODELS = Object.freeze( + INNER_KEEP_POPULATION_ACTORS.flatMap((actor) => actor.models.map((model) => deeplyFreeze({ + ...model, + actorId: actor.id, + family: actor.family, + mounted: actor.mounted, + sourceArchive: actor.sourceArchive, + sourceReleaseTag: actor.sourceReleaseTag + }))) +); +export const INNER_KEEP_POPULATION_RUNTIME_PATHS = Object.freeze( + INNER_KEEP_POPULATION_MODELS.map((model) => model.destinationPath).sort() +); + +export function assertInnerKeepPopulationRuntimeUseAuthorized( + record = INNER_KEEP_POPULATION_SELECTION +) { + if ( + record.authorization?.archiveDistributionAuthorized !== true + || record.authorization?.officialRepositoryRuntimeUseAuthorized !== true + || record.authorization?.status !== 'authorized-owner-runtime-use' + || record.licenseBoundary?.sourceStatus + !== 'public-archive-authorized-no-separate-open-license' + || record.licenseBoundary?.integrationGrant + !== 'owner-authorized-official-warpkeep-runtime-use-only' + || record.licenseBoundary?.redistributionOrRelicensingGranted !== false + || record.runtimePolicy?.clientPresentationOnly !== true + || record.runtimePolicy?.gameplayAuthorityClaimed !== false + ) fail('Inner Keep population runtime installation', 'owner authorization is not recorded.'); +} + +export function readInnerKeepPopulationGlbJson(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 verifyInnerKeepPopulationGlb(bytes, model, label) { + if ( + !model + || !Buffer.isBuffer(bytes) + || bytes.byteLength !== model.bytes + || innerKeepPopulationSha256(bytes) !== model.sha256 + ) fail(label, 'does not match its exact selected bytes.'); + const gltf = readInnerKeepPopulationGlbJson(bytes, label); + const external = [...(gltf.buffers ?? []), ...(gltf.images ?? [])] + .some((entry) => typeof entry?.uri === 'string'); + const animationNames = (gltf.animations ?? []).map((entry) => entry.name).sort(); + const triangles = (gltf.meshes ?? []).reduce((meshTotal, mesh) => ( + meshTotal + (mesh.primitives ?? []).reduce((primitiveTotal, primitive) => { + if ((primitive.mode ?? 4) !== 4) return Number.NaN; + const count = gltf.accessors?.[primitive.indices]?.count + ?? gltf.accessors?.[primitive.attributes?.POSITION]?.count; + return primitiveTotal + (Number.isSafeInteger(count) ? count / 3 : Number.NaN); + }, 0) + ), 0); + if ( + gltf.asset?.version !== '2.0' + || !(gltf.meshes?.length > 0) + || external + || triangles !== model.triangles + || !exactArray(animationNames, model.animations) + || (model.mode === 'animated' && !(gltf.skins?.length > 0)) + || (model.mode === 'static' && (gltf.skins?.length ?? 0) !== 0) + ) fail(label, 'does not satisfy the selected embedded animation and mesh contract.'); + return gltf; +} diff --git a/scripts/inner-keep-rabbit-runtime-contract.d.mts b/scripts/inner-keep-rabbit-runtime-contract.d.mts new file mode 100644 index 00000000..805f7131 --- /dev/null +++ b/scripts/inner-keep-rabbit-runtime-contract.d.mts @@ -0,0 +1,24 @@ +export const INNER_KEEP_RABBIT_SELECTION_RECORD: string; +export const INNER_KEEP_RABBIT_SELECTION_DIGEST: string; +export const INNER_KEEP_RABBIT_RUNTIME_DIRECTORY: string; +export const INNER_KEEP_RABBIT_NESTED_MEMBERS: readonly string[]; +export const INNER_KEEP_RABBIT_SELECTION: Readonly>; +export const INNER_KEEP_RABBIT_MODELS: readonly Readonly>[]; +export const INNER_KEEP_RABBIT_RUNTIME_PATHS: readonly string[]; + +export function innerKeepRabbitSha256(bytes: Uint8Array): string; +export function calculateInnerKeepRabbitSelectionDigest( + record: Readonly> +): string; +export function assertInnerKeepRabbitSelectionRecord(record: unknown): void; +export function assertInnerKeepRabbitRuntimeUseAuthorized(record?: unknown): void; +export function assertTrustedInnerKeepRabbitReleaseManifest(bytes: Buffer): Record; +export function assertInnerKeepRabbitOuterManifest(bytes: Buffer): Record; +export function assertInnerKeepRabbitRuntimeManifest(bytes: Buffer): Record; +export function assertInnerKeepRabbitBundleManifest(bytes: Buffer): Record; +export function readInnerKeepRabbitGlbJson(bytes: Buffer, label: string): Record; +export function verifyInnerKeepRabbitGlb( + bytes: Buffer, + model: Readonly>, + label: string +): Record; diff --git a/scripts/inner-keep-rabbit-runtime-contract.mjs b/scripts/inner-keep-rabbit-runtime-contract.mjs new file mode 100644 index 00000000..03e32efc --- /dev/null +++ b/scripts/inner-keep-rabbit-runtime-contract.mjs @@ -0,0 +1,447 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +export const INNER_KEEP_RABBIT_SELECTION_RECORD = + 'docs/reference/assets/2026-07-30-lowlands-rabbit/manifest.json'; +export const INNER_KEEP_RABBIT_SELECTION_DIGEST = + '58cab83f4c4e1773012d2b099da5b05ab3fa857d6e8395710e60cd4db337b958'; +export const INNER_KEEP_RABBIT_RUNTIME_DIRECTORY = + 'public/models/hegemony/inner-keep/wildlife/rabbit'; + +const ROOT = resolve(import.meta.dirname, '..'); +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const EXPECTED_AUTHORIZATION_INSTRUCTION = + 'looks cool so far, make sure there is lots of topography, water, trees, animals, people, rss nodes outside inner keep so it feels well done and alive like a leaving and breathing world'; +const EXPECTED_INTEGRATION_DECISION = + 'Use the exact official Warpkeep-Assets Lowlands Rabbit runtime family for the requested animals outside the Inner Keep.'; +const EXPECTED_AUTHORIZATION_SCOPE = + 'Runtime use in the public ael-dev3/Warpkeep repository and official warpkeep.com client is authorized for the exact selected files. This does not relicense the source archive, authorize unrelated reuse, give decorative wildlife gameplay authority, approve activation, approve merge, or approve deployment.'; +const EXPECTED_CLIPS = Object.freeze(['Alert', 'Idle', 'Nibble', 'Walk']); +const EXPECTED_MODELS = Object.freeze({ + high: Object.freeze({ + tier: 'LOD0_High', + mode: 'animated', + bytes: 129_388, + sha256: '735f7d72457acfb20581cfcacbd8908eab3ef36d5640b63ecb134bbaa7a10b1d', + triangles: 726, + uploadedVertices: 1_640, + }), + balanced: Object.freeze({ + tier: 'LOD1_Balanced', + mode: 'animated', + bytes: 86_340, + sha256: 'daeb493a827ecbd605c6ad25d83b84050e6a0ad29e96619c9d86c57c37be1f6a', + triangles: 350, + uploadedVertices: 856, + }), + compact: Object.freeze({ + tier: 'LOD2_Compact', + mode: 'static', + bytes: 14_808, + sha256: '2ecc7b1adf4c1d79b7ca2d5ea9a6727ed3f6d9072047466082bb912d34ea930c', + triangles: 146, + uploadedVertices: 384, + }), +}); +const EXPECTED_OUTER_ENTRIES = Object.freeze([ + Object.freeze({ + path: 'rabbit-runtime-ui-bundle-2026-07-30-v1/README.md', + bytes: 2_126, + sha256: '3d6909181d31011dbb0b09935dee07d2e7d36147123ec30371f8b293c31643dd', + }), + Object.freeze({ + path: 'rabbit-runtime-ui-bundle-2026-07-30-v1/Warpkeep_Rabbit_Runtime_UI_Bundle_2026-07-30.zip', + bytes: 2_710_713, + sha256: '1ab9a02a39e68a3ddadcec4ac1824aafaf252f9cb068511e6341d2377f256dc3', + }), + Object.freeze({ + path: 'rabbit-runtime-ui-bundle-2026-07-30-v1/manifest.json', + bytes: 3_745, + sha256: '2174b34ee5f0d025dc0a46e35cb981ce6074a500d7cdbe6f153753155f5b6772', + }), + Object.freeze({ + path: 'rabbit-runtime-ui-bundle-2026-07-30-v1/SHA256SUMS.txt', + bytes: 195, + sha256: 'a3d5f92a30549a12c50dba3e6ba81474e2bc46dddff5a78d8ab3062a06a3d387', + }), +]); + +export const INNER_KEEP_RABBIT_NESTED_MEMBERS = Object.freeze([ + 'Warpkeep_Rabbit_Runtime_UI_Bundle/', + 'Warpkeep_Rabbit_Runtime_UI_Bundle/Warpkeep_Rabbit_UI_Transparent_2048.png', + 'Warpkeep_Rabbit_Runtime_UI_Bundle/Runtime/', + 'Warpkeep_Rabbit_Runtime_UI_Bundle/Runtime/Environment/', + 'Warpkeep_Rabbit_Runtime_UI_Bundle/Runtime/Environment/Wildlife/', + 'Warpkeep_Rabbit_Runtime_UI_Bundle/Runtime/Environment/Wildlife/Rabbit/', + 'Warpkeep_Rabbit_Runtime_UI_Bundle/Runtime/Environment/Wildlife/Rabbit/Warpkeep_Rabbit_LOD1_Balanced_Rigged_Runtime.glb', + 'Warpkeep_Rabbit_Runtime_UI_Bundle/Runtime/Environment/Wildlife/Rabbit/Warpkeep_Rabbit_LOD2_Compact_Static_Runtime.glb', + 'Warpkeep_Rabbit_Runtime_UI_Bundle/Runtime/Environment/Wildlife/Rabbit/Warpkeep_Rabbit_LOD0_High_Rigged_Runtime.glb', + 'Warpkeep_Rabbit_Runtime_UI_Bundle/Runtime/Environment/Wildlife/Rabbit/runtime-manifest.json', + 'Warpkeep_Rabbit_Runtime_UI_Bundle/README.md', + 'Warpkeep_Rabbit_Runtime_UI_Bundle/bundle-manifest.json', + 'Warpkeep_Rabbit_Runtime_UI_Bundle/SHA256SUMS.txt', +]); + +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 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; +} + +function exactArray(actual, expected) { + return Array.isArray(actual) + && actual.length === expected.length + && actual.every((entry, index) => entry === expected[index]); +} + +function exactFileRecord(actual, expected) { + return actual?.path === expected.path + && actual.bytes === expected.bytes + && actual.sha256 === expected.sha256; +} + +function safePath(path) { + return typeof path === 'string' + && path.length > 0 + && !path.startsWith('/') + && !path.includes('\\') + && !path.includes('\0') + && path === path.normalize('NFC') + && path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'); +} + +export function innerKeepRabbitSha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +export function calculateInnerKeepRabbitSelectionDigest(record) { + return innerKeepRabbitSha256(Buffer.from(JSON.stringify(canonicalize({ + selectionId: record?.selectionId, + source: record?.source, + runtimePolicy: record?.runtimePolicy, + models: record?.models, + })), 'utf8')); +} + +export function assertInnerKeepRabbitSelectionRecord(record) { + const label = 'Inner Keep rabbit selection record'; + const source = record?.source; + if ( + record?.schema !== 'warpkeep.inner-keep-rabbit-runtime-selection.v1' + || record.selectionId !== 'inner-keep-lowlands-rabbit-v1' + || record.recordedAt !== '2026-08-04' + || record.selectionDigestAlgorithm !== 'sha256-ecmascript-canonical-json-v1' + || record.selectionDigestSha256 !== INNER_KEEP_RABBIT_SELECTION_DIGEST + || record.authorization?.archiveDistributionAuthorized !== true + || record.authorization?.officialRepositoryRuntimeUseAuthorized !== true + || record.authorization?.status !== 'authorized-owner-runtime-use' + || record.authorization?.recordedAt !== '2026-08-04' + || record.authorization?.instruction !== EXPECTED_AUTHORIZATION_INSTRUCTION + || record.authorization?.integrationDecision !== EXPECTED_INTEGRATION_DECISION + || record.authorization?.scopeBoundary !== EXPECTED_AUTHORIZATION_SCOPE + || record.licenseBoundary?.sourceStatus + !== 'public-archive-authorized-no-separate-open-license' + || record.licenseBoundary?.integrationGrant + !== 'owner-authorized-official-warpkeep-runtime-use-only' + || record.licenseBoundary?.redistributionOrRelicensingGranted !== false + || record.runtimePolicy?.highQualityTier !== 'LOD1_Balanced' + || record.runtimePolicy?.balancedQualityTier !== 'LOD1_Balanced' + || record.runtimePolicy?.reducedQualityTier !== 'LOD2_Compact' + || record.runtimePolicy?.optionalHighDetailTier !== 'LOD0_High' + || !exactArray(record.runtimePolicy?.animatedClips, EXPECTED_CLIPS) + || record.runtimePolicy?.ordinaryBuildMayReadArchive !== false + || record.runtimePolicy?.ordinaryBuildMayUseNetwork !== false + || record.runtimePolicy?.clientPresentationOnly !== true + || record.runtimePolicy?.gameplayAuthorityClaimed !== false + || record.runtimePolicy?.renderMeshCollision !== false + || source?.repository !== 'ael-dev3/Warpkeep-Assets' + || source.releaseCommit !== 'd8c35bb01c399ecde711274ef43880d8d304ae44' + || source.tag !== 'rabbit-runtime-ui-bundle-2026-07-30' + || source.trustedReleaseManifest?.repositoryPath + !== 'releases/rabbit-runtime-ui-bundle-2026-07-30/manifest.json' + || source.trustedReleaseManifest?.bytes !== 3_300 + || source.trustedReleaseManifest?.sha256 + !== 'c1009554053a793da979a8a1aae7558aade09bfbefcc53fcf2dae7a98a80a705' + || source.attachment?.name !== 'rabbit-runtime-ui-bundle-2026-07-30-v1.zip' + || source.attachment?.bytes !== 2_717_585 + || source.attachment?.sha256 + !== 'cf40e6c7149635a8cf6439e618e951219770491a7f364e7776b8af128461a3a9' + || source.attachment?.entries !== 4 + || source.attachment?.packageRoot !== 'rabbit-runtime-ui-bundle-2026-07-30-v1' + || !Array.isArray(source.outerEntries) + || source.outerEntries.length !== EXPECTED_OUTER_ENTRIES.length + || source.outerEntries.some((entry, index) => ( + !exactFileRecord(entry, EXPECTED_OUTER_ENTRIES[index]) + )) + || source.nestedPackage?.path !== EXPECTED_OUTER_ENTRIES[1].path + || source.nestedPackage?.bytes !== EXPECTED_OUTER_ENTRIES[1].bytes + || source.nestedPackage?.sha256 !== EXPECTED_OUTER_ENTRIES[1].sha256 + || source.nestedPackage?.entries !== 13 + || source.nestedPackage?.payloadEntries !== 8 + || source.nestedPackage?.packageRoot !== 'Warpkeep_Rabbit_Runtime_UI_Bundle' + || source.nestedPackage?.privacySanitized !== true + || source.nestedPackage?.decodedPixelsPreserved !== true + || source.runtimeManifest?.path + !== 'Warpkeep_Rabbit_Runtime_UI_Bundle/Runtime/Environment/Wildlife/Rabbit/runtime-manifest.json' + || source.runtimeManifest?.bytes !== 5_453 + || source.runtimeManifest?.sha256 + !== '89e8ed79f1672a98d5bb6cab6c0741f66ba97ca63a7e25758f052375afb3e005' + || source.bundleManifest?.path + !== 'Warpkeep_Rabbit_Runtime_UI_Bundle/bundle-manifest.json' + || source.bundleManifest?.bytes !== 2_161 + || source.bundleManifest?.sha256 + !== '35a98b5a81a824634030635da9a57494a47543cf414d760d0884e560255fcd87' + || source.excludedUiImage?.path + !== 'Warpkeep_Rabbit_Runtime_UI_Bundle/Warpkeep_Rabbit_UI_Transparent_2048.png' + || source.excludedUiImage?.bytes !== 2_468_019 + || source.excludedUiImage?.sha256 + !== '572e67902dd6db561bcce494ac5049463baf939494f74af3a0b1f2459f3b2c3e' + || !Array.isArray(record.models) + || record.models.length !== 3 + ) fail(label, 'source, authorization, license, or runtime policy changed.'); + + const profiles = ['high', 'balanced', 'compact']; + const destinations = new Set(); + record.models.forEach((model, index) => { + const profile = profiles[index]; + const expected = EXPECTED_MODELS[profile]; + const expectedAnimations = expected.mode === 'animated' ? EXPECTED_CLIPS : []; + if ( + model?.profile !== profile + || model.tier !== expected.tier + || model.mode !== expected.mode + || model.bytes !== expected.bytes + || model.sha256 !== expected.sha256 + || model.triangles !== expected.triangles + || model.uploadedVertices !== expected.uploadedVertices + || model.drawCalls !== 1 + || !exactArray(model.animations, expectedAnimations) + || !safePath(model.sourcePath) + || dirname(model.sourcePath) !== dirname(source.runtimeManifest.path) + || model.destinationPath !== `${INNER_KEEP_RABBIT_RUNTIME_DIRECTORY}/` + + `inner-keep-lowlands-rabbit-${profile}-${expected.sha256.slice(0, 16)}.glb` + || !safePath(model.destinationPath) + || destinations.has(model.destinationPath) + || !Array.isArray(model.boundsMeters) + || model.boundsMeters.length !== 3 + || model.boundsMeters.some((value) => !(Number.isFinite(value) && value > 0)) + ) fail(label, `${profile} model identity changed.`); + destinations.add(model.destinationPath); + }); + if ( + record.counts?.selectedModels !== 3 + || record.counts?.selectedBytes !== 230_536 + || record.counts?.selectedTriangles !== 1_222 + || record.counts?.animatedModels !== 2 + || record.counts?.staticModels !== 1 + ) fail(label, 'selection totals changed.'); + const digest = calculateInnerKeepRabbitSelectionDigest(record); + if (digest !== INNER_KEEP_RABBIT_SELECTION_DIGEST) { + fail(label, `selection digest changed to ${digest}.`); + } +} + +function readSelection() { + const record = JSON.parse(readFileSync( + resolve(ROOT, INNER_KEEP_RABBIT_SELECTION_RECORD), + 'utf8' + )); + assertInnerKeepRabbitSelectionRecord(record); + return deeplyFreeze(record); +} + +export const INNER_KEEP_RABBIT_SELECTION = readSelection(); +export const INNER_KEEP_RABBIT_MODELS = INNER_KEEP_RABBIT_SELECTION.models; +export const INNER_KEEP_RABBIT_RUNTIME_PATHS = Object.freeze( + INNER_KEEP_RABBIT_MODELS.map((model) => model.destinationPath).sort() +); + +export function assertInnerKeepRabbitRuntimeUseAuthorized( + record = INNER_KEEP_RABBIT_SELECTION +) { + if ( + record.authorization?.officialRepositoryRuntimeUseAuthorized !== true + || record.authorization?.status !== 'authorized-owner-runtime-use' + || record.licenseBoundary?.sourceStatus + !== 'public-archive-authorized-no-separate-open-license' + || record.licenseBoundary?.integrationGrant + !== 'owner-authorized-official-warpkeep-runtime-use-only' + || record.licenseBoundary?.redistributionOrRelicensingGranted !== false + || record.runtimePolicy?.clientPresentationOnly !== true + || record.runtimePolicy?.gameplayAuthorityClaimed !== false + ) fail('Inner Keep rabbit runtime installation', 'owner authorization is not recorded.'); +} + +function exactJson(bytes, expected, label) { + if ( + !Buffer.isBuffer(bytes) + || bytes.byteLength !== expected.bytes + || innerKeepRabbitSha256(bytes) !== expected.sha256 + ) fail(label, 'does not match its exact pinned bytes.'); + try { + return JSON.parse(bytes.toString('utf8')); + } catch { + fail(label, 'is not valid JSON.'); + } +} + +export function assertTrustedInnerKeepRabbitReleaseManifest(bytes) { + const source = INNER_KEEP_RABBIT_SELECTION.source; + const manifest = exactJson(bytes, source.trustedReleaseManifest, 'Rabbit trusted release manifest'); + const attachment = manifest.attachments?.[0]; + if ( + manifest.schemaVersion !== 1 + || manifest.repository !== 'ael-dev3/Warpkeep-Assets' + || manifest.tag !== source.tag + || manifest.designation?.status + !== 'visual-only Environment/Wildlife runtime/UI archive; current in-game integration not asserted' + || manifest.license?.status !== 'public-archive-authorized-no-separate-open-license' + || manifest.license?.spdx !== null + || manifest.attachments?.length !== 1 + || attachment?.name !== source.attachment.name + || attachment?.bytes !== source.attachment.bytes + || attachment?.sha256 !== source.attachment.sha256 + || attachment?.packageRoot !== source.attachment.packageRoot + || !Array.isArray(attachment.entries) + || attachment.entries.length !== EXPECTED_OUTER_ENTRIES.length + || attachment.entries.some((entry, index) => ( + !exactFileRecord(entry, EXPECTED_OUTER_ENTRIES[index]) + )) + ) fail('Rabbit trusted release manifest', 'release identity or license boundary changed.'); + return manifest; +} + +export function assertInnerKeepRabbitOuterManifest(bytes) { + const expected = EXPECTED_OUTER_ENTRIES[2]; + const manifest = exactJson(bytes, expected, 'Rabbit outer package manifest'); + const source = INNER_KEEP_RABBIT_SELECTION.source; + if ( + manifest.packageId !== 'warpkeep.environment.wildlife.rabbit.runtime-ui' + || manifest.packageVersion !== '1.0.0' + || manifest.designation?.assetId !== 'warpkeep.environment.wildlife.rabbit' + || manifest.designation?.visualOnly !== true + || manifest.designation?.gameplayAuthority !== false + || manifest.designation?.currentInGameIntegration !== 'not asserted' + || manifest.licenseBoundary?.publicArchiveAuthorization !== 'authorized by Ael' + || manifest.licenseBoundary?.separateOpenLicense !== 'not asserted' + || manifest.sourceAttachment?.path !== source.nestedPackage.path.split('/').at(-1) + || manifest.sourceAttachment?.bytes !== source.nestedPackage.bytes + || manifest.sourceAttachment?.sha256 !== source.nestedPackage.sha256 + || manifest.sourceAttachment?.privacySanitized !== true + || manifest.sourceEntryCount !== 13 + || manifest.sourcePayloadEntryCount !== 8 + || !Array.isArray(manifest.sourceEntries) + || manifest.sourceEntries.length !== 8 + ) fail('Rabbit outer package manifest', 'nested package identity changed.'); + return manifest; +} + +export function assertInnerKeepRabbitRuntimeManifest(bytes) { + const expected = INNER_KEEP_RABBIT_SELECTION.source.runtimeManifest; + const manifest = exactJson(bytes, expected, 'Rabbit runtime manifest'); + if ( + manifest.schema !== 'warpkeep.runtime-asset.v1' + || manifest.assetId !== 'warpkeep.environment.wildlife.rabbit' + || manifest.visualOnly !== true + || manifest.gameplayAuthority !== false + || manifest.collision?.renderMeshCollision !== false + || manifest.runtimeContract?.externalDependencies !== 0 + || manifest.runtimeContract?.materialsPerLod !== 1 + || !exactArray(manifest.runtimeContract?.clips?.map((clip) => clip.name).sort(), EXPECTED_CLIPS) + || !Array.isArray(manifest.lods) + || manifest.lods.length !== 3 + ) fail('Rabbit runtime manifest', 'visual, animation, or collision contract changed.'); + return manifest; +} + +export function assertInnerKeepRabbitBundleManifest(bytes) { + const expected = INNER_KEEP_RABBIT_SELECTION.source.bundleManifest; + const manifest = exactJson(bytes, expected, 'Rabbit nested bundle manifest'); + if ( + manifest.schema !== 'warpkeep.runtime-ui-bundle.v1' + || manifest.assetId !== 'warpkeep.environment.wildlife.rabbit' + || manifest.privacySanitization?.pixelsPreserved !== true + || manifest.privacySanitization?.removedPrivateLocalPath !== true + || manifest.runtimeFiles?.length !== 4 + || !exactArray([...manifest.runtimeSummary.clips].sort(), EXPECTED_CLIPS) + || manifest.runtimeSummary?.externalTextures !== 0 + ) fail('Rabbit nested bundle manifest', 'runtime or privacy contract changed.'); + return manifest; +} + +export function readInnerKeepRabbitGlbJson(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 verifyInnerKeepRabbitGlb(bytes, model, label) { + if ( + !model + || !Buffer.isBuffer(bytes) + || bytes.byteLength !== model.bytes + || innerKeepRabbitSha256(bytes) !== model.sha256 + ) fail(label, 'does not match its exact selected bytes.'); + const gltf = readInnerKeepRabbitGlbJson(bytes, label); + const external = [...(gltf.buffers ?? []), ...(gltf.images ?? [])] + .some((entry) => typeof entry?.uri === 'string'); + const primitives = (gltf.meshes ?? []).flatMap((mesh) => mesh.primitives ?? []); + const triangles = primitives.reduce((total, primitive) => { + if ((primitive.mode ?? 4) !== 4) return Number.NaN; + const count = gltf.accessors?.[primitive.indices]?.count + ?? gltf.accessors?.[primitive.attributes?.POSITION]?.count; + return total + (Number.isSafeInteger(count) ? count / 3 : Number.NaN); + }, 0); + const uploadedVertices = primitives.reduce((total, primitive) => ( + total + (gltf.accessors?.[primitive.attributes?.POSITION]?.count ?? Number.NaN) + ), 0); + const animationNames = (gltf.animations ?? []).map((entry) => entry.name).sort(); + const expectedAnimations = [...model.animations].sort(); + const rigged = model.mode === 'animated'; + if ( + gltf.asset?.version !== '2.0' + || !String(gltf.asset?.copyright ?? '').includes('Warpkeep') + || primitives.length !== model.drawCalls + || gltf.materials?.length !== 1 + || external + || triangles !== model.triangles + || uploadedVertices !== model.uploadedVertices + || !exactArray(animationNames, expectedAnimations) + || (rigged && gltf.skins?.length !== 1) + || (!rigged && (gltf.skins?.length ?? 0) !== 0) + || (rigged && primitives.some((primitive) => ( + primitive.attributes?.JOINTS_0 === undefined + || primitive.attributes?.WEIGHTS_0 === undefined + ))) + ) fail(label, 'does not satisfy the selected embedded mesh, rig, and animation contract.'); + return gltf; +} 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..91c20af1 --- /dev/null +++ b/scripts/inner-keep-runtime-asset-contract.d.mts @@ -0,0 +1,38 @@ +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_AUTHORIZATION_INSTRUCTION: string; +export const INNER_KEEP_ASSET_AUTHORIZATION_SCOPE_BOUNDARY: string; +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..aa986951 --- /dev/null +++ b/scripts/inner-keep-runtime-asset-contract.mjs @@ -0,0 +1,549 @@ +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 = + '00304c5dbf819cec6cb656996c1105f64efcf36acf8099c431f5b04b822679f0'; +export const INNER_KEEP_ASSET_PROFILES = Object.freeze(['high', 'balanced', 'compact']); +export const INNER_KEEP_ASSET_AUTHORIZATION_INSTRUCTION = + 'Use Warpkeep-Assets 3D objects to populate the official Inner Keep runtime, with the Grand Covenant Cathedral as the main building.'; +export const INNER_KEEP_ASSET_AUTHORIZATION_SCOPE_BOUNDARY = + 'Runtime use in the public ael-dev3/Warpkeep repository and official warpkeep.com client is authorized for the exact selected files. This does not relicense the source archives, authorize unrelated reuse, approve activation, approve merge, or approve deployment.'; + +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, + landmarks: 2, + 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' || asset.family === 'landmarks') { + 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 buildable buildings and permanent landmarks 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-04' + || record.selectionDigestSha256 !== INNER_KEEP_ASSET_SELECTION_DIGEST + || record.selectionDigestAlgorithm !== 'sha256-ecmascript-canonical-json-v1' + || release?.repository !== 'ael-dev3/Warpkeep-Assets' + || release?.repositoryMainCommit !== '10c84fbcc339f143ee6f25dfe7a0682660e0e458' + || 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 !== true + || record.authorization?.status !== 'authorized-owner-runtime-use' + || record.authorization?.recordedAt !== '2026-08-04' + || record.authorization?.instruction !== INNER_KEEP_ASSET_AUTHORIZATION_INSTRUCTION + || record.authorization?.scopeBoundary !== INNER_KEEP_ASSET_AUTHORIZATION_SCOPE_BOUNDARY + ) fail(label, 'must preserve the exact owner runtime-use authorization and its limits.'); + if ( + !exactArray(record.selectionPolicy?.profiles, INNER_KEEP_ASSET_PROFILES) + || record.selectionPolicy?.sourceAssetCount !== 38 + || record.selectionPolicy?.sourceModelCount !== 114 + || record.selectionPolicy?.sourcePreviewCount !== 6 + || !exactArray(record.selectionPolicy?.excludedProfiles, ['LOD3_Map']) + || !exactArray(record.selectionPolicy?.excludedPackages, []) + || record.selectionPolicy?.inspectionCataloguesAllowed !== false + || record.selectionPolicy?.editableSourcesAllowed !== false + || record.selectionPolicy?.ordinaryBuildMayReadArchive !== false + || record.selectionPolicy?.ordinaryBuildMayUseNetwork !== false + || !Array.isArray(record.assets) + || record.assets.length !== 38 + ) 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.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 map or inspection model 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-population-assets.d.mts b/scripts/install-inner-keep-population-assets.d.mts new file mode 100644 index 00000000..4313f3a7 --- /dev/null +++ b/scripts/install-inner-keep-population-assets.d.mts @@ -0,0 +1,12 @@ +export function parseInnerKeepPopulationPreparationMode( + argumentsList: readonly string[] +): '--audit-only' | '--install'; +export function prepareInnerKeepPopulationAssets(options?: Readonly<{ + argumentsList?: readonly string[]; + cacheRoot?: string; +}>): Readonly<{ + mode: '--audit-only' | '--install'; + files: number; + installed: boolean; +}>; +export function main(argumentsList?: readonly string[]): void; diff --git a/scripts/install-inner-keep-population-assets.mjs b/scripts/install-inner-keep-population-assets.mjs new file mode 100644 index 00000000..0d598812 --- /dev/null +++ b/scripts/install-inner-keep-population-assets.mjs @@ -0,0 +1,170 @@ +import { spawnSync } from 'node:child_process'; +import { + constants, + closeSync, + fstatSync, + lstatSync, + openSync, + readFileSync +} from 'node:fs'; +import { dirname, relative, resolve, sep } from 'node:path'; + +import { + ensureContainedDirectory, + installAtomicFileFamily +} from './atomic-install-file-family.mjs'; +import { + INNER_KEEP_POPULATION_MODELS, + INNER_KEEP_POPULATION_SELECTION, + assertInnerKeepPopulationRuntimeUseAuthorized, + innerKeepPopulationSha256, + verifyInnerKeepPopulationGlb +} from './inner-keep-population-runtime-contract.mjs'; +import { resolveAttestedSystemUnzip } from './system-unzip.mjs'; + +const ROOT = resolve(import.meta.dirname, '..'); +const DEFAULT_CACHE_ROOT = resolve(ROOT, '.cache/warpkeep-assets'); + +function fail(detail) { + throw new Error(`Inner Keep population asset preparation: ${detail}`); +} + +function archiveRecordByName() { + return new Map(INNER_KEEP_POPULATION_SELECTION.sources.flatMap((source) => ( + source.attachments.map((attachment) => [attachment.name, { + ...attachment, + tag: source.tag + }]) + ))); +} + +function exactOrdinaryFile(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 file of ${expected.bytes} bytes.`); + } + const bytes = readFileSync(descriptor); + const after = fstatSync(descriptor); + const status = lstatSync(path, { throwIfNoEntry: false }); + if ( + !status?.isFile() + || status.isSymbolicLink() + || before.dev !== after.dev + || before.ino !== after.ino + || before.size !== after.size + || status.dev !== after.dev + || status.ino !== after.ino + || status.size !== after.size + || innerKeepPopulationSha256(bytes) !== expected.sha256 + ) fail(`${label} changed while being read or does not match its SHA-256.`); + return bytes; + } catch (error) { + if (error instanceof Error && error.message.startsWith('Inner Keep population asset')) { + throw error; + } + fail(`${label} cannot be read safely (${error instanceof Error ? error.message : String(error)}).`); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +function readArchiveMember(unzipBinary, archivePath, sourcePath) { + const result = spawnSync(unzipBinary, ['-p', archivePath, sourcePath], { + cwd: ROOT, + encoding: null, + maxBuffer: 8 * 1024 * 1024 + }); + if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout)) { + fail(`cannot read ${sourcePath} from ${archivePath}.`); + } + return result.stdout; +} + +function archivePath(root, record) { + return resolve(root, record.tag, record.name); +} + +export function parseInnerKeepPopulationPreparationMode(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 prepareInnerKeepPopulationAssets(options = {}) { + const mode = parseInnerKeepPopulationPreparationMode( + options.argumentsList ?? process.argv.slice(2) + ); + assertInnerKeepPopulationRuntimeUseAuthorized(); + const cacheRoot = resolve( + options.cacheRoot + ?? process.env.WARPKEEP_INNER_KEEP_POPULATION_ARCHIVE_ROOT + ?? DEFAULT_CACHE_ROOT + ); + const archives = archiveRecordByName(); + const archiveBytesByName = new Map(); + for (const record of archives.values()) { + const path = archivePath(cacheRoot, record); + const bytes = exactOrdinaryFile(path, record, `${record.tag}/${record.name}`); + archiveBytesByName.set(record.name, { bytes, path }); + } + const unzipBinary = resolveAttestedSystemUnzip(); + const prepared = INNER_KEEP_POPULATION_MODELS.map((model) => { + const archive = archiveBytesByName.get(model.sourceArchive); + if (!archive) fail(`no exact archive is available for ${model.actorId}.`); + const bytes = readArchiveMember(unzipBinary, archive.path, model.sourcePath); + verifyInnerKeepPopulationGlb(bytes, model, model.destinationPath); + return { + bytes, + destinationPath: model.destinationPath, + label: `${model.actorId} ${model.profile}` + }; + }); + for (const archive of archiveBytesByName.values()) archive.bytes.fill(0); + if (mode === '--audit-only') { + return Object.freeze({ mode, files: prepared.length, installed: false }); + } + const directories = [...new Set(prepared.map((entry) => dirname(entry.destinationPath)))]; + for (const directory of directories) { + ensureContainedDirectory({ + root: ROOT, + relativePath: directory, + label: 'Inner Keep population destination' + }); + } + 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 + }; + }) + }); + return Object.freeze({ mode, files: prepared.length, installed: true }); +} + +export function main(argumentsList = process.argv.slice(2)) { + const result = prepareInnerKeepPopulationAssets({ argumentsList }); + console.log( + `${result.installed ? 'Installed' : 'Audited'} ${result.files} exact Inner Keep population GLBs.` + ); + return result; +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) main(); diff --git a/scripts/install-inner-keep-rabbit-assets.mjs b/scripts/install-inner-keep-rabbit-assets.mjs new file mode 100644 index 00000000..068753f4 --- /dev/null +++ b/scripts/install-inner-keep-rabbit-assets.mjs @@ -0,0 +1,307 @@ +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 { dirname, relative, resolve, sep } from 'node:path'; + +import { + ensureContainedDirectory, + installAtomicFileFamily, +} from './atomic-install-file-family.mjs'; +import { createAssetToolEnvironment } from './asset-tool-process.mjs'; +import { + INNER_KEEP_RABBIT_MODELS, + INNER_KEEP_RABBIT_NESTED_MEMBERS, + INNER_KEEP_RABBIT_SELECTION, + assertInnerKeepRabbitBundleManifest, + assertInnerKeepRabbitOuterManifest, + assertInnerKeepRabbitRuntimeManifest, + assertInnerKeepRabbitRuntimeUseAuthorized, + assertTrustedInnerKeepRabbitReleaseManifest, + innerKeepRabbitSha256, + verifyInnerKeepRabbitGlb, +} from './inner-keep-rabbit-runtime-contract.mjs'; +import { resolveAttestedSystemUnzip } from './system-unzip.mjs'; + +const ROOT = resolve(import.meta.dirname, '..'); +const RELEASE = INNER_KEEP_RABBIT_SELECTION.source; +const DEFAULT_CACHE_ROOT = resolve(ROOT, '.cache/warpkeep-assets', RELEASE.tag); + +function fail(detail) { + throw new Error(`Inner Keep rabbit 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 file of ${expected.bytes} bytes.`); + } + const bytes = readFileSync(descriptor); + const after = fstatSync(descriptor); + const status = lstatSync(path, { throwIfNoEntry: false }); + if ( + !status?.isFile() + || status.isSymbolicLink() + || before.dev !== after.dev + || before.ino !== after.ino + || before.size !== after.size + || status.dev !== after.dev + || status.ino !== after.ino + || status.size !== after.size + || bytes.byteLength !== after.size + || innerKeepRabbitSha256(bytes) !== expected.sha256 + ) fail(`${label} changed while being read or does not match its SHA-256.`); + return bytes; + } catch (error) { + if (error instanceof Error && error.message.startsWith('Inner Keep rabbit asset')) { + throw error; + } + fail(`${label} cannot be read safely (${error instanceof Error ? error.message : String(error)}).`); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +function runUnzip( + unzipBinary, + archive, + argumentsList, + workspace, + encoding = 'utf8', + maxBuffer = 8 * 1024 * 1024, +) { + const result = spawnSync(unzipBinary, [...argumentsList, archive], { + cwd: workspace, + env: createAssetToolEnvironment(workspace), + encoding, + maxBuffer, + }); + if (result.error || result.status !== 0) { + fail(`cannot inspect ${archive} with the attested unzip binary.`); + } + return result.stdout; +} + +function readArchiveMember( + unzipBinary, + archive, + member, + workspace, + maxBuffer = 8 * 1024 * 1024, +) { + const result = spawnSync(unzipBinary, ['-p', archive, member], { + cwd: workspace, + env: createAssetToolEnvironment(workspace), + encoding: null, + maxBuffer, + }); + if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout)) { + fail(`cannot read ${member} from ${archive}.`); + } + return result.stdout; +} + +function assertExactMembers( + unzipBinary, + archive, + expectedMembers, + workspace, + directoryMembers = new Set(), +) { + const observed = String(runUnzip(unzipBinary, archive, ['-Z1'], workspace)) + .split(/\r?\n/u) + .filter(Boolean); + const expected = [...expectedMembers]; + if ( + observed.length !== expected.length + || [...observed].sort().some((member, index) => member !== [...expected].sort()[index]) + || observed.some((member) => ( + member.startsWith('/') + || member.includes('\\') + || member.includes('\0') + || member !== member.normalize('NFC') + || member.split('/').filter(Boolean).some((segment) => segment === '.' || segment === '..') + )) + ) fail(`${archive} member set changed or contains an unsafe path.`); + + const detailLines = String(runUnzip(unzipBinary, archive, ['-Z', '-l'], workspace)) + .split(/\r?\n/u) + .filter((line) => /^[bcdlps-][rwxStT-]{9}\s/u.test(line)); + if ( + detailLines.length !== expected.length + || detailLines.some((line, index) => ( + directoryMembers.has(observed[index]) ? !line.startsWith('d') : !line.startsWith('-') + )) + ) fail(`${archive} contains an unexpected symbolic or special member.`); +} + +function exactMember(bytes, expected, label) { + if ( + !Buffer.isBuffer(bytes) + || bytes.byteLength !== expected.bytes + || innerKeepRabbitSha256(bytes) !== expected.sha256 + ) fail(`${label} does not match its pinned archive entry.`); + return bytes; +} + +export function parseInnerKeepRabbitPreparationMode(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 prepareInnerKeepRabbitAssets(options = {}) { + const mode = parseInnerKeepRabbitPreparationMode( + options.argumentsList ?? process.argv.slice(2), + ); + assertInnerKeepRabbitRuntimeUseAuthorized(); + const archivePath = options.archivePath ?? resolveInputPath( + 'WARPKEEP_INNER_KEEP_RABBIT_ARCHIVE', + resolve(DEFAULT_CACHE_ROOT, RELEASE.attachment.name), + ); + const releaseManifestPath = options.releaseManifestPath ?? resolveInputPath( + 'WARPKEEP_INNER_KEEP_RABBIT_RELEASE_MANIFEST', + resolve(DEFAULT_CACHE_ROOT, 'manifest.json'), + ); + const releaseManifestBytes = readExactOrdinaryFile( + releaseManifestPath, + RELEASE.trustedReleaseManifest, + 'trusted Warpkeep-Assets rabbit release manifest', + ); + assertTrustedInnerKeepRabbitReleaseManifest(releaseManifestBytes); + const archiveBytes = readExactOrdinaryFile( + archivePath, + RELEASE.attachment, + `${RELEASE.tag}/${RELEASE.attachment.name}`, + ); + const workspace = mkdtempSync(resolve(tmpdir(), 'warpkeep-inner-keep-rabbit-')); + const verifiedOuterArchive = resolve(workspace, RELEASE.attachment.name); + const verifiedNestedArchive = resolve(workspace, 'Warpkeep_Rabbit_Runtime_UI_Bundle_2026-07-30.zip'); + try { + writeFileSync(verifiedOuterArchive, archiveBytes, { flag: 'wx', mode: 0o600 }); + archiveBytes.fill(0); + const unzipBinary = resolveAttestedSystemUnzip(); + const outerMembers = RELEASE.outerEntries.map((entry) => entry.path); + assertExactMembers(unzipBinary, verifiedOuterArchive, outerMembers, workspace); + const outerByPath = new Map(RELEASE.outerEntries.map((entry) => [entry.path, entry])); + for (const member of outerMembers) { + const bytes = exactMember( + readArchiveMember(unzipBinary, verifiedOuterArchive, member, workspace), + outerByPath.get(member), + member, + ); + if (member.endsWith('/manifest.json')) assertInnerKeepRabbitOuterManifest(bytes); + if (member === RELEASE.nestedPackage.path) { + writeFileSync(verifiedNestedArchive, bytes, { flag: 'wx', mode: 0o600 }); + } + } + + const nestedDirectories = new Set( + INNER_KEEP_RABBIT_NESTED_MEMBERS.filter((member) => member.endsWith('/')), + ); + assertExactMembers( + unzipBinary, + verifiedNestedArchive, + INNER_KEEP_RABBIT_NESTED_MEMBERS, + workspace, + nestedDirectories, + ); + const runtimeManifestBytes = exactMember( + readArchiveMember( + unzipBinary, + verifiedNestedArchive, + RELEASE.runtimeManifest.path, + workspace, + ), + RELEASE.runtimeManifest, + 'Rabbit runtime manifest', + ); + assertInnerKeepRabbitRuntimeManifest(runtimeManifestBytes); + const bundleManifestBytes = exactMember( + readArchiveMember( + unzipBinary, + verifiedNestedArchive, + RELEASE.bundleManifest.path, + workspace, + ), + RELEASE.bundleManifest, + 'Rabbit nested bundle manifest', + ); + assertInnerKeepRabbitBundleManifest(bundleManifestBytes); + + const prepared = INNER_KEEP_RABBIT_MODELS.map((model) => { + const bytes = readArchiveMember( + unzipBinary, + verifiedNestedArchive, + model.sourcePath, + workspace, + ); + verifyInnerKeepRabbitGlb(bytes, model, `Lowlands Rabbit ${model.profile}`); + return { + bytes, + destinationPath: model.destinationPath, + label: `Lowlands Rabbit ${model.profile}`, + }; + }); + if (mode === '--audit-only') { + return Object.freeze({ mode, files: prepared.length, installed: false }); + } + for (const directory of new Set(prepared.map((entry) => dirname(entry.destinationPath)))) { + ensureContainedDirectory({ + root: ROOT, + relativePath: directory, + label: 'Inner Keep rabbit runtime destination', + }); + } + 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 }; + }), + }); + return Object.freeze({ mode, files: prepared.length, installed: true }); + } finally { + archiveBytes.fill(0); + rmSync(workspace, { force: true, recursive: true }); + } +} + +export function main(argumentsList = process.argv.slice(2)) { + const result = prepareInnerKeepRabbitAssets({ argumentsList }); + console.log( + `${result.installed ? 'Installed' : 'Audited'} ${result.files} exact Lowlands Rabbit GLBs; ` + + 'presentation-only, no gameplay authority.', + ); + return result; +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) main(); 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.d.mts b/scripts/qa-observer/inner-keep-browser-probe.d.mts new file mode 100644 index 00000000..e0ed8c3e --- /dev/null +++ b/scripts/qa-observer/inner-keep-browser-probe.d.mts @@ -0,0 +1,42 @@ +export type InnerKeepQaScreenshotViewport = Readonly<{ + width: number; + height: number; +}>; + +export function analyzeInnerKeepQaScreenshot( + value: string, + viewport: InnerKeepQaScreenshotViewport +): Readonly<{ + distinctColourBuckets: number; + luminanceRange: number; + averageSaturationBasisPoints: number; + saturationP95BasisPoints: number; + clippedBlackSamples: number; + clippedWhiteSamples: number; + coolHighAlbedoSamples: number; + coolSpatialBuckets: readonly number[]; + warmLowGreenSamples: number; + warmSpatialBuckets: readonly number[]; + hotYellowSamples: number; + opaqueSamples: number; + sampleCount: number; +}>; + +export function assertInnerKeepQaScreenshotWindow( + beforeValue: unknown, + afterValue: unknown, + expectedScenarioId: string, + phase?: 'steady' | 'reveal' | 'completed' +): Readonly<{ + activeConversationCount: number; + quality: 'high' | 'balanced' | 'reduced'; + scenario: string; +}>; + +export function runInnerKeepBrowserProbe(options?: Readonly<{ + onEvidence?: (value: Readonly<{ + scenario: string; + renderMode: 'webgl' | 'fallback'; + responsive: boolean; + }>) => void; +}>): Promise; 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..d542a45a --- /dev/null +++ b/scripts/qa-observer/inner-keep-browser-probe.mjs @@ -0,0 +1,622 @@ +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 { analyzeRenderedWebglPngScreenshot } from './png-visual-aggregate.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_MAXIMUM_BYTES = 8 * 1_024 * 1_024; +const SCREENSHOT_CASES = new Set([ + 'empty', + 'high-quality', + 'active-conversation', + '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 boolean = (name) => { + const value = root.getAttribute(name); + return value === 'true' ? true : value === 'false' ? false : null; + }; + 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: 2, + 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: boolean('data-inner-keep-qa-reduced-motion'), + status: root.getAttribute('data-inner-keep-qa-status'), + assetStatus: root.getAttribute('data-inner-keep-qa-asset-status'), + progressBasisPoints, + canvasCount: document.querySelectorAll('canvas[data-inner-keep-qa-canvas]').length, + rendererCount: integer('data-inner-keep-qa-renderer-count'), + rendererDrawCalls: integer('data-inner-keep-qa-renderer-draw-calls'), + rendererTriangles: integer('data-inner-keep-qa-renderer-triangles'), + sceneGraphDrawCalls: integer('data-inner-keep-qa-draw-calls'), + sceneGraphTriangles: integer('data-inner-keep-qa-triangle-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'), + grassBladeCount: integer('data-inner-keep-qa-grass-blade-count'), + waterSurfaceCount: integer('data-inner-keep-qa-water-surface-count'), + authoredAssetCount: integer('data-inner-keep-qa-authored-asset-count'), + authoredPlacementCount: integer('data-inner-keep-qa-authored-placement-count'), + authoredTreeCount: integer('data-inner-keep-qa-authored-tree-count'), + ambientActorCount: integer('data-inner-keep-qa-ambient-actor-count'), + animationFrameCap: integer('data-inner-keep-qa-animation-frame-cap'), + mountedActorCount: integer('data-inner-keep-qa-mounted-actor-count'), + patrolUnitCount: integer('data-inner-keep-qa-patrol-unit-count'), + activeConversationCount: + integer('data-inner-keep-qa-active-conversation-count'), + animationMixerCount: integer('data-inner-keep-qa-animation-mixer-count'), + runtimeAssetFailureCount: + integer('data-inner-keep-qa-runtime-asset-failure-count'), + outerWorldStatus: root.getAttribute('data-inner-keep-qa-outer-world-status'), + outerWorldRuntimeAssetFailureCount: + integer('data-inner-keep-qa-outer-world-runtime-asset-failure-count'), + topographicFeatureCount: + integer('data-inner-keep-qa-topographic-feature-count'), + terrainTriangleCount: integer('data-inner-keep-qa-terrain-triangle-count'), + terrainHeightRangeMillimeters: + integer('data-inner-keep-qa-terrain-height-range-millimeters'), + exteriorTreeCount: integer('data-inner-keep-qa-exterior-tree-count'), + scenicResourceNodeCount: + integer('data-inner-keep-qa-scenic-resource-node-count'), + wildlifeAssetStatus: + root.getAttribute('data-inner-keep-qa-wildlife-asset-status'), + wildlifeCount: integer('data-inner-keep-qa-wildlife-count'), + exactWildlifeCount: integer('data-inner-keep-qa-exact-wildlife-count'), + proceduralWildlifeCount: + integer('data-inner-keep-qa-procedural-wildlife-count'), + tradeWagonCount: integer('data-inner-keep-qa-trade-wagon-count'), + exteriorActorCount: integer('data-inner-keep-qa-exterior-actor-count'), + exteriorMountedActorCount: + integer('data-inner-keep-qa-exterior-mounted-actor-count'), + exteriorPatrolUnitCount: + integer('data-inner-keep-qa-exterior-patrol-unit-count'), + barracksPlacementPresent: + boolean('data-inner-keep-qa-barracks-placement-present'), + cathedralPlacementPresent: + boolean('data-inner-keep-qa-cathedral-placement-present'), + 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: boolean('data-inner-keep-qa-scaffold-present'), + completionRevealActive: + boolean('data-inner-keep-qa-completion-reveal-active'), + 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, + }); +} + +/** + * Reduces an in-memory screenshot to anonymous colour/luminance counts. Raw + * pixels are cleared immediately and are never written, logged, or returned. + */ +export function analyzeInnerKeepQaScreenshot(value, viewport) { + if ( + typeof value !== 'string' + || value.length < 64 + || value.length > Math.ceil(SCREENSHOT_MAXIMUM_BYTES * 4 / 3) + 4 + || !/^[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 { + return analyzeRenderedWebglPngScreenshot(bytes, viewport); + } 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.'); + } + // The bounded aggregate is intentionally discarded. Passing analysis is the + // proof; neither the encoded screenshot nor decoded pixels leave this call. + analyzeInnerKeepQaScreenshot(result.data, viewport); +} + +const HIGH_QUALITY_SCREENSHOT_FACTS = Object.freeze({ + ambientActorCount: 20, + animationMixerCount: 30, + assetStatus: 'ready', + authoredAssetCount: 38, + authoredPlacementCount: 76, + authoredTreeCount: 18, + barracksPlacementPresent: true, + cathedralPlacementPresent: true, + exactWildlifeCount: 10, + exteriorActorCount: 9, + exteriorMountedActorCount: 6, + exteriorPatrolUnitCount: 7, + exteriorTreeCount: 72, + grassBladeCount: 2_400, + mountedActorCount: 6, + outerWorldRuntimeAssetFailureCount: 0, + outerWorldStatus: 'ready', + patrolUnitCount: 12, + proceduralWildlifeCount: 0, + runtimeAssetFailureCount: 0, + scenicResourceNodeCount: 8, + topographicFeatureCount: 9, + tradeWagonCount: 1, + wildlifeAssetStatus: 'ready', + wildlifeCount: 10, + waterSurfaceCount: 2, +}); + +/** + * Binds a screenshot to exact aggregate evidence immediately before and after + * capture. This prevents a conversation-bound frame from being accepted after + * its bubble has disappeared and keeps the high-quality proof exact. + */ +export function assertInnerKeepQaScreenshotWindow( + beforeValue, + afterValue, + expectedScenarioId, + phase = 'steady', +) { + const before = assertInnerKeepQaScenarioEvidence( + beforeValue, + expectedScenarioId, + phase, + ); + const after = assertInnerKeepQaScenarioEvidence( + afterValue, + expectedScenarioId, + phase, + ); + if ( + expectedScenarioId === 'active-conversation' + && [before, after].some(({ activeConversationCount }) => ( + activeConversationCount !== 1 + )) + ) throw new Error('Inner Keep conversation screenshot window mismatched.'); + if ( + expectedScenarioId === 'high-quality' + && [before, after].some((evidence) => Object.entries( + HIGH_QUALITY_SCREENSHOT_FACTS, + ).some(([key, expected]) => evidence[key] !== expected)) + ) throw new Error('Inner Keep high-quality screenshot window mismatched.'); + return Object.freeze({ + activeConversationCount: after.activeConversationCount, + quality: after.quality, + scenario: after.scenario, + }); +} + +async function captureVerifiedScenarioScreenshot(session, probeCase, phase = 'steady') { + const before = await waitForEvidence(session, probeCase, phase); + await captureSyntheticScreenshot(session, probeCase.viewport); + const after = await readEvidence(session, probeCase); + assertInnerKeepQaScreenshotWindow(before, after, probeCase.id, phase); +} + +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 captureVerifiedScenarioScreenshot(session, probeCase, 'reveal'); + 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 captureVerifiedScenarioScreenshot(devtools, probeCase); + } + 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..d086b335 --- /dev/null +++ b/scripts/qa-observer/inner-keep-qa-contract.d.mts @@ -0,0 +1,113 @@ +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 const INNER_KEEP_QA_SCENE_GRAPH_RENDER_BUDGETS: Readonly +>>; + +export type InnerKeepQaEvidence = Readonly<{ + version: 2; + scenario: InnerKeepQaScenarioId; + renderMode: 'webgl' | 'fallback'; + innerKeepRenderer: 'webgl' | 'fallback'; + quality: 'high' | 'balanced' | 'reduced'; + reducedMotion: boolean; + status: 'ready'; + assetStatus: 'idle' | 'loading' | 'ready' | 'degraded'; + progressBasisPoints: number | null; + canvasCount: number; + rendererCount: number; + rendererDrawCalls: number; + rendererTriangles: number; + sceneGraphDrawCalls: number; + sceneGraphTriangles: number; + webglContextCount: number; + rafOwnerCount: number; + maximumPendingRafCount: number; + slotControlCount: number; + enabledSlotControlCount: number; + slotCount: number; + slotGeometryCount: number; + smokeSpriteCount: number; + grassBladeCount: number; + waterSurfaceCount: number; + authoredAssetCount: number; + authoredPlacementCount: number; + authoredTreeCount: number; + ambientActorCount: number; + animationFrameCap: number; + mountedActorCount: number; + patrolUnitCount: number; + activeConversationCount: number; + animationMixerCount: number; + runtimeAssetFailureCount: number; + outerWorldStatus: + | 'idle' + | 'loading' + | 'ready' + | 'fallback' + | 'partial' + | 'aborted' + | 'disposed'; + outerWorldRuntimeAssetFailureCount: number; + topographicFeatureCount: number; + terrainTriangleCount: number; + terrainHeightRangeMillimeters: number; + exteriorTreeCount: number; + scenicResourceNodeCount: number; + wildlifeAssetStatus: + | 'idle' + | 'disabled' + | 'loading' + | 'ready' + | 'failed' + | 'aborted' + | 'disposed'; + wildlifeCount: number; + exactWildlifeCount: number; + proceduralWildlifeCount: number; + tradeWagonCount: number; + exteriorActorCount: number; + exteriorMountedActorCount: number; + exteriorPatrolUnitCount: number; + barracksPlacementPresent: boolean; + cathedralPlacementPresent: boolean; + 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..ce9f66ff --- /dev/null +++ b/scripts/qa-observer/inner-keep-qa-contract.mjs @@ -0,0 +1,502 @@ +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([ + 'activeConversationCount', + 'ambientActorCount', + 'animationFrameCap', + 'animationMixerCount', + 'assetFallbackCount', + 'assetStatus', + 'authoredAssetCount', + 'authoredPlacementCount', + 'authoredTreeCount', + 'barracksPlacementPresent', + 'builderBusyVisible', + 'canvasCount', + 'cathedralPlacementPresent', + 'completedBuildingCount', + 'completionRevealActive', + 'constructionSiteCount', + 'documentHeight', + 'documentWidth', + 'enabledSlotControlCount', + 'exactWildlifeCount', + 'exteriorActorCount', + 'exteriorMountedActorCount', + 'exteriorPatrolUnitCount', + 'exteriorTreeCount', + 'finalModelCount', + 'grassBladeCount', + 'horizontalOverflow', + 'innerKeepRenderer', + 'insufficientResourcesVisible', + 'levelVisible', + 'maximumPendingRafCount', + 'mountedActorCount', + 'outerWorldRuntimeAssetFailureCount', + 'outerWorldStatus', + 'patrolUnitCount', + 'proceduralWildlifeCount', + 'progressBasisPoints', + 'quality', + 'rafOwnerCount', + 'reducedMotion', + 'renderMode', + 'rendererCount', + 'rendererDrawCalls', + 'rendererTriangles', + 'runtimeAssetFailureCount', + 'scaffoldPresent', + 'scenario', + 'sceneGraphDrawCalls', + 'sceneGraphTriangles', + 'scenicResourceNodeCount', + 'slotControlCount', + 'slotCount', + 'slotGeometryCount', + 'smokeSpriteCount', + 'status', + 'terrainHeightRangeMillimeters', + 'terrainTriangleCount', + 'topographicFeatureCount', + 'tradeWagonCount', + 'version', + 'verticalOverflow', + 'viewportHeight', + 'viewportWidth', + 'waterSurfaceCount', + 'webglContextCount', + 'wildlifeAssetStatus', + 'wildlifeCount', +]); + +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 !== 2 + || !INNER_KEEP_QA_SCENARIO_IDS.includes(candidate.scenario) + || !['webgl', 'fallback'].includes(candidate.renderMode) + || !['webgl', 'fallback'].includes(candidate.innerKeepRenderer) + || !['high', 'balanced', 'reduced'].includes(candidate.quality) + || !['idle', 'loading', 'ready', 'degraded'].includes(candidate.assetStatus) + || ![ + 'idle', + 'loading', + 'ready', + 'fallback', + 'partial', + 'aborted', + 'disposed', + ].includes(candidate.outerWorldStatus) + || ![ + 'idle', + 'disabled', + 'loading', + 'ready', + 'failed', + 'aborted', + 'disposed', + ].includes(candidate.wildlifeAssetStatus) + || candidate.status !== 'ready' + || typeof candidate.reducedMotion !== 'boolean' + || typeof candidate.barracksPlacementPresent !== 'boolean' + || typeof candidate.cathedralPlacementPresent !== '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.activeConversationCount, + candidate.ambientActorCount, + candidate.animationFrameCap, + candidate.animationMixerCount, + candidate.assetFallbackCount, + candidate.authoredAssetCount, + candidate.authoredPlacementCount, + candidate.authoredTreeCount, + candidate.canvasCount, + candidate.completedBuildingCount, + candidate.constructionSiteCount, + candidate.documentHeight, + candidate.documentWidth, + candidate.enabledSlotControlCount, + candidate.exactWildlifeCount, + candidate.exteriorActorCount, + candidate.exteriorMountedActorCount, + candidate.exteriorPatrolUnitCount, + candidate.exteriorTreeCount, + candidate.finalModelCount, + candidate.grassBladeCount, + candidate.maximumPendingRafCount, + candidate.mountedActorCount, + candidate.outerWorldRuntimeAssetFailureCount, + candidate.patrolUnitCount, + candidate.proceduralWildlifeCount, + candidate.rafOwnerCount, + candidate.rendererCount, + candidate.rendererDrawCalls, + candidate.rendererTriangles, + candidate.runtimeAssetFailureCount, + candidate.scenicResourceNodeCount, + candidate.sceneGraphDrawCalls, + candidate.sceneGraphTriangles, + candidate.slotControlCount, + candidate.slotCount, + candidate.slotGeometryCount, + candidate.smokeSpriteCount, + candidate.terrainHeightRangeMillimeters, + candidate.terrainTriangleCount, + candidate.topographicFeatureCount, + candidate.tradeWagonCount, + candidate.viewportHeight, + candidate.viewportWidth, + candidate.waterSurfaceCount, + candidate.webglContextCount, + candidate.wildlifeCount, + ].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 }); +} + +export const INNER_KEEP_QA_SCENE_GRAPH_RENDER_BUDGETS = Object.freeze({ + high: Object.freeze({ drawCalls: 390, triangles: 420_000 }), + balanced: Object.freeze({ drawCalls: 310, triangles: 215_000 }), + reduced: Object.freeze({ drawCalls: 235, triangles: 110_000 }), +}); + +const EXPECTED_LIVING_SCENE_BY_QUALITY = Object.freeze({ + high: Object.freeze({ + activeConversationMaximum: 3, + ambientActorCount: 20, + animationFrameCap: 30, + authoredTreeCount: 18, + exteriorActorCount: 9, + exteriorMountedActorCount: 6, + exteriorPatrolUnitCount: 7, + exteriorTreeCount: 72, + grassBladeCount: 2_400, + mountedActorCount: 6, + patrolUnitCount: 12, + rendererDrawCallsMaximum: 750, + rendererTrianglesMaximum: 720_000, + scenicResourceNodeCount: 8, + sceneGraphDrawCallsMaximum: INNER_KEEP_QA_SCENE_GRAPH_RENDER_BUDGETS.high.drawCalls, + sceneGraphTrianglesMaximum: INNER_KEEP_QA_SCENE_GRAPH_RENDER_BUDGETS.high.triangles, + terrainTriangleCount: 8_960, + wildlifeCount: 10, + }), + balanced: Object.freeze({ + activeConversationMaximum: 2, + ambientActorCount: 12, + animationFrameCap: 24, + authoredTreeCount: 12, + exteriorActorCount: 6, + exteriorMountedActorCount: 4, + exteriorPatrolUnitCount: 4, + exteriorTreeCount: 44, + grassBladeCount: 1_400, + mountedActorCount: 4, + patrolUnitCount: 6, + rendererDrawCallsMaximum: 380, + rendererTrianglesMaximum: 260_000, + scenicResourceNodeCount: 6, + sceneGraphDrawCallsMaximum: INNER_KEEP_QA_SCENE_GRAPH_RENDER_BUDGETS.balanced.drawCalls, + sceneGraphTrianglesMaximum: INNER_KEEP_QA_SCENE_GRAPH_RENDER_BUDGETS.balanced.triangles, + terrainTriangleCount: 5_184, + wildlifeCount: 7, + }), + reduced: Object.freeze({ + activeConversationMaximum: 0, + ambientActorCount: 8, + animationFrameCap: 18, + authoredTreeCount: 6, + exteriorActorCount: 3, + exteriorMountedActorCount: 2, + exteriorPatrolUnitCount: 2, + exteriorTreeCount: 22, + grassBladeCount: 480, + mountedActorCount: 2, + patrolUnitCount: 4, + rendererDrawCallsMaximum: 260, + rendererTrianglesMaximum: 130_000, + scenicResourceNodeCount: 4, + sceneGraphDrawCallsMaximum: INNER_KEEP_QA_SCENE_GRAPH_RENDER_BUDGETS.reduced.drawCalls, + sceneGraphTrianglesMaximum: INNER_KEEP_QA_SCENE_GRAPH_RENDER_BUDGETS.reduced.triangles, + terrainTriangleCount: 2_040, + wildlifeCount: 4, + }), +}); + +function expectLivingScene(evidence, scenario) { + if (scenario.renderMode === 'fallback') { + return evidence.assetStatus === 'idle' + && evidence.authoredAssetCount === 0 + && evidence.authoredPlacementCount === 0 + && evidence.authoredTreeCount === 0 + && evidence.grassBladeCount === 0 + && evidence.waterSurfaceCount === 0 + && evidence.ambientActorCount === 0 + && evidence.mountedActorCount === 0 + && evidence.patrolUnitCount === 0 + && evidence.activeConversationCount === 0 + && evidence.animationFrameCap === 0 + && evidence.animationMixerCount === 0 + && evidence.sceneGraphDrawCalls === 0 + && evidence.sceneGraphTriangles === 0 + && evidence.runtimeAssetFailureCount === 0 + && evidence.outerWorldStatus === 'idle' + && evidence.outerWorldRuntimeAssetFailureCount === 0 + && evidence.topographicFeatureCount === 0 + && evidence.terrainTriangleCount === 0 + && evidence.terrainHeightRangeMillimeters === 0 + && evidence.exteriorTreeCount === 0 + && evidence.scenicResourceNodeCount === 0 + && evidence.wildlifeAssetStatus === 'idle' + && evidence.wildlifeCount === 0 + && evidence.exactWildlifeCount === 0 + && evidence.proceduralWildlifeCount === 0 + && evidence.tradeWagonCount === 0 + && evidence.exteriorActorCount === 0 + && evidence.exteriorMountedActorCount === 0 + && evidence.exteriorPatrolUnitCount === 0 + && evidence.cathedralPlacementPresent === false + && evidence.barracksPlacementPresent === false; + } + const expected = EXPECTED_LIVING_SCENE_BY_QUALITY[scenario.quality]; + const motionDisabled = scenario.reducedMotion || scenario.quality === 'reduced'; + const conversationEvidenceMatches = scenario.id === 'active-conversation' + ? evidence.activeConversationCount === 1 + : evidence.activeConversationCount <= expected.activeConversationMaximum; + return evidence.assetStatus === 'ready' + && evidence.authoredAssetCount === 38 + && evidence.authoredPlacementCount === 76 + && evidence.authoredTreeCount === expected.authoredTreeCount + && evidence.grassBladeCount === expected.grassBladeCount + && evidence.waterSurfaceCount === 2 + && evidence.ambientActorCount === expected.ambientActorCount + && evidence.mountedActorCount === expected.mountedActorCount + && evidence.patrolUnitCount === expected.patrolUnitCount + && conversationEvidenceMatches + && (!motionDisabled || evidence.activeConversationCount === 0) + && evidence.animationMixerCount === ( + motionDisabled ? 0 : expected.ambientActorCount + expected.wildlifeCount + ) + && evidence.animationFrameCap === ( + scenario.reducedMotion ? 0 : expected.animationFrameCap + ) + && evidence.rendererDrawCalls <= expected.rendererDrawCallsMaximum + && evidence.rendererTriangles <= expected.rendererTrianglesMaximum + && evidence.sceneGraphDrawCalls > 0 + && evidence.sceneGraphDrawCalls <= expected.sceneGraphDrawCallsMaximum + && evidence.sceneGraphTriangles > 0 + && evidence.sceneGraphTriangles <= expected.sceneGraphTrianglesMaximum + && evidence.runtimeAssetFailureCount === 0 + && evidence.outerWorldStatus === 'ready' + && evidence.outerWorldRuntimeAssetFailureCount === 0 + && evidence.topographicFeatureCount === 9 + && evidence.terrainTriangleCount === expected.terrainTriangleCount + && evidence.terrainHeightRangeMillimeters > 0 + && evidence.exteriorTreeCount === expected.exteriorTreeCount + && evidence.scenicResourceNodeCount === expected.scenicResourceNodeCount + && evidence.wildlifeAssetStatus === 'ready' + && evidence.wildlifeCount === expected.wildlifeCount + && evidence.exactWildlifeCount === expected.wildlifeCount + && evidence.proceduralWildlifeCount === 0 + && evidence.tradeWagonCount === 1 + && evidence.exteriorActorCount === expected.exteriorActorCount + && evidence.exteriorMountedActorCount === expected.exteriorMountedActorCount + && evidence.exteriorPatrolUnitCount === expected.exteriorPatrolUnitCount + && evidence.cathedralPlacementPresent === true + && evidence.barracksPlacementPresent === true; +} + +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.rendererDrawCalls < 1 + || evidence.rendererTriangles < 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.rendererDrawCalls !== 0 + || evidence.rendererTriangles !== 0 + || evidence.webglContextCount !== 0 + || evidence.rafOwnerCount !== 0 + || evidence.maximumPendingRafCount !== 0 + || evidence.slotGeometryCount !== 0 + ) { + throw new TypeError('Inner Keep QA fallback resource evidence mismatched.'); + } + + if (!expectLivingScene(evidence, scenario)) { + throw new TypeError('Inner Keep QA living-scene 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..983eeb81 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 = + 'dec272175dc96085b26d2bc96125e77c6433331c698f150d80dfbbb4881ee3d7'; +const INNER_KEEP_ASSET_CATALOG_DIGEST = + '00304c5dbf819cec6cb656996c1105f64efcf36acf8099c431f5b04b822679f0'; +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..af45dae3 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/generate-inner-keep-browser-asset-catalog.mjs --check && node scripts/verify-runtime-assets.mjs && node scripts/verify-inner-keep-runtime-assets.mjs && node scripts/verify-inner-keep-population-assets.mjs && node scripts/verify-inner-keep-rabbit-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-inner-keep-runtime-assets.mjs --production-dist && node scripts/verify-inner-keep-population-assets.mjs --production-dist && node scripts/verify-inner-keep-rabbit-assets.mjs --production-dist && 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-file-sizes.mjs b/scripts/verify-file-sizes.mjs index 052253ae..5425e7c0 100644 --- a/scripts/verify-file-sizes.mjs +++ b/scripts/verify-file-sizes.mjs @@ -11,17 +11,31 @@ const allowedRuntimeAssets = new Set([ 'public/audio/warpkeep-lowlands-theme.mp3', 'public/video/warpkeep-menu-loop-v2.mp4' ]); +const exactOversizedRuntimeAssets = new Map([ + [ + 'public/models/hegemony/inner-keep/landmarks/inner-keep-grand-covenant-cathedral-high-9bf438bdf020d274.glb', + 12_507_408 + ], + [ + 'public/models/hegemony/inner-keep/landmarks/inner-keep-grand-covenant-cathedral-balanced-c90cf49f6b90325b.glb', + 5_810_260 + ] +]); const result = spawnSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'buffer' }); if (result.error) throw result.error; if (result.status !== 0) throw new Error('Unable to enumerate tracked files.'); const tracked = result.stdout.toString('utf8').split('\0').filter(Boolean); const violations = tracked.flatMap((path) => { const details = statSync(resolve(root, path), { throwIfNoEntry: false }); - return details && details.size > limit && !allowedRuntimeAssets.has(path) - ? [`${details.size}\t${path}`] - : []; + if (!details || details.size <= limit || allowedRuntimeAssets.has(path)) return []; + const exactAllowedBytes = exactOversizedRuntimeAssets.get(path); + return exactAllowedBytes === details.size ? [] : [`${details.size}\t${path}`]; }); if (violations.length > 0) { throw new Error(`Tracked non-runtime files exceed 5 MiB:\n${violations.join('\n')}`); } -console.log(`Tracked file-size policy passed; ${allowedRuntimeAssets.size} large runtime assets are allowlisted.`); +console.log( + 'Tracked file-size policy passed; ' + + `${allowedRuntimeAssets.size} legacy runtime assets and ` + + `${exactOversizedRuntimeAssets.size} exact Inner Keep assets are allowlisted.` +); diff --git a/scripts/verify-inner-keep-population-assets.d.mts b/scripts/verify-inner-keep-population-assets.d.mts new file mode 100644 index 00000000..f772f001 --- /dev/null +++ b/scripts/verify-inner-keep-population-assets.d.mts @@ -0,0 +1,17 @@ +export type InnerKeepPopulationVerificationMode = 'repository' | 'production-dist'; + +export function parseInnerKeepPopulationVerificationMode( + argumentsList: readonly string[] +): InnerKeepPopulationVerificationMode; +export function verifyInnerKeepPopulationRuntimeInstall(options?: Readonly<{ + mode?: InnerKeepPopulationVerificationMode; + argumentsList?: readonly string[]; + outputRoot?: string; +}>): Readonly<{ + mode: InnerKeepPopulationVerificationMode; + files: number; + bytes: number; + digest: string; + presentationOnly: true; +}>; +export function main(argumentsList?: readonly string[]): void; diff --git a/scripts/verify-inner-keep-population-assets.mjs b/scripts/verify-inner-keep-population-assets.mjs new file mode 100644 index 00000000..a999006e --- /dev/null +++ b/scripts/verify-inner-keep-population-assets.mjs @@ -0,0 +1,180 @@ +import { constants, closeSync, fstatSync, lstatSync, openSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative, resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + INNER_KEEP_POPULATION_MODELS, + INNER_KEEP_POPULATION_RUNTIME_PATHS, + INNER_KEEP_POPULATION_SELECTION, + INNER_KEEP_POPULATION_SELECTION_DIGEST, + assertInnerKeepPopulationRuntimeUseAuthorized, + calculateInnerKeepPopulationSelectionDigest, + verifyInnerKeepPopulationGlb +} from './inner-keep-population-runtime-contract.mjs'; + +const ROOT = resolve(import.meta.dirname, '..'); +const REPOSITORY_POPULATION_ROOT = 'public/models/hegemony/inner-keep/population'; +const PRODUCTION_POPULATION_ROOT = 'models/hegemony/inner-keep/population'; + +function fail(detail) { + throw new Error(`Inner Keep population runtime verification: ${detail}`); +} + +function assertOrdinaryDirectoryChain(outputRoot, relativePath) { + const absoluteRoot = resolve(outputRoot); + const rootStatus = lstatSync(absoluteRoot, { throwIfNoEntry: false }); + if (!rootStatus?.isDirectory() || rootStatus.isSymbolicLink()) { + fail('output root must be an ordinary non-symbolic directory.'); + } + const destination = resolve(absoluteRoot, relativePath); + const relation = relative(absoluteRoot, destination); + if (relation === '' || relation === '..' || relation.startsWith(`..${sep}`)) { + fail(`runtime directory escaped its output root: ${relativePath}.`); + } + let current = absoluteRoot; + for (const component of relation.split(sep)) { + current = join(current, component); + const status = lstatSync(current, { throwIfNoEntry: false }); + if (!status) fail(`required runtime directory is missing: ${relativePath}.`); + if (!status.isDirectory() || status.isSymbolicLink()) { + fail(`runtime directory chain must be ordinary and non-symbolic: ${relativePath}.`); + } + } +} + +function collectRegularFiles(outputRoot, relativePath) { + const absolutePath = resolve(outputRoot, relativePath); + const status = lstatSync(absolutePath, { throwIfNoEntry: false }); + if (!status) fail(`required path is missing: ${relativePath}.`); + if (status.isSymbolicLink()) fail(`${relativePath} must not be a symbolic link.`); + if (status.isFile()) return [relativePath]; + if (!status.isDirectory()) fail(`${relativePath} must be a regular file or directory.`); + return readdirSync(absolutePath, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + .flatMap((entry) => collectRegularFiles(outputRoot, `${relativePath}/${entry.name}`)); +} + +function readPinnedRuntimeFile(outputRoot, relativePath, expectedBytes) { + const absolutePath = resolve(outputRoot, relativePath); + let descriptor; + try { + descriptor = openSync(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = fstatSync(descriptor); + if (!before.isFile() || before.size !== expectedBytes) { + fail(`${relativePath} must be an exact ${expectedBytes}-byte regular file.`); + } + const bytes = readFileSync(descriptor); + const after = fstatSync(descriptor); + const pathStatus = lstatSync(absolutePath, { 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 + ) fail(`${relativePath} changed while it was being verified.`); + return bytes; + } catch (error) { + if (error instanceof Error && error.message.startsWith('Inner Keep population runtime')) { + throw error; + } + fail(`${relativePath} cannot be read safely (${error instanceof Error ? error.message : String(error)}).`); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +export function parseInnerKeepPopulationVerificationMode(argumentsList) { + if (!Array.isArray(argumentsList) || argumentsList.length > 1) { + fail('use no arguments for public/ verification or exactly --production-dist for dist/.'); + } + if (argumentsList.length === 0) return 'repository'; + if (argumentsList[0] === '--production-dist') return 'production-dist'; + fail('use no arguments for public/ verification or exactly --production-dist for dist/.'); +} + +export function verifyInnerKeepPopulationRuntimeInstall(options = {}) { + const mode = options.mode ?? parseInnerKeepPopulationVerificationMode( + options.argumentsList ?? [] + ); + if (!['repository', 'production-dist'].includes(mode)) { + fail(`unsupported verification mode: ${String(mode)}.`); + } + assertInnerKeepPopulationRuntimeUseAuthorized(); + if ( + INNER_KEEP_POPULATION_SELECTION.runtimePolicy.clientPresentationOnly !== true + || INNER_KEEP_POPULATION_SELECTION.runtimePolicy.gameplayAuthorityClaimed !== false + || INNER_KEEP_POPULATION_SELECTION.runtimePolicy.ordinaryBuildMayReadArchive !== false + || INNER_KEEP_POPULATION_SELECTION.runtimePolicy.ordinaryBuildMayUseNetwork !== false + ) fail('presentation-only or offline ordinary-build policy changed.'); + const observedDigest = calculateInnerKeepPopulationSelectionDigest( + INNER_KEEP_POPULATION_SELECTION + ); + if (observedDigest !== INNER_KEEP_POPULATION_SELECTION_DIGEST) { + fail(`selection digest changed to ${observedDigest}.`); + } + if ( + INNER_KEEP_POPULATION_MODELS.length !== 40 + || INNER_KEEP_POPULATION_RUNTIME_PATHS.length !== 40 + || new Set(INNER_KEEP_POPULATION_RUNTIME_PATHS).size !== 40 + ) fail('exact population allowlist cardinality changed.'); + + const production = mode === 'production-dist'; + const outputRoot = resolve(options.outputRoot ?? (production ? resolve(ROOT, 'dist') : ROOT)); + const populationRoot = production + ? PRODUCTION_POPULATION_ROOT + : REPOSITORY_POPULATION_ROOT; + assertOrdinaryDirectoryChain(outputRoot, populationRoot); + const expectedPaths = INNER_KEEP_POPULATION_RUNTIME_PATHS.map((path) => ( + production ? path.replace(/^public\//u, '') : path + )).sort(); + const observedPaths = collectRegularFiles(outputRoot, populationRoot).sort(); + if ( + observedPaths.length !== expectedPaths.length + || observedPaths.some((path, index) => path !== expectedPaths[index]) + ) { + fail( + `installed ${production ? 'production' : 'repository'} output does not match the exact ` + + `40-file allowlist: observed=${observedPaths.length}, expected=${expectedPaths.length}.` + ); + } + + let totalBytes = 0; + for (const model of INNER_KEEP_POPULATION_MODELS) { + const path = production + ? model.destinationPath.replace(/^public\//u, '') + : model.destinationPath; + const bytes = readPinnedRuntimeFile(outputRoot, path, model.bytes); + verifyInnerKeepPopulationGlb(bytes, model, path); + totalBytes += bytes.byteLength; + } + if (totalBytes !== INNER_KEEP_POPULATION_SELECTION.counts.selectedBytes) { + fail(`installed byte budget changed to ${totalBytes}.`); + } + return Object.freeze({ + mode, + files: INNER_KEEP_POPULATION_MODELS.length, + bytes: totalBytes, + digest: INNER_KEEP_POPULATION_SELECTION_DIGEST, + presentationOnly: true + }); +} + +export function main(argumentsList = process.argv.slice(2)) { + const mode = parseInnerKeepPopulationVerificationMode(argumentsList); + const result = verifyInnerKeepPopulationRuntimeInstall({ mode }); + console.log( + `Verified ${result.files} exact Inner Keep population GLBs (${result.bytes} bytes) ` + + `in ${result.mode}; presentation-only, no gameplay authority.` + ); + console.log(`Selection digest: ${result.digest}`); + return result; +} + +if ( + process.argv[1] + && import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) main(); diff --git a/scripts/verify-inner-keep-rabbit-assets.d.mts b/scripts/verify-inner-keep-rabbit-assets.d.mts new file mode 100644 index 00000000..ec787c0c --- /dev/null +++ b/scripts/verify-inner-keep-rabbit-assets.d.mts @@ -0,0 +1,14 @@ +export function parseInnerKeepRabbitVerificationMode( + argumentsList: readonly string[] +): 'repository' | 'production-dist'; +export function verifyInnerKeepRabbitRuntimeInstall(options?: Readonly<{ + mode?: 'repository' | 'production-dist'; + argumentsList?: readonly string[]; + outputRoot?: string; +}>): Readonly<{ + mode: 'repository' | 'production-dist'; + files: number; + bytes: number; + digest: string; + presentationOnly: true; +}>; diff --git a/scripts/verify-inner-keep-rabbit-assets.mjs b/scripts/verify-inner-keep-rabbit-assets.mjs new file mode 100644 index 00000000..fb6948f4 --- /dev/null +++ b/scripts/verify-inner-keep-rabbit-assets.mjs @@ -0,0 +1,163 @@ +import { + constants, + closeSync, + fstatSync, + lstatSync, + openSync, + readFileSync, + readdirSync, +} from 'node:fs'; +import { join, relative, resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + INNER_KEEP_RABBIT_MODELS, + INNER_KEEP_RABBIT_RUNTIME_DIRECTORY, + INNER_KEEP_RABBIT_RUNTIME_PATHS, + INNER_KEEP_RABBIT_SELECTION, + INNER_KEEP_RABBIT_SELECTION_DIGEST, + assertInnerKeepRabbitRuntimeUseAuthorized, + calculateInnerKeepRabbitSelectionDigest, + verifyInnerKeepRabbitGlb, +} from './inner-keep-rabbit-runtime-contract.mjs'; + +const ROOT = resolve(import.meta.dirname, '..'); +const PRODUCTION_DIRECTORY = INNER_KEEP_RABBIT_RUNTIME_DIRECTORY.replace(/^public\//u, ''); + +function fail(detail) { + throw new Error(`Inner Keep rabbit runtime verification: ${detail}`); +} + +function assertOrdinaryDirectoryChain(outputRoot, relativePath) { + const absoluteRoot = resolve(outputRoot); + const rootStatus = lstatSync(absoluteRoot, { throwIfNoEntry: false }); + if (!rootStatus?.isDirectory() || rootStatus.isSymbolicLink()) { + fail('output root must be an ordinary non-symbolic directory.'); + } + const destination = resolve(absoluteRoot, relativePath); + const relation = relative(absoluteRoot, destination); + if (relation === '' || relation === '..' || relation.startsWith(`..${sep}`)) { + fail(`runtime directory escaped its output root: ${relativePath}.`); + } + let current = absoluteRoot; + for (const component of relation.split(sep)) { + current = join(current, component); + const status = lstatSync(current, { throwIfNoEntry: false }); + if (!status?.isDirectory() || status.isSymbolicLink()) { + fail(`runtime directory chain must be ordinary and non-symbolic: ${relativePath}.`); + } + } +} + +function collectRegularFiles(outputRoot, relativePath) { + const absolutePath = resolve(outputRoot, relativePath); + const status = lstatSync(absolutePath, { throwIfNoEntry: false }); + if (!status) fail(`required path is missing: ${relativePath}.`); + if (status.isSymbolicLink()) fail(`${relativePath} must not be a symbolic link.`); + if (status.isFile()) return [relativePath]; + if (!status.isDirectory()) fail(`${relativePath} must be a regular file or directory.`); + return readdirSync(absolutePath, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + .flatMap((entry) => collectRegularFiles(outputRoot, `${relativePath}/${entry.name}`)); +} + +function readPinnedFile(outputRoot, relativePath, expectedBytes) { + const absolutePath = resolve(outputRoot, relativePath); + let descriptor; + try { + descriptor = openSync(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = fstatSync(descriptor); + if (!before.isFile() || before.size !== expectedBytes) { + fail(`${relativePath} must be an exact ${expectedBytes}-byte regular file.`); + } + const bytes = readFileSync(descriptor); + const after = fstatSync(descriptor); + const status = lstatSync(absolutePath, { throwIfNoEntry: false }); + if ( + !status?.isFile() + || status.isSymbolicLink() + || before.dev !== after.dev + || before.ino !== after.ino + || before.size !== after.size + || status.dev !== after.dev + || status.ino !== after.ino + || status.size !== after.size + ) fail(`${relativePath} changed while it was being verified.`); + return bytes; + } catch (error) { + if (error instanceof Error && error.message.startsWith('Inner Keep rabbit runtime')) { + throw error; + } + fail(`${relativePath} cannot be read safely (${error instanceof Error ? error.message : String(error)}).`); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +export function parseInnerKeepRabbitVerificationMode(argumentsList) { + if (!Array.isArray(argumentsList) || argumentsList.length > 1) { + fail('use no arguments for public/ verification or exactly --production-dist for dist/.'); + } + if (argumentsList.length === 0) return 'repository'; + if (argumentsList[0] === '--production-dist') return 'production-dist'; + fail('use no arguments for public/ verification or exactly --production-dist for dist/.'); +} + +export function verifyInnerKeepRabbitRuntimeInstall(options = {}) { + const mode = options.mode ?? parseInnerKeepRabbitVerificationMode( + options.argumentsList ?? [], + ); + if (!['repository', 'production-dist'].includes(mode)) { + fail(`unsupported verification mode: ${String(mode)}.`); + } + assertInnerKeepRabbitRuntimeUseAuthorized(); + if ( + calculateInnerKeepRabbitSelectionDigest(INNER_KEEP_RABBIT_SELECTION) + !== INNER_KEEP_RABBIT_SELECTION_DIGEST + || INNER_KEEP_RABBIT_MODELS.length !== 3 + || INNER_KEEP_RABBIT_RUNTIME_PATHS.length !== 3 + || new Set(INNER_KEEP_RABBIT_RUNTIME_PATHS).size !== 3 + ) fail('exact selection digest or allowlist cardinality changed.'); + const production = mode === 'production-dist'; + const outputRoot = resolve(options.outputRoot ?? (production ? resolve(ROOT, 'dist') : ROOT)); + const directory = production ? PRODUCTION_DIRECTORY : INNER_KEEP_RABBIT_RUNTIME_DIRECTORY; + assertOrdinaryDirectoryChain(outputRoot, directory); + const expectedPaths = INNER_KEEP_RABBIT_RUNTIME_PATHS.map((path) => ( + production ? path.replace(/^public\//u, '') : path + )).sort(); + const observedPaths = collectRegularFiles(outputRoot, directory).sort(); + if ( + observedPaths.length !== expectedPaths.length + || observedPaths.some((path, index) => path !== expectedPaths[index]) + ) fail(`installed ${mode} output does not match the exact three-file allowlist.`); + + let totalBytes = 0; + for (const model of INNER_KEEP_RABBIT_MODELS) { + const path = production + ? model.destinationPath.replace(/^public\//u, '') + : model.destinationPath; + const bytes = readPinnedFile(outputRoot, path, model.bytes); + verifyInnerKeepRabbitGlb(bytes, model, path); + totalBytes += bytes.byteLength; + } + if (totalBytes !== 230_536) fail(`installed byte budget changed to ${totalBytes}.`); + return Object.freeze({ + mode, + files: 3, + bytes: totalBytes, + digest: INNER_KEEP_RABBIT_SELECTION_DIGEST, + presentationOnly: true, + }); +} + +export function main(argumentsList = process.argv.slice(2)) { + const result = verifyInnerKeepRabbitRuntimeInstall({ argumentsList }); + console.log( + `Verified ${result.files} exact Lowlands Rabbit GLBs (${result.bytes} bytes) ` + + `in ${result.mode}; presentation-only, no gameplay authority.`, + ); + console.log(`Selection digest: ${result.digest}`); + return result; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main(); diff --git a/scripts/verify-inner-keep-runtime-assets.d.mts b/scripts/verify-inner-keep-runtime-assets.d.mts new file mode 100644 index 00000000..81d4b680 --- /dev/null +++ b/scripts/verify-inner-keep-runtime-assets.d.mts @@ -0,0 +1,19 @@ +export type InnerKeepAssetVerificationMode = 'repository' | 'production-dist'; + +export function parseInnerKeepAssetVerificationMode( + argumentsList: readonly string[] +): InnerKeepAssetVerificationMode; +export function verifyInnerKeepRuntimeAssetInstall(options?: Readonly<{ + mode?: InnerKeepAssetVerificationMode; + argumentsList?: readonly string[]; + outputRoot?: string; +}>): Readonly<{ + mode: InnerKeepAssetVerificationMode; + files: number; + models: number; + previews: number; + bytes: number; + digest: string; + reusedTrees: number; +}>; +export function main(argumentsList?: readonly string[]): void; diff --git a/scripts/verify-inner-keep-runtime-assets.mjs b/scripts/verify-inner-keep-runtime-assets.mjs new file mode 100644 index 00000000..dbae735d --- /dev/null +++ b/scripts/verify-inner-keep-runtime-assets.mjs @@ -0,0 +1,225 @@ +import { + constants, + closeSync, + fstatSync, + lstatSync, + openSync, + readFileSync, + readdirSync +} from 'node:fs'; +import { join, relative, resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +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, + assertInnerKeepRuntimeUseAuthorized, + calculateInnerKeepAssetSelectionDigest, + verifyInnerKeepSelectedGlb, + verifyInnerKeepSelectedPreview +} from './inner-keep-runtime-asset-contract.mjs'; + +const ROOT = resolve(import.meta.dirname, '..'); +const REPOSITORY_MODEL_ROOT = 'public/models/hegemony/inner-keep'; +const REPOSITORY_PREVIEW_ROOT = 'public/images/inner-keep/catalog'; +const REPOSITORY_POPULATION_ROOT = `${REPOSITORY_MODEL_ROOT}/population`; +const REPOSITORY_WILDLIFE_ROOT = `${REPOSITORY_MODEL_ROOT}/wildlife`; +const PRODUCTION_MODEL_ROOT = 'models/hegemony/inner-keep'; +const PRODUCTION_PREVIEW_ROOT = 'images/inner-keep/catalog'; +const PRODUCTION_POPULATION_ROOT = `${PRODUCTION_MODEL_ROOT}/population`; +const PRODUCTION_WILDLIFE_ROOT = `${PRODUCTION_MODEL_ROOT}/wildlife`; + +function fail(detail) { + throw new Error(`Inner Keep runtime asset verification: ${detail}`); +} + +function assertOrdinaryDirectoryChain(outputRoot, relativePath) { + const absoluteRoot = resolve(outputRoot); + const rootStatus = lstatSync(absoluteRoot, { throwIfNoEntry: false }); + if (!rootStatus?.isDirectory() || rootStatus.isSymbolicLink()) { + fail('output root must be an ordinary non-symbolic directory.'); + } + const destination = resolve(absoluteRoot, relativePath); + const relation = relative(absoluteRoot, destination); + if (relation === '' || relation === '..' || relation.startsWith(`..${sep}`)) { + fail(`runtime directory escaped its output root: ${relativePath}.`); + } + let current = absoluteRoot; + for (const component of relation.split(sep)) { + current = join(current, component); + const status = lstatSync(current, { throwIfNoEntry: false }); + if (!status) fail(`required runtime directory is missing: ${relativePath}.`); + if (!status.isDirectory() || status.isSymbolicLink()) { + fail(`runtime directory chain must be ordinary and non-symbolic: ${relativePath}.`); + } + } +} + +function collectRegularFiles(outputRoot, relativePath, ignoredPaths) { + // Population and wildlife models have their own exact verifiers. Keep this + // static selection closed over 114 models and six previews in public/ and dist/. + if (ignoredPaths.includes(relativePath)) return []; + const absolutePath = resolve(outputRoot, relativePath); + const status = lstatSync(absolutePath, { throwIfNoEntry: false }); + if (!status) fail(`required path is missing: ${relativePath}.`); + if (status.isSymbolicLink()) fail(`${relativePath} must not be a symbolic link.`); + if (status.isFile()) return [relativePath]; + if (!status.isDirectory()) fail(`${relativePath} must be a regular file or directory.`); + return readdirSync(absolutePath, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + .flatMap((entry) => collectRegularFiles( + outputRoot, + `${relativePath}/${entry.name}`, + ignoredPaths + )); +} + +function readPinnedRuntimeFile(outputRoot, relativePath, expectedBytes) { + const absolutePath = resolve(outputRoot, relativePath); + let descriptor; + try { + descriptor = openSync(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = fstatSync(descriptor); + if (!before.isFile() || before.size !== expectedBytes) { + fail(`${relativePath} must be an exact ${expectedBytes}-byte regular file.`); + } + const bytes = readFileSync(descriptor); + const after = fstatSync(descriptor); + const pathStatus = lstatSync(absolutePath, { 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 + ) fail(`${relativePath} changed while it was being verified.`); + return bytes; + } catch (error) { + if (error instanceof Error && error.message.startsWith('Inner Keep runtime asset')) { + throw error; + } + fail(`${relativePath} cannot be read safely (${error instanceof Error ? error.message : String(error)}).`); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +export function parseInnerKeepAssetVerificationMode(argumentsList) { + if (!Array.isArray(argumentsList) || argumentsList.length > 1) { + fail('use no arguments for public/ verification or exactly --production-dist for dist/.'); + } + if (argumentsList.length === 0) return 'repository'; + if (argumentsList[0] === '--production-dist') return 'production-dist'; + fail('use no arguments for public/ verification or exactly --production-dist for dist/.'); +} + +export function verifyInnerKeepRuntimeAssetInstall(options = {}) { + const mode = options.mode ?? parseInnerKeepAssetVerificationMode( + options.argumentsList ?? [] + ); + if (!['repository', 'production-dist'].includes(mode)) { + fail(`unsupported verification mode: ${String(mode)}.`); + } + assertInnerKeepRuntimeUseAuthorized(); + if ( + INNER_KEEP_ASSET_SELECTION.selectionPolicy.ordinaryBuildMayReadArchive !== false + || INNER_KEEP_ASSET_SELECTION.selectionPolicy.ordinaryBuildMayUseNetwork !== false + ) fail('offline ordinary-build policy changed.'); + 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 !== 114 + || INNER_KEEP_SELECTED_PREVIEWS.length !== 6 + || INNER_KEEP_PLANNED_RUNTIME_PATHS.length !== 120 + || new Set(INNER_KEEP_PLANNED_RUNTIME_PATHS).size !== 120 + ) fail('curated allowlist cardinality changed.'); + + const production = mode === 'production-dist'; + const outputRoot = resolve(options.outputRoot ?? (production ? resolve(ROOT, 'dist') : ROOT)); + const modelRoot = production ? PRODUCTION_MODEL_ROOT : REPOSITORY_MODEL_ROOT; + const previewRoot = production ? PRODUCTION_PREVIEW_ROOT : REPOSITORY_PREVIEW_ROOT; + const populationRoot = production + ? PRODUCTION_POPULATION_ROOT + : REPOSITORY_POPULATION_ROOT; + const wildlifeRoot = production + ? PRODUCTION_WILDLIFE_ROOT + : REPOSITORY_WILDLIFE_ROOT; + const separatelyVerifiedRoots = Object.freeze([populationRoot, wildlifeRoot]); + assertOrdinaryDirectoryChain(outputRoot, modelRoot); + assertOrdinaryDirectoryChain(outputRoot, previewRoot); + const expectedPaths = INNER_KEEP_PLANNED_RUNTIME_PATHS.map((path) => ( + production ? path.replace(/^public\//u, '') : path + )).sort(); + const observedPaths = [ + ...collectRegularFiles(outputRoot, modelRoot, separatelyVerifiedRoots), + ...collectRegularFiles(outputRoot, previewRoot, separatelyVerifiedRoots) + ].sort(); + if ( + observedPaths.length !== expectedPaths.length + || observedPaths.some((path, index) => path !== expectedPaths[index]) + ) { + fail( + `installed ${production ? 'production' : 'repository'} output does not match the exact ` + + `120-file allowlist: observed=${observedPaths.length}, expected=${expectedPaths.length}.` + ); + } + + let totalBytes = 0; + for (const model of INNER_KEEP_SELECTED_MODELS) { + const path = production + ? model.destinationPath.replace(/^public\//u, '') + : model.destinationPath; + const bytes = readPinnedRuntimeFile(outputRoot, path, model.bytes); + verifyInnerKeepSelectedGlb(bytes, model, path); + totalBytes += bytes.byteLength; + } + for (const preview of INNER_KEEP_SELECTED_PREVIEWS) { + const path = production + ? preview.destinationPath.replace(/^public\//u, '') + : preview.destinationPath; + const bytes = readPinnedRuntimeFile(outputRoot, path, preview.bytes); + verifyInnerKeepSelectedPreview(bytes, preview, path); + totalBytes += bytes.byteLength; + } + + 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.`); + } + } + return Object.freeze({ + mode, + files: INNER_KEEP_SELECTED_MODELS.length + INNER_KEEP_SELECTED_PREVIEWS.length, + models: INNER_KEEP_SELECTED_MODELS.length, + previews: INNER_KEEP_SELECTED_PREVIEWS.length, + bytes: totalBytes, + digest: INNER_KEEP_ASSET_SELECTION_DIGEST, + reusedTrees: INNER_KEEP_ASSET_SELECTION.existingRuntimeReuse.length + }); +} + +export function main(argumentsList = process.argv.slice(2)) { + const mode = parseInnerKeepAssetVerificationMode(argumentsList); + const result = verifyInnerKeepRuntimeAssetInstall({ mode }); + console.log( + `Verified ${result.models} exact Inner Keep static GLBs and ${result.previews} previews ` + + `(${result.bytes} bytes) in ${result.mode}, including embedded structure and tree reuse.` + ); + console.log(`Selection digest: ${result.digest}`); + return result; +} + +if ( + process.argv[1] + && import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) main(); 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..88ae427b 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, +exact static-and-population runtime-registry verification, 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,28 @@ 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 unless both owner-authorized static and +population selections and their complete installed runtime registries verify. +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..f585b916 --- /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 = + '0a976765d6f6e740eb6282fca90f59b412ecbd7ed382f001da89a0b7abeca756'; + +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: -9_000_000n, localZMicrounits: -3_400_000n, rotationMilliDegrees: 25_000, sortOrder: 1, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-m02', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'medium', localXMicrounits: -4_600_000n, localZMicrounits: -6_800_000n, rotationMilliDegrees: 15_000, sortOrder: 2, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-m03', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'medium', localXMicrounits: 4_600_000n, localZMicrounits: -6_800_000n, rotationMilliDegrees: 345_000, sortOrder: 3, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-m04', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'medium', localXMicrounits: 9_000_000n, localZMicrounits: -3_400_000n, rotationMilliDegrees: 335_000, sortOrder: 4, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-m05', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'medium', localXMicrounits: -9_100_000n, localZMicrounits: 2_500_000n, rotationMilliDegrees: 155_000, sortOrder: 5, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-m06', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'medium', localXMicrounits: -4_700_000n, localZMicrounits: 6_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_700_000n, localZMicrounits: 6_900_000n, rotationMilliDegrees: 190_000, sortOrder: 7, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-m08', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'medium', localXMicrounits: 9_100_000n, localZMicrounits: 2_500_000n, rotationMilliDegrees: 205_000, sortOrder: 8, active: true }), + Object.freeze({ slotId: 'inner-keep-slot-l01', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'large', localXMicrounits: -13_700_000n, localZMicrounits: -10_800_000n, rotationMilliDegrees: 35_000, sortOrder: 9, active: false }), + Object.freeze({ slotId: 'inner-keep-slot-l02', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'large', localXMicrounits: 13_700_000n, localZMicrounits: -10_800_000n, rotationMilliDegrees: 325_000, sortOrder: 10, active: false }), + Object.freeze({ slotId: 'inner-keep-slot-l03', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'large', localXMicrounits: -13_800_000n, localZMicrounits: 10_600_000n, rotationMilliDegrees: 145_000, sortOrder: 11, active: false }), + Object.freeze({ slotId: 'inner-keep-slot-l04', layoutId: INNER_KEEP_LAYOUT_ID, footprintClass: 'large', localXMicrounits: 13_800_000n, localZMicrounits: 10_600_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 = + 'dec272175dc96085b26d2bc96125e77c6433331c698f150d80dfbbb4881ee3d7'; + +/** + * 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 = + '00304c5dbf819cec6cb656996c1105f64efcf36acf8099c431f5b04b822679f0'; + +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 ( +