@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
package/bin/cyberia.js CHANGED
@@ -10,7 +10,8 @@
10
10
  */
11
11
 
12
12
  import dotenv from 'dotenv';
13
- import { Command } from 'commander';
13
+ import { registerStatCommands } from '../src/projects/cyberia/stat-commands.js';
14
+ import { Command, InvalidArgumentError } from 'commander';
14
15
  import fs from 'fs-extra';
15
16
  import stringify from 'fast-json-stable-stringify';
16
17
  import { shellExec } from '../src/server/runtime/process.js';
@@ -18,9 +19,23 @@ import { cli } from '../src/server/build/execution.js';
18
19
  import { loggerFactory } from '../src/server/ops/logger.js';
19
20
  import { generateBesuManifests, deployBesu, removeBesu } from '../src/projects/cyberia/besu-genesis-generator.js';
20
21
  import { DataBaseProviderService } from '../src/db/DataBaseProvider.js';
22
+ import { CyberiaAudioService } from '../src/api/cyberia-audio/cyberia-audio.service.js';
23
+ import { CyberiaEntityTypeDefaultService } from '../src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js';
24
+ import { CyberiaInstanceConfService } from '../src/api/cyberia-instance-conf/cyberia-instance-conf.service.js';
25
+ import {
26
+ collectInstanceItemIds,
27
+ collectSummonedItemIds,
28
+ selectInstanceSkills,
29
+ } from '../src/api/cyberia-instance/cyberia-instance-items.js';
30
+ import { prepareFallbackAudio, seedFallbackAudio, seedInstanceAudio } from '../src/projects/cyberia/seed-audio.js';
31
+ import {
32
+ CyberiaMapAudioConfService,
33
+ parseEventAudioBinding,
34
+ } from '../src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js';
21
35
  import {
22
36
  deployEnvFilePath,
23
37
  etcHostFactory,
38
+ instanceProjectPathFactory,
24
39
  loadConfServerJson,
25
40
  normalizeInstanceTopology,
26
41
  } from '../src/server/runtime/conf.js';
@@ -30,8 +45,12 @@ import {
30
45
  pngDirectoryIteratorByObjectLayerType,
31
46
  buildImgFromTile,
32
47
  } from '../src/projects/cyberia/object-layer.js';
48
+ import { fetchInstanceObjectLayerItemIds, getInstanceModels } from '../src/projects/cyberia/instance-data.js';
49
+ import { atlasBackupFileKey, restoreObjectLayerBackup } from '../src/projects/cyberia/instance-backup.js';
33
50
  import { getKeyframeDirectionsByCode } from '../src/client/components/cyberia/SharedDefaultsCyberia.js';
