@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
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Persistence for the atlas sprite sheet of one object layer item.
3
+ *
4
+ * Owns the AtlasSpriteSheet document, the File blobs it points at, and the
5
+ * IPFS pins of the atlas PNG and its metadata. Every writer — the REST service
6
+ * and the Cyberia CLI — goes through here, so one item key always resolves to
7
+ * one atlas.
8
+ *
9
+ * @module src/projects/cyberia/atlas-sprite-sheet-store.js
10
+ * @namespace CyberiaAtlasSpriteSheetStore
11
+ */
12
+
13
+ import crypto from 'crypto';
14
+ import { Types } from 'mongoose';
15
+ import { DataBaseProviderService } from '../../db/DataBaseProvider.js';
16
+ import { loggerFactory } from '../../server/ops/logger.js';
17
+ import { createPinRecord } from '../../api/ipfs/ipfs.service.js';
18
+ import { FileFactory } from '../../api/file/file.service.js';
19
+ import { deleteOwnedFiles, documentFileIds, fileRefFields } from '../../api/file/file.ref.js';
20
+ import { AtlasSpriteSheetGenerator, DEFAULT_ATLAS_UPSCALE_FACTOR } from './atlas-sprite-sheet-generator.js';
21
+ import { IpfsClient } from './ipfs-client.js';
22
+
23
+ const logger = loggerFactory(import.meta);
24
+
25
+ /** The File-referencing fields of this model, read from the registry that owns that mapping. */
26
+ export const ATLAS_FILE_FIELDS = fileRefFields('atlas-sprite-sheet');
27
+
28
+ /**
29
+ * The File `name` an atlas render is written under, as {@link upsertRenderFile} spells it.
30
+ * A prune reads it to recognise a render whose atlas is already gone.
31
+ * @constant {RegExp}
32
+ * @memberof CyberiaAtlasSpriteSheetStore
33
+ */
34
+ const ATLAS_RENDER_NAME = /-(atlas|minify|idle)\.png$/;
35
+
36
+ /**
37
+ * MFS paths of the IPFS content an item key owns.
38
+ * @param {string} itemKey - Object layer item id.
39
+ * @returns {{ png: string, metadata: string }}
40
+ * @memberof CyberiaAtlasSpriteSheetStore
41
+ */
42
+ export const atlasMfsPaths = (itemKey) => ({
43
+ png: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
44
+ metadata: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
45
+ });
46
+
47
+ /**
48
+ * The File `_id` a render resolves through, derived from the item key, the render
49
+ * role and the bytes themselves.
50
+ *
51
+ * Content-addressed on purpose: identical bytes re-derive the same id, which makes
52
+ * a rerun a no-op instead of a churn of File documents. Changed bytes land on a new
53
+ * id, and the writer deletes the one it replaced.
54
+ *
55
+ * @param {string} itemKey - Object layer item id.
56
+ * @param {string} role - Render role: `atlas`, `minify` or `idle`.
57
+ * @param {string} md5 - Hex MD5 of the PNG bytes.
58
+ * @returns {import('mongoose').Types.ObjectId}
59
+ * @memberof CyberiaAtlasSpriteSheetStore
60
+ */
61
+ const atlasFileId = (itemKey, role, md5) =>
62
+ new Types.ObjectId(crypto.createHash('sha256').update(`atlas:${itemKey}:${role}:${md5}`).digest('hex').slice(0, 24));
63
+
64
+ /**
65
+ * Writes a PNG render to the File collection under its content-addressed id.
66
+ * Re-writing the same bytes returns the stored document untouched.
67
+ *
68
+ * @param {Object} File - Mongoose File model.
69
+ * @param {string} itemKey - Object layer item id.
70
+ * @param {string} role - Render role: `atlas`, `minify` or `idle`.
71
+ * @param {Buffer} buffer - PNG bytes.
72
+ * @returns {Promise<import('mongoose').Types.ObjectId>} The File id.
73
+ * @memberof CyberiaAtlasSpriteSheetStore
74
+ */
75
+ const upsertRenderFile = async (File, itemKey, role, buffer) => {
76
+ const payload = FileFactory.create(buffer, `${itemKey}-${role}.png`);
77
+ const _id = atlasFileId(itemKey, role, payload.md5);
78
+ await File.updateOne({ _id }, { $setOnInsert: { ...payload, _id } }, { upsert: true });
79
+ return _id;
80
+ };
81
+
82
+ /**
83
+ * Writes the idle-preview still cut from a human-resolution render, or returns null
84
+ * for an atlas with no frame to cut.
85
+ *
86
+ * @param {Object} File - Mongoose File model.
87
+ * @param {string} itemKey - Object layer item id.
88
+ * @param {Buffer} buffer - PNG bytes of the human-resolution render.
89
+ * @param {Object} metadata - Atlas metadata describing that render's layout.
90
+ * @returns {Promise<import('mongoose').Types.ObjectId|null>}
91
+ * @memberof CyberiaAtlasSpriteSheetStore
92
+ */
93
+ const upsertIdlePreview = async (File, itemKey, buffer, metadata) => {
94
+ const still = await AtlasSpriteSheetGenerator.idlePreviewFromAtlas(buffer, metadata);
95
+ return still ? await upsertRenderFile(File, itemKey, 'idle', still) : null;
96
+ };
97
+
98
+ /**
99
+ * Deletes the File documents an atlas no longer points at.
100
+ *
101
+ * @param {Object} File - Mongoose File model.
102
+ * @param {Array<import('mongoose').Types.ObjectId>} previousIds - File ids held before the write.
103
+ * @param {Array<import('mongoose').Types.ObjectId>} currentIds - File ids held after the write.
104
+ * @returns {Promise<void>}
105
+ * @memberof CyberiaAtlasSpriteSheetStore
106
+ */
107
+ const deleteReplacedFiles = async (File, previousIds, currentIds) => {
108
+ const kept = new Set(currentIds.filter(Boolean).map(String));
109
+ const replaced = [...new Set(previousIds.filter(Boolean).map(String))].filter((id) => !kept.has(id));
110
+ if (replaced.length === 0) return;
111
+ await File.deleteMany({ _id: { $in: replaced } });
112
+ logger.info(`Removed ${replaced.length} replaced atlas File document(s)`);
113
+ };
114
+
115
+ /**
116
+ * Pins a payload to IPFS and records it in the registry. A failure is logged and
117
+ * yields an empty CID, so a missing IPFS node never blocks a write.
118
+ *
119
+ * @param {Object} params
120
+ * @param {function(): Promise<{cid: string}|null>} params.add - The IpfsClient call.
121
+ * @param {string} params.resourceType - Registry category.
122
+ * @param {string} params.mfsPath - MFS path of the payload.
123
+ * @param {Object} [params.options] - Router options ({ host, path }).
124
+ * @returns {Promise<string>} The CID, or an empty string.
125
+ * @memberof CyberiaAtlasSpriteSheetStore
126
+ */
127
+ const pin = async ({ add, resourceType, mfsPath, options }) => {
128
+ try {
129
+ const result = await add();
130
+ if (!result) return '';
131
+ await createPinRecord({ cid: result.cid, resourceType, mfsPath, options });
132
+ logger.info(`Pinned ${resourceType} – CID: ${result.cid}`);
133
+ return result.cid;
134
+ } catch (error) {
135
+ logger.warn(`Failed to pin ${resourceType}: ${error.message}`);
136
+ return '';
137
+ }
138
+ };
139
+
140
+ /**
141
+ * Tells whether two atlas layouts place every frame at the same cell.
142
+ *
143
+ * @param {Object} left - Atlas metadata.
144
+ * @param {Object} right - Atlas metadata.
145
+ * @returns {boolean}
146
+ * @memberof CyberiaAtlasSpriteSheetStore
147
+ */
148
+ const sameLayout = (left, right) => {
149
+ if (left.atlasWidth !== right.atlasWidth || left.atlasHeight !== right.atlasHeight) return false;
150
+ const box = (frame) => (frame ? `${frame.x},${frame.y},${frame.width},${frame.height}` : '');
151
+ const directions = new Set([...Object.keys(left.frames ?? {}), ...Object.keys(right.frames ?? {})]);
152
+ for (const direction of directions) {
153
+ const leftFrames = left.frames?.[direction] ?? [];
154
+ const rightFrames = right.frames?.[direction] ?? [];
155
+ if (leftFrames.length !== rightFrames.length) return false;
156
+ for (let index = 0; index < leftFrames.length; index++) {
157
+ if (box(leftFrames[index]) !== box(rightFrames[index])) return false;
158
+ }
159
+ }
160
+ return true;
161
+ };
162
+
163
+ /**
164
+ * Atlas sprite sheet persistence, keyed by object layer item id.
165
+ * @class AtlasSpriteSheetStore
166
+ * @memberof CyberiaAtlasSpriteSheetStore
167
+ */
168
+ export class AtlasSpriteSheetStore {
169
+ /**
170
+ * Generates both renders of an item's atlas and stores them.
171
+ *
172
+ * The write is keyed by `metadata.itemKey`, so a rerun updates the same
173
+ * document. Renders whose bytes did not change keep their File documents;
174
+ * renders that changed replace them and the old ones are deleted.
175
+ *
176
+ * @static
177
+ * @param {Object} params
178
+ * @param {string} params.itemKey - Object layer item id.
179
+ * @param {Object} params.objectLayerRenderFrames - Render frames, document or plain object.
180
+ * @param {number} [params.upscaleFactor=DEFAULT_ATLAS_UPSCALE_FACTOR] - Pixels per cell of the human-resolution render.
181
+ * @param {number} [params.maxAtlasDim=null] - Maximum atlas dimension, auto-calculated when null.
182
+ * @param {Object} [params.options] - Router options ({ host, path }) for model lookup.
183
+ * @returns {Promise<{atlasDoc: Object, metadata: Object, atlasCid: string, atlasMetadataCid: string}>}
184
+ * `metadata` is the plain generated layout, free of the document wrapper a caller would have to unwrap.
185
+ * @memberof CyberiaAtlasSpriteSheetStore
186
+ */
187
+ static async persist({
188
+ itemKey,
189
+ objectLayerRenderFrames,
190
+ upscaleFactor = DEFAULT_ATLAS_UPSCALE_FACTOR,
191
+ maxAtlasDim = null,
192
+ options,
193
+ }) {
194
+ const AtlasSpriteSheet = DataBaseProviderService.getModel('AtlasSpriteSheet', options);
195
+ const File = DataBaseProviderService.getModel('File', options);
196
+
197
+ const { buffer, minifyBuffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
198
+ objectLayerRenderFrames,
199
+ itemKey,
200
+ upscaleFactor,
201
+ maxAtlasDim,
202
+ );
203
+
204
+ const fileId = await upsertRenderFile(File, itemKey, 'atlas', buffer);
205
+ const minifyFileId = await upsertRenderFile(File, itemKey, 'minify', minifyBuffer);
206
+ const idlePreviewFileId = await upsertIdlePreview(File, itemKey, buffer, metadata);
207
+
208
+ const mfsPaths = atlasMfsPaths(itemKey);
209
+ const atlasCid = await pin({
210
+ add: () => IpfsClient.addBufferToIpfs(buffer, `${itemKey}_atlas_sprite_sheet.png`, mfsPaths.png),
211
+ resourceType: 'atlas-sprite-sheet',
212
+ mfsPath: mfsPaths.png,
213
+ options,
214
+ });
215
+ const atlasMetadataCid = await pin({
216
+ add: () => IpfsClient.addJsonToIpfs(metadata, `${itemKey}_atlas_sprite_sheet_metadata.json`, mfsPaths.metadata),
217
+ resourceType: 'atlas-metadata',
218
+ mfsPath: mfsPaths.metadata,
219
+ options,
220
+ });
221
+
222
+ let atlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
223
+ const previousFileIds = atlasDoc ? ATLAS_FILE_FIELDS.map((field) => atlasDoc[field]) : [];
224
+
225
+ const renders = { fileId, minifyFileId, idlePreviewFileId };
226
+ if (atlasDoc) {
227
+ atlasDoc.set({ ...renders, cid: atlasCid, metadata });
228
+ await atlasDoc.save();
229
+ } else {
230
+ atlasDoc = await new AtlasSpriteSheet({ ...renders, cid: atlasCid, metadata }).save();
231
+ }
232
+
233
+ await deleteReplacedFiles(File, previousFileIds, Object.values(renders));
234
+
235
+ return { atlasDoc, metadata, atlasCid, atlasMetadataCid };
236
+ }
237
+
238
+ /**
239
+ * Removes atlases and everything they own: every File render they point at, and
240
+ * the documents themselves.
241
+ *
242
+ * The one place a caller deletes an atlas, because an atlas owns more than its
243
+ * document: a drop that took `fileId` and left `minifyFileId` behind is what put
244
+ * unreachable renders in the File collection. Rerunnable — a second call finds
245
+ * nothing and reports zeros.
246
+ *
247
+ * @static
248
+ * @param {Object} params
249
+ * @param {string[]} [params.itemKeys] - Item keys to purge.
250
+ * @param {Array<*>} [params.atlasIds] - Atlas document ids to purge, for a caller holding the link rather than the key.
251
+ * @param {boolean} [params.all=false] - Purge the whole collection, ignoring both selectors.
252
+ * @param {Object} [params.options] - Router options ({ host, path }) for model lookup.
253
+ * @returns {Promise<{atlases: number, files: number, cids: string[]}>} What was removed, and the CIDs the caller still has to unpin.
254
+ * @memberof CyberiaAtlasSpriteSheetStore
255
+ */
256
+ static async purge({ itemKeys = [], atlasIds = [], all = false, options }) {
257
+ const AtlasSpriteSheet = DataBaseProviderService.getModel('AtlasSpriteSheet', options);
258
+ const File = DataBaseProviderService.getModel('File', options);
259
+
260
+ const clauses = [];
261
+ if (itemKeys.length > 0) clauses.push({ 'metadata.itemKey': { $in: itemKeys } });
262
+ if (atlasIds.length > 0) clauses.push({ _id: { $in: atlasIds } });
263
+ if (!all && clauses.length === 0) return { atlases: 0, files: 0, cids: [] };
264
+ const selector = all ? {} : clauses.length === 1 ? clauses[0] : { $or: clauses };
265
+
266
+ const docs = await AtlasSpriteSheet.find(
267
+ selector,
268
+ Object.fromEntries([...ATLAS_FILE_FIELDS, 'cid'].map((field) => [field, 1])),
269
+ ).lean();
270
+ if (docs.length === 0) return { atlases: 0, files: 0, cids: [] };
271
+
272
+ const fileIds = documentFileIds(docs, ATLAS_FILE_FIELDS);
273
+ const cids = [...new Set(docs.map((doc) => doc.cid).filter(Boolean))];
274
+
275
+ // Documents first: the survivors this reads decide which renders are still
276
+ // reachable, so the ones being dropped must already be gone.
277
+ const { deletedCount } = await AtlasSpriteSheet.deleteMany({ _id: { $in: docs.map((doc) => doc._id) } });
278
+ const files = await deleteOwnedFiles({ File, Owner: AtlasSpriteSheet, fields: ATLAS_FILE_FIELDS, ids: fileIds });
279
+
280
+ return { atlases: deletedCount ?? 0, files, cids };
281
+ }
282
+
283
+ /**
284
+ * Deletes atlas renders no atlas document points at any more.
285
+ *
286
+ * The repair for what an earlier drop left behind: candidates are recognised by
287
+ * the name the writer gives a render, and a candidate a live atlas still holds
288
+ * is kept. Idempotent, and a no-op on a collection that never leaked.
289
+ *
290
+ * @static
291
+ * @param {Object} params
292
+ * @param {Object} [params.options] - Router options ({ host, path }) for model lookup.
293
+ * @returns {Promise<number>} How many orphaned renders were removed.
294
+ * @memberof CyberiaAtlasSpriteSheetStore
295
+ */
296
+ static async pruneOrphanRenders({ options } = {}) {
297
+ const AtlasSpriteSheet = DataBaseProviderService.getModel('AtlasSpriteSheet', options);
298
+ const File = DataBaseProviderService.getModel('File', options);
299
+
300
+ const candidates = await File.find({ name: { $regex: ATLAS_RENDER_NAME } }, { _id: 1 }).lean();
301
+ if (candidates.length === 0) return 0;
302
+
303
+ const removed = await deleteOwnedFiles({
304
+ File,
305
+ Owner: AtlasSpriteSheet,
306
+ fields: ATLAS_FILE_FIELDS,
307
+ ids: candidates.map((doc) => doc._id),
308
+ });
309
+ if (removed > 0) logger.info(`Removed ${removed} orphaned atlas File document(s)`);
310
+ return removed;
311
+ }
312
+
313
+ /**
314
+ * Refreshes only the minified render of a stored atlas.
315
+ *
316
+ * The layout must still match the stored metadata, because the client runtime
317
+ * pairs `GET /blob/:itemKey` with `GET /metadata/:itemKey`. A layout that moved
318
+ * means the render frames changed after the atlas was generated, so the item is
319
+ * reported as stale instead of written.
320
+ *
321
+ * @static
322
+ * @param {Object} params
323
+ * @param {string} params.itemKey - Object layer item id.
324
+ * @param {Object} params.objectLayerRenderFrames - Render frames, document or plain object.
325
+ * @param {Object} [params.options] - Router options ({ host, path }) for model lookup.
326
+ * @returns {Promise<{ status: 'updated'|'unchanged'|'missing'|'stale', minifyFileId?: import('mongoose').Types.ObjectId }>}
327
+ * @memberof CyberiaAtlasSpriteSheetStore
328
+ */
329
+ static async syncMinifyRender({ itemKey, objectLayerRenderFrames, options }) {
330
+ const AtlasSpriteSheet = DataBaseProviderService.getModel('AtlasSpriteSheet', options);
331
+ const File = DataBaseProviderService.getModel('File', options);
332
+
333
+ const atlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
334
+ if (!atlasDoc) return { status: 'missing' };
335
+
336
+ // Only the minified render is wanted here, so ask for no upscaled second render.
337
+ const { minifyBuffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
338
+ objectLayerRenderFrames,
339
+ itemKey,
340
+ 1,
341
+ );
342
+
343
+ if (!sameLayout(metadata, atlasDoc.metadata.toObject ? atlasDoc.metadata.toObject() : atlasDoc.metadata)) {
344
+ return { status: 'stale' };
345
+ }
346
+
347
+ const previousId = atlasDoc.minifyFileId;
348
+ const minifyFileId = await upsertRenderFile(File, itemKey, 'minify', minifyBuffer);
349
+ if (previousId && String(previousId) === String(minifyFileId)) {
350
+ return { status: 'unchanged', minifyFileId };
351
+ }
352
+
353
+ await AtlasSpriteSheet.updateOne({ _id: atlasDoc._id }, { $set: { minifyFileId } });
354
+ await deleteReplacedFiles(File, [previousId], [minifyFileId]);
355
+
356
+ return { status: 'updated', minifyFileId };
357
+ }
358
+
359
+ /**
360
+ * Fills or refreshes the idle-preview still of a stored atlas from the render it already
361
+ * holds, so an atlas written before the still existed gains one without a regeneration.
362
+ *
363
+ * @static
364
+ * @param {Object} params
365
+ * @param {string} params.itemKey - Object layer item id.
366
+ * @param {Object} [params.options] - Router options ({ host, path }) for model lookup.
367
+ * @returns {Promise<{ status: 'updated'|'unchanged'|'missing', idlePreviewFileId?: import('mongoose').Types.ObjectId }>}
368
+ * @memberof CyberiaAtlasSpriteSheetStore
369
+ */
370
+ static async syncIdlePreview({ itemKey, options }) {
371
+ const AtlasSpriteSheet = DataBaseProviderService.getModel('AtlasSpriteSheet', options);
372
+ const File = DataBaseProviderService.getModel('File', options);
373
+
374
+ const atlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
375
+ // Hydrated, not lean: a lean read hands back a BSON Binary the decoder cannot open.
376
+ const render = atlasDoc?.fileId ? await File.findById(atlasDoc.fileId) : null;
377
+ if (!render?.data) return { status: 'missing' };
378
+
379
+ const metadata = atlasDoc.metadata.toObject ? atlasDoc.metadata.toObject() : atlasDoc.metadata;
380
+ const previousId = atlasDoc.idlePreviewFileId;
381
+ const idlePreviewFileId = await upsertIdlePreview(File, itemKey, Buffer.from(render.data), metadata);
382
+ if (previousId && String(previousId) === String(idlePreviewFileId)) {
383
+ return { status: 'unchanged', idlePreviewFileId };
384
+ }
385
+
386
+ await AtlasSpriteSheet.updateOne({ _id: atlasDoc._id }, { $set: { idlePreviewFileId } });
387
+ await deleteReplacedFiles(File, [previousId], [idlePreviewFileId]);
388
+
389
+ return { status: 'updated', idlePreviewFileId };
390
+ }
391
+ }
@@ -61,26 +61,17 @@ function derivePublicKey(privateKeyHex) {
61
61
  * (uncompressed, sans 04 prefix) public key bytes:
62
62
  * `address = keccak256(pubKeyBytes)[12..31]`
63
63
  *
64
- * **Important:** Ethereum uses the *original* Keccak-256 (the pre-NIST
65
- * submission), NOT NIST SHA3-256. NIST added different domain-separation
66
- * padding when standardising SHA-3, so `crypto.createHash('sha3-256')`
67
- * produces **wrong** addresses. We use `ethers.keccak256` which bundles
68
- * a correct Keccak-256 implementation.
64
+ * Ethereum uses the original Keccak-256, not NIST SHA3-256. NIST added other
65
+ * domain-separation padding, so `crypto.createHash('sha3-256')` gives wrong
66
+ * addresses. `ethers.keccak256` carries a correct Keccak-256.
69
67
  *
70
68
  * @param {string} publicKeyHex - 128-char hex public key (no 0x prefix).
71
69
  * @returns {string} 40-char lowercase hex Ethereum address (no 0x prefix).
72
70
  * @memberof BesuGenesisGenerator
73
71
  */
74
72
  function publicKeyToAddress(publicKeyHex) {
75
- // Ethereum addresses are derived using Keccak-256 (the original Keccak submission),
76
- // NOT NIST SHA3-256. NIST SHA3-256 added different domain-separation padding,
77
- // so crypto.createHash('sha3-256') produces WRONG addresses.
78
- //
79
- // Node.js's OpenSSL does not expose the original Keccak-256, so we use ethers
80
- // which bundles a correct implementation.
81
- //
82
- // ethersKeccak256 expects a 0x-prefixed hex string or Uint8Array and returns
83
- // a 0x-prefixed 64-char hex hash. The Ethereum address is the last 20 bytes.
73
+ // ethersKeccak256 takes 0x-prefixed hex or a Uint8Array and returns a
74
+ // 0x-prefixed 64-char hash. The address is its last 20 bytes.
84
75
  const hash = ethersKeccak256(Buffer.from(publicKeyHex, 'hex')); // 0x-prefixed 64-char hex
85
76
  return hash.slice(26); // skip '0x' + first 24 hex chars → last 40 hex chars (20 bytes)
86
77
  }
@@ -0,0 +1,32 @@
1
+ import { STAT_DEFAULTS } from '../../client/components/cyberia/SharedDefaultsCyberia.js';
2
+ import { DefaultCyberiaActions, DefaultCyberiaQuests } from '../../api/cyberia-server-defaults/cyberia-server-defaults.js';
3
+ import { toInstanceConfig, toMapMsg, toInstanceMsg, toObjectLayerMsg, toActionMsg, toQuestMsg } from './instance-data.js';
4
+
5
+ export function buildBootContractArtifacts() {
6
+ const layer = toObjectLayerMsg({
7
+ _id: 'contract-layer', sha256: 'abc',
8
+ data: { stats: { ...STAT_DEFAULTS, effect: -100, resistance: 100 }, item: { id: 'sword', type: 'weapon' } },
9
+ });
10
+ const full = {
11
+ instance: toInstanceMsg({ _id: 'contract-instance', code: 'contract-test', cyberiaMapCodes: ['contract-map'] }),
12
+ maps: [toMapMsg({
13
+ _id: 'contract-map', code: 'contract-map', gridX: 16, gridY: 16,
14
+ entities: [{ entityType: 'bot', level: 7, objectLayerItemIds: ['sword'] }],
15
+ })],
16
+ objectLayers: [layer],
17
+ config: toInstanceConfig({}),
18
+ version: 'contract-fixture',
19
+ actions: DefaultCyberiaActions.slice(0, 1).map(toActionMsg),
20
+ quests: DefaultCyberiaQuests.slice(0, 1).map(toQuestMsg),
21
+ };
22
+ const payloads = {
23
+ boot_full_instance: full,
24
+ boot_object_layer: layer,
25
+ boot_manifest: { entries: [{ itemId: 'sword', sha256: 'abc' }] },
26
+ boot_ping: { serverTimeMs: 1000 },
27
+ };
28
+ return Object.fromEntries(Object.entries(payloads).map(([name, data]) => [
29
+ 'cyberia-server/engine_client/testdata/' + name + '.json',
30
+ JSON.stringify({ status: 'success', data }, null, 2) + '\n',
31
+ ]));
32
+ }
@@ -39,11 +39,10 @@ export default {
39
39
  packageBin: { cyberia: 'bin/index.js', underpost: 'bin/index.js' },
40
40
  // Native-dependency pin list: versions stay reproducible across CI and production deploys.
41
41
  packageDependencies: {
42
- 'adm-zip': '^0.6.0',
43
42
  'maxrects-packer': '^2.7.3',
44
43
  pngjs: '^7.0.0',
45
44
  jimp: '^1.6.0',
46
- sharp: '^0.35.3',
45
+ sharp: '^0.35.4',
47
46
  ethers: '~6.16.0',
48
47
  },
49
48
  packageScripts: {
@@ -73,6 +72,7 @@ export default {
73
72
  '/src/grpc/cyberia',
74
73
  '/src/client/ssr/views/CyberiaServerMetrics.js',
75
74
  '/src/client/ssr/views/Cyberia404.js',
75
+ '/test/unit/cyberia',
76
76
  '/test/integration/app/cyberia',
77
77
  '/src/projects/cyberia',
78
78
  '/src/runtime/cyberia-server',
@@ -102,6 +102,7 @@ export default {
102
102
  './src/runtime/cyberia-server',
103
103
  './src/runtime/cyberia-client',
104
104
  './src/runtime/engine-cyberia',
105
+ './test/unit/cyberia',
105
106
  './test/integration/app/cyberia',
106
107
  './src/client/public/cyberia-docs',
107
108
  './bin/cyberia.js',
@@ -5,9 +5,9 @@
5
5
  * Top-Down PCG saga generator. No streaming, no tool-calls — the saga
6
6
  * orchestration only needs one structured JSON payload per request.
7
7
  *
8
- * General purpose, but for now only the `:generateContent` endpoint is wired,
9
- * matching the Gemma model family (e.g. `gemma-4-26b-a4b-it`). Authentication
10
- * uses the `x-goog-api-key` header sourced from `GEMINI_API_KEY`.
8
+ * Only the `:generateContent` endpoint is wired, matching the Gemma model
9
+ * family (e.g. `gemma-4-26b-a4b-it`). Authentication uses the `x-goog-api-key`
10
+ * header from `GEMINI_API_KEY`.
11
11
  *
12
12
  * @module src/projects/cyberia/gemini-client.js
13
13
  * @namespace GeminiClient
@@ -1,3 +1,4 @@
1
+ import { validateStats } from '../../client/components/cyberia/SharedDefaultsCyberia.js';
1
2
  /**
2
3
  * Top-Down Procedural Generation guided by LLMs (Semantic Reverse-Engineering).
3
4
  *
@@ -1089,9 +1090,10 @@ const STAGE_PROMPTS = {
1089
1090
  'STAGE: foundation. Return ONE JSON object: { "saga": {...}, "objectLayers": [...] }.',
1090
1091
  'saga: { code, name, description, mapCodes:[] } (leave mapCodes an empty array).',
1091
1092
  'objectLayers[]: { stats:{ effect, resistance, agility, range, intelligence, utility },',
1093
+ '- Stats are integer modifiers from -100 to +100. Zero is neutral. Level and XP belong to entities.',
1092
1094
  ' item:{ id, type, description, activable }, render:null }',
1093
1095
  ' item.type is one of: skin, breastplate, weapon, skill, coin, floor, obstacle, portal,',
1094
- ' foreground, resource; every stat is an integer 0..10 balanced to the lore.',
1096
+ ' foreground, resource; every stat is an integer -100..+100, with 0 neutral.',
1095
1097
  'Produce 5-10 object-layer items including at least one "coin" currency item AND at least two',
1096
1098
  '"skin" items that represent NAMED NPC characters players can speak to (these back "talk" quests).',
1097
1099
  ].join('\n'),
@@ -1536,14 +1538,7 @@ function normalizeSagaPayload(raw, { theme }) {
1536
1538
  const objectLayers = asArray(raw.objectLayers).map((ol) => {
1537
1539
  const stats = ol.stats || {};
1538
1540
  return {
1539
- stats: {
1540
- effect: Number(stats.effect) || 0,
1541
- resistance: Number(stats.resistance) || 0,
1542
- agility: Number(stats.agility) || 0,
1543
- range: Number(stats.range) || 0,
1544
- intelligence: Number(stats.intelligence) || 0,
1545
- utility: Number(stats.utility) || 0,
1546
- },
1541
+ stats: validateStats(stats),
1547
1542
  item: {
1548
1543
  id: slugify(ol.item?.id),
1549
1544
  type: ol.item?.type || 'skin',
@@ -1621,7 +1616,6 @@ function normalizeSagaPayload(raw, { theme }) {
1621
1616
  description: saga.description,
1622
1617
  tags: ['saga', 'generated'],
1623
1618
  cyberiaMapCodes: [...saga.mapCodes],
1624
- itemIds: saga.itemIds.map((id) => ({ id, defaultPlayerInventory: false })),
1625
1619
  portals: [],
1626
1620
  topologyMode: 'procedural',
1627
1621
  };
@@ -1687,7 +1681,6 @@ async function persistInstance({ instance, CyberiaInstance }) {
1687
1681
  description: instance.description,
1688
1682
  tags: instance.tags,
1689
1683
  cyberiaMapCodes: instance.cyberiaMapCodes,
1690
- itemIds: instance.itemIds,
1691
1684
  topologyMode: instance.topologyMode,
1692
1685
  },
1693
1686
  $setOnInsert: { code: instance.code, portals: instance.portals || [] },
@@ -1,24 +1,19 @@
1
1
  /**
2
- * Hot-reload trigger — engine-cyberia cyberia-server control channel.
2
+ * Hot-reload trigger — the engine's control channel to a running game server.
3
3
  *
4
- * Asks a running cyberia-server to rebuild its world NOW instead of waiting
5
- * for its polling loop (ENGINE_GRPC_RELOAD_INTERVAL_SEC). Two transports, in
6
- * order:
4
+ * Asks the server to rebuild its world now instead of waiting for its polling
5
+ * loop. Two transports, in order:
7
6
  *
8
- * 1. gRPC cyberia-server's CyberiaControlService. Preferred.
9
- * 2. REST POST {baseUrl}/api/v1/hot-reload. Fallback when the control
10
- * gRPC port is unreachable (not exposed, different network).
7
+ * 1. gRPC, the server's CyberiaControlService. Preferred.
8
+ * 2. REST, POST {baseUrl}/api/v1/hot-reload. Used when the control gRPC port
9
+ * is unreachable.
11
10
  *
12
- * The gRPC control service is served with a JSON codec, so this client is
13
- * hand-rolled with makeUnaryRequest + JSON serializers no .proto codegen on
14
- * either side. Keep SERVICE/METHOD in sync with cyberia-server/hotreload/grpc.go.
11
+ * The control service uses a JSON codec, so this client is hand-rolled with
12
+ * makeUnaryRequest and JSON serializers. There is no .proto codegen; SERVICE
13
+ * and METHOD must match the server's control service.
15
14
  *
16
- * AUTHORIZATION
17
- * -------------
18
- * Both transports carry CYBERIA_SERVER_API_KEY, the INTERNAL shared secret
19
- * between engine-cyberia and cyberia-server. It is read from the environment
20
- * here and never returned to a browser: the engine's REST endpoint is the only
21
- * public surface, and it is moderator/admin guarded.
15
+ * Both transports carry CYBERIA_SERVER_API_KEY, the internal shared secret. It
16
+ * is read from the environment and never returned to a browser.
22
17
  *
23
18
  * @module src/projects/cyberia/hot-reload-trigger.js
24
19
  */
@@ -49,11 +44,9 @@ const serverApiKey = () => process.env.CYBERIA_SERVER_API_KEY || '';
49
44
  * gRPC target. The gRPC host reuses the hostname with the control port, since
50
45
  * the control service listens on its own port.
51
46
  *
52
- * The URL sub-path is preserved as `basePath` ("", "/FOREST", "/TEST") and kept
53
- * on `restBaseUrl`: a path-based multi-instance proxy routes the REST trigger to
54
- * the right variant, and cyberia-server consumes that same base path natively.
55
- * Using `url.origin` alone would drop the sub-path and land every trigger on the
56
- * default (root) deployment.
47
+ * The URL sub-path is kept as `basePath` ("", "/FOREST", "/TEST") and stays on
48
+ * `restBaseUrl`. A path-based multi-instance proxy routes the REST trigger by
49
+ * it; `url.origin` alone would send every trigger to the root deployment.
57
50
  */
58
51
  const resolveTargets = (rawUrl, { grpcPort = DEFAULT_GRPC_PORT } = {}) => {
59
52
  const trimmed = String(rawUrl || '').trim();
@@ -133,11 +126,8 @@ const triggerHotReload = async ({
133
126
  const { restBaseUrl, grpcTarget, basePath } = resolveTargets(serverUrl, { grpcPort });
134
127
  const call = { apiKey, mode, instanceCode, timeoutMs };
135
128
 
136
- // A path-based multi-instance proxy routes by URL sub-path, but a gRPC method
137
- // path is fixed (/cyberia.CyberiaControlService/…) and can't carry the
138
- // /FOREST prefix — a control call to hostname:port reaches the default
139
- // variant. When the target names a variant sub-path, use the sub-path-aware
140
- // REST transport directly instead of triggering the wrong world over gRPC.
129
+ // A gRPC method path is fixed and cannot carry the variant sub-path, so a
130
+ // control call would reach the default variant. Use REST for a sub-path target.
141
131
  if (basePath) {
142
132
  const result = await triggerViaRest({ ...call, restBaseUrl });
143
133
  logger.info(`hot reload via REST ${restBaseUrl}: ${result?.message}`);
@@ -149,8 +139,7 @@ const triggerHotReload = async ({
149
139
  logger.info(`hot reload via gRPC ${grpcTarget}: ${result?.message}`);
150
140
  return { transport: 'grpc', result };
151
141
  } catch (grpcError) {
152
- // A rejected key is a real answer, not a transport failure do not retry
153
- // over REST, or a misconfigured key would be reported twice.
142
+ // A rejected key is an answer, not a transport failure. Do not retry.
154
143
  if (grpcError?.code === grpc.status.PERMISSION_DENIED) throw new Error(grpcError.details || grpcError.message);
155
144
  logger.warn(`hot reload gRPC (${grpcTarget}) unavailable: ${grpcError.message} — falling back to REST`);
156
145