@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
@@ -2,9 +2,8 @@ import { Schema, model } from 'mongoose';
2
2
 
3
3
  // https://mongoosejs.com/docs/2.7.x/docs/schematypes.html
4
4
 
5
- // Expanded metadata for one logic event a trigger item can fire. Mirrors the
6
- // `skills[]` entries in DefaultSkillConfig (cyberia-server-defaults.js) and the
7
- // SkillDefinition consumed by cyberia-server (game/skill.go).
5
+ // Expanded metadata for one logic event a trigger item can fire. Same shape as
6
+ // the `skills[]` entries of DefaultSkillConfig (cyberia-server-defaults.js).
8
7
  const SkillDefinitionSchema = new Schema(
9
8
  {
10
9
  // Handler key dispatched by the simulation skill engine (e.g. 'projectile',
@@ -20,9 +19,10 @@ const SkillDefinitionSchema = new Schema(
20
19
  );
21
20
 
22
21
  /**
23
- * Authoritative skill definition for a single trigger item. Owns the full skill
24
- * record (logic event keys + expanded metadata) so the simulation receives the
25
- * summoned-entity item ids the instance-conf skillConfig schema does not carry.
22
+ * Authoritative skill definition for a single trigger item. Owns the full skill record — logic
23
+ * event keys plus the expanded metadata, including the summoned-entity item ids the simulation
24
+ * needs. The collection is deployment-wide: which of these an instance runs is derived from the
25
+ * content that instance names, never stored on it. See cyberia-instance-items.js.
26
26
  */
27
27
  const CyberiaSkillSchema = new Schema(
28
28
  {
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The File reference registry: which model fields hold a File `_id`.
3
+ *
4
+ * `file.ref.json` is the single source of that mapping — the orphan sweep in
5
+ * `underpost db --clean-fs` reads it, and so does every writer that deletes a
6
+ * document and has to take the blobs it owned with it. Reading it here rather
7
+ * than restating the fields at each call site is what keeps a newly added
8
+ * reference field from being cleaned up in one place and leaked in another.
9
+ *
10
+ * @module src/api/file/file.ref.js
11
+ * @namespace FileReferenceRegistry
12
+ */
13
+
14
+ import fs from 'fs-extra';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ // Resolved from this module rather than the working directory: the CLI, the
18
+ // server and the test runner each start somewhere different.
19
+ const REGISTRY_PATH = fileURLToPath(new URL('./file.ref.json', import.meta.url));
20
+
21
+ let registry = null;
22
+
23
+ /**
24
+ * @method fileRefRegistry
25
+ * @description The parsed registry, read once per process.
26
+ * @returns {Array<{api: string, model: Object}>} Registry entries.
27
+ * @memberof FileReferenceRegistry
28
+ */
29
+ const fileRefRegistry = () => (registry ??= JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8')));
30
+
31
+ /**
32
+ * @method fileRefApis
33
+ * @description Every api that references File.
34
+ * @returns {string[]} Api names.
35
+ * @memberof FileReferenceRegistry
36
+ */
37
+ const fileRefApis = () => fileRefRegistry().map((entry) => entry.api);
38
+
39
+ /**
40
+ * @method fileRefFields
41
+ * @description The File-referencing fields of one api, as dot paths.
42
+ * @param {string} api - Api name, as registered.
43
+ * @returns {string[]} Field paths, empty for an api that references no File.
44
+ * @memberof FileReferenceRegistry
45
+ */
46
+ const fileRefFields = (api) => {
47
+ const walk = (node, prefix) =>
48
+ Object.entries(node ?? {}).flatMap(([field, value]) => {
49
+ const path = prefix ? `${prefix}.${field}` : field;
50
+ if (value === true) return [path];
51
+ return value && typeof value === 'object' ? walk(value, path) : [];
52
+ });
53
+ return walk(fileRefRegistry().find((entry) => entry.api === api)?.model, '');
54
+ };
55
+
56
+ /**
57
+ * @method documentFileIds
58
+ * @description Collects the File ids a set of documents holds, as strings.
59
+ * @param {Array<Object>} docs - Documents, lean or hydrated.
60
+ * @param {string[]} fields - Field paths to read, as {@link fileRefFields} returns them.
61
+ * @returns {string[]} Unique ids.
62
+ * @memberof FileReferenceRegistry
63
+ */
64
+ const documentFileIds = (docs, fields) => {
65
+ const ids = new Set();
66
+ const read = (doc, path) => path.split('.').reduce((value, part) => value?.[part], doc);
67
+ for (const doc of docs ?? []) {
68
+ for (const field of fields) {
69
+ const value = read(doc, field);
70
+ if (!value) continue;
71
+ if (Array.isArray(value)) value.forEach((entry) => entry && ids.add(String(entry)));
72
+ else ids.add(String(value));
73
+ }
74
+ }
75
+ return [...ids];
76
+ };
77
+
78
+ /**
79
+ * @method deleteOwnedFiles
80
+ * @description Deletes the File documents an owner collection no longer points at.
81
+ *
82
+ * An id still held by a surviving owner document is kept: renders are content
83
+ * addressed, so two documents describing the same item resolve to the same File
84
+ * id, and dropping one of them must not blind the other.
85
+ * @param {Object} params
86
+ * @param {import('mongoose').Model} params.File - File model.
87
+ * @param {import('mongoose').Model} params.Owner - The collection that owns these ids.
88
+ * @param {string[]} params.fields - Owner fields holding File ids.
89
+ * @param {Array<*>} params.ids - Candidate ids, as read before the owners were deleted.
90
+ * @returns {Promise<number>} How many File documents were removed.
91
+ * @memberof FileReferenceRegistry
92
+ */
93
+ const deleteOwnedFiles = async ({ File, Owner, fields, ids }) => {
94
+ const candidates = [...new Set((ids ?? []).filter(Boolean).map(String))];
95
+ if (candidates.length === 0) return 0;
96
+
97
+ const held = new Set();
98
+ if (Owner && fields?.length) {
99
+ const survivors = await Owner.find(
100
+ { $or: fields.map((field) => ({ [field]: { $in: candidates } })) },
101
+ Object.fromEntries(fields.map((field) => [field, 1])),
102
+ ).lean();
103
+ for (const id of documentFileIds(survivors, fields)) held.add(id);
104
+ }
105
+
106
+ const removable = candidates.filter((id) => !held.has(id));
107
+ if (removable.length === 0) return 0;
108
+ const { deletedCount } = await File.deleteMany({ _id: { $in: removable } });
109
+ return deletedCount ?? 0;
110
+ };
111
+
112
+ /**
113
+ * @method deleteUnreferencedFiles
114
+ * @description Deletes the candidates no registered model references any more.
115
+ *
116
+ * Refuses to delete anything while a registered api has no model in this
117
+ * connection: an unreadable collection cannot be shown to hold no reference,
118
+ * and a sweep that assumed otherwise would delete a live blob of another domain.
119
+ * @param {Object} params
120
+ * @param {import('mongoose').Model} params.File - File model.
121
+ * @param {Array<*>} params.ids - Candidate ids.
122
+ * @param {function(string): import('mongoose').Model} params.getModel - Resolves an api to its model, throwing when it is not loaded.
123
+ * @returns {Promise<{removed: number, candidates: number, skipped: string}>} `skipped` names the api that could not be read, or is empty.
124
+ * @memberof FileReferenceRegistry
125
+ */
126
+ const deleteUnreferencedFiles = async ({ File, ids, getModel }) => {
127
+ const candidates = [...new Set((ids ?? []).filter(Boolean).map(String))];
128
+ if (candidates.length === 0) return { removed: 0, candidates: 0, skipped: '' };
129
+
130
+ const referenced = new Set();
131
+ for (const api of fileRefApis()) {
132
+ const fields = fileRefFields(api);
133
+ if (fields.length === 0) continue;
134
+ let Model;
135
+ try {
136
+ Model = getModel(api);
137
+ } catch {
138
+ return { removed: 0, candidates: candidates.length, skipped: api };
139
+ }
140
+ const docs = await Model.find(
141
+ { $or: fields.map((field) => ({ [field]: { $in: candidates } })) },
142
+ Object.fromEntries(fields.map((field) => [field, 1])),
143
+ ).lean();
144
+ for (const id of documentFileIds(docs, fields)) referenced.add(id);
145
+ }
146
+
147
+ const removable = candidates.filter((id) => !referenced.has(id));
148
+ if (removable.length === 0) return { removed: 0, candidates: candidates.length, skipped: '' };
149
+ const { deletedCount } = await File.deleteMany({ _id: { $in: removable } });
150
+ return { removed: deletedCount ?? 0, candidates: candidates.length, skipped: '' };
151
+ };
152
+
153
+ export { fileRefRegistry, fileRefApis, fileRefFields, documentFileIds, deleteOwnedFiles, deleteUnreferencedFiles };
@@ -2,7 +2,9 @@
2
2
  {
3
3
  "api": "atlas-sprite-sheet",
4
4
  "model": {
5
- "fileId": true
5
+ "fileId": true,
6
+ "minifyFileId": true,
7
+ "idlePreviewFileId": true
6
8
  }
7
9
  },
8
10
  {
@@ -11,6 +13,12 @@
11
13
  "logo": true
12
14
  }
13
15
  },
16
+ {
17
+ "api": "cyberia-audio",
18
+ "model": {
19
+ "fileId": true
20
+ }
21
+ },
14
22
  {
15
23
  "api": "cyberia-instance",
16
24
  "model": {
@@ -20,7 +28,8 @@
20
28
  {
21
29
  "api": "cyberia-map",
22
30
  "model": {
23
- "thumbnail": true
31
+ "thumbnail": true,
32
+ "preview": true
24
33
  }
25
34
  },
26
35
  {
@@ -4,8 +4,10 @@
4
4
  * @namespace CyberiaObjectLayerModel
5
5
  */
6
6
  import crypto from 'crypto';
7
+ import { STAT_TYPES, STAT_DEFAULT, STAT_MODIFIER_MIN, STAT_MODIFIER_MAX, STAT_CONTRACT_VERSION, validateStats } from '../../client/components/cyberia/SharedDefaultsCyberia.js';
7
8
  import stringify from 'fast-json-stable-stringify';
8
9
  import { Schema, model } from 'mongoose';
10
+ import { deleteOwnedFiles, documentFileIds, fileRefFields } from '../file/file.ref.js';
9
11
  /**
10
12
  * @typedef {Object} Stats
11
13
  * @property {number} effect - The effect attribute value
@@ -17,15 +19,11 @@ import { Schema, model } from 'mongoose';
17
19
  * @memberof CyberiaObjectLayerModel
18
20
  */
19
21
  const StatsSchema = new Schema(
20
- {
21
- effect: { type: Number, required: true, min: 0 },
22
- resistance: { type: Number, required: true, min: 0 },
23
- agility: { type: Number, required: true, min: 0 },
24
- range: { type: Number, required: true, min: 0 },
25
- intelligence: { type: Number, required: true, min: 0 },
26
- utility: { type: Number, required: true, min: 0 },
27
- },
28
- { _id: false },
22
+ Object.fromEntries(STAT_TYPES.map((key) => [key, {
23
+ type: Number, required: true, default: STAT_DEFAULT, min: STAT_MODIFIER_MIN, max: STAT_MODIFIER_MAX,
24
+ validate: Number.isInteger,
25
+ }])),
26
+ { _id: false, strict: 'throw' },
29
27
  );
30
28
  /**
31
29
  * @typedef {Object} Item
@@ -112,6 +110,7 @@ const RenderSchema = new Schema(
112
110
  */
113
111
  const ObjectLayerSchema = new Schema(
114
112
  {
113
+ statContractVersion: { type: Number, default: STAT_CONTRACT_VERSION, enum: [STAT_CONTRACT_VERSION] },
115
114
  data: {
116
115
  stats: { type: StatsSchema, required: true },
117
116
  item: { type: ItemSchema, required: true },
@@ -228,7 +227,7 @@ function mergeObjectLayerData(existing, incoming) {
228
227
  * payload the writer hashed before merging.
229
228
  * @memberof CyberiaObjectLayerModel
230
229
  */
231
- const UPSERTABLE_FIELDS = ['data', 'cid', 'objectLayerRenderFramesId', 'atlasSpriteSheetId'];
230
+ const UPSERTABLE_FIELDS = ['statContractVersion', 'data', 'cid', 'objectLayerRenderFramesId', 'atlasSpriteSheetId'];
232
231
 
233
232
  /**
234
233
  * Collapses any pre-existing duplicates of a given `data.item.id` down to a
@@ -247,12 +246,47 @@ async function collapseItemIdDuplicates(Model, itemId) {
247
246
  const removedIds = duplicates.map((duplicate) => duplicate._id);
248
247
  await Model.deleteMany({ _id: { $in: removedIds } });
249
248
 
249
+ // What the survivor still links is never collateral: a duplicate written by an older
250
+ // upsert can carry the very same render frames or atlas the survivor points at.
251
+ const survivorLinks = new Set(
252
+ [survivor.objectLayerRenderFramesId, survivor.atlasSpriteSheetId].filter(Boolean).map(String),
253
+ );
254
+ const droppedLinks = (field) =>
255
+ [
256
+ ...new Set(
257
+ duplicates
258
+ .map((duplicate) => duplicate[field])
259
+ .filter(Boolean)
260
+ .map(String),
261
+ ),
262
+ ].filter((id) => !survivorLinks.has(id));
263
+
250
264
  // Orphaned render frames have no other cleanup path once their owner is gone.
251
- const renderFramesIds = duplicates.map((duplicate) => duplicate.objectLayerRenderFramesId).filter(Boolean);
265
+ const renderFramesIds = droppedLinks('objectLayerRenderFramesId');
252
266
  if (renderFramesIds.length > 0 && Model.db.models.ObjectLayerRenderFrames) {
253
267
  await Model.db.models.ObjectLayerRenderFrames.deleteMany({ _id: { $in: renderFramesIds } });
254
268
  }
255
269
 
270
+ // An atlas owns File renders, so dropping its document without them is what leaves
271
+ // unreachable blobs in the File collection.
272
+ const atlasIds = droppedLinks('atlasSpriteSheetId');
273
+ const AtlasSpriteSheet = Model.db.models.AtlasSpriteSheet;
274
+ if (atlasIds.length > 0 && AtlasSpriteSheet) {
275
+ const fields = fileRefFields('atlas-sprite-sheet');
276
+ const atlasDocs = await AtlasSpriteSheet.find(
277
+ { _id: { $in: atlasIds } },
278
+ Object.fromEntries(fields.map((field) => [field, 1])),
279
+ ).lean();
280
+ await AtlasSpriteSheet.deleteMany({ _id: { $in: atlasIds } });
281
+ if (Model.db.models.File)
282
+ await deleteOwnedFiles({
283
+ File: Model.db.models.File,
284
+ Owner: AtlasSpriteSheet,
285
+ fields,
286
+ ids: documentFileIds(atlasDocs, fields),
287
+ });
288
+ }
289
+
256
290
  return { survivor, removedIds };
257
291
  }
258
292
 
@@ -332,6 +366,7 @@ ObjectLayerSchema.statics.upsertByItemId = async function (payload, { setOnInser
332
366
  const itemId = payload?.data?.item?.id;
333
367
  if (!itemId) throw new Error('ObjectLayer.upsertByItemId requires data.item.id');
334
368
 
369
+ if (Object.hasOwn(payload.data, 'stats')) validateStats(payload.data.stats);
335
370
  const { survivor } = await collapseItemIdDuplicates(this, itemId);
336
371
 
337
372
  if (!survivor) {
@@ -348,7 +383,7 @@ ObjectLayerSchema.statics.upsertByItemId = async function (payload, { setOnInser
348
383
  }
349
384
  update.sha256 = computeObjectLayerSha256(update.data ?? existing.data);
350
385
 
351
- return await this.findByIdAndUpdate(survivor._id, { $set: update }, { returnDocument: 'after' });
386
+ return await this.findByIdAndUpdate(survivor._id, { $set: update }, { returnDocument: 'after', runValidators: true });
352
387
  };
353
388
 
354
389
  // Create and export the model
@@ -347,8 +347,8 @@ class ObjectLayerRouter {
347
347
  /*
348
348
  #swagger.auto = false
349
349
  #swagger.tags = ['object-layer']
350
- #swagger.summary = 'Search object layer item IDs'
351
- #swagger.description = 'Returns partial matches of data.item.id for type-ahead autocomplete'
350
+ #swagger.summary = 'Resolve object layer item identity'
351
+ #swagger.description = 'Returns { id, type } for partial matches of data.item.id (type-ahead) or for an exact id list'
352
352
  #swagger.path = '/object-layer/search-item-ids'
353
353
  #swagger.method = 'get'
354
354
  #swagger.produces = ['application/json']
@@ -360,8 +360,15 @@ class ObjectLayerRouter {
360
360
  type: 'string'
361
361
  }
362
362
 
363
+ #swagger.parameters['ids'] = {
364
+ in: 'query',
365
+ description: 'Comma separated exact item IDs to resolve',
366
+ required: false,
367
+ type: 'string'
368
+ }
369
+
363
370
  #swagger.responses[200] = {
364
- description: 'Item IDs matching the query',
371
+ description: 'Item identities matching the query',
365
372
  content: {
366
373
  'application/json': {
367
374
  schema: {