34
- import { AtlasSpriteSheetGenerator } from '../src/projects/cyberia/atlas-sprite-sheet-generator.js';
51
+ import { DEFAULT_ATLAS_UPSCALE_FACTOR } from '../src/projects/cyberia/atlas-sprite-sheet-generator.js';
52
+ import { ATLAS_FILE_FIELDS, AtlasSpriteSheetStore } from '../src/projects/cyberia/atlas-sprite-sheet-store.js';
53
+ import { fileRefFields } from '../src/api/file/file.ref.js';
35
54
  import {
36
55
  generateMultiFrame,
37
56
  lookupSemantic,
@@ -51,7 +70,6 @@ import {
51
70
  DefaultCyberiaActions,
52
71
  DefaultCyberiaQuests,
53
72
  ENTITY_TYPE_DEFAULTS,
54
- fillInstanceConfDefaults,
55
73
  } from '../src/api/cyberia-server-defaults/cyberia-server-defaults.js';
56
74
  import cyberiaCatalog from '../src/projects/cyberia/catalog-cyberia.js';
57
75
 
@@ -60,6 +78,7 @@ import {
60
78
  ITEM_TYPES as itemTypes,
61
79
  DefaultCyberiaItems,
62
80
  } from '../src/client/components/cyberia/SharedDefaultsCyberia.js';
81
+ import { balanceStats, resolveStatBounds, statPolicyActive } from '../src/projects/cyberia/stat-balance.js';
63
82
  import { loadDeployCatalog } from '../src/server/build/catalog.js';
64
83
  import {
65
84
  DEPLOY_MANIFEST_INDENT,
@@ -105,9 +124,193 @@ async function connectDbForChain({ envPath, mongoHost }) {
105
124
  return { ObjectLayer, host, path };
106
125
  }
107
126
 
127
+ /**
128
+ * Rewrites a conf backup's `entityDefaults` into the reference shape the schema now stores.
129
+ *
130
+ * Backups written before the collection became the single owner embedded whole documents in the
131
+ * conf. Those files are the only place that shape still exists, so it is converted here, at the
132
+ * boundary where they enter — nothing downstream understands anything but an id.
133
+ *
134
+ * Each embedded entry is matched against the entity-type-default documents travelling in the same
135
+ * backup, by entity type and live-item set. That lookup is safe precisely because it cannot see
136
+ * the database: it can only ever resolve to a document this instance exported. An entry with no
137
+ * counterpart is written to the collection so the reference it gets resolves to something.
138
+ *
139
+ * @param {object} confData - Parsed cyberia-instance-conf.json.
140
+ * @param {string} backupDir - Backup root, holding cyberia-entity-type-defaults/.
141
+ * @param {import('mongoose').Model} CyberiaEntityTypeDefault
142
+ * @returns {Promise<object>} The conf, with `entityDefaults` as ids.
143
+ */
144
+ const adoptEntityTypeDefaultRefs = async (confData, backupDir, CyberiaEntityTypeDefault) => {
145
+ const entries = confData.entityDefaults || [];
146
+ const embedded = entries.filter((entry) => entry && 'object' === typeof entry && entry.entityType);
147
+ if (0 === embedded.length) return confData;
148
+
149
+ const dir = `${backupDir}/cyberia-entity-type-defaults`;
150
+ const exported = fs.existsSync(dir)
151
+ ? fs
152
+ .readdirSync(dir)
153
+ .filter((file) => file.endsWith('.json'))
154
+ .map((file) => fs.readJsonSync(`${dir}/${file}`))
155
+ : [];
156
+ const liveKey = (doc) => `${doc.entityType}::${[...(doc.liveItemIds || [])].sort().join(',')}`;
157
+ const byKey = new Map(exported.map((doc) => [liveKey(doc), doc]));
158
+
159
+ const ids = [];
160
+ let created = 0;
161
+ for (const entry of entries) {
162
+ if (!entry || 'object' !== typeof entry || !entry.entityType) {
163
+ if (entry) ids.push(entry);
164
+ continue;
165
+ }
166
+ const match = byKey.get(liveKey(entry));
167
+ if (match?._id) {
168
+ ids.push(match._id);
169
+ continue;
170
+ }
171
+ const doc = await CyberiaEntityTypeDefault.create({
172
+ entityType: entry.entityType,
173
+ liveItemIds: entry.liveItemIds || [],
174
+ deadItemIds: entry.deadItemIds || [],
175
+ dropItemIds: entry.dropItemIds || [],
176
+ inventoryItemsIds: entry.inventoryItemsIds || [],
177
+ overrideItemsIdsState: entry.overrideItemsIdsState || [],
178
+ behavior: entry.behavior || '',
179
+ });
180
+ ids.push(doc._id);
181
+ created++;
182
+ }
183
+ confData.entityDefaults = ids;
184
+ logger.info('Migrated embedded conf entityDefaults to collection references', {
185
+ instanceCode: confData.instanceCode,
186
+ references: ids.length,
187
+ created,
188
+ });
189
+ return confData;
190
+ };
191
+
192
+ /** Default source of recorded `<name>.wav` + `<name>.json` pairs for `cyberia audio --import`. */
193
+ const DEFAULT_AUDIO_RECORDS_PATH = './cyberia-audio/records';
194
+
195
+ /**
196
+ * Commander parser for the repeatable `<logic-event-id>:<audio-code>` flag.
197
+ *
198
+ * @function eventAudioBindingFactory
199
+ * @param {string} flag - Flag name, used in the usage error.
200
+ * @returns {(value: string, previous: Array<{event: string, code: string}>) => Array<{event: string, code: string}>} Accumulating parser.
201
+ * @memberof CyberiaCLI
202
+ */
203
+ const eventAudioBindingFactory =
204
+ (flag) =>
205
+ (value, previous = []) => {
206
+ try {
207
+ return previous.concat([parseEventAudioBinding(value)]);
208
+ } catch {
209
+ throw new InvalidArgumentError(`${flag} expects <logic-event-id>:<audio-code>`);
210
+ }
211
+ };
212
+
108
213
  /** @type {Function} */
109
214
  const logger = loggerFactory(import.meta);
110
215
 
216
+ /**
217
+ * Reads the comma-separated item-id argument of the `ol` command.
218
+ *
219
+ * @param {string} [itemId] - The raw command argument.
220
+ * @returns {string[]} Trimmed, non-empty item ids.
221
+ */
222
+ const parseItemIds = (itemId) =>
223
+ itemId
224
+ ? itemId
225
+ .split(',')
226
+ .map((id) => id.trim())
227
+ .filter(Boolean)
228
+ : [];
229
+
230
+ /**
231
+ * Applies the `ol` stat policy (`--normalize-stats`, `--random-stats`,
232
+ * `--min-stat`, `--max-stat`) to one object layer before it is written.
233
+ * Marks the path on a Mongoose document so the save carries it; a plain
234
+ * payload needs no mark. A policy that changes nothing leaves the stats alone.
235
+ *
236
+ * @param {{ data: { item: { id: string, type: string }, stats: Object }, markModified?: Function }} objectLayer
237
+ * @param {import('../src/projects/cyberia/stat-balance.js').StatPolicy} policy
238
+ * @returns {boolean} Whether the stats were rewritten.
239
+ */
240
+ const applyStatPolicy = (objectLayer, policy) => {
241
+ if (!statPolicyActive(policy)) return false;
242
+ const { stats, item } = objectLayer.data;
243
+ objectLayer.data.stats = balanceStats({
244
+ stats: typeof stats?.toObject === 'function' ? stats.toObject() : stats,
245
+ itemType: item.type,
246
+ policy,
247
+ });
248
+ if (typeof objectLayer.markModified === 'function') objectLayer.markModified('data.stats');
249
+ logger.info(
250
+ `Stats for '${objectLayer.data.item.id}' (${objectLayer.data.item.type}): ${JSON.stringify(objectLayer.data.stats)}`,
251
+ );
252
+ return true;
253
+ };
254
+
255
+ /**
256
+ * Finds the asset type directory that holds one item id.
257
+ *
258
+ * @param {string} itemId - Object layer item id.
259
+ * @returns {{ type: string, folder: string }|null} The type and folder, or null when absent.
260
+ */
261
+ const findAssetFolder = (itemId) => {
262
+ for (const type of Object.keys(itemTypes)) {
263
+ const folder = `./src/client/public/cyberia/assets/${type}/${itemId}`;
264
+ if (fs.existsSync(folder) && fs.statSync(folder).isDirectory()) return { type, folder };
265
+ }
266
+ return null;
267
+ };
268
+
269
+ /**
270
+ * Resolves the stored item ids one `ol` action works on.
271
+ *
272
+ * The scope is the item-id argument, one instance, or the whole ObjectLayer
273
+ * collection. Only ids the collection holds survive, so an action can never
274
+ * create an object layer. Exits when the scope resolves to nothing.
275
+ *
276
+ * @param {Object} params
277
+ * @param {import('mongoose').Model} params.ObjectLayer - Mongoose ObjectLayer model.
278
+ * @param {string} [params.itemId] - The comma-separated item-id argument.
279
+ * @param {string} [params.instance] - Instance code from `--instance`.
280
+ * @param {string} params.host - Deploy host.
281
+ * @param {string} params.path - Deploy path.
282
+ * @param {string} params.action - The flag being served, for the log lines.
283
+ * @returns {Promise<string[]>} Item ids to work on.
284
+ */
285
+ const selectScopedItemIds = async ({ ObjectLayer, itemId, instance, host, path, action }) => {
286
+ let storedItemIds;
287
+ if (instance) {
288
+ try {
289
+ storedItemIds = await fetchInstanceObjectLayerItemIds(getInstanceModels({ host, path }), instance);
290
+ } catch (instanceError) {
291
+ logger.error(instanceError.message);
292
+ process.exit(1);
293
+ }
294
+ logger.info(`Instance '${instance}' runs on ${storedItemIds.length} stored object layer(s)`);
295
+ } else {
296
+ const storedDocs = await ObjectLayer.find({}, { 'data.item.id': 1 }).lean();
297
+ storedItemIds = storedDocs.map((doc) => doc?.data?.item?.id);
298
+ }
299
+
300
+ const { itemIds, missingItemIds } = ObjectLayerEngine.selectStoredItemIds({
301
+ storedItemIds,
302
+ requestedItemIds: parseItemIds(itemId),
303
+ });
304
+
305
+ const scope = instance ? `instance '${instance}'` : 'the ObjectLayer collection';
306
+ if (missingItemIds.length > 0) logger.warn(`Not in ${scope}, skipped: ${missingItemIds.join(', ')}`);
307
+ if (itemIds.length === 0) {
308
+ logger.error(`No object layer of ${scope} matches the requested item-id(s) for ${action}`);
309
+ process.exit(1);
310
+ }
311
+ return itemIds;
312
+ };
313
+
111
314
  const CYBERIA_DOCKER_HOST_ALIASES = ['cyberia-client', 'cyberia-server', 'engine-cyberia'];
112
315
 
113
316
  const installCyberiaDockerHostAliases = () => {
@@ -126,6 +329,7 @@ const installCyberiaDockerHostAliases = () => {
126
329
 
127
330
  try {
128
331
  const program = new Command();
332
+ registerStatCommands(program);
129
333
 
130
334
  /** @type {string} */
131
335
  const version = Underpost.version;
@@ -142,14 +346,52 @@ try {
142
346
  .command('ol [item-id]')
143
347
  .option(
144
348
  '--to-atlas-sprite-sheet [dim]',
145
- 'Convert object layers to atlas sprite sheets, specify dimension (default: auto-calculated based on frame count)',
349
+ 'Rebuild both atlas renders of stored object layers, optionally capped to a dimension (default: auto-calculated based on frame count)',
146
350
  )
147
351
  .option('--show-atlas-sprite-sheet', 'Show consolidated atlas sprite sheet PNG for given item-id')
148
352
  .option(
149
353
  '--import',
150
- 'Import specific item-id(s) passed as comma-separated command argument (e.g. ol hatchet,sword --import)',
354
+ 'Import specific item-id(s) passed as comma-separated command argument (e.g. ol hatchet,sword --instance FOREST --import); with --from-directory, from the asset directory instead',
355
+ )
356
+ .option(
357
+ '--from-directory',
358
+ 'Source --import and --import-types from src/client/public/cyberia/assets/<type>/<item-id>/<direction>/<frame>.png',
359
+ )
360
+ .option(
361
+ '--minify',
362
+ 'Refresh the minified atlas render the client downloads, for stored object layers (e.g. ol hatchet --minify, or ol --minify for all)',
363
+ )
364
+ .option(
365
+ '--instance <instance-code>',
366
+ 'Limit --minify and --to-atlas-sprite-sheet to the object layers one instance runs on, or make --import restore item(s) from that instance backup under engine-private (e.g. ol hatchet --instance FOREST --import)',
367
+ )
368
+ .option(
369
+ '--normalize-stats',
370
+ 'Clamp every stat of each object layer the action writes into the semantic bounds of its item type',
371
+ )
372
+ .option(
373
+ '--random-stats',
374
+ 'Regenerate every stat of each object layer the action writes, with random signed modifiers',
375
+ )
376
+ .option(
377
+ '--min-stat <value>',
378
+ 'Lowest value --random-stats or --normalize-stats may leave (default: -100)',
379
+ parseInt,
380
+ )
381
+ .option(
382
+ '--max-stat <value>',
383
+ 'Highest value --random-stats or --normalize-stats may leave (default: 100)',
384
+ parseInt,
385
+ )
386
+ .option(
387
+ '--upscale <px-factor>',
388
+ `Pixels per cell of the human-resolution atlas render; on its own it rebuilds that render (default: ${DEFAULT_ATLAS_UPSCALE_FACTOR})`,
389
+ parseInt,
390
+ )
391
+ .option(
392
+ '--import-types [object-layer-type]',
393
+ 'Batch import by object layer type from the asset directory, needs --from-directory (e.g. skin,floors or all)',
151
394
  )
152
- .option('--import-types [object-layer-type]', 'Batch import by object layer type e.g. skin,floors or all')
153
395
  .option('--show-frame [direction-frame]', 'View object layer frame for given item-id e.g. 08_0 (default: 08_0)')
154
396
  .option('--generate', 'Generate procedural object layers from semantic item-id (e.g. floor-desert)')
155
397
  .option('--count <count>', 'Shape element count multiplier for --generate (default: 3)', parseFloat)
@@ -159,7 +401,6 @@ try {
159
401
  .option('--density <density>', 'Density factor 0..1 for --generate (default: 0.5)', parseFloat)
160
402
  .option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
161
403
  .option('--mongo-host <mongo-host>', 'Mongo host override')
162
- .option('--storage-file-path <storage-file-path>', 'Storage file path override')
163
404
  .option('--drop', 'Drop existing data before importing')
164
405
  .option('--client-public', 'When used with --drop, also remove static asset folders for dropped items')
165
406
  .option('--git-clean', 'When used with --drop, run underpost clean on the cyberia asset directory')
@@ -172,11 +413,18 @@ try {
172
413
  * @param {string|undefined} itemId - Optional item ID argument.
173
414
  * @param {Object} options - Command options parsed by Commander.
174
415
  * @param {boolean} options.import - Import specific item-id(s) from the command argument (comma-separated).
416
+ * @param {boolean} options.fromDirectory - Source --import and --import-types from the asset directory.
417
+ * @param {boolean} options.minify - Refresh the minified atlas render of stored item(s).
418
+ * @param {string} options.instance - Instance code whose object layers --minify reprocesses.
419
+ * @param {boolean} options.normalizeStats - Clamp the stats of every object layer the action writes to its type's bounds.
420
+ * @param {boolean} options.randomStats - Regenerate the stats of every object layer the action writes.
421
+ * @param {number} [options.minStat] - Lowest value --random-stats may draw.
422
+ * @param {number} [options.maxStat] - Highest value --random-stats may draw.
423
+ * @param {number} options.upscale - Pixels per cell of the human-resolution atlas render.
175
424
  * @param {boolean|string} options.importTypes - Object layer types to batch import (e.g., 'all', 'skin,floor') or `false`.
176
425
  * @param {boolean|string} options.showFrame - Direction-frame string (e.g., '08_0') or `true` for default.
177
426
  * @param {string} options.envPath - Path to the `.env` file.
178
427
  * @param {string} options.mongoHost - MongoDB host override.
179
- * @param {string} options.storageFilePath - Path to a storage filter JSON file.
180
428
  * @param {boolean|string} options.toAtlasSpriteSheet - Atlas dimension or `true` for auto-calc.
181
429
  * @param {boolean} options.showAtlasSpriteSheet - Whether to display the atlas sprite sheet.
182
430
  * @param {boolean} options.drop - Whether to drop existing data before importing.
@@ -196,11 +444,16 @@ try {
196
444
  itemId,
197
445
  options = {
198
446
  import: false,
447
+ fromDirectory: false,
448
+ minify: false,
449
+ instance: '',
450
+ upscale: DEFAULT_ATLAS_UPSCALE_FACTOR,
199
451
  importTypes: false,
452
+ normalizeStats: false,
453
+ randomStats: false,
200
454
  showFrame: '',
201
455
  envPath: '',
202
456
  mongoHost: '',
203
- storageFilePath: '',
204
457
  toAtlasSpriteSheet: '',
205
458
  showAtlasSpriteSheet: false,
206
459
  drop: false,
@@ -215,6 +468,12 @@ try {
215
468
  density: 0.5,
216
469
  },
217
470
  ) => {
471
+ const upscaleFactor = options.upscale ?? DEFAULT_ATLAS_UPSCALE_FACTOR;
472
+ if (!Number.isInteger(upscaleFactor) || upscaleFactor < 1) {
473
+ logger.error('--upscale takes a whole pixel factor of 1 or more');
474
+ process.exit(1);
475
+ }
476
+
218
477
  if (!options.envPath) options.envPath = `./.env`;
219
478
  if (fs.existsSync(options.envPath)) dotenv.config({ path: options.envPath, override: true });
220
479
 
@@ -250,8 +509,21 @@ try {
250
509
  path,
251
510
  });
252
511
 
512
+ // --instance reads the world the runtime reads, so its content collections load too.
513
+ const instanceApis = options.instance
514
+ ? [
515
+ 'cyberia-instance',
516
+ 'cyberia-instance-conf',
517
+ 'cyberia-map',
518
+ 'cyberia-quest',
519
+ 'cyberia-action',
520
+ 'cyberia-skill',
521
+ 'cyberia-entity-type-default',
522
+ ]
523
+ : [];
524
+
253
525
  await DataBaseProviderService.load({
254
- apis: ['object-layer', 'object-layer-render-frames', 'atlas-sprite-sheet', 'file', 'ipfs'],
526
+ apis: ['object-layer', 'object-layer-render-frames', 'atlas-sprite-sheet', 'file', 'ipfs', ...instanceApis],
255
527
  host,
256
528
  path,
257
529
  db,
@@ -268,12 +540,47 @@ try {
268
540
  /** @type {import('mongoose').Model} */
269
541
  const Ipfs = DataBaseProviderService.getModel('ipfs', { host, path });
270
542
 
271
- // Idempotent repair, run only before flows that write: collapses legacy
272
- // duplicates and upgrades the data.item.id index to unique so every
273
- // later write has exactly one document to land on. Read-only
274
- // subcommands stay side-effect free findByItemId already resolves the
275
- // same canonical document whether or not duplicates are still present.
276
- if (options.import || options.importTypes || options.drop || options.generate) {
543
+ // A model handle binds to one connection, and the health monitor replaces that
544
+ // connection when it drops. A batch that runs for minutes therefore resolves its
545
+ // model per item instead of holding the handle it started with.
546
+ const liveObjectLayer = () => DataBaseProviderService.getModel('object-layer', { host, path });
547
+
548
+ const rebuildAtlases = ObjectLayerEngine.selectAtlasRebuild(options);
549
+
550
+ /* Bounds fail here, before any write, rather than on the first item. */
551
+ const statPolicy = {
552
+ normalize: !!options.normalizeStats,
553
+ random: !!options.randomStats,
554
+ min: options.minStat,
555
+ max: options.maxStat,
556
+ };
557
+ if (statPolicyActive(statPolicy)) {
558
+ try {
559
+ resolveStatBounds('', statPolicy);
560
+ } catch (boundsError) {
561
+ logger.error(`--min-stat/--max-stat: ${boundsError.message}`);
562
+ process.exit(1);
563
+ }
564
+ } else if (statPolicy.min !== undefined || statPolicy.max !== undefined) {
565
+ logger.warn('--min-stat and --max-stat only bound --random-stats and --normalize-stats, ignored');
566
+ }
567
+
568
+ if (options.instance && !options.minify && !rebuildAtlases && !options.import) {
569
+ logger.warn('--instance only narrows --minify and --to-atlas-sprite-sheet, or sources --import, ignored');
570
+ }
571
+
572
+ // Idempotent repair, run only before a flow that writes: collapse
573
+ // duplicates and make the data.item.id index unique, so every later
574
+ // write lands on one document. A read-only subcommand stays free of
575
+ // side effects; findByItemId resolves the same canonical document.
576
+ if (
577
+ options.import ||
578
+ options.minify ||
579
+ options.importTypes ||
580
+ options.drop ||
581
+ options.generate ||
582
+ rebuildAtlases
583
+ ) {
277
584
  const { removedIds, indexUpgraded } = await ObjectLayer.ensureUniqueItemIdIndex();
278
585
  if (removedIds.length > 0) logger.warn(`Removed ${removedIds.length} duplicate ObjectLayer document(s)`);
279
586
  if (indexUpgraded) logger.info('Upgraded data.item.id index to unique');
@@ -295,9 +602,9 @@ try {
295
602
  logger.info('Dropping ALL object layer data');
296
603
  }
297
604
 
298
- // Build query filter: targeted or all
605
+ // Build query filter: targeted or all. The atlas side of the drop is selected by
606
+ // the store, from the item keys and the links collected below.
299
607
  const olFilter = isTargetedDrop ? { 'data.item.id': { $in: dropItemIds } } : {};
300
- const atlasFilter = isTargetedDrop ? { 'metadata.itemKey': { $in: dropItemIds } } : {};
301
608
 
302
609
  // Collect data before deletion
303
610
  const olDocs = await ObjectLayer.find(olFilter, {
@@ -308,7 +615,6 @@ try {
308
615
  objectLayerRenderFramesId: 1,
309
616
  atlasSpriteSheetId: 1,
310
617
  }).lean();
311
- const atlasDocs = await AtlasSpriteSheet.find(atlasFilter, { fileId: 1, cid: 1 }).lean();
312
618
 
313
619
  const cidsToUnpin = new Set();
314
620
  const itemIdsToClean = new Set();
@@ -324,34 +630,36 @@ try {
324
630
  if (doc.atlasSpriteSheetId) atlasIds.push(doc.atlasSpriteSheetId);
325
631
  }
326
632
 
327
- const atlasFileIds = atlasDocs.map((a) => a.fileId).filter(Boolean);
328
- for (const atlas of atlasDocs) {
329
- if (atlas.cid) cidsToUnpin.add(atlas.cid);
330
- }
331
-
332
633
  const olCount = olDocs.length;
333
- const atlasCount = atlasDocs.length;
334
634
 
335
635
  // Delete targeted documents
336
636
  if (isTargetedDrop) {
337
637
  const olIds = olDocs.map((d) => d._id);
338
638
  if (olIds.length > 0) await ObjectLayer.deleteMany({ _id: { $in: olIds } });
339
639
  if (renderFrameIds.length > 0) await ObjectLayerRenderFrames.deleteMany({ _id: { $in: renderFrameIds } });
340
- if (atlasIds.length > 0) await AtlasSpriteSheet.deleteMany({ _id: { $in: atlasIds } });
341
640
  } else {
342
641
  await ObjectLayer.deleteMany();
343
642
  await ObjectLayerRenderFrames.deleteMany();
344
- await AtlasSpriteSheet.deleteMany();
345
643
  }
346
644
 
347
645
  const rfCount = renderFrameIds.length;
348
646
 
349
- // Remove only the File documents that were referenced by atlas sprite sheets
350
- let fileCount = 0;
351
- if (atlasFileIds.length > 0) {
352
- const result = await File.deleteMany({ _id: { $in: atlasFileIds } });
353
- fileCount = result.deletedCount || 0;
354
- }
647
+ // The atlas owns two renders and the store is what knows that: a drop naming
648
+ // `fileId` alone left every minified render unreachable in the File collection.
649
+ // Both selectors are passed, so an atlas linked by the object layer and one
650
+ // matching the item key are the same drop.
651
+ const purged = await AtlasSpriteSheetStore.purge({
652
+ itemKeys: isTargetedDrop ? dropItemIds : [],
653
+ atlasIds: isTargetedDrop ? atlasIds : [],
654
+ all: !isTargetedDrop,
655
+ options: { host, path },
656
+ });
657
+ for (const cid of purged.cids) cidsToUnpin.add(cid);
658
+ const atlasCount = purged.atlases;
659
+ // Renders an earlier drop left behind are unreachable by definition, so this
660
+ // drop takes them too instead of letting them accumulate.
661
+ const fileCount =
662
+ purged.files + (await AtlasSpriteSheetStore.pruneOrphanRenders({ options: { host, path } }));
355
663
 
356
664
  // Delete IPFS pin registry records for all collected CIDs
357
665
  if (cidsToUnpin.size > 0) {
@@ -404,374 +712,166 @@ try {
404
712
  }
405
713
  }
406
714
 
407
- /** @type {Object|null} */
408
- const storage = options.storageFilePath ? JSON.parse(fs.readFileSync(options.storageFilePath, 'utf8')) : null;
715
+ // ── Handle --minify (stored item-id(s)) ──────────────────────────
716
+ // Refreshes only the minified atlas render, the one the client runtime
717
+ // downloads. It reads its item ids from the collection, so it never
718
+ // creates an object layer. With --random-stats every stored document
719
+ // in scope is rewritten, whether or not its render can be refreshed.
720
+ if (options.minify) {
721
+ const selectedItemIds = await selectScopedItemIds({
722
+ ObjectLayer,
723
+ itemId,
724
+ instance: options.instance,
725
+ host,
726
+ path,
727
+ action: '--minify',
728
+ });
409
729
 
410
- // ── Handle --import (specific item-id(s)) ─────────────────────
411
- if (options.import) {
412
- if (!itemId) {
413
- logger.error('item-id is required for --import (comma-separated item IDs, e.g. ol hatchet,sword --import)');
414
- process.exit(1);
730
+ logger.info(`Minify refresh for ${selectedItemIds.length} stored item(s)`);
731
+ const tally = { updated: 0, unchanged: 0, missing: 0, stale: 0, failed: [] };
732
+
733
+ // Isolated per item, for the same reason the atlas rebuild is.
734
+ for (const currentItemId of selectedItemIds) {
735
+ try {
736
+ const objectLayer = await liveObjectLayer()
737
+ .findByItemId(currentItemId)
738
+ .populate('objectLayerRenderFramesId');
739
+ if (objectLayer && applyStatPolicy(objectLayer, statPolicy)) {
740
+ await objectLayer.save();
741
+ await ObjectLayerEngine.computeAndSaveFinalSha256({ objectLayer, options: { host, path } });
742
+ }
743
+ if (!objectLayer?.objectLayerRenderFramesId) {
744
+ logger.warn(`No render frames stored for '${currentItemId}', render skipped`);
745
+ tally.missing++;
746
+ continue;
747
+ }
748
+
749
+ const { status } = await AtlasSpriteSheetStore.syncMinifyRender({
750
+ itemKey: currentItemId,
751
+ objectLayerRenderFrames: objectLayer.objectLayerRenderFramesId,
752
+ options: { host, path },
753
+ });
754
+ tally[status]++;
755
+
756
+ if (status === 'missing') logger.warn(`No atlas stored for '${currentItemId}'; generate it first`);
757
+ else if (status === 'stale')
758
+ logger.warn(`Render frames of '${currentItemId}' moved the atlas layout; regenerate the atlas`);
759
+ else logger.info(`Minified render ${status} for '${currentItemId}'`);
760
+
761
+ const still = await AtlasSpriteSheetStore.syncIdlePreview({
762
+ itemKey: currentItemId,
763
+ options: { host, path },
764
+ });
765
+ if (still.status !== 'missing') logger.info(`Idle preview ${still.status} for '${currentItemId}'`);
766
+ } catch (minifyError) {
767
+ logger.error(`Minify failed for '${currentItemId}': ${minifyError.message}`);
768
+ tally.failed.push(currentItemId);
769
+ }
415
770
  }
416
771
 
417
- const itemIds = itemId
418
- .split(',')
419
- .map((id) => id.trim())
420
- .filter(Boolean);
421
- logger.info(`Importing specific item(s): ${itemIds.join(', ')}`);
772
+ logger.info(
773
+ `Minify done: ${tally.updated} updated, ${tally.unchanged} unchanged, ` +
774
+ `${tally.stale} stale, ${tally.missing} without an atlas, ${tally.failed.length} failed`,
775
+ );
776
+ if (tally.failed.length > 0) {
777
+ logger.warn(`Rerun for the failed item(s): ${tally.failed.join(',')}`);
778
+ }
779
+ }
422
780
 
781
+ // ── Handle --import --instance: restore item(s) from the instance backup ──
782
+ // The backup under engine-private is the authority for the item id: every document it
783
+ // holds for the item replaces the database's, rather than regenerating from the asset
784
+ // directory. The item never touches the stat policy — the backup already states its stats.
785
+ if (options.import && options.instance) {
786
+ const itemIds = parseItemIds(itemId);
787
+ if (itemIds.length === 0) {
788
+ logger.error(
789
+ 'item-id is required for --import --instance (e.g. ol hatchet,sword --instance FOREST --import)',
790
+ );
791
+ process.exit(1);
792
+ }
793
+ const backupDir = `./engine-private/cyberia-instances/${options.instance}`;
794
+ if (!fs.existsSync(backupDir)) {
795
+ logger.error(`No instance backup at ${backupDir}`);
796
+ process.exit(1);
797
+ }
798
+ logger.info(`Restoring ${itemIds.length} item(s) from instance backup '${options.instance}'`);
799
+ let restored = 0;
423
800
  for (const currentItemId of itemIds) {
424
- // Search across all asset type directories to find which type contains this item-id
425
- let foundType = null;
426
- let foundFolder = null;
427
- for (const type of Object.keys(itemTypes)) {
428
- const candidateFolder = `./src/client/public/cyberia/assets/${type}/${currentItemId}`;
429
- if (fs.existsSync(candidateFolder) && fs.statSync(candidateFolder).isDirectory()) {
430
- foundType = type;
431
- foundFolder = candidateFolder;
432
- break;
433
- }
801
+ try {
802
+ const summary = await restoreObjectLayerBackup({
803
+ backupDir,
804
+ itemId: currentItemId,
805
+ options: { host, path },
806
+ });
807
+ logger.info(`Restored '${currentItemId}' from backup`, summary);
808
+ restored++;
809
+ } catch (restoreError) {
810
+ logger.error(`Restore failed for '${currentItemId}': ${restoreError.message}`);
434
811
  }
812
+ }
813
+ logger.info(`Instance restore done: ${restored}/${itemIds.length} item(s)`);
814
+ }
435
815
 
436
- if (!foundType) {
816
+ if (options.import && !options.instance === !options.fromDirectory) {
817
+ logger.error(
818
+ '--import takes exactly one source: --instance <code> for a backup, or --from-directory for the asset tree',
819
+ );
820
+ process.exit(1);
821
+ }
822
+ if (options.importTypes && !options.fromDirectory) {
823
+ logger.error('--import-types reads the asset tree and needs --from-directory');
824
+ process.exit(1);
825
+ }
826
+
827
+ // ── Handle --import --from-directory (specific item-id(s)) ────────
828
+ if (options.import && options.fromDirectory) {
829
+ const itemIds = parseItemIds(itemId);
830
+ if (itemIds.length === 0) {
831
+ logger.error(
832
+ 'item-id is required for --import --from-directory (comma-separated item IDs, e.g. ol hatchet,sword --from-directory --import)',
833
+ );
834
+ process.exit(1);
835
+ }
836
+ logger.info(`Importing specific item(s) from the asset directory: ${itemIds.join(', ')}`);
837
+
838
+ for (const currentItemId of itemIds) {
839
+ const found = findAssetFolder(currentItemId);
840
+ if (!found) {
437
841
  logger.error(
438
842
  `Item-id '${currentItemId}' not found in any asset type directory (${Object.keys(itemTypes).join(', ')})`,
439
843
  );
440
844
  continue;
441
845
  }
442
-
443
- logger.info(`Found item '${currentItemId}' in type '${foundType}' at ${foundFolder}`);
846
+ logger.info(`Found item '${currentItemId}' in type '${found.type}' at ${found.folder}`);
444
847
 
445
848
  const { objectLayerRenderFramesData, objectLayerData } =
446
849
  await ObjectLayerEngine.buildObjectLayerDataFromDirectory({
447
- folder: foundFolder,
448
- objectLayerType: foundType,
850
+ folder: found.folder,
851
+ objectLayerType: found.type,
449
852
  objectLayerId: currentItemId,
450
853
  });
854
+ applyStatPolicy(objectLayerData, statPolicy);
451
855
 
452
856
  // Write processed frames back to disk so WebP matches atlas
453
- const srcBasePath = './src/client/public/cyberia/';
454
- const publicBasePath = `./public/${host}${path}`;
455
857
  await ObjectLayerEngine.writeStaticFrameAssets({
456
- basePaths: [srcBasePath, publicBasePath],
457
- itemType: foundType,
858
+ basePaths: ['./src/client/public/cyberia/', `./public/${host}${path}`],
859
+ itemType: found.type,
458
860
  itemId: currentItemId,
459
861
  objectLayerRenderFramesData,
460
862
  objectLayerData,
461
- cellPixelDim: 20,
863
+ cellPixelDim: upscaleFactor,
462
864
  });
463
865
 
464
- // Check if an ObjectLayer with the same item.id already exists (upsert by item ID)
465
- const existingOL = await ObjectLayer.findByItemId(currentItemId);
466
- let objectLayer;
467
-
468
- if (existingOL) {
469
- // ── Cut-over consistency: stage everything in memory before touching the live document ──
470
- logger.info(`ObjectLayer '${currentItemId}' already exists (${existingOL._id}), staging update...`);
471
-
472
- // 1. Prepare staging data entirely in memory (no DB writes yet)
473
- const stagingData = JSON.parse(JSON.stringify(objectLayerData.data));
474
- if (!stagingData.render) stagingData.render = {};
475
- stagingData.render.cid = '';
476
- stagingData.render.metadataCid = '';
477
-
478
- // 2. Generate atlas, pin to IPFS, compute SHA-256 — all in memory
479
- let cutoverReady = false;
480
- let stagingFileDoc = null;
481
- let stagingAtlasDoc = null;
482
- let stagingCid = '';
483
- try {
484
- const itemKey = currentItemId;
485
-
486
- // Generate atlas from in-memory render frames data (plain object, no DB doc needed)
487
- const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
488
- objectLayerRenderFramesData,
489
- itemKey,
490
- 20,
491
- );
492
-
493
- stagingFileDoc = await new File({
494
- name: `${itemKey}-atlas.png`,
495
- data: buffer,
496
- size: buffer.length,
497
- mimetype: 'image/png',
498
- md5: crypto.createHash('md5').update(buffer).digest('hex'),
499
- }).save();
500
-
501
- let importItemCid = '';
502
- let importItemMetadataCid = '';
503
- try {
504
- const ipfsResult = await IpfsClient.addBufferToIpfs(
505
- buffer,
506
- `${itemKey}_atlas_sprite_sheet.png`,
507
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
508
- );
509
- if (ipfsResult) {
510
- importItemCid = ipfsResult.cid;
511
- logger.info(`[staging] Atlas pinned to IPFS – CID: ${importItemCid}`);
512
- try {
513
- await createPinRecord({
514
- cid: importItemCid,
515
- resourceType: 'atlas-sprite-sheet',
516
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
517
- options: { host, path },
518
- });
519
- } catch (prErr) {
520
- logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
521
- }
522
- }
523
- } catch (ipfsError) {
524
- logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
525
- }
526
-
527
- try {
528
- const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
529
- metadata,
530
- `${itemKey}_atlas_sprite_sheet_metadata.json`,
531
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
532
- );
533
- if (metadataIpfsResult) {
534
- importItemMetadataCid = metadataIpfsResult.cid;
535
- logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importItemMetadataCid}`);
536
- try {
537
- await createPinRecord({
538
- cid: importItemMetadataCid,
539
- resourceType: 'atlas-metadata',
540
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
541
- options: { host, path },
542
- });
543
- } catch (prErr) {
544
- logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
545
- }
546
- }
547
- } catch (ipfsError) {
548
- logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
549
- }
550
-
551
- // Persist atlas doc (or update existing one for this itemKey)
552
- stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
553
- if (stagingAtlasDoc) {
554
- if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
555
- stagingAtlasDoc.fileId = stagingFileDoc._id;
556
- stagingAtlasDoc.cid = importItemCid;
557
- stagingAtlasDoc.metadata = metadata;
558
- await stagingAtlasDoc.save();
559
- } else {
560
- stagingAtlasDoc = await new AtlasSpriteSheet({
561
- fileId: stagingFileDoc._id,
562
- cid: importItemCid,
563
- metadata,
564
- }).save();
565
- }
566
-
567
- // Finalize staging data in memory with render CIDs
568
- stagingData.render.cid = importItemCid;
569
- stagingData.render.metadataCid = importItemMetadataCid;
570
-
571
- // Pin data JSON to IPFS
572
- try {
573
- const ipfsDataResult = await IpfsClient.addJsonToIpfs(
574
- stagingData,
575
- `${itemKey}_data.json`,
576
- `/object-layer/${itemKey}/${itemKey}_data.json`,
577
- );
578
- if (ipfsDataResult) {
579
- stagingCid = ipfsDataResult.cid;
580
- logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
581
- try {
582
- await createPinRecord({
583
- cid: stagingCid,
584
- resourceType: 'object-layer-data',
585
- mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
586
- options: { host, path },
587
- });
588
- } catch (prErr) {
589
- logger.warn('[staging] Failed to create data pin record:', prErr.message);
590
- }
591
- }
592
- } catch (ipfsError) {
593
- logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
594
- }
595
-
596
- cutoverReady = true;
597
- logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for cut-over`);
598
- } catch (atlasError) {
599
- logger.error(`[staging] Failed for ${currentItemId}, live document untouched:`, atlasError);
600
- }
601
-
602
- // 3. Atomic cut-over: create new RenderFrames, swap live ObjectLayer in a single update
603
- if (cutoverReady) {
604
- const oldRenderFramesId = existingOL.objectLayerRenderFramesId;
605
-
606
- // Create the new RenderFrames doc (only now touches DB)
607
- const newRenderFrames = await ObjectLayerRenderFrames.create(objectLayerRenderFramesData);
608
-
609
- // Single atomic upsert on the natural key; empty staged CIDs keep their stored value
610
- objectLayer = await ObjectLayer.upsertByItemId({
611
- data: stagingData,
612
- cid: stagingCid,
613
- objectLayerRenderFramesId: newRenderFrames._id,
614
- atlasSpriteSheetId: stagingAtlasDoc._id,
615
- });
616
-
617
- // Clean up old render frames
618
- if (oldRenderFramesId) {
619
- await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
620
- }
621
-
622
- logger.info(`[cut-over] Live document ${objectLayer._id} updated atomically`);
623
- } else {
624
- // Rollback: only File/AtlasSpriteSheet were written, clean those up
625
- if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
626
- logger.warn(`[cut-over] Staging rolled back for ${currentItemId}, live document preserved`);
627
- objectLayer = await ObjectLayer.findByItemId(currentItemId);
628
- }
629
- } else {
630
- // ── New item: stage everything before creating (same cut-over pattern) ──
631
- logger.info(`ObjectLayer '${currentItemId}' is new, staging creation...`);
632
-
633
- const itemKey = currentItemId;
634
- const stagingData = JSON.parse(JSON.stringify(objectLayerData.data));
635
- if (!stagingData.render) stagingData.render = {};
636
- stagingData.render.cid = '';
637
- stagingData.render.metadataCid = '';
638
-
639
- let cutoverReady = false;
640
- let stagingFileDoc = null;
641
- let stagingAtlasDoc = null;
642
- let stagingCid = '';
643
- try {
644
- const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
645
- objectLayerRenderFramesData,
646
- itemKey,
647
- 20,
648
- );
649
-
650
- stagingFileDoc = await new File({
651
- name: `${itemKey}-atlas.png`,
652
- data: buffer,
653
- size: buffer.length,
654
- mimetype: 'image/png',
655
- md5: crypto.createHash('md5').update(buffer).digest('hex'),
656
- }).save();
657
-
658
- let importItemCid = '';
659
- let importItemMetadataCid = '';
660
- try {
661
- const ipfsResult = await IpfsClient.addBufferToIpfs(
662
- buffer,
663
- `${itemKey}_atlas_sprite_sheet.png`,
664
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
665
- );
666
- if (ipfsResult) {
667
- importItemCid = ipfsResult.cid;
668
- logger.info(`[staging] Atlas pinned to IPFS – CID: ${importItemCid}`);
669
- try {
670
- await createPinRecord({
671
- cid: importItemCid,
672
- resourceType: 'atlas-sprite-sheet',
673
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
674
- options: { host, path },
675
- });
676
- } catch (prErr) {
677
- logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
678
- }
679
- }
680
- } catch (ipfsError) {
681
- logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
682
- }
683
-
684
- try {
685
- const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
686
- metadata,
687
- `${itemKey}_atlas_sprite_sheet_metadata.json`,
688
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
689
- );
690
- if (metadataIpfsResult) {
691
- importItemMetadataCid = metadataIpfsResult.cid;
692
- logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importItemMetadataCid}`);
693
- try {
694
- await createPinRecord({
695
- cid: importItemMetadataCid,
696
- resourceType: 'atlas-metadata',
697
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
698
- options: { host, path },
699
- });
700
- } catch (prErr) {
701
- logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
702
- }
703
- }
704
- } catch (ipfsError) {
705
- logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
706
- }
707
-
708
- stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
709
- if (stagingAtlasDoc) {
710
- if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
711
- stagingAtlasDoc.fileId = stagingFileDoc._id;
712
- stagingAtlasDoc.cid = importItemCid;
713
- stagingAtlasDoc.metadata = metadata;
714
- await stagingAtlasDoc.save();
715
- } else {
716
- stagingAtlasDoc = await new AtlasSpriteSheet({
717
- fileId: stagingFileDoc._id,
718
- cid: importItemCid,
719
- metadata,
720
- }).save();
721
- }
722
-
723
- stagingData.render.cid = importItemCid;
724
- stagingData.render.metadataCid = importItemMetadataCid;
725
-
726
- try {
727
- const ipfsDataResult = await IpfsClient.addJsonToIpfs(
728
- stagingData,
729
- `${itemKey}_data.json`,
730
- `/object-layer/${itemKey}/${itemKey}_data.json`,
731
- );
732
- if (ipfsDataResult) {
733
- stagingCid = ipfsDataResult.cid;
734
- logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
735
- try {
736
- await createPinRecord({
737
- cid: stagingCid,
738
- resourceType: 'object-layer-data',
739
- mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
740
- options: { host, path },
741
- });
742
- } catch (prErr) {
743
- logger.warn('[staging] Failed to create data pin record:', prErr.message);
744
- }
745
- }
746
- } catch (ipfsError) {
747
- logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
748
- }
749
-
750
- cutoverReady = true;
751
- logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for creation`);
752
- } catch (atlasError) {
753
- logger.error(`[staging] Failed for ${currentItemId}, no document created:`, atlasError);
754
- }
755
-
756
- if (cutoverReady) {
757
- const newRenderFrames = await ObjectLayerRenderFrames.create(objectLayerRenderFramesData);
758
- objectLayer = await ObjectLayer.upsertByItemId({
759
- data: stagingData,
760
- cid: stagingCid,
761
- objectLayerRenderFramesId: newRenderFrames._id,
762
- atlasSpriteSheetId: stagingAtlasDoc._id,
763
- });
764
- logger.info(`[cut-over] New ObjectLayer ${objectLayer._id} created with all CIDs populated`);
765
- } else {
766
- if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
767
- logger.warn(`[cut-over] Staging failed for ${currentItemId}, no ObjectLayer created`);
768
- continue;
769
- }
770
- }
866
+ const { objectLayer } = await ObjectLayerEngine.persistObjectLayerDocuments({
867
+ ObjectLayer,
868
+ ObjectLayerRenderFrames,
869
+ objectLayerRenderFramesData,
870
+ objectLayerData,
871
+ persistOptions: { upscaleFactor, options: { host, path } },
872
+ });
771
873
 
772
- // Reload final state to include CID and render updates
773
- const finalObjectLayer = await ObjectLayer.findById(objectLayer._id).populate('objectLayerRenderFramesId');
774
- console.log(finalObjectLayer.toObject());
874
+ console.log((await ObjectLayer.findById(objectLayer._id).populate('objectLayerRenderFramesId')).toObject());
775
875
  }
776
876
  }
777
877
 
@@ -806,12 +906,6 @@ try {
806
906
  await pngDirectoryIteratorByObjectLayerType(
807
907
  argItemType,
808
908
  async ({ path: framePath, objectLayerType, objectLayerId, direction, frame }) => {
809
- if (
810
- storage &&
811
- !storage[`src/client/public/cyberia/assets/${objectLayerType}/${objectLayerId}/08/0.png`]
812
- )
813
- return;
814
-
815
909
  // Skip items that already exist in the database (bulk import only)
816
910
  if (isImportAll && existingItemIds.has(objectLayerId)) return;
817
911
 
@@ -826,6 +920,7 @@ try {
826
920
  objectLayerType,
827
921
  objectLayerId,
828
922
  });
923
+ applyStatPolicy(objectLayerData, statPolicy);
829
924
 
830
925
  // Write processed frames back to disk so WebP matches atlas
831
926
  const srcBasePath = './src/client/public/cyberia/';
@@ -836,14 +931,10 @@ try {
836
931
  itemId: objectLayerId,
837
932
  objectLayerRenderFramesData,
838
933
  objectLayerData,
839
- cellPixelDim: 20,
934
+ cellPixelDim: upscaleFactor,
840
935
  });
841
936
 
842
- objectLayers[objectLayerId] = {
843
- ...objectLayerData,
844
- objectLayerRenderFramesData,
845
- _processed: true,
846
- };
937
+ objectLayers[objectLayerId] = { ...objectLayerData, objectLayerRenderFramesData };
847
938
  }
848
939
  },
849
940
  );
@@ -852,369 +943,17 @@ try {
852
943
  for (const objectLayerId of Object.keys(objectLayers)) {
853
944
  const entry = objectLayers[objectLayerId];
854
945
 
855
- // Skip atlas generation when importing all object layers at once (bulk import).
856
- // Individual imports or explicit --to-atlas-sprite-sheet calls will still generate atlases.
857
- const shouldGenerateAtlas = !isImportAll;
858
-
859
- if (shouldGenerateAtlas) {
860
- // Check if an ObjectLayer with the same item.id already exists (upsert by item ID)
861
- const existingOL = await ObjectLayer.findByItemId(objectLayerId);
862
- let objectLayer;
863
-
864
- if (existingOL) {
865
- // ── Cut-over consistency: stage everything in memory before touching the live document ──
866
- logger.info(`ObjectLayer '${objectLayerId}' already exists (${existingOL._id}), staging update...`);
867
-
868
- // 1. Prepare staging data entirely in memory (no DB writes yet)
869
- const stagingData = JSON.parse(JSON.stringify(entry.data));
870
- if (!stagingData.render) stagingData.render = {};
871
- stagingData.render.cid = '';
872
- stagingData.render.metadataCid = '';
873
-
874
- // 2. Generate atlas, pin to IPFS, compute SHA-256 — all in memory
875
- let cutoverReady = false;
876
- let stagingFileDoc = null;
877
- let stagingAtlasDoc = null;
878
- let stagingCid = '';
879
- try {
880
- const itemKey = objectLayerId;
881
-
882
- // Generate atlas from in-memory render frames data (plain object, no DB doc needed)
883
- const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
884
- entry.objectLayerRenderFramesData,
885
- itemKey,
886
- 20,
887
- );
888
-
889
- stagingFileDoc = await new File({
890
- name: `${itemKey}-atlas.png`,
891
- data: buffer,
892
- size: buffer.length,
893
- mimetype: 'image/png',
894
- md5: crypto.createHash('md5').update(buffer).digest('hex'),
895
- }).save();
896
-
897
- let importAtlasCid = '';
898
- let importAtlasMetadataCid = '';
899
- try {
900
- const ipfsResult = await IpfsClient.addBufferToIpfs(
901
- buffer,
902
- `${itemKey}_atlas_sprite_sheet.png`,
903
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
904
- );
905
- if (ipfsResult) {
906
- importAtlasCid = ipfsResult.cid;
907
- logger.info(`[staging] Atlas pinned to IPFS – CID: ${importAtlasCid}`);
908
- try {
909
- await createPinRecord({
910
- cid: importAtlasCid,
911
- resourceType: 'atlas-sprite-sheet',
912
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
913
- options: { host, path },
914
- });
915
- } catch (prErr) {
916
- logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
917
- }
918
- }
919
- } catch (ipfsError) {
920
- logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
921
- }
922
-
923
- try {
924
- const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
925
- metadata,
926
- `${itemKey}_atlas_sprite_sheet_metadata.json`,
927
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
928
- );
929
- if (metadataIpfsResult) {
930
- importAtlasMetadataCid = metadataIpfsResult.cid;
931
- logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importAtlasMetadataCid}`);
932
- try {
933
- await createPinRecord({
934
- cid: importAtlasMetadataCid,
935
- resourceType: 'atlas-metadata',
936
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
937
- options: { host, path },
938
- });
939
- } catch (prErr) {
940
- logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
941
- }
942
- }
943
- } catch (ipfsError) {
944
- logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
945
- }
946
-
947
- stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
948
- if (stagingAtlasDoc) {
949
- if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
950
- stagingAtlasDoc.fileId = stagingFileDoc._id;
951
- stagingAtlasDoc.cid = importAtlasCid;
952
- stagingAtlasDoc.metadata = metadata;
953
- await stagingAtlasDoc.save();
954
- } else {
955
- stagingAtlasDoc = await new AtlasSpriteSheet({
956
- fileId: stagingFileDoc._id,
957
- cid: importAtlasCid,
958
- metadata,
959
- }).save();
960
- }
961
-
962
- // Finalize staging data in memory with render CIDs
963
- stagingData.render.cid = importAtlasCid;
964
- stagingData.render.metadataCid = importAtlasMetadataCid;
965
-
966
- // Pin data JSON to IPFS
967
- try {
968
- const ipfsDataResult = await IpfsClient.addJsonToIpfs(
969
- stagingData,
970
- `${itemKey}_data.json`,
971
- `/object-layer/${itemKey}/${itemKey}_data.json`,
972
- );
973
- if (ipfsDataResult) {
974
- stagingCid = ipfsDataResult.cid;
975
- logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
976
- try {
977
- await createPinRecord({
978
- cid: stagingCid,
979
- resourceType: 'object-layer-data',
980
- mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
981
- options: { host, path },
982
- });
983
- } catch (prErr) {
984
- logger.warn('[staging] Failed to create data pin record:', prErr.message);
985
- }
986
- }
987
- } catch (ipfsError) {
988
- logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
989
- }
990
-
991
- cutoverReady = true;
992
- logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for cut-over`);
993
- } catch (atlasError) {
994
- logger.error(`[staging] Failed for ${objectLayerId}, live document untouched:`, atlasError);
995
- }
996
-
997
- // 3. Atomic cut-over: create new RenderFrames, swap live ObjectLayer in a single update
998
- if (cutoverReady) {
999
- const oldRenderFramesId = existingOL.objectLayerRenderFramesId;
1000
- const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
1001
-
1002
- objectLayer = await ObjectLayer.upsertByItemId({
1003
- data: stagingData,
1004
- cid: stagingCid,
1005
- objectLayerRenderFramesId: newRenderFrames._id,
1006
- atlasSpriteSheetId: stagingAtlasDoc._id,
1007
- });
1008
-
1009
- if (oldRenderFramesId) {
1010
- await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
1011
- }
1012
- logger.info(`[cut-over] Live document ${objectLayer._id} updated atomically`);
1013
- } else {
1014
- if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
1015
- logger.warn(`[cut-over] Staging rolled back for ${objectLayerId}, live document preserved`);
1016
- objectLayer = await ObjectLayer.findByItemId(objectLayerId);
1017
- }
1018
- } else {
1019
- // ── New item: stage everything before creating (same cut-over pattern) ──
1020
- logger.info(`ObjectLayer '${objectLayerId}' is new, staging creation...`);
1021
-
1022
- const itemKey = objectLayerId;
1023
- const stagingData = JSON.parse(JSON.stringify(entry.data));
1024
- if (!stagingData.render) stagingData.render = {};
1025
- stagingData.render.cid = '';
1026
- stagingData.render.metadataCid = '';
1027
-
1028
- let cutoverReady = false;
1029
- let stagingFileDoc = null;
1030
- let stagingAtlasDoc = null;
1031
- let stagingCid = '';
1032
- try {
1033
- const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
1034
- entry.objectLayerRenderFramesData,
1035
- itemKey,
1036
- 20,
1037
- );
1038
-
1039
- stagingFileDoc = await new File({
1040
- name: `${itemKey}-atlas.png`,
1041
- data: buffer,
1042
- size: buffer.length,
1043
- mimetype: 'image/png',
1044
- md5: crypto.createHash('md5').update(buffer).digest('hex'),
1045
- }).save();
1046
-
1047
- let importAtlasCid = '';
1048
- let importAtlasMetadataCid = '';
1049
- try {
1050
- const ipfsResult = await IpfsClient.addBufferToIpfs(
1051
- buffer,
1052
- `${itemKey}_atlas_sprite_sheet.png`,
1053
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
1054
- );
1055
- if (ipfsResult) {
1056
- importAtlasCid = ipfsResult.cid;
1057
- logger.info(`[staging] Atlas pinned to IPFS – CID: ${importAtlasCid}`);
1058
- try {
1059
- await createPinRecord({
1060
- cid: importAtlasCid,
1061
- resourceType: 'atlas-sprite-sheet',
1062
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
1063
- options: { host, path },
1064
- });
1065
- } catch (prErr) {
1066
- logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
1067
- }
1068
- }
1069
- } catch (ipfsError) {
1070
- logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
1071
- }
1072
-
1073
- try {
1074
- const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
1075
- metadata,
1076
- `${itemKey}_atlas_sprite_sheet_metadata.json`,
1077
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
1078
- );
1079
- if (metadataIpfsResult) {
1080
- importAtlasMetadataCid = metadataIpfsResult.cid;
1081
- logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importAtlasMetadataCid}`);
1082
- try {
1083
- await createPinRecord({
1084
- cid: importAtlasMetadataCid,
1085
- resourceType: 'atlas-metadata',
1086
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
1087
- options: { host, path },
1088
- });
1089
- } catch (prErr) {
1090
- logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
1091
- }
1092
- }
1093
- } catch (ipfsError) {
1094
- logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
1095
- }
1096
-
1097
- stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
1098
- if (stagingAtlasDoc) {
1099
- if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
1100
- stagingAtlasDoc.fileId = stagingFileDoc._id;
1101
- stagingAtlasDoc.cid = importAtlasCid;
1102
- stagingAtlasDoc.metadata = metadata;
1103
- await stagingAtlasDoc.save();
1104
- } else {
1105
- stagingAtlasDoc = await new AtlasSpriteSheet({
1106
- fileId: stagingFileDoc._id,
1107
- cid: importAtlasCid,
1108
- metadata,
1109
- }).save();
1110
- }
1111
-
1112
- stagingData.render.cid = importAtlasCid;
1113
- stagingData.render.metadataCid = importAtlasMetadataCid;
1114
-
1115
- try {
1116
- const ipfsDataResult = await IpfsClient.addJsonToIpfs(
1117
- stagingData,
1118
- `${itemKey}_data.json`,
1119
- `/object-layer/${itemKey}/${itemKey}_data.json`,
1120
- );
1121
- if (ipfsDataResult) {
1122
- stagingCid = ipfsDataResult.cid;
1123
- logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
1124
- try {
1125
- await createPinRecord({
1126
- cid: stagingCid,
1127
- resourceType: 'object-layer-data',
1128
- mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
1129
- options: { host, path },
1130
- });
1131
- } catch (prErr) {
1132
- logger.warn('[staging] Failed to create data pin record:', prErr.message);
1133
- }
1134
- }
1135
- } catch (ipfsError) {
1136
- logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
1137
- }
1138
-
1139
- cutoverReady = true;
1140
- logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for creation`);
1141
- } catch (atlasError) {
1142
- logger.error(`[staging] Failed for ${objectLayerId}, no document created:`, atlasError);
1143
- }
1144
-
1145
- if (cutoverReady) {
1146
- const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
1147
- objectLayer = await ObjectLayer.upsertByItemId({
1148
- data: stagingData,
1149
- cid: stagingCid,
1150
- objectLayerRenderFramesId: newRenderFrames._id,
1151
- atlasSpriteSheetId: stagingAtlasDoc._id,
1152
- });
1153
- logger.info(`[cut-over] New ObjectLayer ${objectLayer._id} created with all CIDs populated`);
1154
- } else {
1155
- if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
1156
- logger.warn(`[cut-over] Staging failed for ${objectLayerId}, no ObjectLayer created`);
1157
- continue;
1158
- }
1159
- }
1160
-
1161
- // Reload final state to include CID and render updates
1162
- const finalObjectLayer = await ObjectLayer.findById((objectLayer._id || objectLayer).toString()).populate(
1163
- 'objectLayerRenderFramesId',
1164
- );
1165
- console.log(finalObjectLayer.toObject());
1166
- } else {
1167
- // --import all: skip items that already exist in the database
1168
- if (existingItemIds.has(objectLayerId)) continue;
1169
-
1170
- // --import all: create documents without atlas generation
1171
- const existingOL = await ObjectLayer.findByItemId(objectLayerId);
1172
- let objectLayer;
1173
-
1174
- if (existingOL) {
1175
- logger.info(
1176
- `ObjectLayer '${objectLayerId}' already exists (${existingOL._id}), staging update (atlas skipped)...`,
1177
- );
1178
-
1179
- // ── In-memory staging (no atlas) ──────────────────────
1180
- const stagingData = JSON.parse(JSON.stringify(entry.data));
1181
- if (!stagingData.render) stagingData.render = {};
1182
- stagingData.render.cid = '';
1183
- stagingData.render.metadataCid = '';
1184
-
1185
- // Atomic cut-over: create new RenderFrames, swap live doc, delete old
1186
- const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
1187
- const oldRenderFramesId = existingOL.objectLayerRenderFramesId;
1188
-
1189
- objectLayer = await ObjectLayer.upsertByItemId({
1190
- data: stagingData,
1191
- objectLayerRenderFramesId: newRenderFrames._id,
1192
- });
1193
-
1194
- if (oldRenderFramesId) {
1195
- await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
1196
- }
1197
-
1198
- logger.info(`[cut-over] Live document ${objectLayer._id} updated atomically (atlas skipped)`);
1199
- } else {
1200
- // New item (no atlas for bulk import)
1201
- const stagingData = JSON.parse(JSON.stringify(entry.data));
1202
- if (!stagingData.render) stagingData.render = {};
1203
- stagingData.render.cid = '';
1204
- stagingData.render.metadataCid = '';
1205
-
1206
- const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
1207
- objectLayer = await ObjectLayer.upsertByItemId({
1208
- data: stagingData,
1209
- objectLayerRenderFramesId: newRenderFrames._id,
1210
- });
1211
- }
946
+ // A bulk import of every type skips atlas generation; `--to-atlas-sprite-sheet`
947
+ // or a targeted `--import` builds the atlas for an item that needs one.
948
+ const { objectLayer } = await ObjectLayerEngine.persistObjectLayerDocuments({
949
+ ObjectLayer,
950
+ ObjectLayerRenderFrames,
951
+ objectLayerRenderFramesData: entry.objectLayerRenderFramesData,
952
+ objectLayerData: { data: entry.data },
953
+ persistOptions: { generateAtlas: !isImportAll, upscaleFactor, options: { host, path } },
954
+ });
1212
955
 
1213
- logger.info(
1214
- `ObjectLayer ${existingOL ? 'updated' : 'created'} (atlas skipped for bulk import): ${objectLayerId}`,
1215
- );
1216
- console.log(objectLayer.toObject ? objectLayer.toObject() : objectLayer);
1217
- }
956
+ console.log(objectLayer.toObject());
1218
957
  }
1219
958
  }
1220
959
 
@@ -1234,20 +973,16 @@ try {
1234
973
 
1235
974
  logger.info(`Showing frame for item: ${itemId}, direction: ${direction}, frame: ${frameIndexNum}`);
1236
975
 
1237
- // Find ObjectLayer by item-id
1238
976
  const objectLayer = await ObjectLayer.findByItemId(itemId).populate('objectLayerRenderFramesId');
1239
-
1240
977
  if (!objectLayer) {
1241
978
  logger.error(`ObjectLayer not found for item-id: ${itemId}`);
1242
979
  process.exit(1);
1243
980
  }
1244
-
1245
981
  if (!objectLayer.objectLayerRenderFramesId) {
1246
982
  logger.error(`ObjectLayerRenderFrames not found for item: ${itemId}`);
1247
983
  process.exit(1);
1248
984
  }
1249
985
 
1250
- // Get the keyframe direction name from the numerical direction code
1251
986
  const objectLayerFrameDirections = getKeyframeDirectionsByCode(direction);
1252
987
  if (objectLayerFrameDirections.length === 0) {
1253
988
  logger.error(`Invalid direction code: ${direction}. Valid codes: 08, 18, 02, 12, 04, 14, 06, 16`);
@@ -1271,15 +1006,14 @@ try {
1271
1006
  process.exit(1);
1272
1007
  }
1273
1008
 
1274
- const itemKey = objectLayer.data.item.id;
1275
- const outputPath = `./${itemKey}_${showFrameInput}.png`;
1009
+ const outputPath = `./${objectLayer.data.item.id}_${showFrameInput}.png`;
1276
1010
 
1277
1011
  await buildImgFromTile({
1278
1012
  tile: {
1279
1013
  map_color: objectLayer.objectLayerRenderFramesId.colors,
1280
1014
  frame_matrix: frames[frameIndexNum],
1281
1015
  },
1282
- cellPixelDim: 20,
1016
+ cellPixelDim: upscaleFactor,
1283
1017
  opacityFilter: (x, y, color) => 255,
1284
1018
  imagePath: outputPath,
1285
1019
  });
@@ -1289,158 +1023,90 @@ try {
1289
1023
  }
1290
1024
 
1291
1025
  // ── Handle --to-atlas-sprite-sheet ───────────────────────────────
1292
- if (options.toAtlasSpriteSheet !== undefined) {
1293
- // If toAtlasSpriteSheet is true (flag without value), use null for auto-calc
1294
- // If it's a string/number, parse it as integer
1026
+ // Rebuilds both atlas renders. The scope is the same selection --minify
1027
+ // uses: the given item-id(s), one instance, or the whole collection.
1028
+ // A bare --upscale asks for the same rebuild, since the factor only
1029
+ // takes effect on the human-resolution render.
1030
+ if (rebuildAtlases) {
1295
1031
  /** @type {number|null} */
1296
- const maxAtlasDim = options.toAtlasSpriteSheet === true ? null : parseInt(options.toAtlasSpriteSheet) || null;
1032
+ const maxAtlasDim =
1033
+ options.toAtlasSpriteSheet === true || options.toAtlasSpriteSheet === undefined
1034
+ ? null
1035
+ : parseInt(options.toAtlasSpriteSheet) || null;
1297
1036
 
1298
- if (!itemId) {
1299
- logger.error('item-id is required for --to-atlas-sprite-sheet');
1300
- process.exit(1);
1301
- }
1302
-
1303
- if (maxAtlasDim) {
1304
- /** @type {string} */
1037
+ if (maxAtlasDim !== null) {
1305
1038
  const sizeRecommendation =
1306
1039
  maxAtlasDim < 2048
1307
1040
  ? ' (Warning: May be too small for all frames)'
1308
1041
  : maxAtlasDim > 4096
1309
- ? ' (Large size: ensure GPU compatibility)'
1310
- : ' (Recommended size)';
1311
-
1312
- logger.info(
1313
- `Generating atlas sprite sheet for item: ${itemId} with max dimension: ${maxAtlasDim}x${maxAtlasDim}${sizeRecommendation}`,
1314
- );
1315
- } else {
1316
- logger.info(
1317
- `Generating atlas sprite sheet for item: ${itemId} with auto-calculated dimensions (based on frame count)`,
1318
- );
1319
- }
1320
-
1321
- // Find ObjectLayer by item-id
1322
- const objectLayer = await ObjectLayer.findByItemId(itemId).populate('objectLayerRenderFramesId');
1323
-
1324
- if (!objectLayer) {
1325
- logger.error(`ObjectLayer not found for item-id: ${itemId}`);
1326
- process.exit(1);
1327
- }
1328
-
1329
- const itemKey = objectLayer.data.item.id;
1330
- logger.info(`Found ObjectLayer: ${itemKey} (${objectLayer._id})`);
1331
-
1332
- // Generate atlas sprite sheet
1333
- const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
1334
- objectLayer.objectLayerRenderFramesId,
1335
- itemKey,
1336
- 20, // cellPixelDim
1337
- maxAtlasDim,
1338
- );
1339
-
1340
- /** @type {number} */
1341
- const frameCount = Object.values(metadata.frames).reduce((sum, frames) => sum + frames.length, 0);
1342
- logger.info(
1343
- `Atlas generated: ${metadata.atlasWidth}x${metadata.atlasHeight} pixels (${frameCount} frames packed)`,
1344
- );
1345
-
1346
- // Save to File collection
1347
- const fileDoc = await new File({
1348
- name: `${itemKey}-atlas.png`,
1349
- data: buffer,
1350
- size: buffer.length,
1351
- mimetype: 'image/png',
1352
- md5: crypto.createHash('md5').update(buffer).digest('hex'),
1353
- }).save();
1354
-
1355
- logger.info(`File saved with ID: ${fileDoc._id}`);
1356
-
1357
- // Pin atlas PNG to IPFS
1358
- let toAtlasCid = '';
1359
- let toAtlasMetadataCid = '';
1360
- try {
1361
- const ipfsResult = await IpfsClient.addBufferToIpfs(
1362
- buffer,
1363
- `${itemKey}_atlas_sprite_sheet.png`,
1364
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
1365
- );
1366
- if (ipfsResult) {
1367
- toAtlasCid = ipfsResult.cid;
1368
- logger.info(`Atlas sprite sheet pinned to IPFS – CID: ${toAtlasCid}`);
1369
- try {
1370
- await createPinRecord({
1371
- cid: toAtlasCid,
1372
- resourceType: 'atlas-sprite-sheet',
1373
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
1374
- });
1375
- } catch (e) {
1376
- logger.warn('Failed to create pin record for atlas sprite sheet:', e.message);
1377
- }
1378
- }
1379
- } catch (ipfsError) {
1380
- logger.warn('Failed to add atlas sprite sheet to IPFS:', ipfsError.message);
1042
+ ? ' (Large size: ensure GPU compatibility)'
1043
+ : ' (Recommended size)';
1044
+ logger.info(`Max atlas dimension: ${maxAtlasDim}x${maxAtlasDim}${sizeRecommendation}`);
1381
1045
  }
1382
1046
 
1383
- // Pin atlas metadata JSON to IPFS (fast-json-stable-stringify)
1384
- try {
1385
- const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
1386
- metadata,
1387
- `${itemKey}_atlas_sprite_sheet_metadata.json`,
1388
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
1389
- );
1390
- if (metadataIpfsResult) {
1391
- toAtlasMetadataCid = metadataIpfsResult.cid;
1392
- logger.info(`Atlas metadata pinned to IPFS CID: ${toAtlasMetadataCid}`);
1393
- try {
1394
- await createPinRecord({
1395
- cid: toAtlasMetadataCid,
1396
- resourceType: 'atlas-metadata',
1397
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
1398
- });
1399
- } catch (e) {
1400
- logger.warn('Failed to create pin record for atlas metadata:', e.message);
1047
+ const selectedItemIds = await selectScopedItemIds({
1048
+ ObjectLayer,
1049
+ itemId,
1050
+ instance: options.instance,
1051
+ host,
1052
+ path,
1053
+ action: '--to-atlas-sprite-sheet',
1054
+ });
1055
+
1056
+ logger.info(`Atlas rebuild for ${selectedItemIds.length} stored item(s) at ${upscaleFactor}px per cell`);
1057
+ const tally = { rebuilt: 0, skipped: 0, failed: [] };
1058
+
1059
+ // One item at a time, isolated: a long batch runs through IPFS and can
1060
+ // meet a dropped database connection, and a rerun of a failed item is a
1061
+ // no-op for every item that already succeeded.
1062
+ for (const currentItemId of selectedItemIds) {
1063
+ try {
1064
+ const objectLayer = await liveObjectLayer()
1065
+ .findByItemId(currentItemId)
1066
+ .populate('objectLayerRenderFramesId');
1067
+ if (!objectLayer?.objectLayerRenderFramesId) {
1068
+ logger.warn(`No render frames stored for '${currentItemId}', skipped`);
1069
+ tally.skipped++;
1070
+ continue;
1401
1071
  }
1072
+
1073
+ const { atlasDoc, metadata, atlasCid, atlasMetadataCid } = await AtlasSpriteSheetStore.persist({
1074
+ itemKey: currentItemId,
1075
+ objectLayerRenderFrames: objectLayer.objectLayerRenderFramesId,
1076
+ upscaleFactor,
1077
+ maxAtlasDim,
1078
+ options: { host, path },
1079
+ });
1080
+
1081
+ const frameCount = Object.values(metadata.frames).reduce((sum, frames) => sum + frames.length, 0);
1082
+ logger.info(
1083
+ `Atlas for '${currentItemId}': ${metadata.atlasWidth}x${metadata.atlasHeight} cells, ` +
1084
+ `${frameCount} frames packed`,
1085
+ );
1086
+
1087
+ objectLayer.atlasSpriteSheetId = atlasDoc._id;
1088
+ if (!objectLayer.data.render) objectLayer.data.render = {};
1089
+ objectLayer.data.render.cid = atlasCid;
1090
+ objectLayer.data.render.metadataCid = atlasMetadataCid;
1091
+ objectLayer.markModified('data.render');
1092
+ applyStatPolicy(objectLayer, statPolicy);
1093
+ await objectLayer.save();
1094
+
1095
+ await ObjectLayerEngine.computeAndSaveFinalSha256({ objectLayer, options: { host, path } });
1096
+ tally.rebuilt++;
1097
+ } catch (rebuildError) {
1098
+ logger.error(`Atlas rebuild failed for '${currentItemId}': ${rebuildError.message}`);
1099
+ tally.failed.push(currentItemId);
1402
1100
  }
1403
- } catch (ipfsError) {
1404
- logger.warn('Failed to add atlas metadata to IPFS:', ipfsError.message);
1405
1101
  }
1406
1102
 
1407
- // Check if atlas sprite sheet already exists
1408
- let atlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
1409
-
1410
- if (atlasDoc) {
1411
- // Update existing remove old File to prevent orphans
1412
- if (atlasDoc.fileId) await File.findByIdAndDelete(atlasDoc.fileId);
1413
- atlasDoc.fileId = fileDoc._id;
1414
- atlasDoc.cid = toAtlasCid;
1415
- atlasDoc.metadata = metadata;
1416
- await atlasDoc.save();
1417
- logger.info(`Updated existing AtlasSpriteSheet document: ${atlasDoc._id}`);
1418
- } else {
1419
- // Create new
1420
- atlasDoc = await new AtlasSpriteSheet({
1421
- fileId: fileDoc._id,
1422
- cid: toAtlasCid,
1423
- metadata,
1424
- }).save();
1425
- logger.info(`Created new AtlasSpriteSheet document: ${atlasDoc._id}`);
1103
+ logger.info(
1104
+ `Atlas rebuild done: ${tally.rebuilt} rebuilt, ${tally.skipped} without render frames, ` +
1105
+ `${tally.failed.length} failed`,
1106
+ );
1107
+ if (tally.failed.length > 0) {
1108
+ logger.warn(`Rerun for the failed item(s): ${tally.failed.join(',')}`);
1426
1109
  }
1427
-
1428
- // Update ObjectLayer with reference to atlas sprite sheet and render CIDs
1429
- objectLayer.atlasSpriteSheetId = atlasDoc._id;
1430
- if (!objectLayer.data.render) objectLayer.data.render = {};
1431
- objectLayer.data.render.cid = toAtlasCid;
1432
- objectLayer.data.render.metadataCid = toAtlasMetadataCid;
1433
- objectLayer.markModified('data.render');
1434
- await objectLayer.save();
1435
-
1436
- // Compute final SHA-256 and pin object layer data JSON to IPFS
1437
- await ObjectLayerEngine.computeAndSaveFinalSha256({
1438
- objectLayer,
1439
- ipfsClient: IpfsClient,
1440
- createPinRecord,
1441
- });
1442
-
1443
- logger.info(`Atlas sprite sheet completed for item: ${itemKey}`);
1444
1110
  }
1445
1111
 
1446
1112
  // ── Handle --show-atlas-sprite-sheet ─────────────────────────────
@@ -1541,6 +1207,7 @@ try {
1541
1207
 
1542
1208
  // Overwrite the item id in the generated data with the unique variant
1543
1209
  multiFrameResult.objectLayerData.data.item.id = uniqueItemId;
1210
+ applyStatPolicy(multiFrameResult.objectLayerData, statPolicy);
1544
1211
 
1545
1212
  logger.info(
1546
1213
  `Generated ${multiFrameResult.frameCount} frame(s) with ${multiFrameResult.objectLayerRenderFramesData.colors.length} unique colors`,
@@ -1555,7 +1222,7 @@ try {
1555
1222
  itemId: uniqueItemId,
1556
1223
  objectLayerRenderFramesData: multiFrameResult.objectLayerRenderFramesData,
1557
1224
  objectLayerData: multiFrameResult.objectLayerData,
1558
- cellPixelDim: 20,
1225
+ cellPixelDim: upscaleFactor,
1559
1226
  });
1560
1227
 
1561
1228
  logger.info(`Wrote ${writtenFiles.length} asset file(s):`);
@@ -1563,148 +1230,34 @@ try {
1563
1230
  logger.info(` → ${f}`);
1564
1231
  }
1565
1232
 
1566
- // 3. Persist to MongoDB (ObjectLayerRenderFrames + ObjectLayer)
1567
- const { objectLayer } = await ObjectLayerEngine.createObjectLayerDocuments({
1233
+ // 3. Persist ObjectLayer, render frames and the atlas, with every CID staged first
1234
+ const { objectLayer } = await ObjectLayerEngine.persistObjectLayerDocuments({
1568
1235
  ObjectLayer,
1569
1236
  ObjectLayerRenderFrames,
1570
1237
  objectLayerRenderFramesData: multiFrameResult.objectLayerRenderFramesData,
1571
1238
  objectLayerData: multiFrameResult.objectLayerData,
1572
- createOptions: {
1573
- generateAtlas: false,
1574
- },
1239
+ persistOptions: { upscaleFactor, options: { host, path } },
1575
1240
  });
1576
1241
 
1577
1242
  logger.info(`ObjectLayer persisted to MongoDB: ${objectLayer._id} (item: ${objectLayer.data.item.id})`);
1578
-
1579
- // 4. Generate atlas sprite sheet + pin to IPFS
1580
- let atlasCid = '';
1581
- try {
1582
- const atlasItemKey = objectLayer.data.item.id;
1583
- const populatedObjectLayer = await ObjectLayer.findById(objectLayer._id).populate(
1584
- 'objectLayerRenderFramesId',
1585
- );
1586
-
1587
- const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
1588
- populatedObjectLayer.objectLayerRenderFramesId,
1589
- atlasItemKey,
1590
- 20,
1591
- );
1592
-
1593
- // Save atlas file to File collection
1594
- const fileDoc = await new File({
1595
- name: `${atlasItemKey}-atlas.png`,
1596
- data: buffer,
1597
- size: buffer.length,
1598
- mimetype: 'image/png',
1599
- md5: crypto.createHash('md5').update(buffer).digest('hex'),
1600
- }).save();
1601
-
1602
- // Pin atlas PNG to IPFS + copy into MFS
1603
- let atlasMetadataCid = '';
1604
- try {
1605
- const ipfsResult = await IpfsClient.addBufferToIpfs(
1606
- buffer,
1607
- `${atlasItemKey}_atlas_sprite_sheet.png`,
1608
- `/object-layer/${atlasItemKey}/${atlasItemKey}_atlas_sprite_sheet.png`,
1609
- );
1610
- if (ipfsResult) {
1611
- atlasCid = ipfsResult.cid;
1612
- logger.info(`Atlas sprite sheet pinned to IPFS – CID: ${atlasCid}`);
1613
- try {
1614
- await createPinRecord({
1615
- cid: atlasCid,
1616
- resourceType: 'atlas-sprite-sheet',
1617
- mfsPath: `/object-layer/${atlasItemKey}/${atlasItemKey}_atlas_sprite_sheet.png`,
1618
- options: { host, path },
1619
- });
1620
- } catch (e) {
1621
- logger.warn('Failed to create pin record for atlas sprite sheet:', e.message);
1622
- }
1623
- }
1624
- } catch (ipfsError) {
1625
- logger.warn('Failed to add atlas sprite sheet to IPFS:', ipfsError.message);
1626
- }
1627
-
1628
- // Pin atlas metadata JSON to IPFS (fast-json-stable-stringify)
1629
- try {
1630
- const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
1631
- metadata,
1632
- `${atlasItemKey}_atlas_sprite_sheet_metadata.json`,
1633
- `/object-layer/${atlasItemKey}/${atlasItemKey}_atlas_sprite_sheet_metadata.json`,
1634
- );
1635
- if (metadataIpfsResult) {
1636
- atlasMetadataCid = metadataIpfsResult.cid;
1637
- logger.info(`Atlas metadata pinned to IPFS – CID: ${atlasMetadataCid}`);
1638
- try {
1639
- await createPinRecord({
1640
- cid: atlasMetadataCid,
1641
- resourceType: 'atlas-metadata',
1642
- mfsPath: `/object-layer/${atlasItemKey}/${atlasItemKey}_atlas_sprite_sheet_metadata.json`,
1643
- options: { host, path },
1644
- });
1645
- } catch (e) {
1646
- logger.warn('Failed to create pin record for atlas metadata:', e.message);
1647
- }
1243
+ logger.info(`Final SHA-256: ${objectLayer.sha256}`);
1244
+ if (objectLayer.cid) logger.info(`ObjectLayer data pinned to IPFS CID: ${objectLayer.cid}`);
1245
+
1246
+ // 4. Mirror the human-resolution atlas PNG into both static asset directories
1247
+ if (objectLayer.atlasSpriteSheetId) {
1248
+ const atlasDoc = await AtlasSpriteSheet.findById(objectLayer.atlasSpriteSheetId);
1249
+ const atlasFile = atlasDoc ? await File.findById(atlasDoc.fileId) : null;
1250
+ if (atlasFile?.data) {
1251
+ for (const bp of [srcBasePath, publicBasePath]) {
1252
+ const atlasOutputDir = nodePath.join(bp, 'assets', descriptor.itemType, uniqueItemId);
1253
+ await fs.ensureDir(atlasOutputDir);
1254
+ const atlasOutputPath = nodePath.join(atlasOutputDir, `${uniqueItemId}-atlas.png`);
1255
+ await fs.writeFile(atlasOutputPath, atlasFile.data);
1256
+ logger.info(
1257
+ `Atlas sprite sheet written: ${atlasDoc.metadata.atlasWidth}x${atlasDoc.metadata.atlasHeight} cells → ${atlasOutputPath}`,
1258
+ );
1648
1259
  }
1649
- } catch (ipfsError) {
1650
- logger.warn('Failed to add atlas metadata to IPFS:', ipfsError.message);
1651
- }
1652
-
1653
- // Upsert AtlasSpriteSheet document (with CID)
1654
- let atlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': atlasItemKey });
1655
- if (atlasDoc) {
1656
- if (atlasDoc.fileId) await File.findByIdAndDelete(atlasDoc.fileId);
1657
- atlasDoc.fileId = fileDoc._id;
1658
- atlasDoc.cid = atlasCid;
1659
- atlasDoc.metadata = metadata;
1660
- await atlasDoc.save();
1661
- logger.info(`Updated existing AtlasSpriteSheet document: ${atlasDoc._id}`);
1662
- } else {
1663
- atlasDoc = await new AtlasSpriteSheet({
1664
- fileId: fileDoc._id,
1665
- cid: atlasCid,
1666
- metadata,
1667
- }).save();
1668
- logger.info(`Created new AtlasSpriteSheet document: ${atlasDoc._id}`);
1669
- }
1670
-
1671
- // Link atlas to ObjectLayer and set data.render.cid + data.render.metadataCid
1672
- populatedObjectLayer.atlasSpriteSheetId = atlasDoc._id;
1673
- if (!populatedObjectLayer.data.render) populatedObjectLayer.data.render = {};
1674
- populatedObjectLayer.data.render.cid = atlasCid;
1675
- populatedObjectLayer.data.render.metadataCid = atlasMetadataCid;
1676
- populatedObjectLayer.markModified('data.render');
1677
- await populatedObjectLayer.save();
1678
-
1679
- // Also write atlas PNG to both static asset directories
1680
- for (const bp of [srcBasePath, publicBasePath]) {
1681
- const atlasOutputDir = nodePath.join(bp, 'assets', descriptor.itemType, uniqueItemId);
1682
- await fs.ensureDir(atlasOutputDir);
1683
- const atlasOutputPath = nodePath.join(atlasOutputDir, `${atlasItemKey}-atlas.png`);
1684
- await fs.writeFile(atlasOutputPath, buffer);
1685
- logger.info(
1686
- `Atlas sprite sheet generated: ${metadata.atlasWidth}x${metadata.atlasHeight} → ${atlasOutputPath}`,
1687
- );
1688
- }
1689
- } catch (atlasError) {
1690
- logger.error(`Failed to generate atlas for ${uniqueItemId}:`, atlasError);
1691
- }
1692
-
1693
- // 5. Compute final SHA-256, pin OL data JSON to IPFS, create pin records
1694
- try {
1695
- const finalObjectLayer = await ObjectLayer.findById(objectLayer._id).populate('objectLayerRenderFramesId');
1696
- const finalized = await ObjectLayerEngine.computeAndSaveFinalSha256({
1697
- objectLayer: finalObjectLayer,
1698
- ipfsClient: IpfsClient,
1699
- createPinRecord,
1700
- options: { host, path },
1701
- });
1702
- logger.info(`Final SHA-256: ${finalized.sha256}`);
1703
- if (finalized.cid) {
1704
- logger.info(`ObjectLayer data pinned to IPFS – CID: ${finalized.cid}`);
1705
1260
  }
1706
- } catch (finalizeError) {
1707
- logger.error('Failed to finalize SHA-256 / IPFS:', finalizeError);
1708
1261
  }
1709
1262
 
1710
1263
  logger.info(`✓ Generation complete for "${uniqueItemId}" (seed: ${genSeed}, frames: ${genFrameCount})`);
@@ -1748,6 +1301,10 @@ try {
1748
1301
  .option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
1749
1302
  .option('--mongo-host <mongo-host>', 'Mongo host override')
1750
1303
  .option('--dev', 'Force development environment')
1304
+ .option(
1305
+ '--sync-entities',
1306
+ 'Point the instance conf at every entity-type default its maps place and every skill their items trigger, dropping what the world no longer carries',
1307
+ )
1751
1308
  .option('--publish-build', 'Build instance backup directory with all related maps, entities and object layers')
1752
1309
  .option('--publish-remove', 'Remove published instance from underpostnet/cyberia-instances repository')
1753
1310
  .option('--publish', 'Publish instance in underpostnet/cyberia-instances repository')
@@ -1818,14 +1375,9 @@ try {
1818
1375
  // and git refuses to touch a tree owned by someone else until it is declared safe.
1819
1376
  Underpost.repo.declareSafeDirectory('/home/dd/cyberia-instances');
1820
1377
  if (options.publishBuild) {
1821
- if (!fs.existsSync('/home/dd/cyberia-instances')) {
1378
+ if (!fs.existsSync('/home/dd/cyberia-instances'))
1822
1379
  shellExec(`cd /home/dd && ${cli()} clone underpostnet/cyberia-instances`);
1823
- } else {
1824
- shellExec(`${cli()} run clean /home/dd/cyberia-instances`);
1825
- shellExec(`cd /home/dd/cyberia-instances && ${cli()} pull . underpostnet/cyberia-instances`, {
1826
- silentOnError: true,
1827
- });
1828
- }
1380
+ else shellExec(`cd /home/dd/cyberia-instances && ${cli()} cmt --switch-repo underpostnet/cyberia-instances`);
1829
1381
 
1830
1382
  fs.mkdirpSync(`/home/dd/cyberia-instances/conf/dd-cyberia`);
1831
1383
  fs.copyFileSync(
@@ -1902,11 +1454,21 @@ try {
1902
1454
  `./engine-private/conf/dd-cyberia/instances/mmo-server/build/development/.`,
1903
1455
  `/home/dd/cyberia-instances/deployments/cyberia-server/.`,
1904
1456
  );
1457
+ const folders = ['ui-icons', 'cursor', 'fonts', 'icons', 'splash', 'templates'];
1458
+ for (const folder of folders)
1459
+ fs.copySync(
1460
+ `./src/client/public/cyberia/assets/${folder}`,
1461
+ `/home/dd/cyberia-instances/public/cyberia/assets/${folder}`,
1462
+ );
1905
1463
  // fs.removeSync(`/home/dd/cyberia-instances/public/cyberia`);
1906
1464
  // fs.mkdirpSync(`/home/dd/cyberia-instances/public/cyberia`);
1907
1465
  fs.mkdirpSync(`/home/dd/cyberia-instances/instances`);
1908
1466
  fs.mkdirpSync(`/home/dd/cyberia-instances/sagas`);
1909
1467
  for (const _instanceCode of instanceCode.split(',')) {
1468
+ if (fs.existsSync(`/home/dd/cyberia-instances/instances/${_instanceCode}`))
1469
+ shellExec(`rm -rf /home/dd/cyberia-instances/instances/${_instanceCode}`);
1470
+ if (fs.existsSync(`/home/dd/cyberia-instances/sagas/${_instanceCode}.json`))
1471
+ shellExec(`rm -rf /home/dd/cyberia-instances/sagas/${_instanceCode}.json`);
1910
1472
  fs.copySync(
1911
1473
  `./engine-private/cyberia-instances/${_instanceCode}`,
1912
1474
  `/home/dd/cyberia-instances/instances/${_instanceCode}`,
@@ -1923,6 +1485,19 @@ try {
1923
1485
  '/home/dd/cyberia-instances/conf/dd-cyberia/conf.instances.json',
1924
1486
  );
1925
1487
 
1488
+ if (!fs.existsSync('/home/dd/cyberia-instances/manifests'))
1489
+ fs.mkdirSync('/home/dd/cyberia-instances/manifests');
1490
+ fs.copySync('./cyberia-server/manifests', '/home/dd/cyberia-instances/manifests', { overwrite: true });
1491
+ fs.copySync('./cyberia-client/manifests', '/home/dd/cyberia-instances/manifests', { overwrite: true });
1492
+ if (!fs.existsSync('/home/dd/cyberia-instances/manifests/deployments/dd-cyberia-development'))
1493
+ fs.mkdirSync('/home/dd/cyberia-instances/manifests/deployments/dd-cyberia-development', {
1494
+ recursive: true,
1495
+ });
1496
+ fs.copySync(
1497
+ './manifests/deployment/dd-cyberia-development',
1498
+ '/home/dd/cyberia-instances/manifests/deployments/dd-cyberia-development',
1499
+ { overwrite: true },
1500
+ );
1926
1501
  const fromN = parseInt(options.fromNCommit) > 0 ? parseInt(options.fromNCommit) : 1;
1927
1502
  const publishMessage =
1928
1503
  shellExec(`node bin cmt --changelog-msg --from-n-commit ${fromN} --changelog-no-hash`, {
@@ -1987,6 +1562,8 @@ try {
1987
1562
  'cyberia-skill',
1988
1563
  'cyberia-entity-type-default',
1989
1564
  'cyberia-saga',
1565
+ 'cyberia-audio',
1566
+ 'cyberia-map-audio-conf',
1990
1567
  'object-layer',
1991
1568
  'object-layer-render-frames',
1992
1569
  'atlas-sprite-sheet',
@@ -2007,6 +1584,8 @@ try {
2007
1584
  const CyberiaSkill = DataBaseProviderService.getModel('cyberia-skill', { host, path });
2008
1585
  const CyberiaEntityTypeDefault = DataBaseProviderService.getModel('cyberia-entity-type-default', { host, path });
2009
1586
  const CyberiaSaga = DataBaseProviderService.getModel('cyberia-saga', { host, path });
1587
+ const CyberiaAudio = DataBaseProviderService.getModel('cyberia-audio', { host, path });
1588
+ const CyberiaMapAudioConf = DataBaseProviderService.getModel('cyberia-map-audio-conf', { host, path });
2010
1589
  const ObjectLayer = DataBaseProviderService.getModel('object-layer', { host, path });
2011
1590
  const ObjectLayerRenderFrames = DataBaseProviderService.getModel('object-layer-render-frames', { host, path });
2012
1591
  const AtlasSpriteSheet = DataBaseProviderService.getModel('atlas-sprite-sheet', { host, path });
@@ -2027,8 +1606,8 @@ try {
2027
1606
  atlasMetadata: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
2028
1607
  });
2029
1608
 
2030
- // Canonical pins now carry a single `mfsPath`; `mfsPaths` (plural) is only read
2031
- // for backward compatibility with older backups that consolidated shared CIDs.
1609
+ // A canonical pin carries one `mfsPath`. `mfsPaths` (plural) is read too,
1610
+ // because a backup that consolidated shared CIDs writes that field.
2032
1611
  const collectMfsPaths = (doc = {}) => {
2033
1612
  const paths = new Set();
2034
1613
  if (doc.mfsPath) paths.add(doc.mfsPath);
@@ -2084,10 +1663,9 @@ try {
2084
1663
  ...(entry.mfsPath ? { mfsPath: entry.mfsPath } : {}),
2085
1664
  }));
2086
1665
 
2087
- // Bring the live Ipfs collection in line with the mfsPath-unique model: collapse
2088
- // duplicate mfsPath rows (keeping the most recently updated association) then sync
2089
- // indexes so the legacy {cid,resourceType} unique index is dropped and the new
2090
- // partial unique index on mfsPath is built. Idempotent and safe to re-run.
1666
+ // Bring the Ipfs collection in line with the mfsPath-unique model: collapse
1667
+ // duplicate mfsPath rows, keeping the most recent, then sync indexes so the
1668
+ // partial unique index on mfsPath is built. Safe to re-run.
2091
1669
  const reconcileIpfsRegistryIndexes = async () => {
2092
1670
  let removedDuplicates = 0;
2093
1671
  const duplicateGroups = await Ipfs.aggregate([
@@ -2141,13 +1719,10 @@ try {
2141
1719
 
2142
1720
  // ── CAPTURE CURRENT FALLBACK WORLD ──────────────────────────────
2143
1721
  //
2144
- // The procedural fallback world only exists in engine memory (see
2145
- // cyberia-fallback-world.js): it is rebuilt from code defaults on every
2146
- // boot and intercepts any service that asks for an absent instance.
2147
- // Capturing writes that exact world into MongoDB under [instance-code]
2148
- // together with the content collections the fallback path serves from
2149
- // code defaults rather than the DB — so the export below can back it up
2150
- // and `--import` can restore it as an ordinary persisted instance.
1722
+ // The procedural fallback world lives only in engine memory. A capture
1723
+ // writes it to MongoDB under [instance-code], with the content
1724
+ // collections the fallback path serves from code defaults, so the export
1725
+ // below can back it up and `--import` can restore it.
2151
1726
  if (options.exportCurrentFallbackworld) {
2152
1727
  const { generateFallbackWorld } = await import('../src/api/cyberia-instance/cyberia-fallback-world.js');
2153
1728
  const { captureFallbackWorld } = await import('../src/api/cyberia-instance/cyberia-fallback-capture.js');
@@ -2188,6 +1763,8 @@ try {
2188
1763
  CyberiaSkill,
2189
1764
  CyberiaEntityTypeDefault,
2190
1765
  CyberiaDialogue,
1766
+ CyberiaMapAudioConf,
1767
+ CyberiaAudio,
2191
1768
  ObjectLayer,
2192
1769
  },
2193
1770
  world,
@@ -2202,7 +1779,7 @@ try {
2202
1779
  logger.error(
2203
1780
  `Capture aborted: ${capture.missingObjectLayerItemIds.length} referenced item id(s) have no ObjectLayer in MongoDB:`,
2204
1781
  capture.missingObjectLayerItemIds.join(', '),
2205
- `— run \`node bin/cyberia ol ${capture.missingObjectLayerItemIds.join(' ')} --import\` (or ` +
1782
+ `— run \`node bin/cyberia ol ${capture.missingObjectLayerItemIds.join(',')} --from-directory --import\` (or ` +
2206
1783
  '`node bin/cyberia run-workflow import-default-items`) first.',
2207
1784
  );
2208
1785
  await DataBaseProviderService.getProvider({ host, path }, 'mongoose').close();
@@ -2214,6 +1791,7 @@ try {
2214
1791
  mapCodes: capture.plan.instance.cyberiaMapCodes,
2215
1792
  actions: capture.plan.actions.length,
2216
1793
  quests: capture.plan.quests.length,
1794
+ audioConfs: capture.audio.audioConfs,
2217
1795
  objectLayerItemIds: capture.plan.itemIds.length,
2218
1796
  });
2219
1797
 
@@ -2222,6 +1800,28 @@ try {
2222
1800
  if (options.export === undefined) options.export = true;
2223
1801
  }
2224
1802
 
1803
+ // ── SYNC ENTITY-TYPE DEFAULTS ───────────────────────────────────
1804
+ if (options.syncEntities) {
1805
+ const result = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode }, { host, path });
1806
+ logger.info(`sync-entities ${instanceCode}`, {
1807
+ maps: result.mapCodes.length,
1808
+ references: result.entityDefaults.length,
1809
+ ...(result.linked.length > 0 ? { linked: result.linked } : {}),
1810
+ ...(result.dropped.length > 0 ? { dropped: result.dropped } : {}),
1811
+ ...(result.skipped.length > 0 ? { claimedByAnotherInstance: result.skipped } : {}),
1812
+ ...(result.skills.length > 0 ? { skills: result.skills } : {}),
1813
+ ...(result.entitiesUpdated.length > 0
1814
+ ? { entitiesUpdated: result.entitiesUpdated.map(({ mapCode, entities }) => `${mapCode}:${entities}`) }
1815
+ : {}),
1816
+ ...(result.conflicts.length > 0 ? { notLinkedSameBuildAlreadyReferenced: result.conflicts } : {}),
1817
+ ...(result.duplicates.length > 0 ? { duplicateReferencesDeleteOne: result.duplicates } : {}),
1818
+ });
1819
+ if (options.export === undefined && !options.import && !options.drop) {
1820
+ await DataBaseProviderService.getProvider({ host, path }, 'mongoose').close();
1821
+ return;
1822
+ }
1823
+ }
1824
+
2225
1825
  // ── EXPORT ──────────────────────────────────────────────────────
2226
1826
  if (options.export !== undefined) {
2227
1827
  const instance = await CyberiaInstance.findOne({ code: instanceCode }).lean();
@@ -2237,6 +1837,25 @@ try {
2237
1837
  : `./engine-private/cyberia-instances/${instanceCode}`;
2238
1838
 
2239
1839
  fs.ensureDirSync(backupDir);
1840
+ // The export is a projection of what this instance references, never an archive of
1841
+ // everything it once did: a document that drops out of the world has to leave the
1842
+ // directory with it, or the next import brings it back from the dead. Emptied up front,
1843
+ // so a collection that exports nothing this run ends up empty rather than stale. The
1844
+ // asset directories — files, ipfs, render-frames, atlas sheets — are content-addressed
1845
+ // and expensive to refetch, so they keep what they have.
1846
+ for (const collection of [
1847
+ 'maps',
1848
+ 'cyberia-map-audio-confs',
1849
+ 'cyberia-audio',
1850
+ 'cyberia-quests',
1851
+ 'cyberia-actions',
1852
+ 'cyberia-skills',
1853
+ 'cyberia-entity-type-defaults',
1854
+ 'cyberia-dialogues',
1855
+ 'object-layers',
1856
+ ]) {
1857
+ fs.emptyDirSync(`${backupDir}/${collection}`);
1858
+ }
2240
1859
  logger.info('Exporting instance', { code: instanceCode, backupDir });
2241
1860
 
2242
1861
  // Helper: export a File document to the files/ directory
@@ -2266,6 +1885,11 @@ try {
2266
1885
  // 1b. Export linked CyberiaInstanceConf (skillRules, equipmentRules, entityDefaults, etc.)
2267
1886
  // If no conf doc exists yet (instance created before auto-upsert logic), create one using
2268
1887
  // schema defaults — identical to the behaviour in CyberiaInstanceService.post().
1888
+ //
1889
+ // Compact first: a reference whose entity-type default is gone cannot be exported, and
1890
+ // writing it into the backup would carry the dangling id into every world restored from
1891
+ // it. Dropping it here repairs the live conf and the backup in one step.
1892
+ await CyberiaEntityTypeDefaultService.compactInstanceRefs({ host, path }, { instanceCode });
2269
1893
  let instanceConf =
2270
1894
  (await CyberiaInstanceConf.findOne({ instanceCode }).lean()) ||
2271
1895
  (instance.conf ? await CyberiaInstanceConf.findById(instance.conf).lean() : null);
@@ -2283,10 +1907,10 @@ try {
2283
1907
  instanceConf = created?.toObject ? created.toObject() : created;
2284
1908
  }
2285
1909
  if (instanceConf) {
2286
- // `.lean()` skips Mongoose schema defaults and older docs may predate
2287
- // some fields, so backfill every CyberiaInstanceConfSchema field from
2288
- // the canonical defaults before writing the backup.
2289
- instanceConf = fillInstanceConfDefaults(instanceConf);
1910
+ // `.lean()` skips the schema defaults and a document written before a bound tightened
1911
+ // may carry a value the schema now rejects; the backup is made whole and valid here so
1912
+ // it imports back as-is.
1913
+ ({ conf: instanceConf } = await CyberiaInstanceConfService.coerceToSchema(instanceConf, CyberiaInstanceConf));
2290
1914
  fs.writeJsonSync(`${backupDir}/cyberia-instance-conf.json`, instanceConf, { spaces: 2 });
2291
1915
  logger.info('Exported CyberiaInstanceConf', { instanceCode });
2292
1916
  } else {
@@ -2323,6 +1947,50 @@ try {
2323
1947
  }
2324
1948
  logger.info(`Exported ${maps.length} CyberiaMap document(s)`, { codes: maps.map((m) => m.code) });
2325
1949
 
1950
+ // 3a. Export the audio configuration of those maps, and the assets it binds.
1951
+ // A CyberiaMapAudioConf belongs to one map, so it travels with the instance. A
1952
+ // CyberiaAudio is global and shared by every map that binds its code, so it travels
1953
+ // as a copy: the import upserts it by code and leaves other instances' bindings alone.
1954
+ // The WAV rides along as an ordinary File document, keeping its _id so `fileId` still
1955
+ // resolves after a restore.
1956
+ const audioConfs = await CyberiaMapAudioConf.find({ mapCode: { $in: [...mapCodes] } }).lean();
1957
+ if (audioConfs.length > 0) {
1958
+ fs.ensureDirSync(`${backupDir}/cyberia-map-audio-confs`);
1959
+ const audioCodes = new Set();
1960
+ for (const conf of audioConfs) {
1961
+ fs.writeJsonSync(`${backupDir}/cyberia-map-audio-confs/${encodeURIComponent(conf.mapCode)}.json`, conf, {
1962
+ spaces: 2,
1963
+ });
1964
+ if (conf.defaultMusic) audioCodes.add(conf.defaultMusic);
1965
+ for (const event of conf.events || []) if (event.audioCode) audioCodes.add(event.audioCode);
1966
+ }
1967
+ logger.info(`Exported ${audioConfs.length} CyberiaMapAudioConf document(s)`, {
1968
+ mapCodes: audioConfs.map((c) => c.mapCode),
1969
+ });
1970
+
1971
+ const audioAssets = await CyberiaAudio.find({ code: { $in: [...audioCodes] } }).lean();
1972
+ if (audioAssets.length > 0) {
1973
+ fs.ensureDirSync(`${backupDir}/cyberia-audio`);
1974
+ for (const asset of audioAssets) {
1975
+ fs.writeJsonSync(`${backupDir}/cyberia-audio/${encodeURIComponent(asset.code)}.json`, asset, {
1976
+ spaces: 2,
1977
+ });
1978
+ if (asset.fileId) await exportFileDoc(asset.fileId, `audio-${asset.code}`);
1979
+ }
1980
+ }
1981
+ const missingAudioCodes = [...audioCodes].filter((code) => !audioAssets.some((asset) => asset.code === code));
1982
+ logger.info(`Exported ${audioAssets.length} CyberiaAudio document(s)`, {
1983
+ codes: audioAssets.map((a) => a.code),
1984
+ });
1985
+ if (missingAudioCodes.length > 0) {
1986
+ logger.warn(
1987
+ 'Audio bindings reference codes with no imported CyberiaAudio document — ' +
1988
+ 'run `node bin/cyberia audio --import` before restoring this backup',
1989
+ { codes: missingAudioCodes },
1990
+ );
1991
+ }
1992
+ }
1993
+
2326
1994
  // 3b. Export quests + actions bound to THIS instance's maps (sourceMapCode
2327
1995
  // in the instance's map codes) — only the content tied to this instance.
2328
1996
  // Dialogue codes the actions reference are collected so they travel too.
@@ -2353,134 +2021,51 @@ try {
2353
2021
  logger.info(`Exported ${actions.length} CyberiaAction document(s)`, { codes: actions.map((a) => a.code) });
2354
2022
  }
2355
2023
 
2356
- // 4. Collect all objectLayerItemIds from map entities
2357
- const objectLayerItemIds = new Set();
2358
- for (const map of maps) {
2359
- for (const entity of map.entities || []) {
2360
- for (const itemId of entity.objectLayerItemIds || []) {
2361
- objectLayerItemIds.add(itemId);
2362
- }
2363
- }
2364
- }
2365
-
2366
- // 4b. Add instance-level itemIds ({ id, defaultPlayerInventory }).
2367
- for (const entry of instance.itemIds || []) {
2368
- const id = typeof entry === 'string' ? entry : entry?.id;
2369
- if (id) objectLayerItemIds.add(id);
2370
- }
2371
-
2372
- const contentItemIds = new Set(objectLayerItemIds);
2373
-
2374
- // 4c. Add all itemIds referenced by CyberiaInstanceConf (entityDefaults + skillConfig).
2375
- // This ensures liveItemIds, deadItemIds, dropItemIds, defaultObjectLayers and
2376
- // skill trigger items are included even if no map entity currently uses them.
2377
- if (instanceConf) {
2378
- for (const ed of instanceConf.entityDefaults || []) {
2379
- for (const id of ed.liveItemIds || []) if (id) objectLayerItemIds.add(id);
2380
- for (const id of ed.deadItemIds || []) if (id) objectLayerItemIds.add(id);
2381
- for (const id of ed.dropItemIds || []) if (id) objectLayerItemIds.add(id);
2382
- for (const slot of ed.defaultObjectLayers || []) {
2383
- if (slot.itemId) objectLayerItemIds.add(slot.itemId);
2384
- }
2385
- }
2386
- for (const sc of instanceConf.skillConfig || []) {
2387
- if (sc.triggerItemId) objectLayerItemIds.add(sc.triggerItemId);
2388
- for (const skill of sc.skills || []) {
2389
- if (skill.summonedEntityItemId && !skill.summonedEntityItemId.startsWith('$')) {
2390
- objectLayerItemIds.add(skill.summonedEntityItemId);
2391
- }
2392
- }
2393
- }
2394
- }
2395
-
2396
- // 4c-bis. Add the item ids the instance's vendor / assembler / quest
2397
- // catalogs name. The interact modal draws an icon for every shop
2398
- // item, recipe ingredient/output and quest reward, so their atlases
2399
- // must travel with the backup even when no map entity wears them.
2400
- // Kept out of contentItemIds: catalogs must not widen the
2401
- // entity-type-default match surface below.
2402
- for (const action of actions) {
2403
- for (const shopItem of action.shopItems || []) {
2404
- if (shopItem.itemId) objectLayerItemIds.add(shopItem.itemId);
2405
- if (shopItem.priceItemId) objectLayerItemIds.add(shopItem.priceItemId);
2406
- }
2407
- for (const recipe of action.craftRecipes || []) {
2408
- for (const ingredient of recipe.ingredients || []) {
2409
- if (ingredient.itemId) objectLayerItemIds.add(ingredient.itemId);
2410
- }
2411
- for (const output of recipe.outputItems || []) {
2412
- if (output.itemId) objectLayerItemIds.add(output.itemId);
2413
- }
2414
- }
2415
- }
2416
- for (const quest of quests) {
2417
- for (const step of quest.steps || []) {
2418
- for (const objective of step.objectives || []) {
2419
- if (objective.itemId) objectLayerItemIds.add(objective.itemId);
2420
- }
2421
- }
2422
- for (const reward of quest.rewards || []) {
2423
- if (reward.itemId) objectLayerItemIds.add(reward.itemId);
2024
+ // 4. Export the entity-type defaults this instance's conf references, by _id.
2025
+ // Membership is a reference, not a resemblance: matching on item ids used to pull in
2026
+ // every document that happened to share a skin, so two instances built on the same
2027
+ // art exported each other's wiring and overwrote it on the way back in.
2028
+ const referencedIds = (instanceConf?.entityDefaults || []).map((id) => String(id?._id ?? id));
2029
+ // Every reference resolves: the conf was compacted before it was read.
2030
+ const entityDefaults = referencedIds.length
2031
+ ? await CyberiaEntityTypeDefault.find({ _id: { $in: referencedIds } }).lean()
2032
+ : [];
2033
+ if (entityDefaults.length > 0) {
2034
+ fs.ensureDirSync(`${backupDir}/cyberia-entity-type-defaults`);
2035
+ for (const ed of entityDefaults) {
2036
+ fs.writeJsonSync(`${backupDir}/cyberia-entity-type-defaults/${ed._id}.json`, ed, { spaces: 2 });
2424
2037
  }
2038
+ logger.info(`Exported ${entityDefaults.length} CyberiaEntityTypeDefault document(s)`, {
2039
+ entityTypes: entityDefaults.map((ed) => ed.entityType),
2040
+ });
2425
2041
  }
2426
2042
 
2427
- // 4d. Export skills whose trigger item belongs to this instance (own model:
2428
- // CyberiaSkill, keyed by triggerItemId). Their summoned-entity items are
2429
- // added to the OL set so those atlases export too. Runs before the
2430
- // dialogue + OL queries so the summoned ids are included.
2431
- if (objectLayerItemIds.size > 0) {
2432
- const skills = await CyberiaSkill.find({ triggerItemId: { $in: [...objectLayerItemIds] } }).lean();
2043
+ // 4b. Everything this instance names: what its maps place, what its entity-type defaults
2044
+ // wire, and what its vendor / assembler / quest catalogs trade. Every one of these
2045
+ // draws an icon somewhere, so the atlases travel with the backup even when no map
2046
+ // entity wears them. One rule, shared with the boot payload and the editor's sync.
2047
+ const objectLayerItemIds = collectInstanceItemIds({ maps, entityDefaults, actions, quests });
2048
+
2049
+ // 4c. Export the skills this instance runs: the collection owns the definitions, and one
2050
+ // belongs here when its trigger item is an id the instance names — which is how a
2051
+ // trigger only a quest objective or a vendor's shelf mentions still travels. Their
2052
+ // summoned entities join the OL set, so this runs before the dialogue + OL queries.
2053
+ {
2054
+ const skills = selectInstanceSkills(await CyberiaSkill.find({}).lean(), objectLayerItemIds);
2433
2055
  if (skills.length > 0) {
2434
2056
  fs.ensureDirSync(`${backupDir}/cyberia-skills`);
2435
2057
  for (const skill of skills) {
2436
2058
  fs.writeJsonSync(`${backupDir}/cyberia-skills/${encodeURIComponent(skill.triggerItemId)}.json`, skill, {
2437
2059
  spaces: 2,
2438
2060
  });
2439
- for (const def of skill.skills || []) {
2440
- if (def.summonedEntityItemId && !def.summonedEntityItemId.startsWith('$')) {
2441
- objectLayerItemIds.add(def.summonedEntityItemId);
2442
- }
2443
- }
2444
2061
  }
2062
+ for (const summoned of collectSummonedItemIds(skills)) objectLayerItemIds.add(summoned);
2445
2063
  logger.info(`Exported ${skills.length} CyberiaSkill document(s)`, {
2446
2064
  triggerItemIds: skills.map((sk) => sk.triggerItemId),
2447
2065
  });
2448
2066
  }
2449
2067
  }
2450
2068
 
2451
- // 4d-bis. Export entity-type defaults whose item ids belong to this
2452
- // instance's real content (own model: CyberiaEntityTypeDefault). A
2453
- // default is related when any of its live/dead/drop ids or default
2454
- // object-layer ids appears in contentItemIds — map/instance content
2455
- // only, NOT the canonical conf defaults every instance shares (which
2456
- // would spuriously drag in the global seed entity-type-defaults).
2457
- // Matched ids are folded back into objectLayerItemIds so the related
2458
- // atlases + dialogues export too.
2459
- if (contentItemIds.size > 0) {
2460
- const idsForMatch = [...contentItemIds];
2461
- const entityDefaults = await CyberiaEntityTypeDefault.find({
2462
- $or: [
2463
- { liveItemIds: { $in: idsForMatch } },
2464
- { deadItemIds: { $in: idsForMatch } },
2465
- { dropItemIds: { $in: idsForMatch } },
2466
- { 'defaultObjectLayers.itemId': { $in: idsForMatch } },
2467
- ],
2468
- }).lean();
2469
- if (entityDefaults.length > 0) {
2470
- fs.ensureDirSync(`${backupDir}/cyberia-entity-type-defaults`);
2471
- for (const ed of entityDefaults) {
2472
- fs.writeJsonSync(`${backupDir}/cyberia-entity-type-defaults/${ed._id}.json`, ed, { spaces: 2 });
2473
- for (const id of ed.liveItemIds || []) if (id) objectLayerItemIds.add(id);
2474
- for (const id of ed.deadItemIds || []) if (id) objectLayerItemIds.add(id);
2475
- for (const id of ed.dropItemIds || []) if (id) objectLayerItemIds.add(id);
2476
- for (const slot of ed.defaultObjectLayers || []) if (slot.itemId) objectLayerItemIds.add(slot.itemId);
2477
- }
2478
- logger.info(`Exported ${entityDefaults.length} CyberiaEntityTypeDefault document(s)`, {
2479
- entityTypes: entityDefaults.map((ed) => ed.entityType),
2480
- });
2481
- }
2482
- }
2483
-
2484
2069
  // 4e. Export sagas related to this instance. A saga is considered related
2485
2070
  // when its code matches the instance code (direct namespace match), or
2486
2071
  // when its mapCodes or itemIds overlap with the instance's data.
@@ -2488,22 +2073,7 @@ try {
2488
2073
  // conf-default, and skill-summoned item IDs — giving the broadest possible
2489
2074
  // match surface for saga discovery.
2490
2075
  const sagaCodeMatch = instanceCode ? await CyberiaSaga.find({ code: instanceCode }).lean() : [];
2491
- // Disabling overlaps queries for now because they can be very expensive and are not strictly necessary for a backup.
2492
- const sagaMapOverlap = true
2493
- ? []
2494
- : mapCodes.size > 0
2495
- ? await CyberiaSaga.find({ mapCodes: { $in: [...mapCodes] } }).lean()
2496
- : [];
2497
- const sagaItemOverlap = true
2498
- ? []
2499
- : objectLayerItemIds.size > 0
2500
- ? await CyberiaSaga.find({ itemIds: { $in: [...objectLayerItemIds] } }).lean()
2501
- : [];
2502
- const allSagas = [
2503
- ...new Map(
2504
- [...sagaCodeMatch, ...sagaMapOverlap, ...sagaItemOverlap].map((s) => [s._id.toString(), s]),
2505
- ).values(),
2506
- ];
2076
+ const allSagas = [...new Map(sagaCodeMatch.map((s) => [s._id.toString(), s])).values()];
2507
2077
  if (allSagas.length > 0) {
2508
2078
  fs.ensureDirSync(`${backupDir}/cyberia-sagas`);
2509
2079
  for (const saga of allSagas) {
@@ -2615,8 +2185,9 @@ try {
2615
2185
  if (atlas) {
2616
2186
  const atlasExport = newInstance(atlas);
2617
2187
  objectLayerExport.atlasSpriteSheetId = atlas._id;
2618
- if (atlas.fileId) {
2619
- await exportFileDoc(atlas.fileId, `atlas-${itemKey}`);
2188
+ // Every render travels with the atlas, so a restore leaves no reference dangling.
2189
+ for (const field of ATLAS_FILE_FIELDS) {
2190
+ if (atlas[field]) await exportFileDoc(atlas[field], atlasBackupFileKey(field, itemKey));
2620
2191
  }
2621
2192
 
2622
2193
  const atlasFile = atlas.fileId ? await File.findById(atlas.fileId).lean() : null;
@@ -2812,15 +2383,15 @@ try {
2812
2383
 
2813
2384
  logger.info('Importing instance', { code: instanceCode, backupDir });
2814
2385
 
2815
- // Idempotent: a backup restore writes object layers, so collapse any
2816
- // legacy duplicates and make the data.item.id index unique first.
2386
+ // A restore writes object layers, so collapse duplicates and make the
2387
+ // data.item.id index unique first.
2817
2388
  const { removedIds, indexUpgraded } = await ObjectLayer.ensureUniqueItemIdIndex();
2818
2389
  if (removedIds.length > 0) logger.warn(`Removed ${removedIds.length} duplicate ObjectLayer document(s)`);
2819
2390
  if (indexUpgraded) logger.info('Upgraded data.item.id index to unique');
2820
2391
 
2821
- // Item ids belonging to this instance (collected from imported object
2822
- // layers + the instance doc) used to backfill missing skills from the
2823
- // canonical DefaultSkillConfig when the backup predates the skill model.
2392
+ // Item ids of this instance, from the imported object layers and the
2393
+ // instance doc. They backfill skills from DefaultSkillConfig when the
2394
+ // backup carries none.
2824
2395
  const importedItemIds = new Set();
2825
2396
 
2826
2397
  // 0. Drop existing documents if --drop is set
@@ -2841,30 +2412,17 @@ try {
2841
2412
  // Query other instances/maps for shared thumbnail exclusion
2842
2413
  const otherInstances = await CyberiaInstance.find({ code: { $ne: instanceCode } }, { thumbnail: 1 }).lean();
2843
2414
 
2844
- // Add instance-level itemIds (may not appear in any map entity)
2845
- for (const entry of existingInstance.itemIds || []) {
2846
- const id = typeof entry === 'string' ? entry : entry?.id;
2847
- if (id) dropOlItemIds.add(id);
2848
- }
2849
-
2850
- // Add conf entityDefaults and skillConfig itemIds (liveItemIds, deadItemIds, dropItemIds, defaultObjectLayers)
2415
+ // Add the item ids the conf's referenced entity-type defaults name.
2851
2416
  const existingConf =
2852
2417
  (await CyberiaInstanceConf.findOne({ instanceCode }).lean()) ||
2853
2418
  (existingInstance.conf ? await CyberiaInstanceConf.findById(existingInstance.conf).lean() : null);
2854
2419
  if (existingConf) {
2855
- for (const ed of existingConf.entityDefaults || []) {
2856
- for (const id of ed.liveItemIds || []) if (id) dropOlItemIds.add(id);
2857
- for (const id of ed.deadItemIds || []) if (id) dropOlItemIds.add(id);
2858
- for (const id of ed.dropItemIds || []) if (id) dropOlItemIds.add(id);
2859
- for (const slot of ed.defaultObjectLayers || []) if (slot.itemId) dropOlItemIds.add(slot.itemId);
2860
- }
2861
- for (const sc of existingConf.skillConfig || []) {
2862
- if (sc.triggerItemId) dropOlItemIds.add(sc.triggerItemId);
2863
- for (const skill of sc.skills || []) {
2864
- if (skill.summonedEntityItemId && !skill.summonedEntityItemId.startsWith('$'))
2865
- dropOlItemIds.add(skill.summonedEntityItemId);
2866
- }
2867
- }
2420
+ // The conf names its entity-type defaults by _id; read the documents to reach their items.
2421
+ const referencedIds = (existingConf.entityDefaults || []).map((id) => String(id?._id ?? id));
2422
+ const referenced = referencedIds.length
2423
+ ? await CyberiaEntityTypeDefault.find({ _id: { $in: referencedIds } }).lean()
2424
+ : [];
2425
+ for (const itemId of collectInstanceItemIds({ entityDefaults: referenced })) dropOlItemIds.add(itemId);
2868
2426
  }
2869
2427
 
2870
2428
  const otherMaps = await CyberiaMap.find(
@@ -2888,6 +2446,12 @@ try {
2888
2446
  const mapResult = await CyberiaMap.deleteMany({ code: { $in: [...dropMapCodes] } });
2889
2447
  logger.info(`Dropped ${mapResult.deletedCount} CyberiaMap document(s)`);
2890
2448
 
2449
+ // A map's audio configuration belongs to that map. The assets it bound do not:
2450
+ // they are global and shared, so they stay.
2451
+ const audioConfResult = await CyberiaMapAudioConf.deleteMany({ mapCode: { $in: [...dropMapCodes] } });
2452
+ if (audioConfResult.deletedCount > 0)
2453
+ logger.info(`Dropped ${audioConfResult.deletedCount} CyberiaMapAudioConf document(s)`);
2454
+
2891
2455
  // Quests + actions are bound to maps by sourceMapCode, so they drop
2892
2456
  // with this instance's maps — only the content tied to this instance.
2893
2457
  const questResult = await CyberiaQuest.deleteMany({ sourceMapCode: { $in: [...dropMapCodes] } });
@@ -2920,6 +2484,15 @@ try {
2920
2484
  }
2921
2485
  }
2922
2486
 
2487
+ // A conf stores no skills: the ones this instance ran are the ones its own items trigger.
2488
+ // What those summon is drawn by this instance alone, so it joins the drop surface and is
2489
+ // then protected by the same shared-with-another-map check as everything else.
2490
+ for (const itemId of collectSummonedItemIds(
2491
+ selectInstanceSkills(await CyberiaSkill.find({}).lean(), dropOlItemIds),
2492
+ )) {
2493
+ dropOlItemIds.add(itemId);
2494
+ }
2495
+
2923
2496
  // Exclude OL item IDs referenced by maps outside this instance
2924
2497
  const sharedOlItemIds = new Set();
2925
2498
  for (const m of otherMaps) {
@@ -2979,19 +2552,17 @@ try {
2979
2552
  if (doc.atlasSpriteSheetId) atlasIds.push(doc.atlasSpriteSheetId);
2980
2553
  }
2981
2554
 
2982
- // Delete AtlasSpriteSheet + referenced File docs
2983
- if (atlasIds.length > 0) {
2984
- const atlasDocs = await AtlasSpriteSheet.find({ _id: { $in: atlasIds } }, { fileId: 1, cid: 1 }).lean();
2985
- const atlasFileIds = atlasDocs.map((a) => a.fileId).filter(Boolean);
2986
- for (const atlas of atlasDocs) {
2987
- if (atlas.cid) cidsToUnpin.add(atlas.cid);
2988
- }
2989
- if (atlasFileIds.length > 0) {
2990
- const fileResult = await File.deleteMany({ _id: { $in: atlasFileIds } });
2991
- logger.info(`Dropped ${fileResult.deletedCount} File document(s) (atlas)`);
2992
- }
2993
- const atlasResult = await AtlasSpriteSheet.deleteMany({ _id: { $in: atlasIds } });
2994
- logger.info(`Dropped ${atlasResult.deletedCount} AtlasSpriteSheet document(s)`);
2555
+ // Delete AtlasSpriteSheet + every File render it owns, through the store that
2556
+ // knows how many renders that is.
2557
+ if (atlasIds.length > 0 || itemKeysToClean.size > 0) {
2558
+ const purged = await AtlasSpriteSheetStore.purge({
2559
+ itemKeys: [...itemKeysToClean],
2560
+ atlasIds,
2561
+ options: { host, path },
2562
+ });
2563
+ for (const cid of purged.cids) cidsToUnpin.add(cid);
2564
+ if (purged.files > 0) logger.info(`Dropped ${purged.files} File document(s) (atlas)`);
2565
+ if (purged.atlases > 0) logger.info(`Dropped ${purged.atlases} AtlasSpriteSheet document(s)`);
2995
2566
  }
2996
2567
 
2997
2568
  // Delete RenderFrames
@@ -3048,13 +2619,16 @@ try {
3048
2619
  const confImportPath = `${backupDir}/cyberia-instance-conf.json`;
3049
2620
  let importedConf = null;
3050
2621
  if (fs.existsSync(confImportPath)) {
3051
- // Backfill any missing schema fields so older backups import a
3052
- // complete, playable config into the DB.
3053
- const confData = fillInstanceConfDefaults(fs.readJsonSync(confImportPath));
2622
+ // Made whole and valid before the live conf is touched, so a backup the schema
2623
+ // rejects resets to defaults rather than leaving the instance with no conf.
2624
+ const { conf: confData } = await CyberiaInstanceConfService.coerceToSchema(
2625
+ await adoptEntityTypeDefaultRefs(fs.readJsonSync(confImportPath), backupDir, CyberiaEntityTypeDefault),
2626
+ CyberiaInstanceConf,
2627
+ );
3054
2628
  if (confData._id) await CyberiaInstanceConf.deleteOne({ _id: confData._id });
3055
2629
  await CyberiaInstanceConf.deleteOne({ instanceCode: confData.instanceCode });
3056
- // Always bump updatedAt so the Go server's version hash changes and
3057
- // ReloadWorld re-applies the config without requiring a full restart.
2630
+ // Bump updatedAt so the world version changes and the server
2631
+ // re-applies the config without a restart.
3058
2632
  confData.updatedAt = new Date();
3059
2633
  importedConf = await CyberiaInstanceConf.create(confData);
3060
2634
  logger.info('Imported CyberiaInstanceConf', { instanceCode: confData.instanceCode });
@@ -3062,11 +2636,10 @@ try {
3062
2636
  logger.warn(`CyberiaInstanceConf backup not found: ${confImportPath}`);
3063
2637
  }
3064
2638
 
3065
- // In --conf mode we must NOT delete + recreate the CyberiaInstance because
3066
- // that would overwrite cyberiaMapCodes / portals / itemIds with whatever was
3067
- // in the (possibly stale) backup, effectively removing the live maps and OLs
3068
- // from the instance. Only update the conf ref and bump updatedAt so the Go
3069
- // server's version hash changes and ReloadWorld re-applies the config.
2639
+ // --conf must not recreate the CyberiaInstance: that would overwrite
2640
+ // cyberiaMapCodes, portals and itemIds from a possibly stale backup.
2641
+ // Update the conf ref and bump updatedAt, so the world version changes
2642
+ // and the server re-applies the config.
3070
2643
  if (importedConf) {
3071
2644
  const result = await CyberiaInstance.updateOne(
3072
2645
  { code: instanceCode },
@@ -3130,6 +2703,7 @@ try {
3130
2703
 
3131
2704
  // 3. Import AtlasSpriteSheet
3132
2705
  const atlasDir = `${backupDir}/atlas-sprite-sheets`;
2706
+ const atlasesWithoutMinify = new Set();
3133
2707
  if (fs.existsSync(atlasDir)) {
3134
2708
  const atlasFiles = fs.readdirSync(atlasDir).filter((f) => f.endsWith('.json'));
3135
2709
  let atlasCount = 0;
@@ -3143,6 +2717,22 @@ try {
3143
2717
  atlasCount++;
3144
2718
  }
3145
2719
  logger.info(`Imported ${atlasCount} AtlasSpriteSheet document(s)`);
2720
+ // A backup from before the still existed restores without one; cut each from its render.
2721
+ // An atlas restored without its minified render is rebuilt whole once its object
2722
+ // layer is in place, below.
2723
+ let stillCount = 0;
2724
+ for (const f of atlasFiles) {
2725
+ const atlasData = fs.readJsonSync(`${atlasDir}/${f}`);
2726
+ const itemKey = atlasData.metadata?.itemKey;
2727
+ if (!itemKey) continue;
2728
+ if (!atlasData.minifyFileId) atlasesWithoutMinify.add(itemKey);
2729
+ const { status } = await AtlasSpriteSheetStore.syncIdlePreview({ itemKey, options: { host, path } });
2730
+ if (status === 'updated') stillCount++;
2731
+ }
2732
+ if (stillCount) logger.info(`Filled ${stillCount} idle preview still(s)`);
2733
+ // The replaced atlases took their renders out of reach; the imported ones are
2734
+ // already stored, so what no atlas points at now is exactly the leftover.
2735
+ await AtlasSpriteSheetStore.pruneOrphanRenders({ options: { host, path } });
3146
2736
  }
3147
2737
 
3148
2738
  // 4. Import object layers
@@ -3172,6 +2762,36 @@ try {
3172
2762
  logger.info(`Imported ${olCount} ObjectLayer document(s)`);
3173
2763
  }
3174
2764
 
2765
+ // 4a. An atlas the backup restored without its minified render cannot be served: the
2766
+ // client runtime pairs the blob with the metadata, so a render cut from the frames
2767
+ // alone would be refused wherever the stored layout moved. Regenerate the whole
2768
+ // atlas from the frames the backup carries, and relink the object layer to it the
2769
+ // way `ol --to-atlas-sprite-sheet` does.
2770
+ for (const itemKey of atlasesWithoutMinify) {
2771
+ try {
2772
+ const objectLayer = await ObjectLayer.findByItemId(itemKey).populate('objectLayerRenderFramesId');
2773
+ if (!objectLayer?.objectLayerRenderFramesId) {
2774
+ logger.warn(`Backup atlas '${itemKey}' has no minified render and no render frames to rebuild it from`);
2775
+ continue;
2776
+ }
2777
+ const { atlasDoc, atlasCid, atlasMetadataCid } = await AtlasSpriteSheetStore.persist({
2778
+ itemKey,
2779
+ objectLayerRenderFrames: objectLayer.objectLayerRenderFramesId,
2780
+ options: { host, path },
2781
+ });
2782
+ objectLayer.atlasSpriteSheetId = atlasDoc._id;
2783
+ if (!objectLayer.data.render) objectLayer.data.render = {};
2784
+ objectLayer.data.render.cid = atlasCid;
2785
+ objectLayer.data.render.metadataCid = atlasMetadataCid;
2786
+ objectLayer.markModified('data.render');
2787
+ await objectLayer.save();
2788
+ await ObjectLayerEngine.computeAndSaveFinalSha256({ objectLayer, options: { host, path } });
2789
+ logger.info(`Rebuilt the atlas of '${itemKey}': the backup carried no minified render`);
2790
+ } catch (rebuildError) {
2791
+ logger.error(`Atlas rebuild failed for '${itemKey}': ${rebuildError.message}`);
2792
+ }
2793
+ }
2794
+
3175
2795
  // 4b. Regenerate static frame PNGs from imported render-frames + object-layer documents.
3176
2796
  // Mirrors the writeStaticFrameAssets call in `ol --import` so src/client/public/cyberia
3177
2797
  // and the public/<host><path> deployment dir are populated even when the cyberia
@@ -3210,7 +2830,7 @@ try {
3210
2830
  itemId,
3211
2831
  objectLayerRenderFramesData,
3212
2832
  objectLayerData: olData,
3213
- cellPixelDim: 20,
2833
+ cellPixelDim: DEFAULT_ATLAS_UPSCALE_FACTOR,
3214
2834
  });
3215
2835
  staticWriteCount += written.length;
3216
2836
  } catch (err) {
@@ -3235,12 +2855,69 @@ try {
3235
2855
  logger.info(`Imported ${mapCount} CyberiaMap document(s)`);
3236
2856
  }
3237
2857
 
2858
+ // 5a. Import audio assets, then the map bindings that reference them by code. Assets are
2859
+ // upserted by code (they are global and may already be present from another import);
2860
+ // their File documents were restored above with their original _id, so `fileId` still
2861
+ // resolves. A binding whose asset is absent is kept: the code is the reference, and
2862
+ // importing the asset later makes it play.
2863
+ const audioAssetsDir = `${backupDir}/cyberia-audio`;
2864
+ if (fs.existsSync(audioAssetsDir)) {
2865
+ const assetFiles = fs.readdirSync(audioAssetsDir).filter((f) => f.endsWith('.json'));
2866
+ let assetCount = 0;
2867
+ let replacedFiles = 0;
2868
+ for (const file of assetFiles) {
2869
+ const assetData = fs.readJsonSync(`${audioAssetsDir}/${file}`);
2870
+ if (!assetData.code) {
2871
+ logger.warn(`Skipping CyberiaAudio backup without code: ${file}`);
2872
+ continue;
2873
+ }
2874
+ // The asset this restore supersedes may hold different bytes under a different
2875
+ // fileId. Replacing the document without dropping that blob leaves it referenced by
2876
+ // nothing — an orphan `db clean-fs` would later have to sweep.
2877
+ const superseded = await CyberiaAudio.find({
2878
+ $or: [{ code: assetData.code }, ...(assetData._id ? [{ _id: assetData._id }] : [])],
2879
+ }).lean();
2880
+ await CyberiaAudio.deleteOne({ code: assetData.code });
2881
+ if (assetData._id) await CyberiaAudio.deleteOne({ _id: assetData._id });
2882
+ await CyberiaAudio.create(assetData);
2883
+ for (const old of superseded) {
2884
+ if (!old.fileId || String(old.fileId) === String(assetData.fileId)) continue;
2885
+ if (await File.findByIdAndDelete(old.fileId)) replacedFiles++;
2886
+ }
2887
+ assetCount++;
2888
+ }
2889
+ logger.info(`Imported ${assetCount} CyberiaAudio document(s)`, {
2890
+ ...(replacedFiles > 0 ? { replacedFiles } : {}),
2891
+ });
2892
+ }
2893
+
2894
+ const audioConfsDir = `${backupDir}/cyberia-map-audio-confs`;
2895
+ if (fs.existsSync(audioConfsDir)) {
2896
+ const confFiles = fs.readdirSync(audioConfsDir).filter((f) => f.endsWith('.json'));
2897
+ let audioConfCount = 0;
2898
+ for (const file of confFiles) {
2899
+ const confData = fs.readJsonSync(`${audioConfsDir}/${file}`);
2900
+ if (!confData.mapCode) {
2901
+ logger.warn(`Skipping CyberiaMapAudioConf backup without mapCode: ${file}`);
2902
+ continue;
2903
+ }
2904
+ await CyberiaMapAudioConf.deleteOne({ mapCode: confData.mapCode });
2905
+ if (confData._id) await CyberiaMapAudioConf.deleteOne({ _id: confData._id });
2906
+ await CyberiaMapAudioConf.create(confData);
2907
+ audioConfCount++;
2908
+ }
2909
+ logger.info(`Imported ${audioConfCount} CyberiaMapAudioConf document(s)`);
2910
+ }
2911
+
3238
2912
  // 6. Import CyberiaInstanceConf (skillRules, equipmentRules, entityDefaults, etc.)
3239
2913
  const confImportPath = `${backupDir}/cyberia-instance-conf.json`;
3240
2914
  if (fs.existsSync(confImportPath)) {
3241
- // Backfill any missing schema fields so older backups import a
3242
- // complete, playable config into the DB.
3243
- const confData = fillInstanceConfDefaults(fs.readJsonSync(confImportPath));
2915
+ // Made whole and valid before the live conf is touched, so a backup the schema
2916
+ // rejects resets to defaults rather than leaving the instance with no conf.
2917
+ const { conf: confData } = await CyberiaInstanceConfService.coerceToSchema(
2918
+ await adoptEntityTypeDefaultRefs(fs.readJsonSync(confImportPath), backupDir, CyberiaEntityTypeDefault),
2919
+ CyberiaInstanceConf,
2920
+ );
3244
2921
  if (confData._id) await CyberiaInstanceConf.deleteOne({ _id: confData._id });
3245
2922
  await CyberiaInstanceConf.deleteOne({ instanceCode: confData.instanceCode });
3246
2923
  await CyberiaInstanceConf.create(confData);
@@ -3253,13 +2930,6 @@ try {
3253
2930
  const instancePath = `${backupDir}/cyberia-instance.json`;
3254
2931
  if (fs.existsSync(instancePath)) {
3255
2932
  const instanceData = fs.readJsonSync(instancePath);
3256
- // Heal legacy shapes against the current model. itemIds migrated from a
3257
- // flat string[] to [{ id, defaultPlayerInventory }] — a raw old backup
3258
- // would fail Mongoose embedded-cast validation, so normalize it here.
3259
- instanceData.itemIds = (instanceData.itemIds || [])
3260
- .map((entry) => (typeof entry === 'string' ? { id: entry, defaultPlayerInventory: false } : entry))
3261
- .filter((entry) => entry && entry.id);
3262
- for (const entry of instanceData.itemIds) importedItemIds.add(entry.id);
3263
2933
  await CyberiaInstance.deleteOne({ code: instanceCode });
3264
2934
  await CyberiaInstance.deleteOne({ _id: instanceData._id });
3265
2935
  await CyberiaInstance.create(instanceData);
@@ -3354,29 +3024,32 @@ try {
3354
3024
  logger.info(`Imported ${skillCount} CyberiaSkill document(s)`);
3355
3025
  }
3356
3026
 
3357
- // 8d-bis. Import CyberiaEntityTypeDefault documents (own model, overwrite
3358
- // by the natural (entityType, liveItemIds) key, then by _id).
3027
+ // 8d-bis. Import CyberiaEntityTypeDefault documents by _id (preserveUUID), which is how
3028
+ // the conf's references keep resolving after a restore. Only the documents this
3029
+ // backup carries are touched: overwriting by an (entityType, liveItemIds) "natural
3030
+ // key" clobbered another instance's document whenever two worlds shared a skin.
3359
3031
  const entityDefaultsDir = `${backupDir}/cyberia-entity-type-defaults`;
3360
3032
  if (fs.existsSync(entityDefaultsDir)) {
3361
3033
  const entityDefaultFiles = fs.readdirSync(entityDefaultsDir).filter((f) => f.endsWith('.json'));
3362
3034
  let entityDefaultCount = 0;
3363
3035
  for (const file of entityDefaultFiles) {
3364
3036
  const edData = fs.readJsonSync(`${entityDefaultsDir}/${file}`);
3365
- if (!edData.entityType) {
3366
- logger.warn(`Skipping CyberiaEntityTypeDefault backup without entityType: ${file}`);
3037
+ if (!edData.entityType || !edData._id) {
3038
+ logger.warn(`Skipping CyberiaEntityTypeDefault backup without entityType or _id: ${file}`);
3367
3039
  continue;
3368
3040
  }
3369
- await CyberiaEntityTypeDefault.deleteMany({
3370
- entityType: edData.entityType,
3371
- liveItemIds: edData.liveItemIds || [],
3372
- });
3373
- if (edData._id) await CyberiaEntityTypeDefault.deleteOne({ _id: edData._id });
3041
+ await CyberiaEntityTypeDefault.deleteOne({ _id: edData._id });
3374
3042
  await CyberiaEntityTypeDefault.create(edData);
3375
3043
  entityDefaultCount++;
3376
3044
  }
3377
3045
  logger.info(`Imported ${entityDefaultCount} CyberiaEntityTypeDefault document(s)`);
3378
3046
  }
3379
3047
 
3048
+ // A conf can reference a default this backup does not carry — an older backup, or one
3049
+ // exported before the reference existed. Restoring that reference would recreate the
3050
+ // orphan the export just removed, so the restored conf is compacted too.
3051
+ await CyberiaEntityTypeDefaultService.compactInstanceRefs({ host, path }, { instanceCode });
3052
+
3380
3053
  // 8e. Backfill missing skills from the canonical DefaultSkillConfig. Old
3381
3054
  // backups predate the CyberiaSkill model and ship no skills/ dir, so
3382
3055
  // any instance item that has a canonical skill (e.g. atlas_pistol_mk2,
@@ -3693,30 +3366,17 @@ try {
3693
3366
  // Query other instances for shared thumbnail exclusion
3694
3367
  const otherInstances = await CyberiaInstance.find({ code: { $ne: instanceCode } }, { thumbnail: 1 }).lean();
3695
3368
 
3696
- // Add instance-level itemIds (may not appear in any map entity)
3697
- for (const entry of existingInstance.itemIds || []) {
3698
- const id = typeof entry === 'string' ? entry : entry?.id;
3699
- if (id) dropOlItemIds.add(id);
3700
- }
3701
-
3702
- // Add conf entityDefaults and skillConfig itemIds (liveItemIds, deadItemIds, dropItemIds, defaultObjectLayers)
3369
+ // Add the item ids the conf's referenced entity-type defaults name.
3703
3370
  const existingConf =
3704
3371
  (await CyberiaInstanceConf.findOne({ instanceCode }).lean()) ||
3705
3372
  (existingInstance.conf ? await CyberiaInstanceConf.findById(existingInstance.conf).lean() : null);
3706
3373
  if (existingConf) {
3707
- for (const ed of existingConf.entityDefaults || []) {
3708
- for (const id of ed.liveItemIds || []) if (id) dropOlItemIds.add(id);
3709
- for (const id of ed.deadItemIds || []) if (id) dropOlItemIds.add(id);
3710
- for (const id of ed.dropItemIds || []) if (id) dropOlItemIds.add(id);
3711
- for (const slot of ed.defaultObjectLayers || []) if (slot.itemId) dropOlItemIds.add(slot.itemId);
3712
- }
3713
- for (const sc of existingConf.skillConfig || []) {
3714
- if (sc.triggerItemId) dropOlItemIds.add(sc.triggerItemId);
3715
- for (const skill of sc.skills || []) {
3716
- if (skill.summonedEntityItemId && !skill.summonedEntityItemId.startsWith('$'))
3717
- dropOlItemIds.add(skill.summonedEntityItemId);
3718
- }
3719
- }
3374
+ // The conf names its entity-type defaults by _id; read the documents to reach their items.
3375
+ const referencedIds = (existingConf.entityDefaults || []).map((id) => String(id?._id ?? id));
3376
+ const referenced = referencedIds.length
3377
+ ? await CyberiaEntityTypeDefault.find({ _id: { $in: referencedIds } }).lean()
3378
+ : [];
3379
+ for (const itemId of collectInstanceItemIds({ entityDefaults: referenced })) dropOlItemIds.add(itemId);
3720
3380
  }
3721
3381
 
3722
3382
  const otherMaps = await CyberiaMap.find(
@@ -3737,6 +3397,20 @@ try {
3737
3397
  }
3738
3398
  const mapResult = await CyberiaMap.deleteMany({ code: { $in: [...dropMapCodes] } });
3739
3399
  logger.info(`Dropped ${mapResult.deletedCount} CyberiaMap document(s)`);
3400
+
3401
+ // A map's audio configuration belongs to that map; the shared assets it bound do not.
3402
+ const audioConfResult = await CyberiaMapAudioConf.deleteMany({ mapCode: { $in: [...dropMapCodes] } });
3403
+ if (audioConfResult.deletedCount > 0)
3404
+ logger.info(`Dropped ${audioConfResult.deletedCount} CyberiaMapAudioConf document(s)`);
3405
+ }
3406
+
3407
+ // A conf stores no skills: the ones this instance ran are the ones its own items trigger.
3408
+ // What those summon is drawn by this instance alone, so it joins the drop surface and is
3409
+ // then protected by the same shared-with-another-map check as everything else.
3410
+ for (const itemId of collectSummonedItemIds(
3411
+ selectInstanceSkills(await CyberiaSkill.find({}).lean(), dropOlItemIds),
3412
+ )) {
3413
+ dropOlItemIds.add(itemId);
3740
3414
  }
3741
3415
 
3742
3416
  // Exclude OL item IDs referenced by maps outside this instance
@@ -3793,18 +3467,15 @@ try {
3793
3467
  if (doc.atlasSpriteSheetId) atlasIds.push(doc.atlasSpriteSheetId);
3794
3468
  }
3795
3469
 
3796
- if (atlasIds.length > 0) {
3797
- const atlasDocs = await AtlasSpriteSheet.find({ _id: { $in: atlasIds } }, { fileId: 1, cid: 1 }).lean();
3798
- const atlasFileIds = atlasDocs.map((a) => a.fileId).filter(Boolean);
3799
- for (const atlas of atlasDocs) {
3800
- if (atlas.cid) cidsToUnpin.add(atlas.cid);
3801
- }
3802
- if (atlasFileIds.length > 0) {
3803
- const fileResult = await File.deleteMany({ _id: { $in: atlasFileIds } });
3804
- logger.info(`Dropped ${fileResult.deletedCount} File document(s) (atlas)`);
3805
- }
3806
- const atlasResult = await AtlasSpriteSheet.deleteMany({ _id: { $in: atlasIds } });
3807
- logger.info(`Dropped ${atlasResult.deletedCount} AtlasSpriteSheet document(s)`);
3470
+ if (atlasIds.length > 0 || itemKeysToClean.size > 0) {
3471
+ const purged = await AtlasSpriteSheetStore.purge({
3472
+ itemKeys: [...itemKeysToClean],
3473
+ atlasIds,
3474
+ options: { host, path },
3475
+ });
3476
+ for (const cid of purged.cids) cidsToUnpin.add(cid);
3477
+ if (purged.files > 0) logger.info(`Dropped ${purged.files} File document(s) (atlas)`);
3478
+ if (purged.atlases > 0) logger.info(`Dropped ${purged.atlases} AtlasSpriteSheet document(s)`);
3808
3479
  }
3809
3480
 
3810
3481
  if (renderFrameIds.length > 0) {
@@ -3956,6 +3627,130 @@ try {
3956
3627
  }
3957
3628
  });
3958
3629
 
3630
+ const runAudioCommand = async (audioCode, options = {}) => {
3631
+ const assignments = {
3632
+ ...(options.setDefaultMusic === undefined ? {} : { defaultMusic: options.setDefaultMusic }),
3633
+ events: options.setEvent ?? [],
3634
+ };
3635
+ const configuring = assignments.defaultMusic !== undefined || assignments.events.length > 0;
3636
+
3637
+ if (configuring && !options.map) {
3638
+ logger.error('--map <map-code> is required to apply --set-default-music/--set-event');
3639
+ process.exit(1);
3640
+ }
3641
+ if (!options.import && !options.map) {
3642
+ logger.error('Nothing to do: pass --import, or --map <map-code> to read or configure a map');
3643
+ process.exit(1);
3644
+ }
3645
+
3646
+ if (options.envPath && !fs.existsSync(options.envPath)) {
3647
+ logger.error(`Env file not found: ${options.envPath}`);
3648
+ process.exit(1);
3649
+ }
3650
+ const envPath =
3651
+ options.envPath || `./engine-private/conf/dd-cyberia/.env.${options.dev ? 'development' : 'production'}`;
3652
+ if (fs.existsSync(envPath)) dotenv.config({ path: envPath, override: true });
3653
+
3654
+ const deployId = process.env.DEFAULT_DEPLOY_ID;
3655
+ const host = process.env.DEFAULT_DEPLOY_HOST;
3656
+ const path = process.env.DEFAULT_DEPLOY_PATH;
3657
+ const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
3658
+ if (!fs.existsSync(confServerPath)) {
3659
+ logger.error(`Server config not found: ${confServerPath}. Ensure DEFAULT_DEPLOY_ID is set.`);
3660
+ process.exit(1);
3661
+ }
3662
+ const confServer = loadConfServerJson(confServerPath, { resolve: true });
3663
+ const { db } = confServer[host][path];
3664
+ db.host = options.mongoHost
3665
+ ? options.mongoHost
3666
+ : options.dev
3667
+ ? db.host
3668
+ : db.host.replace('127.0.0.1', 'mongodb-0.mongodb-service');
3669
+
3670
+ logger.info('env', { env: envPath, deployId, host, path });
3671
+
3672
+ await DataBaseProviderService.load({
3673
+ apis: ['cyberia-audio', 'cyberia-instance', 'cyberia-map', 'cyberia-map-audio-conf', 'file'],
3674
+ host,
3675
+ path,
3676
+ db,
3677
+ });
3678
+
3679
+ try {
3680
+ if (options.seedWorld) {
3681
+ // An instance names its own maps, in its own order; without one the fallback world is the
3682
+ // world being scored. Either way the bank and the rotation are the same.
3683
+ const result = options.instance
3684
+ ? await seedInstanceAudio(
3685
+ { instanceCode: options.instance, recordsPath: options.recordsPath },
3686
+ { host, path },
3687
+ )
3688
+ : await seedFallbackAudio({ recordsPath: options.recordsPath }, { host, path });
3689
+ logger.info(
3690
+ `seed-audio${options.instance ? ` --instance ${options.instance}` : ''}: ` +
3691
+ `${result.assets.length} assets, ${result.maps.length} maps`,
3692
+ );
3693
+ } else if (options.import) {
3694
+ const recordsPath = options.recordsPath || DEFAULT_AUDIO_RECORDS_PATH;
3695
+ const codes = audioCode
3696
+ ? audioCode
3697
+ .split(',')
3698
+ .map((code) => code.trim())
3699
+ .filter(Boolean)
3700
+ : null;
3701
+ const imported = await CyberiaAudioService.importRecords({ recordsPath, codes }, { host, path });
3702
+ logger.info(`audio --import: imported ${imported.length} asset(s) from ${recordsPath}`);
3703
+ }
3704
+
3705
+ if (options.map) {
3706
+ /** @type {import('mongoose').Model} */
3707
+ const CyberiaMap = DataBaseProviderService.getModel('cyberia-map', { host, path });
3708
+ if (!(await CyberiaMap.exists({ code: options.map }))) {
3709
+ throw new Error(`cyberia-map not found for code="${options.map}"`);
3710
+ }
3711
+
3712
+ if (configuring) {
3713
+ const conf = await CyberiaMapAudioConfService.assign(
3714
+ { mapCode: options.map, ...assignments },
3715
+ { host, path },
3716
+ );
3717
+ logger.info(`audio --map: updated audio configuration for "${options.map}"`, {
3718
+ defaultMusic: conf.defaultMusic || null,
3719
+ events: conf.events.map(({ logicEventId, audioCode: code }) => `${logicEventId}:${code}`),
3720
+ });
3721
+ } else {
3722
+ const conf = await CyberiaMapAudioConfService.getByMapCode(options.map, { host, path });
3723
+ if (!conf) logger.warn(`No audio configuration for map "${options.map}"`);
3724
+ else console.log(JSON.stringify(conf, null, 2));
3725
+ }
3726
+ }
3727
+ } finally {
3728
+ await DataBaseProviderService.getProvider({ host, path }, 'mongoose').close();
3729
+ }
3730
+ };
3731
+
3732
+ // ── audio: import cyberia-audio assets and configure per-map audio ──
3733
+ program
3734
+ .command('audio [audio-code]')
3735
+ .option('--import', 'Import <name>.wav + <name>.json pairs into MongoDB (all pairs when no id is given)')
3736
+ .option(
3737
+ '--records-path <records-path>',
3738
+ `Records directory to import from (default: ${DEFAULT_AUDIO_RECORDS_PATH})`,
3739
+ )
3740
+ .option('--map <map-code>', 'Target cyberia-map code to read or configure')
3741
+ .option('--set-default-music <audio-code>', 'Set the default background music of --map')
3742
+ .option(
3743
+ '--set-event <logic-event-id:audio-code>',
3744
+ 'Bind an audio asset to a logic event e.g. combat:combat or shoot:shoot, repeatable',
3745
+ eventAudioBindingFactory('--set-event'),
3746
+ [],
3747
+ )
3748
+ .option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
3749
+ .option('--mongo-host <mongo-host>', 'Mongo host override')
3750
+ .option('--dev', 'Force development environment')
3751
+ .description('Import cyberia-audio assets into MongoDB and configure cyberia-map audio')
3752
+ .action(runAudioCommand);
3753
+
3959
3754
  // ── generate-saga: Top-Down PCG guided by LLMs (Semantic Reverse-Engineering) ──
3960
3755
  program
3961
3756
  .command('generate-saga')
@@ -3965,7 +3760,7 @@ try {
3965
3760
  )
3966
3761
  .option('--import <file>', 'Load a previously generated payload file (the shape --out writes) into the database')
3967
3762
  .option('--model <model>', 'Gemini model id (default: gemma-4-26b-a4b-it)')
3968
- .option('--timeout <ms>', 'Per-request timeout in ms (default: 300000)', (v) => parseInt(v, 10))
3763
+ .option('--timeout <ms>', 'Per-request timeout in ms (default: 10000)', (v) => parseInt(v, 10))
3969
3764
  .option('--thinking-level <level>', 'Gemini thinking level: low | medium | high (default: high)')
3970
3765
  .option(
3971
3766
  '--lore-path <path>',
@@ -4921,6 +4716,26 @@ try {
4921
4716
 
4922
4717
  const runner = program.command('run-workflow').description('Run a Cyberia script from the "scripts" directory');
4923
4718
 
4719
+ runner
4720
+ .command('seed-audio')
4721
+ .option('--records-path <path>', 'Recorded WAV and manifest directory', DEFAULT_AUDIO_RECORDS_PATH)
4722
+ .option('--records-only', 'Record the WAV and manifest pairs without touching the database')
4723
+ .option(
4724
+ '--instance <instance-code>',
4725
+ "Configure that instance's maps instead of the fallback world's, in its own cyberiaMapCodes order",
4726
+ )
4727
+ .option('--env-path <path>', 'Engine environment file')
4728
+ .option('--mongo-host <host>', 'Mongo host override')
4729
+ .option('--dev', 'Use the development environment')
4730
+ .description("Record audio, upsert generic files and audio metadata, and configure a world's maps")
4731
+ .action(async (options) => {
4732
+ // Recording writes only `records/`: the client bundles no audio and fetches every asset
4733
+ // from engine-cyberia by code, so seeding the database is what makes a recording reachable.
4734
+ await prepareFallbackAudio({ recordsPath: options.recordsPath ?? DEFAULT_AUDIO_RECORDS_PATH });
4735
+ if (!options.recordsOnly) await runAudioCommand(undefined, { ...options, import: true, seedWorld: true });
4736
+ logger.info('seed-audio complete');
4737
+ });
4738
+
4924
4739
  runner
4925
4740
  .command('import-default-items')
4926
4741
  .option('--dev', 'Force development environment (loads .env.development for IPFS localhost, etc.)')
@@ -4976,15 +4791,51 @@ try {
4976
4791
  .description('Packs this engine checkout as underpost-cli.tgz for a runtime image build context')
4977
4792
  .action((options) => stageCliPackage(options.outputPath || '.'));
4978
4793
 
4979
- runner.command('sync-src').action(() => {
4980
- fs.copyFileSync('./cyberia-server/README.md', './src/client/public/cyberia-docs/CYBERIA-SERVER.md');
4981
- fs.copyFileSync('./cyberia-server/Dockerfile', './src/runtime/cyberia-server/Dockerfile');
4982
- fs.copyFileSync('./cyberia-client/README.md', './src/client/public/cyberia-docs/CYBERIA-CLIENT.md');
4983
- fs.copyFileSync('./cyberia-client/Dockerfile', './src/runtime/cyberia-client/Dockerfile');
4984
- });
4794
+ // Every file mirrored between this engine and a product checkout, engine path first. One
4795
+ // table for both directions, so a pair cannot be synced one way and forgotten the other.
4796
+ const cyberiaSrcSyncPairs = [
4797
+ ['./src/client/public/cyberia-docs/CYBERIA-SERVER.md', './cyberia-server/README.md'],
4798
+ ['./src/runtime/cyberia-server/Dockerfile', './cyberia-server/Dockerfile'],
4799
+ ['./src/runtime/cyberia-server/Dockerfile.dev', './cyberia-server/Dockerfile.dev'],
4800
+ ['./src/client/public/cyberia-docs/CYBERIA-CLIENT.md', './cyberia-client/README.md'],
4801
+ ['./src/runtime/cyberia-client/Dockerfile', './cyberia-client/Dockerfile'],
4802
+ ['./src/runtime/cyberia-client/Dockerfile.dev', './cyberia-client/Dockerfile.dev'],
4803
+ ];
4804
+
4805
+ runner
4806
+ .command('sync-src')
4807
+ .option('--from-repo', 'Copy from the product checkouts into this engine instead of out to them')
4808
+ .option('--dry-run', 'Report what would be copied without writing it')
4809
+ .description(
4810
+ 'Mirrors the cyberia product READMEs and runtime Dockerfiles between this engine and the product checkouts',
4811
+ )
4812
+ .action((options) => {
4813
+ const fromRepo = options.fromRepo === true;
4814
+ const dryRun = options.dryRun === true;
4815
+ const copied = [];
4816
+ const missing = [];
4817
+
4818
+ for (const [enginePath, repoPath] of cyberiaSrcSyncPairs) {
4819
+ const [source, target] = fromRepo ? [repoPath, enginePath] : [enginePath, repoPath];
4820
+ // A product checkout `setup-workspace` has not cloned yet must not leave the run
4821
+ // half applied, so a missing source is reported rather than thrown on.
4822
+ if (!fs.existsSync(source)) {
4823
+ missing.push(source);
4824
+ continue;
4825
+ }
4826
+ if (!dryRun) fs.copySync(source, target);
4827
+ copied.push(`${source} -> ${target}`);
4828
+ }
4829
+
4830
+ logger.info(`Cyberia sources synced ${fromRepo ? 'from' : 'to'} the product checkouts`, {
4831
+ copied,
4832
+ missing,
4833
+ dryRun,
4834
+ });
4835
+ });
4985
4836
 
4986
4837
  runner.command('setup-workspace').action(() => {
4987
- shellExec(`node bin fs src/client/public/cyberia --git --recursive --pull --deploy-id dd-cyberia`);
4838
+ shellExec(`node bin fs src/client/public/cyberia --tracked --pull --deploy-id dd-cyberia`);
4988
4839
  shellExec(`node bin/deploy.js cyberia`);
4989
4840
  if (!fs.existsSync('./cyberia-server')) shellExec(`${cli()} clone underpostnet/cyberia-server`);
4990
4841
  if (!fs.existsSync('./cyberia-client')) shellExec(`${cli()} clone underpostnet/cyberia-client`);
@@ -5096,7 +4947,7 @@ try {
5096
4947
  .option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
5097
4948
  .option('--mongo-host <mongo-host>', 'Mongo host override')
5098
4949
  .option('--dev', 'Force development environment')
5099
- .description('Drop all Cyberia collections and remove File documents referenced by instance/map thumbnails')
4950
+ .description('Drop all Cyberia collections and remove the File documents they reference')
5100
4951
  .action(async (options = {}) => {
5101
4952
  if (!options.envPath) options.envPath = `./.env`;
5102
4953
  if (fs.existsSync(options.envPath)) dotenv.config({ path: options.envPath, override: true });
@@ -5139,30 +4990,37 @@ try {
5139
4990
  'cyberia-entity-type-default',
5140
4991
  'cyberia-client-hints',
5141
4992
  'cyberia-saga',
4993
+ 'cyberia-audio',
4994
+ 'cyberia-map-audio-conf',
5142
4995
  ];
5143
4996
 
4997
+ // Every File _id a Cyberia document owns: instance and map thumbnails and previews, and the
4998
+ // recorded WAV each audio asset points at. Read from the registry that maps a model to its
4999
+ // File fields, so a reference added there is dropped here without editing this command, and
5000
+ // read before anything is dropped, so no backing File survives the collection that held it.
5001
+ const fileReferences = cyberiaCollections
5002
+ .map((api) => ({ api, fields: fileRefFields(api) }))
5003
+ .filter(({ fields }) => fields.length > 0);
5004
+
5144
5005
  await DataBaseProviderService.load({ apis: [...cyberiaCollections, 'file'], host, path, db });
5145
5006
 
5146
5007
  const File = DataBaseProviderService.getModel('file', { host, path });
5147
5008
 
5148
- // Thumbnails/previews on instances/maps are File _id references; collect
5149
- // them before dropping so the backing File documents don't leak as orphans.
5150
- const thumbnailFileIds = new Set();
5151
- for (const api of ['cyberia-instance', 'cyberia-map']) {
5009
+ const fileIds = new Set();
5010
+ for (const { api, fields } of fileReferences) {
5152
5011
  const Model = DataBaseProviderService.getModel(api, { host, path });
5153
5012
  const docs = await Model.find(
5154
- { $or: [{ thumbnail: { $ne: null } }, { preview: { $ne: null } }] },
5155
- { thumbnail: 1, preview: 1 },
5013
+ { $or: fields.map((field) => ({ [field]: { $ne: null } })) },
5014
+ Object.fromEntries(fields.map((field) => [field, 1])),
5156
5015
  ).lean();
5157
5016
  for (const doc of docs) {
5158
- if (doc.thumbnail) thumbnailFileIds.add(doc.thumbnail.toString());
5159
- if (doc.preview) thumbnailFileIds.add(doc.preview.toString());
5017
+ for (const field of fields) if (doc[field]) fileIds.add(doc[field].toString());
5160
5018
  }
5161
5019
  }
5162
5020
 
5163
- if (thumbnailFileIds.size > 0) {
5164
- const result = await File.deleteMany({ _id: { $in: [...thumbnailFileIds] } });
5165
- logger.info(`Removed ${result.deletedCount} thumbnail File document(s)`);
5021
+ if (fileIds.size > 0) {
5022
+ const result = await File.deleteMany({ _id: { $in: [...fileIds] } });
5023
+ logger.info(`Removed ${result.deletedCount} referenced File document(s)`);
5166
5024
  }
5167
5025
 
5168
5026
  for (const api of cyberiaCollections) {
@@ -5242,7 +5100,7 @@ node bin test cyberia --grep 'Cyberia load'`);
5242
5100
  // no funca
5243
5101
  if (options.loadTar) {
5244
5102
  for (const imageId of dockerImageIds)
5245
- if (imageId === id || id === '.') shellExec(`docker load -i ./${imageId}-dev_v3.3.73.tar`);
5103
+ if (imageId === id || id === '.') shellExec(`docker load -i ./${imageId}-dev_v3.3.77.tar`);
5246
5104
  return;
5247
5105
  }
5248
5106
  switch (id) {
@@ -5254,7 +5112,7 @@ node bin/build dd-cyberia --update-private
5254
5112
  node bin image --path src/runtime/engine-cyberia \
5255
5113
  --docker-compose --pull-base --build \
5256
5114
  --dockerfile-name Dockerfile.dev \
5257
- --image-name engine-cyberia-dev:v3.3.73 \
5115
+ --image-name engine-cyberia-dev:v3.3.77 \
5258
5116
  --image-out-path .
5259
5117
  `);
5260
5118
  break;
@@ -5266,7 +5124,7 @@ cp -f src/runtime/cyberia-server/Dockerfile.dev cyberia-server/Dockerfile.dev
5266
5124
  node bin image --path cyberia-server \
5267
5125
  --docker-compose --pull-base --build \
5268
5126
  --dockerfile-name Dockerfile.dev \
5269
- --image-name cyberia-server-dev:v3.3.73 \
5127
+ --image-name cyberia-server-dev:v3.3.77 \
5270
5128
  --image-out-path .
5271
5129
  `);
5272
5130
  break;
@@ -5277,7 +5135,7 @@ cp -f src/runtime/cyberia-client/Dockerfile.dev cyberia-client/Dockerfile.dev
5277
5135
  node bin image --path cyberia-client \
5278
5136
  --docker-compose --pull-base --build \
5279
5137
  --dockerfile-name Dockerfile.dev \
5280
- --image-name cyberia-client-dev:v3.3.73 \
5138
+ --image-name cyberia-client-dev:v3.3.77 \
5281
5139
  --image-out-path .
5282
5140
  `);
5283
5141
  break;
@@ -5443,9 +5301,9 @@ node bin image --path cyberia-client \
5443
5301
 
5444
5302
  const CyberiaSkill = DataBaseProviderService.getModel('cyberia-skill', { host, path });
5445
5303
 
5446
- // Upsert each skill record keyed by triggerItemId — full record (logic
5447
- // event keys + expanded skills metadata), unlike the instance-conf
5448
- // skillConfig schema which keeps only triggerItemId + logicEventIds.
5304
+ // Upsert each skill record keyed by triggerItemId — the full record (logic event keys +
5305
+ // expanded skills metadata). The collection is deployment-wide; an instance runs the
5306
+ // subset its own content triggers, decided at export and boot, never stored.
5449
5307
  let upserted = 0;
5450
5308
  for (const sk of DefaultSkillConfig) {
5451
5309
  await CyberiaSkill.findOneAndUpdate(
@@ -5468,6 +5326,10 @@ node bin image --path cyberia-client \
5468
5326
  .command('seed-entities')
5469
5327
  .option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
5470
5328
  .option('--mongo-host <mongo-host>', 'Mongo host override')
5329
+ .option(
5330
+ '--instance <instance-code>',
5331
+ "Point that instance's conf at the seeded documents, replacing whatever it referenced",
5332
+ )
5471
5333
  .option('--dev', 'Force development environment')
5472
5334
  .description('Upsert ENTITY_TYPE_DEFAULTS into the cyberia-entity-type-default collection (idempotent)')
5473
5335
  .action(async (options) => {
@@ -5499,7 +5361,12 @@ node bin image --path cyberia-client \
5499
5361
 
5500
5362
  logger.info('seed-entities', { deployId, host, path });
5501
5363
 
5502
- await DataBaseProviderService.load({ apis: ['cyberia-entity-type-default'], host, path, db });
5364
+ await DataBaseProviderService.load({
5365
+ apis: ['cyberia-entity-type-default', 'cyberia-instance-conf'],
5366
+ host,
5367
+ path,
5368
+ db,
5369
+ });
5503
5370
 
5504
5371
  const CyberiaEntityTypeDefault = DataBaseProviderService.getModel('cyberia-entity-type-default', { host, path });
5505
5372
 
@@ -5518,8 +5385,9 @@ node bin image --path cyberia-client \
5518
5385
  // (across entity types, or within one type at different specificity). Every
5519
5386
  // entry is upserted, idempotently, by its exact (entityType, liveItemIds) key.
5520
5387
  let upserted = 0;
5388
+ const seededIds = [];
5521
5389
  for (const ed of ENTITY_TYPE_DEFAULTS) {
5522
- await CyberiaEntityTypeDefault.findOneAndUpdate(
5390
+ const doc = await CyberiaEntityTypeDefault.findOneAndUpdate(
5523
5391
  { entityType: ed.entityType, liveItemIds: ed.liveItemIds || [] },
5524
5392
  {
5525
5393
  $set: {
@@ -5527,12 +5395,14 @@ node bin image --path cyberia-client \
5527
5395
  liveItemIds: ed.liveItemIds || [],
5528
5396
  deadItemIds: ed.deadItemIds || [],
5529
5397
  dropItemIds: ed.dropItemIds || [],
5530
- defaultObjectLayers: ed.defaultObjectLayers || [],
5398
+ inventoryItemsIds: ed.inventoryItemsIds || [],
5399
+ overrideItemsIdsState: ed.overrideItemsIdsState || [],
5531
5400
  behavior: ed.behavior || '',
5532
5401
  },
5533
5402
  },
5534
- { upsert: true },
5403
+ { upsert: true, returnDocument: 'after' },
5535
5404
  );
5405
+ if (doc?._id) seededIds.push(doc._id);
5536
5406
  upserted++;
5537
5407
  }
5538
5408
 
@@ -5541,6 +5411,23 @@ node bin image --path cyberia-client \
5541
5411
  ENTITY_TYPE_DEFAULTS.map((e) => `${e.entityType} → [${(e.liveItemIds || []).join(', ')}]`),
5542
5412
  );
5543
5413
 
5414
+ // A seeded document reaches a world only when that world's conf names it. Binding here is
5415
+ // what makes an edited row take effect, and it states the whole reference set so re-running
5416
+ // converges instead of accumulating.
5417
+ if (options.instance) {
5418
+ const CyberiaInstanceConf = DataBaseProviderService.getModel('cyberia-instance-conf', { host, path });
5419
+ const conf = await CyberiaInstanceConf.findOneAndUpdate(
5420
+ { instanceCode: options.instance },
5421
+ { $set: { entityDefaults: seededIds, updatedAt: new Date() } },
5422
+ { returnDocument: 'after' },
5423
+ );
5424
+ if (!conf) {
5425
+ logger.error(`cyberia-instance-conf not found for instanceCode="${options.instance}"`);
5426
+ process.exit(1);
5427
+ }
5428
+ logger.info(`seed-entities --instance ${options.instance}: ${seededIds.length} reference(s) bound`);
5429
+ }
5430
+
5544
5431
  await DataBaseProviderService.getProvider({ host, path }, 'mongoose').close();
5545
5432
  });
5546
5433
 
@@ -5564,26 +5451,6 @@ node bin image --path cyberia-client \
5564
5451
  'skin-vivid',
5565
5452
  'skin-natural',
5566
5453
  'skin-shaved',
5567
- // 'resource-desert-petal',
5568
- // 'resource-desert-stone',
5569
- // 'resource-desert-polygon',
5570
- // 'resource-desert-thread',
5571
- // 'resource-grass-petal',
5572
- // 'resource-grass-stone',
5573
- // 'resource-grass-polygon',
5574
- // 'resource-grass-thread',
5575
- // 'resource-water-petal',
5576
- // 'resource-water-stone',
5577
- // 'resource-water-polygon',
5578
- // 'resource-water-thread',
5579
- // 'resource-stone-petal',
5580
- // 'resource-stone-stone',
5581
- // 'resource-stone-polygon',
5582
- // 'resource-stone-thread',
5583
- // 'resource-lava-petal',
5584
- // 'resource-lava-stone',
5585
- // 'resource-lava-polygon',
5586
- // 'resource-lava-thread',
5587
5454
  ];
5588
5455
 
5589
5456
  const baseSeed = options.seed || 'example';
@@ -5608,10 +5475,10 @@ node bin image --path cyberia-client \
5608
5475
  // Instance id → project root. Single source of truth for the workloads this
5609
5476
  // workflow builds: both the k8s manifests and the status page artifacts each
5610
5477
  // project ships are resolved from this list plus conf.instances.json.
5611
- const CYBERIA_INSTANCE_PROJECTS = [
5612
- { id: 'mmo-client', rootPath: './cyberia-client' },
5613
- { id: 'mmo-server', rootPath: './cyberia-server' },
5614
- ];
5478
+ // The template instances this deploy builds artifacts for. Where each one publishes is not
5479
+ // listed here: instanceProjectPathFactory reads it off the conf entry, the same rule
5480
+ // instance-build-manifest applies, so the two can never name different checkouts.
5481
+ const CYBERIA_INSTANCE_IDS = ['mmo-client', 'mmo-server'];
5615
5482
  const CYBERIA_CONF_INSTANCES_PATH = './engine-private/conf/dd-cyberia/conf.instances.json';
5616
5483
  const CYBERIA_CONF_SSR_PATH = './engine-private/conf/dd-cyberia/conf.ssr.json';
5617
5484
  // Copy shared by the server and the client for a given status code. A status
@@ -5704,10 +5571,9 @@ node bin image --path cyberia-client \
5704
5571
  const nodeFlag = options.nodeName ? ` --node-name ${options.nodeName}` : '';
5705
5572
 
5706
5573
  // ── Dynamically resolve instance codes from conf.instances.json ──────
5707
- // Read all cyberia-server runtime instances and collect their
5708
- // multiInstance variant codes. These are used to update the
5709
- // INSTANCE_CODES label in Dockerfile.dev so the dev image
5710
- // provisions every variant's backup dir and saga at build time.
5574
+ // Collect the multiInstance variant codes of every game-server runtime
5575
+ // instance. They set the INSTANCE_CODES label in Dockerfile.dev, so the
5576
+ // dev image provisions each variant's backup dir and saga at build time.
5711
5577
  //
5712
5578
  // Only codes that have an on-disk instance backup directory are
5713
5579
  // included. The saga file is optional — the Dockerfile's for loop
@@ -5769,14 +5635,12 @@ node bin image --path cyberia-client \
5769
5635
  }
5770
5636
 
5771
5637
  // ── Update catalog-cyberia.js privateConfPaths ───────────────────────
5772
- // The array block in privateConfPaths is bounded by /** INSTANCE_CODES */
5773
- // markers (valid JS comments here). Replace everything between them with
5774
- // the resolved per-code paths. These are synced by syncPrivateConf, which
5775
- // copies each entry from `./engine-private/<path>` so they must match the
5776
- // LOCAL engine-private layout (`cyberia-instances/<code>`,
5777
- // `cyberia-sagas/<code>.json`), not the published cyberia-instances repo
5778
- // (which uses `instances/` + `sagas/`). Only emit paths that exist on disk
5779
- // so the sync never hits ENOENT on a variant without local content.
5638
+ // The privateConfPaths array block sits between the /** INSTANCE_CODES */
5639
+ // markers. Replace its content with the resolved per-code paths.
5640
+ // syncPrivateConf copies each entry from `./engine-private/<path>`, so the
5641
+ // paths follow the local engine-private layout (`cyberia-instances/<code>`,
5642
+ // `cyberia-sagas/<code>.json`). Emit only paths that exist on disk, so the
5643
+ // sync never hits ENOENT for a variant without local content.
5780
5644
  const catalogPath = './src/projects/cyberia/catalog-cyberia.js';
5781
5645
  try {
5782
5646
  const catalogContent = fs.readFileSync(catalogPath, 'utf8');
@@ -5809,11 +5673,11 @@ node bin image --path cyberia-client \
5809
5673
  // embed each document declared under an instance's `customStatusPages`,
5810
5674
  // so the artifact has to exist at its `hostPath` by manifest time.
5811
5675
  const statusPagesBuilt = [];
5812
- for (const { id, rootPath } of CYBERIA_INSTANCE_PROJECTS) {
5676
+ for (const id of CYBERIA_INSTANCE_IDS) {
5813
5677
  const instance = confInstancesEntries.find((entry) => entry.id === id);
5814
5678
  for (const page of instance?.customStatusPages || []) {
5815
5679
  if (!page?.status || !page?.hostPath) continue;
5816
- const outputPath = nodePath.normalize(`${rootPath}/${page.hostPath}`);
5680
+ const outputPath = nodePath.normalize(`${instanceProjectPathFactory(instance)}/${page.hostPath}`);
5817
5681
  if (buildCyberiaStatusPage({ status: page.status, outputPath, dev: isDev }))
5818
5682
  statusPagesBuilt.push({ instance: id, status: page.status, outputPath });
5819
5683
  }
@@ -5826,23 +5690,22 @@ node bin image --path cyberia-client \
5826
5690
  // ── Build dev manifests (always --kind --dev) ────────────────────────
5827
5691
  {
5828
5692
  const flags = `--kind --dev${nodeFlag}`;
5829
- for (const { id, rootPath } of CYBERIA_INSTANCE_PROJECTS)
5830
- shellExec(`node bin run instance-build-manifest 'dd-cyberia,${id},${rootPath}' ${flags}`);
5693
+ for (const id of CYBERIA_INSTANCE_IDS)
5694
+ shellExec(`node bin run instance-build-manifest --deploy-id dd-cyberia --instance-id ${id} ${flags}`);
5831
5695
  }
5832
5696
  // ── Build prod manifests (--kubeadm, no --dev) ───────────────────────
5833
5697
  if (!isDev) {
5834
5698
  const flags = `--kubeadm${nodeFlag}`;
5835
- for (const { id, rootPath } of CYBERIA_INSTANCE_PROJECTS)
5836
- shellExec(`node bin run instance-build-manifest 'dd-cyberia,${id},${rootPath}' ${flags}`);
5699
+ for (const id of CYBERIA_INSTANCE_IDS)
5700
+ shellExec(`node bin run instance-build-manifest --deploy-id dd-cyberia --instance-id ${id} ${flags}`);
5837
5701
  }
5838
5702
 
5839
5703
  // Copy canonical doc sources into the generated project READMEs.
5840
5704
  // Edit the canonical sources; never hand-edit these generated outputs.
5841
- // The mirrored deploy tree is a generated artifact, rebuilt from scratch so a file
5842
- // dropped or renamed upstream cannot linger in the published repo. It reproduces the
5843
- // engine layout exactly — the <deploy-id> directory beside lib/ because every deploy
5844
- // script sources `$SCRIPT_DIR/../lib/logging.sh`, which only resolves when lib/ is the
5845
- // script directory's sibling there too.
5705
+ // The mirrored deploy tree is generated, and rebuilt from scratch so a file
5706
+ // renamed upstream cannot linger. It keeps the engine layout, the
5707
+ // <deploy-id> directory beside lib/, because every deploy script sources
5708
+ // `$SCRIPT_DIR/../lib/logging.sh`.
5846
5709
  for (const project of ['cyberia-client', 'cyberia-server']) {
5847
5710
  const scripts = `./deploy/${project}`;
5848
5711
  // A tree assembled before these scripts were packaged does not carry them; mirroring is
@@ -5866,9 +5729,8 @@ node bin image --path cyberia-client \
5866
5729
  './cyberia-server/.github/workflows/cyberia-server.cd.yml',
5867
5730
  );
5868
5731
  shellExec('cp -a ./engine-private/conf/dd-cyberia/docker-compose/cyberia/. ./src/runtime/engine-cyberia/');
5869
- // The publish is scoped to the deployment this workflow builds — every other step here is —
5870
- // so it reads dd-cyberia's own environment instead of the working-tree `./.env`, which names
5871
- // whichever deployment `app load` ran for last.
5732
+ // Scope the publish to the deployment this workflow builds: read
5733
+ // dd-cyberia's own environment, not the working-tree `./.env`.
5872
5734
  shellExec(
5873
5735
  `node bin/cyberia.js instance --publish-build --env-path ${deployEnvFilePath(
5874
5736
  'dd-cyberia',
@@ -5885,6 +5747,7 @@ node bin image --path cyberia-client \
5885
5747
  if (options.dryRun) {
5886
5748
  shellExec('node bin cmt --log --unpush cyberia-server');
5887
5749
  shellExec('node bin cmt --log --unpush cyberia-client');
5750
+ shellExec('node bin cmt --log --unpush cyberia-audio');
5888
5751
  shellExec('node bin cmt --log --unpush');
5889
5752
  shellExec('node bin cmt --log --unpush ../cyberia-instances');
5890
5753
  } else {
@@ -5897,6 +5760,9 @@ node bin image --path cyberia-client \
5897
5760
  shellExec('node bin push cyberia-client underpostnet/cyberia-client', {
5898
5761
  silentOnError: true,
5899
5762
  });
5763
+ shellExec('node bin push cyberia-audio underpostnet/cyberia-audio', {
5764
+ silentOnError: true,
5765
+ });
5900
5766
  shellExec('node bin run template-deploy', {
5901
5767
  silentOnError: true,
5902
5768
  });
@@ -5995,15 +5861,23 @@ node bin image --path cyberia-client \
5995
5861
 
5996
5862
  await program.parseAsync();
5997
5863
  } catch (error) {
5998
- // ONLY reroute on the explicit passthrough sentinel. Any other thrown
5999
- // error (subprocess non-zero from shellExec's fail-fast default, CLI
6000
- // parse errors, missing modules) must propagate as a non-zero process
6001
- // exit so GitHub Actions / CI parents observe the failure. Without this
6002
- // guard, a genuine build failure was being silently rerouted into the
6003
- // underpost CLI and then masked behind a misleading "unknown command"
6004
- // line.
5864
+ // Reroute only on the passthrough sentinel. Every other error — a non-zero
5865
+ // subprocess, a CLI parse error, a missing module must exit non-zero, so a
5866
+ // CI parent sees the failure.
6005
5867
  if (error && error.message === 'Trigger underpost passthrough') {
6006
- process.argv = process.argv.filter((c) => c !== 'underpost');
5868
+ // A redundant CLI name can only appear before the command; everything from the command
5869
+ // onward is an argument. Filtering the whole of argv removed those too, so an option whose
5870
+ // value happens to be `underpost` — a storage id, a public asset path — lost it, and the
5871
+ // parse failed on a missing argument rather than on anything the caller wrote.
5872
+ const commandIndex = process.argv.findIndex(
5873
+ (token, index) => index >= 2 && underpostProgram.commands.some((command) => command._name === token),
5874
+ );
5875
+ if (commandIndex > 2)
5876
+ process.argv = [
5877
+ ...process.argv.slice(0, 2),
5878
+ ...process.argv.slice(2, commandIndex).filter((token) => token !== 'underpost'),
5879
+ ...process.argv.slice(commandIndex),
5880
+ ];
6007
5881
  if (!process.argv.includes('--plain')) logger.info('Rerouting to underpost cli...');
6008
5882
  try {
6009
5883
  await underpostProgram.parseAsync();