@underpostnet/cyberia 3.3.73 → 3.3.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/.env.example +17 -5
  2. package/.github/workflows/coverall.cyberia.ci.yml +10 -0
  3. package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
  4. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
  5. package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
  6. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
  7. package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
  8. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
  9. package/.github/workflows/engine-cyberia.ci.yml +1 -1
  10. package/.github/workflows/publish.cyberia.ci.yml +44 -0
  11. package/AGENTS.md +61 -12
  12. package/CHANGELOG.md +159 -161
  13. package/CLI-HELP.md +20 -13
  14. package/Dockerfile +1 -1
  15. package/Dockerfile.dev +1 -1
  16. package/Dockerfile.test +2 -2
  17. package/README.md +28 -28
  18. package/bin/build.js +96 -21
  19. package/bin/cyberia.js +1247 -1373
  20. package/bin/index.js +1247 -1373
  21. package/compose.env +17 -5
  22. package/conf.js +7 -4
  23. package/deploy/cyberia-client/deploy.sh +10 -11
  24. package/deploy/cyberia-client/package.sh +0 -1
  25. package/deploy/cyberia-client/state.sh +3 -2
  26. package/deploy/cyberia-server/deploy.sh +10 -11
  27. package/deploy/cyberia-server/package.sh +0 -1
  28. package/deploy/cyberia-server/state.sh +3 -2
  29. package/deploy/dd-cyberia/deploy.sh +5 -5
  30. package/deploy/dd-cyberia/init.sh +15 -14
  31. package/deploy/dd-cyberia/package.sh +0 -1
  32. package/deploy/dd-cyberia/state.sh +2 -2
  33. package/deploy/dd-cyberia/sync-deploy.sh +139 -75
  34. package/deploy/lib/config.sh +29 -0
  35. package/deploy/lib/github-actions-logging.sh +92 -76
  36. package/deploy/lib/host.sh +28 -19
  37. package/deploy/lib/state.sh +4 -1
  38. package/deploy/pwa-microservices-template/deploy.sh +10 -8
  39. package/deploy/release/deploy.sh +0 -2
  40. package/deployment.yaml +1 -1
  41. package/docker-compose.yml +23 -17
  42. package/docs/coverage/cyberia/api/cyberia-instance/cyberia-fallback-capture.js.html +1591 -0
  43. package/docs/coverage/cyberia/api/cyberia-instance/index.html +116 -0
  44. package/docs/coverage/cyberia/api/cyberia-server-defaults/cyberia-server-defaults.js.html +4138 -0
  45. package/docs/coverage/cyberia/api/cyberia-server-defaults/index.html +116 -0
  46. package/docs/coverage/cyberia/api/object-layer/index.html +116 -0
  47. package/docs/coverage/cyberia/api/object-layer/object-layer.model.js.html +1393 -0
  48. package/docs/coverage/cyberia/base.css +362 -0
  49. package/docs/coverage/cyberia/block-navigation.js +82 -0
  50. package/docs/coverage/cyberia/favicon.png +0 -0
  51. package/docs/coverage/cyberia/index.html +161 -0
  52. package/docs/coverage/cyberia/prettify.css +101 -0
  53. package/docs/coverage/cyberia/prettify.js +937 -0
  54. package/docs/coverage/cyberia/projects/cyberia/atlas-sprite-sheet-generator.js.html +1315 -0
  55. package/docs/coverage/cyberia/projects/cyberia/index.html +176 -0
  56. package/docs/coverage/cyberia/projects/cyberia/instance-backup.js.html +715 -0
  57. package/docs/coverage/cyberia/projects/cyberia/instance-data.js.html +2182 -0
  58. package/docs/coverage/cyberia/projects/cyberia/shape-generator.js.html +3265 -0
  59. package/docs/coverage/cyberia/projects/cyberia/stat-balance.js.html +328 -0
  60. package/docs/coverage/cyberia/sort-arrow-sprite.png +0 -0
  61. package/docs/coverage/cyberia/sorter.js +205 -0
  62. package/hardhat/package-lock.json +2 -2
  63. package/hardhat/package.json +1 -1
  64. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  65. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
  66. package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
  67. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  68. package/manifests/deployment/mongo-express/deployment.yaml +46 -27
  69. package/manifests/deployment/mongo-express/kustomization.yaml +9 -0
  70. package/manifests/deployment/mongo-express/mongo-express-nodeport.yaml +18 -0
  71. package/manifests/deployment/mongo-express/service.yaml +16 -0
  72. package/manifests/deployment/mongo-express-no-auth/disable-mongodb-auth.yaml +16 -0
  73. package/manifests/deployment/mongo-express-no-auth/kustomization.yaml +16 -0
  74. package/package.json +19 -17
  75. package/scripts/test-monitor.sh +3 -3
  76. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +14 -9
  77. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +36 -5
  78. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +5 -0
  79. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +88 -184
  80. package/src/api/cyberia-action/cyberia-action.router.js +2 -2
  81. package/src/api/cyberia-action/cyberia-action.service.js +3 -4
  82. package/src/api/cyberia-audio/cyberia-audio.controller.js +6 -0
  83. package/src/api/cyberia-audio/cyberia-audio.model.js +61 -0
  84. package/src/api/cyberia-audio/cyberia-audio.router.js +21 -0
  85. package/src/api/cyberia-audio/cyberia-audio.service.js +220 -0
  86. package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -3
  87. package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +10 -19
  88. package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +12 -34
  89. package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +15 -32
  90. package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +1 -1
  91. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
  92. package/src/api/cyberia-entity/cyberia-entity.model.js +2 -0
  93. package/src/api/cyberia-entity/cyberia-entity.service.js +1 -1
  94. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +8 -2
  95. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +31 -20
  96. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +16 -1
  97. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +466 -3
  98. package/src/api/cyberia-instance/cyberia-fallback-capture.js +87 -51
  99. package/src/api/cyberia-instance/cyberia-fallback-world.js +21 -40
  100. package/src/api/cyberia-instance/cyberia-instance-boot.service.js +5 -5
  101. package/src/api/cyberia-instance/cyberia-instance-items.js +98 -0
  102. package/src/api/cyberia-instance/cyberia-instance-map.service.js +34 -141
  103. package/src/api/cyberia-instance/cyberia-instance.controller.js +1 -4
  104. package/src/api/cyberia-instance/cyberia-instance.model.js +0 -14
  105. package/src/api/cyberia-instance/cyberia-instance.router.js +3 -16
  106. package/src/api/cyberia-instance/cyberia-instance.service.js +3 -43
  107. package/src/api/cyberia-instance/cyberia-world-generator.js +8 -13
  108. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +49 -75
  109. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +74 -1
  110. package/src/api/cyberia-map/cyberia-map.service.js +6 -2
  111. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.controller.js +31 -0
  112. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.model.js +56 -0
  113. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.router.js +33 -0
  114. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +189 -0
  115. package/src/api/cyberia-quest/cyberia-quest.router.js +3 -3
  116. package/src/api/cyberia-quest/cyberia-quest.service.js +3 -3
  117. package/src/api/cyberia-quest-progress/cyberia-quest-progress.model.js +1 -1
  118. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +403 -188
  119. package/src/api/cyberia-skill/cyberia-skill.model.js +6 -6
  120. package/src/api/file/file.ref.js +153 -0
  121. package/src/api/file/file.ref.json +11 -2
  122. package/src/api/object-layer/object-layer.model.js +47 -12
  123. package/src/api/object-layer/object-layer.router.js +10 -3
  124. package/src/api/object-layer/object-layer.service.js +113 -120
  125. package/src/cli/app.js +12 -1
  126. package/src/cli/client.js +2 -2
  127. package/src/cli/cluster.js +80 -18
  128. package/src/cli/deploy.js +215 -34
  129. package/src/cli/docker-compose.js +1 -1
  130. package/src/cli/fs-selection.js +115 -0
  131. package/src/cli/fs.js +158 -342
  132. package/src/cli/index.js +22 -10
  133. package/src/cli/release.js +3 -1
  134. package/src/cli/repository.js +35 -9
  135. package/src/cli/run.js +80 -199
  136. package/src/cli/test.js +4 -0
  137. package/src/cli/wireguard.js +32 -31
  138. package/src/client/components/core/Badge.js +0 -1
  139. package/src/client/components/core/CssCore.js +8 -1
  140. package/src/client/components/core/Docs.js +77 -100
  141. package/src/client/components/core/Modal.js +77 -23
  142. package/src/client/components/core/ToolTip.js +15 -0
  143. package/src/client/components/cryptokoyn/AppShellCryptokoyn.js +0 -2
  144. package/src/client/components/cyberia/ActionEngineCyberia.js +12 -36
  145. package/src/client/components/cyberia/EntityEngineCyberia.js +492 -121
  146. package/src/client/components/cyberia/InstanceEngineCyberia.js +129 -160
  147. package/src/client/components/cyberia/InstanceSelectionView.js +7 -1
  148. package/src/client/components/cyberia/MapEngineCyberia.js +175 -69
  149. package/src/client/components/cyberia/ObjectLayerEngine.js +1011 -97
  150. package/src/client/components/cyberia/ObjectLayerEngineModal.js +81 -155
  151. package/src/client/components/cyberia/ObjectLayerEngineViewer.js +113 -98
  152. package/src/client/components/cyberia/SharedDefaultsCyberia.js +215 -56
  153. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +0 -34
  154. package/src/client/components/cyberia-portal/CssCyberiaPortal.js +1 -1
  155. package/src/client/components/cyberia-portal/MainBodyCyberiaPortal.js +712 -76
  156. package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +0 -4
  157. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +0 -4
  158. package/src/client/components/default/AppShellDefault.js +1 -4
  159. package/src/client/components/itemledger/AppShellItemledger.js +0 -2
  160. package/src/client/components/underpost/AppShellUnderpost.js +1 -1
  161. package/src/client/public/cyberia-docs/ARCHITECTURE.md +15 -15
  162. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +280 -24
  163. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +51 -29
  164. package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +52 -53
  165. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +16 -11
  166. package/src/client/public/cyberia-docs/CYBERIA.md +28 -28
  167. package/src/client/public/cyberia-docs/ENTITY-PROFILE.md +11 -5
  168. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  169. package/src/client/public/cyberia-docs/STATS-PROGRESSION.md +217 -0
  170. package/src/client/public/cyberia-docs/WHITE-PAPER.md +20 -12
  171. package/src/client/services/cyberia-audio/cyberia-audio.service.js +99 -0
  172. package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +38 -0
  173. package/src/client/services/cyberia-instance/cyberia-instance.management.js +5 -4
  174. package/src/client/services/cyberia-instance/cyberia-instance.service.js +0 -40
  175. package/src/client/services/cyberia-map/cyberia-map.management.js +5 -4
  176. package/src/client/services/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +138 -0
  177. package/src/client/services/object-layer/object-layer.management.js +6 -10
  178. package/src/client/services/object-layer/object-layer.service.js +3 -1
  179. package/src/client/ssr/views/Cyberia404.js +3 -3
  180. package/src/client-builder/client-build-docs.js +52 -52
  181. package/src/client-builder/client-build.js +67 -44
  182. package/src/client-builder/client-bundle.js +347 -0
  183. package/src/db/mongo/MongoExpress.js +187 -0
  184. package/src/grpc/cyberia/grpc-server.js +5 -7
  185. package/src/index.js +1 -1
  186. package/src/projects/cyberia/atlas-sprite-sheet-generator.js +143 -146
  187. package/src/projects/cyberia/atlas-sprite-sheet-store.js +391 -0
  188. package/src/projects/cyberia/besu-genesis-generator.js +5 -14
  189. package/src/projects/cyberia/boot-contract-fixtures.js +32 -0
  190. package/src/projects/cyberia/catalog-cyberia.js +3 -2
  191. package/src/projects/cyberia/gemini-client.js +3 -3
  192. package/src/projects/cyberia/generate-saga.js +4 -11
  193. package/src/projects/cyberia/hot-reload-trigger.js +17 -28
  194. package/src/projects/cyberia/instance-backup.js +210 -0
  195. package/src/projects/cyberia/instance-data.js +177 -257
  196. package/src/projects/cyberia/map-preview-generator.js +32 -68
  197. package/src/projects/cyberia/object-layer.js +152 -319
  198. package/src/projects/cyberia/seed-audio.js +139 -0
  199. package/src/projects/cyberia/semantic-layer-generator-skin.js +2 -8
  200. package/src/projects/cyberia/semantic-layer-generator.js +2 -18
  201. package/src/projects/cyberia/stat-balance.js +81 -0
  202. package/src/projects/cyberia/stat-commands.js +23 -0
  203. package/src/projects/cyberia/stat-contract-generator.js +101 -0
  204. package/src/runtime/cyberia-client/Dockerfile +8 -4
  205. package/src/runtime/cyberia-client/Dockerfile.dev +5 -2
  206. package/src/runtime/cyberia-server/Dockerfile +9 -2
  207. package/src/runtime/cyberia-server/Dockerfile.dev +8 -1
  208. package/src/runtime/engine-cyberia/Dockerfile +1 -1
  209. package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
  210. package/src/runtime/engine-cyberia/Dockerfile.test +2 -2
  211. package/src/runtime/engine-cyberia/compose.env +17 -5
  212. package/src/runtime/engine-cyberia/docker-compose.yml +23 -17
  213. package/src/server/build/coverage.js +113 -42
  214. package/src/server/build/package.js +55 -12
  215. package/src/server/build/testing.js +59 -9
  216. package/src/server/network/middlewares.js +8 -2
  217. package/src/server/network/underpost-gateway.js +113 -14
  218. package/src/server/ops/logger.js +70 -35
  219. package/src/server/runtime/conf.js +40 -9
  220. package/src/server/runtime/start.js +11 -2
  221. package/src/server/storage/data-query.js +16 -0
  222. package/src/server/storage/downloader.js +62 -14
  223. package/src/server/storage/zip.js +153 -0
  224. package/test/integration/app/cyberia/atlas-sprite-sheet-store.test.js +402 -0
  225. package/test/integration/app/cyberia/atlas-sprite-sheet.test.js +86 -0
  226. package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +34 -0
  227. package/test/integration/app/cyberia/cyberia-entity-type-default.test.js +842 -0
  228. package/test/integration/app/cyberia/cyberia-instance-conf-coerce.test.js +53 -0
  229. package/test/integration/app/cyberia/cyberia-instance-conf-defaults.test.js +16 -22
  230. package/test/integration/app/cyberia/cyberia-instance-items.test.js +90 -0
  231. package/test/integration/app/cyberia/cyberia-load.test.js +6 -7
  232. package/test/integration/app/cyberia/cyberia-map-audio-conf.test.js +256 -0
  233. package/test/integration/app/cyberia/cyberia-stats.test.js +150 -0
  234. package/test/integration/app/cyberia/fallback-world-capture.test.js +87 -2
  235. package/test/integration/app/cyberia/object-layer-item-selection.test.js +56 -0
  236. package/test/integration/app/cyberia/object-layer-natural-key.test.js +32 -0
  237. package/test/integration/app/cyberia/seed-audio.test.js +275 -0
  238. package/test/integration/infra/2-network/wireguard-cli.test.js +33 -9
  239. package/test/integration/infra/2-network/wireguard-edge.test.js +58 -18
  240. package/test/integration/infra/3-cluster/mongo-express-deploy.test.js +156 -0
  241. package/test/integration/infra/4-ingress/api-cross-origin.test.js +79 -0
  242. package/test/integration/infra/4-ingress/underpost-ingress.test.js +1 -1
  243. package/test/unit/catalog.test.js +13 -2
  244. package/test/unit/client-build-docs.test.js +97 -70
  245. package/test/unit/client-bundle.test.js +313 -0
  246. package/test/unit/conf-resolution.test.js +58 -7
  247. package/test/unit/coverage-artifact.test.js +120 -29
  248. package/test/unit/cyberia/instance-backup.test.js +183 -0
  249. package/test/unit/cyberia/instance-data.test.js +314 -0
  250. package/test/unit/cyberia/instance-object-layer-items.test.js +46 -0
  251. package/test/unit/cyberia/publish-workflow.test.js +22 -0
  252. package/test/unit/cyberia/stat-balance.test.js +69 -0
  253. package/test/unit/deploy-legacy-gateway-sweep.test.js +63 -0
  254. package/test/unit/deploy-log-table.test.js +76 -5
  255. package/test/unit/downloader-integrity.test.js +98 -0
  256. package/test/unit/file-reference-registry.test.js +141 -0
  257. package/test/unit/fs-storage-paths.test.js +596 -0
  258. package/test/unit/gateway-pod-fetch.test.js +95 -0
  259. package/test/unit/letsencrypt-issuer.test.js +101 -0
  260. package/test/unit/logger-redaction.test.js +29 -0
  261. package/test/unit/package.test.js +70 -16
  262. package/test/unit/prepare-host.test.js +84 -1
  263. package/test/unit/release-bump.test.js +3 -1
  264. package/test/unit/start-options.test.js +12 -0
  265. package/test/unit/test-tiers.test.js +27 -0
  266. package/test/unit/zip-archive.test.js +142 -0
  267. package/vitest.config.js +11 -3
  268. package/src/api/cyberia-instance/cyberia-fallback-default-items.js +0 -63
  269. package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +0 -368
@@ -29,7 +29,7 @@ class ObjectLayerEngineViewer {
29
29
  currentMode: 'idle',
30
30
  webp: null,
31
31
  isGenerating: false,
32
- currentObjectId: undefined, // Track current loaded object layer id to prevent unnecessary reloads
32
+ currentItemId: undefined, // Item id in the URL, so an unchanged route skips a reload
33
33
  atlasSpriteSheet: null,
34
34
  isGeneratingAtlas: false,
35
35
  webpMetadata: null,
@@ -52,8 +52,8 @@ class ObjectLayerEngineViewer {
52
52
  }
53
53
  static async instance({ appStore }) {
54
54
  const id = 'object-layer-engine-viewer';
55
- // Reset currentObjectId when modal is rendered to ensure Reload triggers properly
56
- ObjectLayerEngineViewer.Data.currentObjectId = undefined;
55
+ // Reset so a modal render always triggers Reload.
56
+ ObjectLayerEngineViewer.Data.currentItemId = undefined;
57
57
  Modal.Data[`modal-${id}`].onReloadModalListener[id] = async () => {
58
58
  ObjectLayerEngineViewer.Reload({ appStore });
59
59
  };
@@ -61,13 +61,12 @@ class ObjectLayerEngineViewer {
61
61
  listenQueryParamsChange({
62
62
  id: `${id}-query-listener`,
63
63
  event: async (queryParams) => {
64
- const objectId = queryParams.id || null;
64
+ const itemId = queryParams.itemId || null;
65
65
  if (!s(`.modal-${id}`) || !s(`#${id}`)) {
66
66
  logger.warn('ObjectLayerEngineViewer DOM not ready for query param change');
67
67
  return;
68
68
  }
69
- // Only reload if object id actually changed (normalize undefined to null for comparison)
70
- if (objectId !== ObjectLayerEngineViewer.Data.currentObjectId) {
69
+ if (itemId !== ObjectLayerEngineViewer.Data.currentItemId) {
71
70
  await ObjectLayerEngineViewer.Reload({ appStore });
72
71
  }
73
72
  },
@@ -90,8 +89,7 @@ class ObjectLayerEngineViewer {
90
89
  logger.warn('ObjectLayerEngineViewer DOM not ready for renderEmpty');
91
90
  return;
92
91
  }
93
- // Clear current object id when rendering empty state
94
- ObjectLayerEngineViewer.Data.currentObjectId = null;
92
+ ObjectLayerEngineViewer.Data.currentItemId = null;
95
93
  // Check if the management table grid already exists AND its DOM is still present
96
94
  // If it does, don't re-render (just let DefaultManagement's RouterEvents handle URL changes)
97
95
  const gridId = `object-layer-engine-management-grid-${idModal}`;
@@ -116,7 +114,7 @@ class ObjectLayerEngineViewer {
116
114
  }),
117
115
  );
118
116
  }
119
- static async loadObjectLayer(objectLayerId, appStore, options = {}) {
117
+ static async loadObjectLayer(itemId, appStore, options = {}) {
120
118
  const { skipWebp = false } = options;
121
119
  const id = 'object-layer-engine-viewer';
122
120
  // Check if DOM element exists
@@ -126,7 +124,7 @@ class ObjectLayerEngineViewer {
126
124
  }
127
125
  try {
128
126
  // Load metadata first
129
- const { status: metaStatus, data: metadata } = await ObjectLayerService.getMetadata({ id: objectLayerId });
127
+ const { status: metaStatus, data: metadata } = await ObjectLayerService.getMetadata({ id: itemId });
130
128
  if (metaStatus !== 'success' || !metadata) {
131
129
  throw new Error('Failed to load object layer metadata');
132
130
  }
@@ -142,15 +140,11 @@ class ObjectLayerEngineViewer {
142
140
  ObjectLayerEngineViewer.Data.atlasSpriteSheet = null;
143
141
  }
144
142
  // Load frame counts for all directions
145
- const { status: frameStatus, data: frameData } = await ObjectLayerService.getFrameCounts({ id: objectLayerId });
143
+ const { status: frameStatus, data: frameData } = await ObjectLayerService.getFrameCounts({ id: itemId });
146
144
  if (frameStatus !== 'success' || !frameData) {
147
145
  throw new Error('Failed to load frame counts');
148
146
  }
149
147
  ObjectLayerEngineViewer.Data.frameCounts = frameData.frameCounts;
150
- // Priority order for directions
151
- const directions = ['down', 'up', 'left', 'right'];
152
- // Priority order for modes
153
- const modes = ['idle', 'walking'];
154
148
  ObjectLayerEngineViewer.Data.currentDirection = 'down';
155
149
  ObjectLayerEngineViewer.Data.currentMode = 'idle';
156
150
  // instance the viewer UI
@@ -162,7 +156,7 @@ class ObjectLayerEngineViewer {
162
156
  } catch (error) {
163
157
  logger.error('Error loading object layer:', error);
164
158
  NotificationManager.Push({
165
- html: `Failed to load object layer: ${error.message}`,
159
+ html: `Failed to load object layer "${itemId}": ${error.message}`,
166
160
  status: 'error',
167
161
  });
168
162
  htmls(
@@ -170,12 +164,16 @@ class ObjectLayerEngineViewer {
170
164
  html`
171
165
  <div class="in section-mp">
172
166
  <div class="in">
173
- <h3>Error</h3>
174
- <p>Failed to load object layer. Please try again.</p>
167
+ <h3>Object layer not found</h3>
168
+ <p>No object layer answers to the item id <strong>${itemId}</strong>.</p>
169
+ <button class="default-viewer-btn" id="return-to-list-btn">
170
+ <i class="fa-solid fa-list"></i> Back to the list
171
+ </button>
175
172
  </div>
176
173
  </div>
177
174
  `,
178
175
  );
176
+ ObjectLayerEngineViewer.attachReturnToList({ appStore });
179
177
  }
180
178
  }
181
179
  static async renderViewer({ appStore }) {
@@ -208,6 +206,29 @@ class ObjectLayerEngineViewer {
208
206
  const numericCode = ObjectLayerEngineViewer.getDirectionCode(direction, mode);
209
207
  return numericCode ? frameCounts[numericCode] || 0 : 0;
210
208
  };
209
+ // One atlas render: which one it is, how large it is, and its PNG.
210
+ // `native` draws the blob at its own pixel size, with no fit to the panel.
211
+ const atlasRender = ({ label, fileId, pixelsPerCell, emptyText, native }) => {
212
+ const metadata = ObjectLayerEngineViewer.Data.atlasSpriteSheet.metadata;
213
+ const size =
214
+ pixelsPerCell > 0
215
+ ? `${metadata.atlasWidth * pixelsPerCell}x${metadata.atlasHeight * pixelsPerCell}px · ${pixelsPerCell}px per cell`
216
+ : '';
217
+ return html`<div class="atlas-render">
218
+ <p class="atlas-render-label">
219
+ <strong class="item-data-key-label">${label}</strong>
220
+ <span class="atlas-render-size">${size}</span>
221
+ </p>
222
+ <div class="atlas-img-wrapper">
223
+ ${fileId
224
+ ? html`<img
225
+ src="${getProxyPath()}api/file/blob/${fileId._id || fileId}"
226
+ class="in atlas-img-preview ${native ? 'atlas-img-native' : ''}"
227
+ />`
228
+ : html`<div class="atlas-img-placeholder">${emptyText}</div>`}
229
+ </div>
230
+ </div>`;
231
+ };
211
232
  ThemeEvents[id] = () => {
212
233
  if (!s(`.style-${id}`)) return;
213
234
  htmls(
@@ -240,6 +261,32 @@ class ObjectLayerEngineViewer {
240
261
  }
241
262
  .atlas-img-preview {
242
263
  width: 100%;
264
+ image-rendering: pixelated;
265
+ background: repeating-conic-gradient(#80808020 0% 25%, #fff0 0% 50%) 50% / 20px 20px;
266
+ }
267
+ /* Own pixel size, so one atlas pixel is one screen pixel. */
268
+ .atlas-img-native {
269
+ width: auto;
270
+ height: auto;
271
+ margin: auto;
272
+ }
273
+ .atlas-img-placeholder {
274
+ padding: 20px;
275
+ text-align: center;
276
+ font-size: 13px;
277
+ color: ${darkTheme ? '#aaa' : '#666'};
278
+ }
279
+ .atlas-render-label {
280
+ display: flex;
281
+ flex-wrap: wrap;
282
+ align-items: baseline;
283
+ gap: 8px;
284
+ padding: 2px;
285
+ font-size: 14px;
286
+ }
287
+ .atlas-render-size {
288
+ font-size: 12px;
289
+ opacity: 0.75;
243
290
  }
244
291
  .atlas-metadata-grid {
245
292
  display: grid;
@@ -723,7 +770,7 @@ class ObjectLayerEngineViewer {
723
770
  />
724
771
  <span class="item-data-key-label">${statInfo.title}</span>
725
772
  </div>
726
- <span class="item-data-value-label">${statValue}</span>
773
+ <span class="item-data-value-label" style="color: ${statValue < 0 ? '#ef7777' : statValue > 0 ? '#7bdd9a' : '#aaa'}">${statValue > 0 ? '+' : ''}${statValue}</span>
727
774
  </div>
728
775
  `;
729
776
  })
@@ -871,24 +918,19 @@ class ObjectLayerEngineViewer {
871
918
  ${ObjectLayerEngineViewer.Data.atlasSpriteSheet
872
919
  ? html`
873
920
  <div class="atlas-preview-container">
874
- ${
875
- ObjectLayerEngineViewer.Data.atlasSpriteSheet.fileId
876
- ? html`
877
- <div class="atlas-img-wrapper">
878
- <img
879
- src="${getProxyPath()}api/file/blob/${ObjectLayerEngineViewer.Data
880
- .atlasSpriteSheet.fileId._id ||
881
- ObjectLayerEngineViewer.Data.atlasSpriteSheet.fileId}"
882
- class="in atlas-img-preview"
883
- />
884
- </div>
885
- `
886
- : html`
887
- <div class="atlas-img-wrapper">
888
- <div class="atlas-img-placeholder">Atlas image not available</div>
889
- </div>
890
- `
891
- }
921
+ ${atlasRender({
922
+ label: 'Upscaled',
923
+ fileId: ObjectLayerEngineViewer.Data.atlasSpriteSheet.fileId,
924
+ pixelsPerCell: ObjectLayerEngineViewer.Data.atlasSpriteSheet.metadata.upscaleFactor,
925
+ emptyText: 'Upscaled render not available',
926
+ })}
927
+ ${atlasRender({
928
+ label: 'Minified',
929
+ fileId: ObjectLayerEngineViewer.Data.atlasSpriteSheet.minifyFileId,
930
+ pixelsPerCell: ObjectLayerEngineViewer.Data.atlasSpriteSheet.metadata.cellPixelDim,
931
+ emptyText: 'Minified render not available',
932
+ native: true,
933
+ })}
892
934
  <div class="atlas-metadata-grid">
893
935
  <div>
894
936
  <p style="padding: 2px"><strong class="item-data-key-label">ID:</strong></p>
@@ -904,16 +946,6 @@ class ObjectLayerEngineViewer {
904
946
  </div>`
905
947
  : ''
906
948
  }
907
- <div>
908
- <p style="padding: 2px"><strong class="item-data-key-label">Dimensions:</strong></p>
909
- <p style="padding: 2px">
910
- ${ObjectLayerEngineViewer.Data.atlasSpriteSheet.metadata.atlasWidth}x${ObjectLayerEngineViewer.Data.atlasSpriteSheet.metadata.atlasHeight}
911
- </p>
912
- </div>
913
- <div>
914
- <p style="padding: 2px"><strong class="item-data-key-label">Cell Dim:</strong></p>
915
- <p style="padding: 2px">${ObjectLayerEngineViewer.Data.atlasSpriteSheet.metadata.cellPixelDim}px</p>
916
- </div>
917
949
  <div>
918
950
  <p style="padding: 2px"><strong class="item-data-key-label">Item Key:</strong></p>
919
951
  <p style="padding: 2px">${ObjectLayerEngineViewer.Data.atlasSpriteSheet.metadata.itemKey}</p>
@@ -1125,12 +1157,12 @@ class ObjectLayerEngineViewer {
1125
1157
  }
1126
1158
  delete ThemeEvents['metadata-json-editor-theme'];
1127
1159
  // Navigate back to list
1128
- ObjectLayerEngineViewer.Data.currentObjectId = undefined;
1160
+ ObjectLayerEngineViewer.Data.currentItemId = undefined;
1129
1161
  ObjectLayerEngineViewer.Data.objectLayer = null;
1130
1162
  ObjectLayerEngineViewer.Data.webp = null;
1131
1163
  ObjectLayerEngineViewer.Data.webpMetadata = null;
1132
1164
  ObjectLayerEngineViewer.Data.atlasSpriteSheet = null;
1133
- setQueryParams({ id: null }, { replace: false });
1165
+ setQueryParams({ itemId: null }, { replace: false });
1134
1166
  } else {
1135
1167
  throw new Error(result.message || 'Failed to delete object layer');
1136
1168
  }
@@ -1142,6 +1174,21 @@ class ObjectLayerEngineViewer {
1142
1174
  });
1143
1175
  }
1144
1176
  }
1177
+ static attachReturnToList({ appStore }) {
1178
+ const listBtn = s('#return-to-list-btn');
1179
+ if (!listBtn) return;
1180
+ listBtn.addEventListener('click', async () => {
1181
+ ObjectLayerEngineViewer.Data.webp = null;
1182
+ ObjectLayerEngineViewer.Data.webpMetadata = null;
1183
+ ObjectLayerEngineViewer.Data.objectLayer = null;
1184
+ ObjectLayerEngineViewer.Data.frameCounts = null;
1185
+ // Cleared before the URL changes, so the query listener sees a match and
1186
+ // skips the Reload that would render the list twice.
1187
+ ObjectLayerEngineViewer.Data.currentItemId = null;
1188
+ setQueryParams({ itemId: null }, { replace: false });
1189
+ await ObjectLayerEngineViewer.renderEmpty({ appStore });
1190
+ });
1191
+ }
1145
1192
  static attachEventListeners({ appStore }) {
1146
1193
  // Direction buttons
1147
1194
  const directionButtons = document.querySelectorAll('[data-direction]');
@@ -1178,28 +1225,7 @@ class ObjectLayerEngineViewer {
1178
1225
  ObjectLayerEngineViewer.downloadWebp();
1179
1226
  });
1180
1227
  }
1181
- // Return to list button
1182
- const listBtn = s('#return-to-list-btn');
1183
- if (listBtn) {
1184
- listBtn.addEventListener('click', async () => {
1185
- // Clear object data and reset state
1186
- ObjectLayerEngineViewer.Data.webp = null;
1187
- ObjectLayerEngineViewer.Data.webpMetadata = null;
1188
- ObjectLayerEngineViewer.Data.objectLayer = null;
1189
- ObjectLayerEngineViewer.Data.frameCounts = null;
1190
- // Set currentObjectId to null BEFORE setQueryParams so the
1191
- // listenQueryParamsChange listener sees the id already matches
1192
- // and skips calling Reload (avoids double-render race condition)
1193
- ObjectLayerEngineViewer.Data.currentObjectId = null;
1194
- // Update the URL to remove the id parameter
1195
- setQueryParams({ id: null }, { replace: false });
1196
- // Directly render the list view instead of relying on the
1197
- // listener → Reload → renderEmpty chain which can silently
1198
- // fail when the URL was already clean or currentObjectId
1199
- // was already null
1200
- await ObjectLayerEngineViewer.renderEmpty({ appStore });
1201
- });
1202
- }
1228
+ ObjectLayerEngineViewer.attachReturnToList({ appStore });
1203
1229
  // Edit button
1204
1230
  const editBtn = s('#edit-object-layer-btn');
1205
1231
  if (editBtn) {
@@ -1477,12 +1503,10 @@ class ObjectLayerEngineViewer {
1477
1503
  });
1478
1504
  }
1479
1505
  static toEngine() {
1480
- const { objectLayer } = ObjectLayerEngineViewer.Data;
1481
- if (!objectLayer || !objectLayer._id) return;
1482
- // Navigate to editor route first
1506
+ const itemId = ObjectLayerEngineViewer.Data.objectLayer?.data?.item?.id;
1507
+ if (!itemId) return;
1483
1508
  setPath(`${getProxyPath()}object-layer-engine`);
1484
- // Then add query param without replacing history
1485
- setQueryParams({ id: objectLayer._id }, { replace: true });
1509
+ setQueryParams({ itemId }, { replace: true });
1486
1510
  if (s(`.modal-object-layer-engine`)) {
1487
1511
  ObjectLayerEngineModal.Reload();
1488
1512
  } else {
@@ -1491,31 +1515,22 @@ class ObjectLayerEngineViewer {
1491
1515
  }
1492
1516
  static async Reload(options = {}) {
1493
1517
  const { appStore, force = false, skipWebp = false } = options;
1494
- const queryParams = getQueryParams();
1495
- const objectId = queryParams.id || null;
1496
- // Only reload if object id actually changed (same logic as listener) or forced
1497
- if (objectId !== ObjectLayerEngineViewer.Data.currentObjectId || force) {
1498
- if (objectId !== ObjectLayerEngineViewer.Data.currentObjectId && !skipWebp) {
1518
+ const itemId = getQueryParams().itemId || null;
1519
+ const changed = itemId !== ObjectLayerEngineViewer.Data.currentItemId;
1520
+ if (changed || force) {
1521
+ if (changed && !skipWebp) {
1499
1522
  ObjectLayerEngineViewer.Data.webp = null;
1500
1523
  ObjectLayerEngineViewer.Data.webpMetadata = null;
1501
1524
  }
1502
- ObjectLayerEngineViewer.Data.currentObjectId = objectId;
1503
- if (objectId) {
1504
- await ObjectLayerEngineViewer.loadObjectLayer(objectId, appStore, { skipWebp });
1505
- } else {
1506
- await ObjectLayerEngineViewer.renderEmpty({ appStore });
1507
- }
1508
- } else if (!objectId && (ObjectLayerEngineViewer.Data.currentObjectId === null || force)) {
1509
- // Special case: if we're already in empty state but DOM might have been reset
1510
- // (e.g., modal reopened), force render the table if DOM is missing
1511
- const id = 'object-layer-engine-viewer';
1512
- const idModal = 'modal-object-layer-engine-viewer';
1513
- const gridId = `object-layer-engine-management-grid-${idModal}`;
1514
- const gridDomExists = s(`.${gridId}`);
1515
- if (!gridDomExists) {
1516
- // DOM was reset (e.g., modal HTML reloaded), re-render the table
1517
- await ObjectLayerEngineViewer.renderEmpty({ appStore });
1518
- }
1525
+ ObjectLayerEngineViewer.Data.currentItemId = itemId;
1526
+ if (itemId) await ObjectLayerEngineViewer.loadObjectLayer(itemId, appStore, { skipWebp });
1527
+ else await ObjectLayerEngineViewer.renderEmpty({ appStore });
1528
+ return;
1529
+ }
1530
+ // Already on the list, but a modal reopen can drop its DOM.
1531
+ if (!itemId && ObjectLayerEngineViewer.Data.currentItemId === null) {
1532
+ const gridId = `object-layer-engine-management-grid-modal-object-layer-engine-viewer`;
1533
+ if (!s(`.${gridId}`)) await ObjectLayerEngineViewer.renderEmpty({ appStore });
1519
1534
  }
1520
1535
  }
1521
1536
  }
@@ -1,51 +1,4 @@
1
- /**
2
- * Shared Cyberia defaults — isomorphic, dependency-free, importable from
3
- * **both** Node and the browser.
4
- *
5
- * Pure plain-data ESM module: no Node built-ins, no DOM APIs, no
6
- * environment guards. Every export is a frozen literal, a constant
7
- * lookup table, or a pure function over those tables. Side-effect free
8
- * on import.
9
- *
10
- * Two responsibilities, no others:
11
- *
12
- * 1. **Presentation values** — palette, entity colour keys, status-icon
13
- * visuals (icon stem + border colour + bounce), camera tunings,
14
- * render flags. Mirrored 1-to-1 by the C/WASM client's compile-time
15
- * defaults in `cyberia-client/src/domain/presentation_defaults.h`.
16
- * Optionally overridable per deployment through the REST endpoint
17
- * `/api/cyberia-client-hints/:code`.
18
- *
19
- * 2. **Shared content vocabulary** — `ITEM_TYPES`, `ENTITY_TYPES`,
20
- * `DefaultCyberiaItems` registry + lookups, type-to-item mapping,
21
- * quest step objective enum. The data shape both the
22
- * browser-side editor UI and the engine REST controllers need to
23
- * understand; it is **not** simulation state.
24
- *
25
- * STRICT BOUNDARIES
26
- * -----------------
27
- * - The cyberia-server (Go) MUST NOT load this file. None of these
28
- * values influence the authoritative simulation. The server owns the
29
- * numeric Entity Status Indicator IDs only (see `cyberia-server-defaults`).
30
- *
31
- * - The C/WASM cyberia-client embeds *presentation* defaults at compile
32
- * time and fetches optional overrides through REST. It does NOT carry
33
- * the JS vocabulary constants — entity-type and item-id strings
34
- * arrive on the wire and are matched directly.
35
- *
36
- * - The browser editor bundles this file via esbuild. It MUST NOT
37
- * import `cyberia-server-defaults.js` — that would pull simulation
38
- * rules, economy, and seed content into the browser bundle. All
39
- * shared vocabulary the editor needs lives here.
40
- *
41
- * Naming: this file is intentionally placed under `src/client/` so its
42
- * URL is `/components/cyberia/SharedDefaultsCyberia.js` when the engine
43
- * static server resolves it. Node-side importers reach in with a
44
- * relative path; both halves see the same module instance.
45
- *
46
- * @module src/client/components/cyberia/SharedDefaultsCyberia.js
47
- */
48
-
1
+ // Shared content, stat contract, and presentation defaults.
49
2
  // ─────────────────────────────────────────────────────────────────────────────
50
3
  // Shared content vocabulary
51
4
  // ─────────────────────────────────────────────────────────────────────────────
@@ -150,6 +103,126 @@ export const SKILL_LOGIC_ID_VALUES = Object.freeze(SKILL_LOGIC_IDS.map((l) => l.
150
103
  /** True when `logicEventId` is a known canonical skill LogicId. */
151
104
  export const isCanonicalSkillLogicId = (logicEventId) => SKILL_LOGIC_ID_VALUES.includes(logicEventId);
152
105
 
106
+ /**
107
+ * The two audio routes: a held music bed, and one-shot effects over it.
108
+ *
109
+ * One bus vocabulary, spelled once: the same ids name the `src/audio-module/<bus-id>/` directory
110
+ * an asset is authored in, the argument `cyberia-audio <bus-id> <id>` dispatches on, the `bus` a
111
+ * recorded manifest carries, and the route a binding's `settings.bus` selects.
112
+ */
113
+ export const AUDIO_BUS_MUSIC = 'music';
114
+ export const AUDIO_BUS_SFX = 'sfx';
115
+ export const AUDIO_BUSES = Object.freeze([AUDIO_BUS_MUSIC, AUDIO_BUS_SFX]);
116
+
117
+ /**
118
+ * Canonical audio LogicId registry — the runtime events an audio binding may answer.
119
+ *
120
+ * Audio is a consumer of the game's semantic event vocabulary, never a second one: a
121
+ * `cyberia-map-audio-conf` binding resolves one of these ids (or a canonical skill LogicId, which
122
+ * the client emits by the same name) to an `audioCode`. The ids live here, beside the other shared
123
+ * vocabularies, because three parties must agree on them — the C client that emits them, the seed
124
+ * that binds them, and the editor that offers them.
125
+ *
126
+ * `bus` is the binding's natural routing: music events take over the map's bed until `idle`
127
+ * restores it; sfx events are one shots. A binding may override it in its own settings.
128
+ *
129
+ * MUST stay aligned with the events emitted by cyberia-client (`src/audio/audio_events.h`).
130
+ *
131
+ * @type {ReadonlyArray<{id:string,name:string,description:string,bus:'music'|'sfx'}>}
132
+ */
133
+ export const AUDIO_LOGIC_IDS = Object.freeze([
134
+ Object.freeze({
135
+ id: 'idle',
136
+ name: 'Idle',
137
+ description: 'No event is holding the bed: the map returns to its default music.',
138
+ bus: AUDIO_BUS_MUSIC,
139
+ }),
140
+ Object.freeze({
141
+ id: 'combat',
142
+ name: 'Combat',
143
+ description: 'The player is taking damage: combat music takes over until the exchange ends.',
144
+ bus: AUDIO_BUS_MUSIC,
145
+ }),
146
+ Object.freeze({ id: 'boss', name: 'Boss', description: 'A boss encounter is under way.', bus: AUDIO_BUS_MUSIC }),
147
+ Object.freeze({
148
+ id: 'portal-cooldown',
149
+ name: 'Portal Cooldown',
150
+ description: 'The player is standing on a portal while its teleport charge runs.',
151
+ bus: AUDIO_BUS_MUSIC,
152
+ }),
153
+ Object.freeze({
154
+ id: 'portal',
155
+ name: 'Portal',
156
+ description: 'The teleport charge completed and the player jumps — within a map or across one.',
157
+ bus: AUDIO_BUS_SFX,
158
+ }),
159
+ Object.freeze({
160
+ id: 'hit',
161
+ name: 'Hit',
162
+ description: 'Damage landed on any entity in view, not only on the player.',
163
+ bus: AUDIO_BUS_SFX,
164
+ }),
165
+ Object.freeze({ id: 'heal', name: 'Heal', description: 'Life was restored to an entity in view.', bus: AUDIO_BUS_SFX }),
166
+ Object.freeze({ id: 'drop', name: 'Drop', description: 'An item drop spawned in the world.', bus: AUDIO_BUS_SFX }),
167
+ Object.freeze({
168
+ id: 'victory',
169
+ name: 'Victory',
170
+ description: 'A quest or encounter was completed.',
171
+ bus: AUDIO_BUS_SFX,
172
+ }),
173
+ Object.freeze({
174
+ id: 'level-up',
175
+ name: 'Level Up',
176
+ description: 'An entity in view reached a new level.',
177
+ bus: AUDIO_BUS_SFX,
178
+ }),
179
+ Object.freeze({
180
+ id: 'death',
181
+ name: 'Death',
182
+ description: 'An entity in view was defeated.',
183
+ bus: AUDIO_BUS_SFX,
184
+ }),
185
+ Object.freeze({
186
+ id: 'item-pickup',
187
+ name: 'Item Pickup',
188
+ description: 'An item landed in the player inventory — loot, a reward, a purchase, an assembly output.',
189
+ bus: AUDIO_BUS_SFX,
190
+ }),
191
+ Object.freeze({
192
+ id: 'craft',
193
+ name: 'Craft',
194
+ description: 'An assembly is charging: the bed is held until the recipe progress bar finishes.',
195
+ bus: AUDIO_BUS_MUSIC,
196
+ }),
197
+ Object.freeze({
198
+ id: 'ui-click',
199
+ name: 'UI Click',
200
+ description: 'The interface accepted a tap — a button, a modal control, a bubble.',
201
+ bus: AUDIO_BUS_SFX,
202
+ }),
203
+ Object.freeze({
204
+ id: 'footsteps',
205
+ name: 'Footsteps',
206
+ description: 'Something with feet is walking in view; repeats on a gait cadence while it lasts.',
207
+ bus: AUDIO_BUS_SFX,
208
+ }),
209
+ ]);
210
+
211
+ /** Ordered list of canonical audio LogicId values. */
212
+ export const AUDIO_LOGIC_ID_VALUES = Object.freeze(AUDIO_LOGIC_IDS.map((l) => l.id));
213
+
214
+ /** Map: audio LogicId → its natural bus. */
215
+ export const AUDIO_LOGIC_ID_BUSES = Object.freeze(
216
+ Object.fromEntries(AUDIO_LOGIC_IDS.map((l) => [l.id, l.bus])),
217
+ );
218
+
219
+ /**
220
+ * True when `logicEventId` is a runtime event an audio binding may answer: an audio event, or a
221
+ * skill event the dispatcher fires (a skill's own id is what the client emits when it runs).
222
+ */
223
+ export const isCanonicalAudioLogicId = (logicEventId) =>
224
+ AUDIO_LOGIC_ID_VALUES.includes(logicEventId) || isCanonicalSkillLogicId(logicEventId);
225
+
153
226
  /**
154
227
  * Canonical entity-behavior registry — the authoritative vocabulary for the
155
228
  * `behavior` an entity-type default may bind to its matched entities. The Go
@@ -277,19 +350,105 @@ export const OBJECT_LAYER_DIRECTION_NAME_TO_CODE = Object.freeze(
277
350
  }, {}),
278
351
  );
279
352
 
353
+ // Stat order is shared by authoring, simulation, and snapshots. `scale` is what one point is
354
+ // worth in the simulation; the server reads it from the generated contract.
355
+ export const STAT_DEFINITIONS = Object.freeze([
356
+ { key: 'effect', title: 'Effect', description: 'Removes life on impact.', detail: 'Each point removes one life point per hit.', floor: 1, scale: 1 },
357
+ { key: 'resistance', title: 'Resistance', description: 'Adds maximum life and regeneration amount.', detail: 'Each point adds one point of maximum life and a tenth of a point to each regeneration.', floor: 0, scale: 1 },
358
+ { key: 'agility', title: 'Agility', description: 'Changes movement speed.', detail: 'Each point adds one percent of base speed.', floor: -90, scale: 0.01 },
359
+ { key: 'range', title: 'Range', description: 'Adds lifetime to summoned entities.', detail: 'Each point adds fifty milliseconds of summon lifetime.', floor: 0, scale: 50 },
360
+ { key: 'intelligence', title: 'Intelligence', description: 'Adds summon success chance.', detail: 'Each point adds five percentage points of summon chance.', floor: 0, scale: 0.05 },
361
+ { key: 'utility', title: 'Utility', description: 'Reduces action cooldown and increases regeneration chance.', detail: 'Each point removes one percent of base cooldown and adds one percentage point of regeneration chance.', floor: 0, scale: 0.01 },
362
+ ].map((stat) => Object.freeze({ ...stat, icon: `stat-${stat.key}.png` })));
363
+ export const STAT_TYPES = Object.freeze(STAT_DEFINITIONS.map(({ key }) => key));
364
+ /** What one point of each stat is worth in the simulation. */
365
+ export const STAT_SCALES = Object.freeze(Object.fromEntries(STAT_DEFINITIONS.map(({ key, scale }) => [key, scale])));
366
+ /** Life regenerated per trigger for each point of resistance, on top of the entity's base regeneration. */
367
+ export const STAT_REGEN_PER_RESISTANCE = 0.1;
368
+ export const STAT_MODIFIER_MIN = -100;
369
+ export const STAT_MODIFIER_MAX = 100;
370
+ export const STAT_DEFAULT = 0;
371
+ export const ENTITY_LEVEL_MIN = 1;
372
+ export const ENTITY_LEVEL_MAX = 65535;
373
+ export function validateEntityLevel(level) {
374
+ if (!Number.isInteger(level) || level < ENTITY_LEVEL_MIN || level > ENTITY_LEVEL_MAX) {
375
+ throw new RangeError('Entity level must be an integer from 1 to 65535.');
376
+ }
377
+ return level;
378
+ }
379
+ export const STAT_CONTRACT_VERSION = 2;
380
+ export const STAT_DEFAULTS = Object.freeze(Object.fromEntries(STAT_TYPES.map((key) => [key, STAT_DEFAULT])));
381
+ export const STAT_EFFECTIVE_FLOORS = Object.freeze(Object.fromEntries(STAT_DEFINITIONS.map(({ key, floor }) => [key, floor])));
382
+ export const STAT_DESCRIPTIONS = Object.freeze(Object.fromEntries(STAT_DEFINITIONS.map(({ key, ...info }) => [key, Object.freeze(info)])));
383
+
384
+ export function validateStatModifier(value) {
385
+ if (!Number.isInteger(value) || value < STAT_MODIFIER_MIN || value > STAT_MODIFIER_MAX) {
386
+ throw new RangeError(`Stat modifiers must be integers from ${STAT_MODIFIER_MIN} to +${STAT_MODIFIER_MAX}.`);
387
+ }
388
+ return value;
389
+ }
390
+
391
+ export function validateStats(stats = {}) {
392
+ if (!stats || typeof stats !== 'object' || Array.isArray(stats)) throw new TypeError('Stats must be an object.');
393
+ for (const key of Object.keys(stats)) {
394
+ if (!STAT_TYPES.includes(key)) throw new TypeError(`Unknown stat: ${key}`);
395
+ }
396
+ return Object.fromEntries(STAT_TYPES.map((key) => [key, validateStatModifier(stats[key] === undefined ? STAT_DEFAULT : stats[key])]));
397
+ }
398
+
280
399
  /**
281
- * Canonical entity/item stat types, in display order. Shared by the object-layer
282
- * editor (stat inputs) and the asset pipeline (random stat generation).
400
+ * Semantic stat bounds per item type, inclusive, as `[min, max]` per stat.
401
+ *
402
+ * What a type may carry: a weapon deals its effect and can cost resistance, a skin and a
403
+ * breastplate defend, a skill reaches, and world content carries nothing. A type absent here,
404
+ * or a stat a type leaves out, keeps the contract bounds.
283
405
  */
284
- export const STAT_TYPES = Object.freeze(['effect', 'resistance', 'agility', 'range', 'intelligence', 'utility']);
406
+ export const STAT_TYPE_BOUNDS = Object.freeze({
407
+ weapon: { effect: [5, 20], resistance: [-10, 2], agility: [-5, 5], range: [0, 10], intelligence: [0, 5], utility: [0, 10] },
408
+ skin: { effect: [1, 1], resistance: [0, 20], agility: [-5, 10], range: [0, 0], intelligence: [0, 2], utility: [0, 5] },
409
+ breastplate: { effect: [0, 0], resistance: [5, 30], agility: [-15, 0], range: [0, 0], intelligence: [0, 3], utility: [0, 5] },
410
+ skill: { effect: [0, 15], resistance: [0, 5], agility: [0, 5], range: [5, 30], intelligence: [0, 10], utility: [0, 10] },
411
+ resource: { effect: [0, 0], resistance: [0, 10], agility: [0, 0], range: [0, 0], intelligence: [0, 0], utility: [0, 0] },
412
+ coin: { effect: [0, 0], resistance: [0, 0], agility: [0, 0], range: [0, 0], intelligence: [0, 0], utility: [0, 0] },
413
+ floor: { effect: [0, 0], resistance: [0, 0], agility: [0, 0], range: [0, 0], intelligence: [0, 0], utility: [0, 0] },
414
+ obstacle: { effect: [0, 0], resistance: [0, 0], agility: [0, 0], range: [0, 0], intelligence: [0, 0], utility: [0, 0] },
415
+ portal: { effect: [0, 0], resistance: [0, 0], agility: [0, 0], range: [0, 0], intelligence: [0, 0], utility: [0, 0] },
416
+ foreground: { effect: [0, 0], resistance: [0, 0], agility: [0, 0], range: [0, 0], intelligence: [0, 0], utility: [0, 0] },
417
+ static: { effect: [0, 0], resistance: [0, 0], agility: [0, 0], range: [0, 0], intelligence: [0, 0], utility: [0, 0] },
418
+ });
419
+
420
+ for (const [type, bounds] of Object.entries(STAT_TYPE_BOUNDS)) {
421
+ if (!Object.values(ITEM_TYPES).includes(type)) throw new Error(`STAT_TYPE_BOUNDS: unknown item type "${type}"`);
422
+ for (const [key, [min, max]] of Object.entries(bounds)) {
423
+ if (!STAT_TYPES.includes(key)) throw new Error(`STAT_TYPE_BOUNDS: ${type} names unknown stat "${key}"`);
424
+ validateStatModifier(min);
425
+ validateStatModifier(max);
426
+ if (min > max) throw new Error(`STAT_TYPE_BOUNDS: ${type}.${key} minimum exceeds its maximum`);
427
+ }
428
+ }
285
429
 
286
430
  /**
287
- * Build a random stat block keyed by STAT_TYPES, each value 0–10 inclusive.
288
- * Non-deterministic (uses Math.random) the asset pipeline uses it to seed
289
- * default stats for newly authored object layers.
431
+ * The inclusive `[min, max]` every stat may take for an item type: the semantic bound where the
432
+ * type declares one, the contract bound where it does not.
433
+ *
434
+ * @param {string} itemType
435
+ * @returns {Readonly<Record<string,[number,number]>>}
290
436
  */
291
- export const generateRandomStats = () =>
292
- Object.fromEntries(STAT_TYPES.map((statType) => [statType, Math.floor(Math.random() * 11)]));
437
+ export function statBoundsForType(itemType) {
438
+ const typed = STAT_TYPE_BOUNDS[itemType] ?? {};
439
+ return Object.freeze(Object.fromEntries(STAT_TYPES.map((key) => [key, typed[key] ?? [STAT_MODIFIER_MIN, STAT_MODIFIER_MAX]])));
440
+ }
441
+
442
+ export function generateRandomStats(min = STAT_MODIFIER_MIN, max = STAT_MODIFIER_MAX, random = Math.random) {
443
+ validateStatModifier(min);
444
+ validateStatModifier(max);
445
+ if (min > max) throw new RangeError('Random minimum must not exceed maximum.');
446
+ return Object.fromEntries(STAT_TYPES.map((key) => {
447
+ const value = random();
448
+ if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError('Random source must return [0, 1).');
449
+ return [key, Math.floor(value * (max - min + 1)) + min];
450
+ }));
451
+ }
293
452
 
294
453
  /**
295
454
  * Canonical (itemId → itemType) registry shipped with the engine. Used