@underpostnet/cyberia 3.3.0 → 3.3.76

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 (568) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +17 -5
  3. package/.github/workflows/coverall.cyberia.ci.yml +39 -14
  4. package/.github/workflows/cyberia-client.cd.yml +5 -0
  5. package/.github/workflows/cyberia-server.cd.yml +5 -0
  6. package/.github/workflows/docker-image.cyberia-client.ci.yml +8 -5
  7. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +8 -3
  8. package/.github/workflows/docker-image.cyberia-server.ci.yml +8 -5
  9. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +8 -5
  10. package/.github/workflows/docker-image.engine-cyberia.ci.yml +15 -1
  11. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +15 -1
  12. package/.github/workflows/engine-cyberia.cd.yml +3 -0
  13. package/.github/workflows/engine-cyberia.ci.yml +4 -5
  14. package/.github/workflows/ghpkg.ci.yml +35 -20
  15. package/.github/workflows/hardhat.ci.yml +12 -11
  16. package/.github/workflows/npmpkg.ci.yml +17 -15
  17. package/.github/workflows/publish.cyberia.ci.yml +44 -0
  18. package/.github/workflows/pwa-microservices-template-test.ci.yml +2 -2
  19. package/.github/workflows/release.cd.yml +1 -0
  20. package/.prettierignore +1 -0
  21. package/AGENTS.md +96 -0
  22. package/CHANGELOG.md +653 -2513
  23. package/CLI-HELP.md +323 -157
  24. package/Dockerfile +25 -9
  25. package/Dockerfile.dev +25 -9
  26. package/Dockerfile.test +5 -5
  27. package/README.md +48 -28
  28. package/bin/build.js +86 -41
  29. package/bin/build.template.js +8 -3
  30. package/bin/cyberia.js +1494 -1403
  31. package/bin/deploy.js +18 -104
  32. package/bin/index.js +1494 -1403
  33. package/compose.env +17 -5
  34. package/conf.js +98 -7
  35. package/deploy/cyberia-client/deploy.sh +46 -0
  36. package/deploy/cyberia-client/package.sh +17 -0
  37. package/deploy/cyberia-client/state.sh +15 -0
  38. package/deploy/cyberia-server/deploy.sh +46 -0
  39. package/deploy/cyberia-server/package.sh +17 -0
  40. package/deploy/cyberia-server/state.sh +15 -0
  41. package/deploy/dd-cyberia/deploy.sh +9 -29
  42. package/deploy/dd-cyberia/init.sh +38 -45
  43. package/deploy/dd-cyberia/package.sh +16 -0
  44. package/deploy/dd-cyberia/state.sh +14 -0
  45. package/deploy/dd-cyberia/sync-deploy.sh +133 -147
  46. package/deploy/lib/config.sh +29 -0
  47. package/deploy/lib/github-actions-logging.sh +610 -0
  48. package/deploy/lib/host.sh +194 -0
  49. package/deploy/lib/state.sh +65 -0
  50. package/deploy/pwa-microservices-template/deploy.sh +17 -42
  51. package/deploy/release/deploy.sh +9 -41
  52. package/deployment.yaml +7 -2
  53. package/docker-compose.yml +23 -17
  54. package/examples/static-page/README.md +19 -19
  55. package/examples/static-page/ssr-components/CustomPage.js +2 -2
  56. package/hardhat/hardhat.config.js +1 -6
  57. package/hardhat/package-lock.json +101 -40
  58. package/hardhat/package.json +2 -2
  59. package/manifests/alertmanager/deployment.yaml +81 -0
  60. package/manifests/blackbox-exporter/deployment.yaml +75 -0
  61. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +32 -7
  62. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +32 -7
  63. package/manifests/deployment/dd-cyberia-development/deployment.yaml +7 -2
  64. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  65. package/manifests/deployment/mongo-express/deployment.yaml +46 -27
  66. package/manifests/deployment/mongo-express/kustomization.yaml +9 -0
  67. package/manifests/deployment/mongo-express/mongo-express-nodeport.yaml +18 -0
  68. package/manifests/deployment/mongo-express/service.yaml +16 -0
  69. package/manifests/deployment/mongo-express-no-auth/disable-mongodb-auth.yaml +16 -0
  70. package/manifests/deployment/mongo-express-no-auth/kustomization.yaml +16 -0
  71. package/manifests/grafana/deployment.yaml +26 -7
  72. package/manifests/grafana/kustomization.yaml +1 -0
  73. package/manifests/grafana/service.yaml +6 -1
  74. package/manifests/mariadb/pv.yaml +1 -0
  75. package/manifests/mongodb/statefulset.yaml +9 -2
  76. package/manifests/mysql/pv-pvc.yaml +14 -1
  77. package/manifests/postgresql/pv.yaml +12 -1
  78. package/manifests/postgresql/pvc.yaml +1 -0
  79. package/manifests/prometheus/deployment.yaml +18 -22
  80. package/package.json +87 -12
  81. package/scripts/event-monitor.sh +56 -0
  82. package/scripts/k3s-node-setup.sh +27 -14
  83. package/scripts/kubeadm-node-setup.sh +31 -11
  84. package/scripts/maas-setup.sh +8 -8
  85. package/scripts/selinux-normalize.sh +907 -0
  86. package/scripts/shutdown-machine.sh +843 -0
  87. package/scripts/test-monitor.sh +5 -5
  88. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +15 -10
  89. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +36 -5
  90. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +7 -2
  91. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +90 -186
  92. package/src/api/core/core.controller.js +1 -1
  93. package/src/api/core/core.router.js +2 -2
  94. package/src/api/core/core.service.js +2 -2
  95. package/src/api/crypto/crypto.controller.js +1 -1
  96. package/src/api/crypto/crypto.service.js +1 -1
  97. package/src/api/cyberia-action/cyberia-action.controller.js +1 -1
  98. package/src/api/cyberia-action/cyberia-action.router.js +3 -3
  99. package/src/api/cyberia-action/cyberia-action.service.js +5 -6
  100. package/src/api/cyberia-audio/cyberia-audio.controller.js +6 -0
  101. package/src/api/cyberia-audio/cyberia-audio.model.js +61 -0
  102. package/src/api/cyberia-audio/cyberia-audio.router.js +21 -0
  103. package/src/api/cyberia-audio/cyberia-audio.service.js +220 -0
  104. package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +2 -4
  105. package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +10 -19
  106. package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +13 -35
  107. package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +17 -34
  108. package/src/api/cyberia-dialogue/cyberia-dialogue.controller.js +1 -1
  109. package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +1 -1
  110. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +2 -2
  111. package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +2 -2
  112. package/src/api/cyberia-entity/cyberia-entity.controller.js +1 -1
  113. package/src/api/cyberia-entity/cyberia-entity.model.js +2 -0
  114. package/src/api/cyberia-entity/cyberia-entity.router.js +1 -1
  115. package/src/api/cyberia-entity/cyberia-entity.service.js +3 -3
  116. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +8 -2
  117. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +31 -20
  118. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +17 -2
  119. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +468 -5
  120. package/src/api/cyberia-instance/cyberia-fallback-capture.js +502 -0
  121. package/src/api/cyberia-instance/cyberia-fallback-world.js +29 -40
  122. package/src/api/cyberia-instance/cyberia-instance-boot.service.js +5 -5
  123. package/src/api/cyberia-instance/cyberia-instance-items.js +98 -0
  124. package/src/api/cyberia-instance/cyberia-instance-map.service.js +35 -142
  125. package/src/api/cyberia-instance/cyberia-instance.controller.js +2 -5
  126. package/src/api/cyberia-instance/cyberia-instance.model.js +0 -14
  127. package/src/api/cyberia-instance/cyberia-instance.router.js +5 -18
  128. package/src/api/cyberia-instance/cyberia-instance.service.js +5 -45
  129. package/src/api/cyberia-instance/cyberia-world-generator.js +8 -13
  130. package/src/api/cyberia-instance-conf/cyberia-instance-conf.controller.js +1 -1
  131. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +49 -75
  132. package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +1 -1
  133. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +76 -3
  134. package/src/api/cyberia-map/cyberia-map.controller.js +1 -1
  135. package/src/api/cyberia-map/cyberia-map.router.js +1 -1
  136. package/src/api/cyberia-map/cyberia-map.service.js +8 -4
  137. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.controller.js +31 -0
  138. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.model.js +56 -0
  139. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.router.js +33 -0
  140. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +189 -0
  141. package/src/api/cyberia-quest/cyberia-quest.controller.js +1 -1
  142. package/src/api/cyberia-quest/cyberia-quest.router.js +4 -4
  143. package/src/api/cyberia-quest/cyberia-quest.service.js +5 -5
  144. package/src/api/cyberia-quest-progress/cyberia-quest-progress.controller.js +1 -1
  145. package/src/api/cyberia-quest-progress/cyberia-quest-progress.model.js +1 -1
  146. package/src/api/cyberia-quest-progress/cyberia-quest-progress.router.js +1 -1
  147. package/src/api/cyberia-quest-progress/cyberia-quest-progress.service.js +2 -2
  148. package/src/api/cyberia-saga/cyberia-saga.controller.js +1 -1
  149. package/src/api/cyberia-saga/cyberia-saga.router.js +1 -1
  150. package/src/api/cyberia-saga/cyberia-saga.service.js +2 -2
  151. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +403 -214
  152. package/src/api/cyberia-skill/cyberia-skill.controller.js +1 -1
  153. package/src/api/cyberia-skill/cyberia-skill.model.js +6 -6
  154. package/src/api/cyberia-skill/cyberia-skill.router.js +1 -1
  155. package/src/api/cyberia-skill/cyberia-skill.service.js +2 -2
  156. package/src/api/default/default.controller.js +1 -1
  157. package/src/api/default/default.router.js +1 -1
  158. package/src/api/default/default.service.js +2 -2
  159. package/src/api/document/document.controller.js +1 -1
  160. package/src/api/document/document.router.js +1 -1
  161. package/src/api/document/document.service.js +2 -2
  162. package/src/api/file/file.controller.js +1 -1
  163. package/src/api/file/file.ref.js +153 -0
  164. package/src/api/file/file.ref.json +11 -2
  165. package/src/api/file/file.router.js +1 -1
  166. package/src/api/file/file.service.js +2 -2
  167. package/src/api/instance/instance.controller.js +1 -1
  168. package/src/api/instance/instance.router.js +1 -1
  169. package/src/api/instance/instance.service.js +2 -2
  170. package/src/api/ipfs/ipfs.controller.js +1 -1
  171. package/src/api/ipfs/ipfs.router.js +2 -2
  172. package/src/api/ipfs/ipfs.service.js +2 -2
  173. package/src/api/object-layer/object-layer.controller.js +1 -1
  174. package/src/api/object-layer/object-layer.model.js +47 -12
  175. package/src/api/object-layer/object-layer.router.js +12 -5
  176. package/src/api/object-layer/object-layer.service.js +116 -123
  177. package/src/api/object-layer-render-frames/object-layer-render-frames.controller.js +1 -1
  178. package/src/api/object-layer-render-frames/object-layer-render-frames.router.js +1 -1
  179. package/src/api/object-layer-render-frames/object-layer-render-frames.service.js +2 -2
  180. package/src/api/test/test.controller.js +1 -1
  181. package/src/api/test/test.service.js +1 -1
  182. package/src/api/user/guest.service.js +3 -3
  183. package/src/api/user/user.controller.js +1 -1
  184. package/src/api/user/user.router.js +2 -2
  185. package/src/api/user/user.service.js +4 -4
  186. package/src/api.js +5 -9
  187. package/src/cli/app.js +402 -0
  188. package/src/cli/baremetal.js +30 -35
  189. package/src/cli/client.js +306 -0
  190. package/src/cli/cloud-init.js +3 -3
  191. package/src/cli/cluster.js +191 -82
  192. package/src/cli/db.js +120 -68
  193. package/src/cli/deploy.js +157 -75
  194. package/src/cli/docker-compose.js +5 -5
  195. package/src/cli/domains.js +184 -0
  196. package/src/cli/dotenv-store.js +143 -0
  197. package/src/cli/event.js +2281 -0
  198. package/src/cli/fs-selection.js +115 -0
  199. package/src/cli/fs.js +159 -343
  200. package/src/cli/host.js +672 -0
  201. package/src/cli/image.js +4 -6
  202. package/src/cli/index.js +343 -151
  203. package/src/cli/ipfs.js +34 -30
  204. package/src/cli/kickstart.js +1 -1
  205. package/src/cli/kubectl.js +218 -41
  206. package/src/cli/lxd.js +3 -3
  207. package/src/cli/monitor.js +1359 -26
  208. package/src/cli/package.js +90 -0
  209. package/src/cli/release.js +16 -12
  210. package/src/cli/repository.js +586 -363
  211. package/src/cli/run.js +398 -843
  212. package/src/cli/secrets.js +2158 -964
  213. package/src/cli/ssh.js +478 -279
  214. package/src/cli/state.js +394 -0
  215. package/src/cli/static.js +2 -2
  216. package/src/cli/system.js +1 -1
  217. package/src/cli/test.js +203 -125
  218. package/src/cli/vultr.js +61 -21
  219. package/src/cli/wireguard.js +1112 -306
  220. package/src/client/components/core/CalendarCore.js +53 -96
  221. package/src/client/components/core/Docs.js +327 -303
  222. package/src/client/components/core/FileExplorer.js +0 -21
  223. package/src/client/components/core/Modal.js +56 -1
  224. package/src/client/components/core/PanelForm.js +4 -4
  225. package/src/client/components/core/Repository.js +56 -0
  226. package/src/client/components/core/Translate.js +7 -0
  227. package/src/client/components/core/{FullScreen.js → ViewModeController.js} +42 -39
  228. package/src/client/components/core/Wallet.js +0 -11
  229. package/src/client/components/core/Worker.js +7 -1
  230. package/src/client/components/cryptokoyn/SettingsCryptokoyn.js +2 -2
  231. package/src/client/components/cyberia/ActionEngineCyberia.js +12 -36
  232. package/src/client/components/cyberia/EntityEngineCyberia.js +492 -121
  233. package/src/client/components/cyberia/InstanceEngineCyberia.js +129 -160
  234. package/src/client/components/cyberia/InstanceSelectionView.js +7 -1
  235. package/src/client/components/cyberia/MapEngineCyberia.js +175 -69
  236. package/src/client/components/cyberia/ObjectLayerEngine.js +1011 -97
  237. package/src/client/components/cyberia/ObjectLayerEngineModal.js +81 -155
  238. package/src/client/components/cyberia/ObjectLayerEngineViewer.js +113 -98
  239. package/src/client/components/cyberia/SharedDefaultsCyberia.js +215 -56
  240. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +2 -35
  241. package/src/client/components/cyberia-portal/CssCyberiaPortal.js +1 -1
  242. package/src/client/components/cyberia-portal/MainBodyCyberiaPortal.js +712 -76
  243. package/src/client/components/cyberia-portal/SettingsCyberiaPortal.js +2 -2
  244. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +0 -4
  245. package/src/client/components/default/AppShellDefault.js +1 -1
  246. package/src/client/components/default/SettingsDefault.js +2 -2
  247. package/src/client/components/itemledger/SettingsItemledger.js +2 -2
  248. package/src/client/components/underpost/AppShellUnderpost.js +3 -2
  249. package/src/client/components/underpost/SettingsUnderpost.js +2 -2
  250. package/src/client/public/cyberia-docs/ARCHITECTURE.md +15 -15
  251. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +313 -30
  252. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +51 -29
  253. package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +52 -53
  254. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +16 -11
  255. package/src/client/public/cyberia-docs/CYBERIA.md +48 -28
  256. package/src/client/public/cyberia-docs/ENTITY-PROFILE.md +11 -5
  257. package/src/client/public/cyberia-docs/HARDHAT-MODULE.md +23 -6
  258. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  259. package/src/client/public/cyberia-docs/STATS-PROGRESSION.md +217 -0
  260. package/src/client/public/cyberia-docs/WHITE-PAPER.md +20 -12
  261. package/src/client/public/default/android-chrome-144x144.png +0 -0
  262. package/src/client/public/default/android-chrome-192x192.png +0 -0
  263. package/src/client/public/default/android-chrome-256x256.png +0 -0
  264. package/src/client/public/default/android-chrome-36x36.png +0 -0
  265. package/src/client/public/default/android-chrome-384x384.png +0 -0
  266. package/src/client/public/default/android-chrome-48x48.png +0 -0
  267. package/src/client/public/default/android-chrome-512x512.png +0 -0
  268. package/src/client/public/default/android-chrome-72x72.png +0 -0
  269. package/src/client/public/default/android-chrome-96x96.png +0 -0
  270. package/src/client/public/default/apple-touch-icon-1024x1024.png +0 -0
  271. package/src/client/public/default/apple-touch-icon-114x114.png +0 -0
  272. package/src/client/public/default/apple-touch-icon-120x120.png +0 -0
  273. package/src/client/public/default/apple-touch-icon-144x144.png +0 -0
  274. package/src/client/public/default/apple-touch-icon-152x152.png +0 -0
  275. package/src/client/public/default/apple-touch-icon-167x167.png +0 -0
  276. package/src/client/public/default/apple-touch-icon-180x180.png +0 -0
  277. package/src/client/public/default/apple-touch-icon-57x57.png +0 -0
  278. package/src/client/public/default/apple-touch-icon-60x60.png +0 -0
  279. package/src/client/public/default/apple-touch-icon-72x72.png +0 -0
  280. package/src/client/public/default/apple-touch-icon-76x76.png +0 -0
  281. package/src/client/public/default/apple-touch-icon-precomposed.png +0 -0
  282. package/src/client/public/default/apple-touch-icon.png +0 -0
  283. package/src/client/public/default/apple-touch-startup-image-1125x2436.png +0 -0
  284. package/src/client/public/default/apple-touch-startup-image-1136x640.png +0 -0
  285. package/src/client/public/default/apple-touch-startup-image-1170x2532.png +0 -0
  286. package/src/client/public/default/apple-touch-startup-image-1179x2556.png +0 -0
  287. package/src/client/public/default/apple-touch-startup-image-1242x2208.png +0 -0
  288. package/src/client/public/default/apple-touch-startup-image-1242x2688.png +0 -0
  289. package/src/client/public/default/apple-touch-startup-image-1284x2778.png +0 -0
  290. package/src/client/public/default/apple-touch-startup-image-1290x2796.png +0 -0
  291. package/src/client/public/default/apple-touch-startup-image-1334x750.png +0 -0
  292. package/src/client/public/default/apple-touch-startup-image-1488x2266.png +0 -0
  293. package/src/client/public/default/apple-touch-startup-image-1536x2048.png +0 -0
  294. package/src/client/public/default/apple-touch-startup-image-1620x2160.png +0 -0
  295. package/src/client/public/default/apple-touch-startup-image-1640x2160.png +0 -0
  296. package/src/client/public/default/apple-touch-startup-image-1668x2224.png +0 -0
  297. package/src/client/public/default/apple-touch-startup-image-1668x2388.png +0 -0
  298. package/src/client/public/default/apple-touch-startup-image-1792x828.png +0 -0
  299. package/src/client/public/default/apple-touch-startup-image-2048x1536.png +0 -0
  300. package/src/client/public/default/apple-touch-startup-image-2048x2732.png +0 -0
  301. package/src/client/public/default/apple-touch-startup-image-2160x1620.png +0 -0
  302. package/src/client/public/default/apple-touch-startup-image-2160x1640.png +0 -0
  303. package/src/client/public/default/apple-touch-startup-image-2208x1242.png +0 -0
  304. package/src/client/public/default/apple-touch-startup-image-2224x1668.png +0 -0
  305. package/src/client/public/default/apple-touch-startup-image-2266x1488.png +0 -0
  306. package/src/client/public/default/apple-touch-startup-image-2388x1668.png +0 -0
  307. package/src/client/public/default/apple-touch-startup-image-2436x1125.png +0 -0
  308. package/src/client/public/default/apple-touch-startup-image-2532x1170.png +0 -0
  309. package/src/client/public/default/apple-touch-startup-image-2556x1179.png +0 -0
  310. package/src/client/public/default/apple-touch-startup-image-2688x1242.png +0 -0
  311. package/src/client/public/default/apple-touch-startup-image-2732x2048.png +0 -0
  312. package/src/client/public/default/apple-touch-startup-image-2778x1284.png +0 -0
  313. package/src/client/public/default/apple-touch-startup-image-2796x1290.png +0 -0
  314. package/src/client/public/default/apple-touch-startup-image-640x1136.png +0 -0
  315. package/src/client/public/default/apple-touch-startup-image-750x1334.png +0 -0
  316. package/src/client/public/default/apple-touch-startup-image-828x1792.png +0 -0
  317. package/src/client/public/default/assets/background/white.jpg +0 -0
  318. package/src/client/public/default/browserconfig.xml +1 -1
  319. package/src/client/public/default/favicon-16x16.png +0 -0
  320. package/src/client/public/default/favicon-32x32.png +0 -0
  321. package/src/client/public/default/favicon-48x48.png +0 -0
  322. package/src/client/public/default/favicon.ico +0 -0
  323. package/src/client/public/default/manifest.webmanifest +5 -5
  324. package/src/client/public/default/mstile-144x144.png +0 -0
  325. package/src/client/public/default/mstile-150x150.png +0 -0
  326. package/src/client/public/default/mstile-310x150.png +0 -0
  327. package/src/client/public/default/mstile-310x310.png +0 -0
  328. package/src/client/public/default/mstile-70x70.png +0 -0
  329. package/src/client/public/default/yandex-browser-50x50.png +0 -0
  330. package/src/client/public/default/yandex-browser-manifest.json +1 -1
  331. package/src/client/services/cyberia-audio/cyberia-audio.service.js +99 -0
  332. package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +38 -0
  333. package/src/client/services/cyberia-instance/cyberia-instance.management.js +5 -4
  334. package/src/client/services/cyberia-instance/cyberia-instance.service.js +0 -40
  335. package/src/client/services/cyberia-map/cyberia-map.management.js +5 -4
  336. package/src/client/services/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +138 -0
  337. package/src/client/services/object-layer/object-layer.management.js +6 -10
  338. package/src/client/services/object-layer/object-layer.service.js +3 -1
  339. package/src/client/ssr/RootDocument.js +1 -1
  340. package/src/client/ssr/body/404.js +1 -1
  341. package/src/client/ssr/body/500.js +1 -1
  342. package/src/client/ssr/body/CacheControl.js +1 -1
  343. package/src/client/ssr/body/CyberiaDefaultSplashScreen.js +1 -1
  344. package/src/client/ssr/body/DefaultSplashScreen.js +1 -1
  345. package/src/client/ssr/body/SwaggerDarkMode.js +1 -1
  346. package/src/client/ssr/body/UnderpostDefaultSplashScreen.js +1 -1
  347. package/src/client/ssr/head/CryptokoynScripts.js +1 -1
  348. package/src/client/ssr/head/Css.js +1 -1
  349. package/src/client/ssr/head/CyberiaPortalScripts.js +1 -1
  350. package/src/client/ssr/head/DefaultScripts.js +1 -1
  351. package/src/client/ssr/head/ItemledgerScripts.js +1 -1
  352. package/src/client/ssr/head/Microdata.js +1 -1
  353. package/src/client/ssr/head/Production.js +1 -1
  354. package/src/client/ssr/head/Pwa.js +1 -1
  355. package/src/client/ssr/head/PwaItemledger.js +1 -1
  356. package/src/client/ssr/head/Seo.js +1 -1
  357. package/src/client/ssr/head/UnderpostScripts.js +1 -1
  358. package/src/client/ssr/mailer/DefaultRecoverEmail.js +1 -1
  359. package/src/client/ssr/mailer/DefaultVerifyEmail.js +1 -1
  360. package/src/client/ssr/views/Cyberia404.js +7 -5
  361. package/src/client/ssr/views/CyberiaServerMetrics.js +1 -1
  362. package/src/client/ssr/views/Maintenance.js +1 -1
  363. package/src/client/ssr/views/NoNetworkConnection.js +1 -1
  364. package/src/client/ssr/views/Test.js +1 -1
  365. package/src/client/sw/core.sw.js +20 -14
  366. package/src/client-builder/client-build-docs.js +92 -77
  367. package/src/client-builder/client-build-live.js +2 -2
  368. package/src/client-builder/client-build.js +105 -74
  369. package/src/client-builder/client-bundle.js +347 -0
  370. package/src/client-builder/client-dev-server.js +3 -3
  371. package/src/client-builder/client-icons.js +2 -2
  372. package/src/client-builder/ssr.js +3 -3
  373. package/src/client.build.js +3 -6
  374. package/src/client.dev.js +3 -6
  375. package/src/db/DataBaseProvider.js +186 -3
  376. package/src/db/mariadb/MariaDB.js +3 -2
  377. package/src/db/mongo/MongoBootstrap.js +273 -101
  378. package/src/db/mongo/MongoExpress.js +187 -0
  379. package/src/db/mongo/MongooseDB.js +7 -8
  380. package/src/{server/valkey.js → db/valkey/Valkey.js} +5 -4
  381. package/src/grpc/cyberia/grpc-server.js +6 -8
  382. package/src/index.js +79 -23
  383. package/src/mailer/EmailRender.js +2 -2
  384. package/src/mailer/MailerInterceptor.js +87 -0
  385. package/src/mailer/MailerProvider.js +50 -6
  386. package/src/projects/cyberia/atlas-sprite-sheet-generator.js +144 -147
  387. package/src/projects/cyberia/atlas-sprite-sheet-store.js +391 -0
  388. package/src/projects/cyberia/besu-genesis-generator.js +8 -17
  389. package/src/projects/cyberia/boot-contract-fixtures.js +32 -0
  390. package/src/projects/cyberia/catalog-cyberia.js +54 -10
  391. package/src/projects/cyberia/gemini-client.js +4 -4
  392. package/src/projects/cyberia/generate-saga.js +5 -12
  393. package/src/projects/cyberia/hot-reload-trigger.js +18 -29
  394. package/src/projects/cyberia/instance-backup.js +210 -0
  395. package/src/projects/cyberia/instance-data.js +178 -258
  396. package/src/projects/cyberia/ipfs-client.js +4 -4
  397. package/src/projects/cyberia/map-preview-generator.js +33 -69
  398. package/src/projects/cyberia/object-layer.js +153 -320
  399. package/src/projects/cyberia/seed-audio.js +139 -0
  400. package/src/projects/cyberia/semantic-layer-generator-skin.js +2 -8
  401. package/src/projects/cyberia/semantic-layer-generator.js +3 -19
  402. package/src/projects/cyberia/stat-balance.js +81 -0
  403. package/src/projects/cyberia/stat-commands.js +23 -0
  404. package/src/projects/cyberia/stat-contract-generator.js +101 -0
  405. package/src/projects/underpost/catalog-underpost.js +9 -1
  406. package/src/proxy.js +4 -7
  407. package/src/runtime/cyberia-client/Dockerfile +17 -8
  408. package/src/runtime/cyberia-client/Dockerfile.dev +13 -4
  409. package/src/runtime/cyberia-server/Dockerfile +18 -6
  410. package/src/runtime/cyberia-server/Dockerfile.dev +16 -3
  411. package/src/runtime/engine-cyberia/Dockerfile +25 -9
  412. package/src/runtime/engine-cyberia/Dockerfile.dev +25 -9
  413. package/src/runtime/engine-cyberia/Dockerfile.test +5 -5
  414. package/src/runtime/engine-cyberia/compose.env +17 -5
  415. package/src/runtime/engine-cyberia/docker-compose.yml +23 -17
  416. package/src/runtime/express/Express.js +32 -27
  417. package/src/runtime/lampp/Lampp.js +22 -70
  418. package/src/runtime/nginx/Nginx.js +1 -1
  419. package/src/runtime/wp/Wp.js +16 -48
  420. package/src/server/{catalog.js → build/catalog.js} +15 -5
  421. package/src/server/build/coverage.js +188 -0
  422. package/src/server/build/execution.js +402 -0
  423. package/src/server/build/package.js +508 -0
  424. package/src/server/build/testing.js +578 -0
  425. package/src/server/{dns.js → network/dns.js} +144 -11
  426. package/src/server/{forward-proxy.js → network/forward-proxy.js} +10 -18
  427. package/src/server/{middlewares.js → network/middlewares.js} +11 -5
  428. package/src/server/network/node-capability.js +98 -0
  429. package/src/server/{peer.js → network/peer.js} +3 -3
  430. package/src/server/{proxy.js → network/proxy.js} +7 -13
  431. package/src/server/network/router.js +356 -0
  432. package/src/server/{tls.js → network/tls.js} +2 -2
  433. package/src/server/{underpost-compression.js → network/underpost-compression.js} +1 -1
  434. package/src/server/{underpost-gateway.js → network/underpost-gateway.js} +7 -7
  435. package/src/server/{underpost-ingress.js → network/underpost-ingress.js} +2 -2
  436. package/src/server/{cri.js → ops/cri.js} +2 -2
  437. package/src/server/{cron.js → ops/cron.js} +349 -74
  438. package/src/server/ops/event-notification.js +284 -0
  439. package/src/server/{logger.js → ops/logger.js} +143 -23
  440. package/src/server/ops/monitoring.js +1724 -0
  441. package/src/server/{systemd.js → ops/systemd.js} +55 -1
  442. package/src/server/{conf.js → runtime/conf.js} +516 -423
  443. package/src/server/runtime/config-scope.js +215 -0
  444. package/src/server/runtime/environment.js +149 -0
  445. package/src/server/{process.js → runtime/process.js} +153 -12
  446. package/src/server/{runtime-status.js → runtime/runtime-status.js} +155 -16
  447. package/src/server/{runtime.js → runtime/runtime.js} +12 -8
  448. package/src/server/{start.js → runtime/start.js} +128 -39
  449. package/src/server/{auth.js → security/auth.js} +7 -8
  450. package/src/server/security/container-storage.js +382 -0
  451. package/src/server/{crypto.js → security/crypto.js} +1 -1
  452. package/src/server/{selinux.js → security/selinux.js} +1 -1
  453. package/src/server/{backup.js → storage/backup.js} +17 -8
  454. package/src/server/{data-query.js → storage/data-query.js} +17 -1
  455. package/src/server/storage/downloader.js +121 -0
  456. package/src/server/storage/repository.js +68 -0
  457. package/src/server/storage/zip.js +153 -0
  458. package/src/server.js +4 -7
  459. package/src/ws/IoInterface.js +1 -1
  460. package/src/ws/IoServer.js +1 -1
  461. package/src/ws/core/core.ws.connection.js +1 -1
  462. package/src/ws/core/core.ws.emit.js +1 -1
  463. package/src/ws/core/core.ws.server.js +1 -1
  464. package/src/ws/default/default.ws.connection.js +1 -1
  465. package/src/ws/default/default.ws.emit.js +1 -1
  466. package/src/ws/default/default.ws.server.js +1 -1
  467. package/test/e2e/event-e2e-public-ingress-down.js +45 -0
  468. package/test/e2e/event-e2e-wireguard-server-down.js +38 -0
  469. package/test/e2e/event-e2e-wireguard-spoke-down.js +40 -0
  470. package/test/{api.test.js → integration/app/api.test.js} +33 -14
  471. package/test/integration/app/cyberia/atlas-sprite-sheet-store.test.js +402 -0
  472. package/test/integration/app/cyberia/atlas-sprite-sheet.test.js +86 -0
  473. package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +64 -0
  474. package/test/integration/app/cyberia/cyberia-entity-type-default.test.js +842 -0
  475. package/test/integration/app/cyberia/cyberia-instance-conf-coerce.test.js +53 -0
  476. package/test/{cyberia-instance-conf-defaults.test.js → integration/app/cyberia/cyberia-instance-conf-defaults.test.js} +17 -23
  477. package/test/integration/app/cyberia/cyberia-instance-items.test.js +90 -0
  478. package/test/integration/app/cyberia/cyberia-load.test.js +466 -0
  479. package/test/integration/app/cyberia/cyberia-map-audio-conf.test.js +256 -0
  480. package/test/integration/app/cyberia/cyberia-stats.test.js +150 -0
  481. package/test/integration/app/cyberia/fallback-world-capture.test.js +338 -0
  482. package/test/integration/app/cyberia/object-layer-item-id.test.js +327 -0
  483. package/test/integration/app/cyberia/object-layer-item-selection.test.js +56 -0
  484. package/test/integration/app/cyberia/object-layer-natural-key.test.js +32 -0
  485. package/test/integration/app/cyberia/seed-audio.test.js +275 -0
  486. package/test/{shape-generator.test.js → integration/app/cyberia/shape-generator.test.js} +1 -1
  487. package/test/integration/infra/1-security/config-scope.test.js +305 -0
  488. package/test/integration/infra/1-security/container-storage.test.js +356 -0
  489. package/test/integration/infra/1-security/in-pod-cli-surface.test.js +86 -0
  490. package/test/integration/infra/1-security/secret-onboarding.test.js +930 -0
  491. package/test/{selinux.test.js → integration/infra/1-security/selinux.test.js} +2 -4
  492. package/test/{sops-secret-store.test.js → integration/infra/1-security/sops-secret-store.test.js} +415 -61
  493. package/test/integration/infra/1-security/systemd-service.test.js +70 -0
  494. package/test/integration/infra/1-security/underpost-config-secret.test.js +367 -0
  495. package/test/integration/infra/2-network/dns-firewall.test.js +566 -0
  496. package/test/integration/infra/2-network/node-capability.test.js +182 -0
  497. package/test/integration/infra/2-network/wireguard-cli.test.js +1187 -0
  498. package/test/{wireguard-edge.test.js → integration/infra/2-network/wireguard-edge.test.js} +649 -55
  499. package/test/{cluster-instances.test.js → integration/infra/3-cluster/cluster-instances.test.js} +11 -15
  500. package/test/{deploy-node-placement.test.js → integration/infra/3-cluster/deploy-node-placement.test.js} +3 -3
  501. package/test/integration/infra/3-cluster/docker-compose-stack.test.js +313 -0
  502. package/test/integration/infra/3-cluster/mongo-express-deploy.test.js +156 -0
  503. package/test/integration/infra/4-ingress/api-cross-origin.test.js +79 -0
  504. package/test/integration/infra/4-ingress/deploy-routes.test.js +379 -0
  505. package/test/integration/infra/4-ingress/gateway-static-assets.test.js +453 -0
  506. package/test/{instance-traffic-plan.test.js → integration/infra/4-ingress/instance-traffic-plan.test.js} +10 -6
  507. package/test/{underpost-gateway.test.js → integration/infra/4-ingress/underpost-gateway.test.js} +6 -6
  508. package/test/{underpost-ingress.test.js → integration/infra/4-ingress/underpost-ingress.test.js} +4 -4
  509. package/test/integration/infra/5-observability/cron-jobs.test.js +652 -0
  510. package/test/{deploy-monitor.test.js → integration/infra/5-observability/deploy-monitor.test.js} +29 -31
  511. package/test/integration/infra/5-observability/event-notification.test.js +197 -0
  512. package/test/integration/infra/5-observability/event-remediation.test.js +502 -0
  513. package/test/integration/infra/5-observability/event-targets.test.js +1174 -0
  514. package/test/integration/infra/5-observability/monitoring-stack.test.js +902 -0
  515. package/test/support/shell-harness.js +73 -0
  516. package/test/unit/build-template.test.js +97 -0
  517. package/test/unit/build-workflow-offline.test.js +132 -0
  518. package/test/unit/catalog.test.js +71 -0
  519. package/test/unit/client-build-docs.test.js +361 -0
  520. package/test/unit/client-bundle.test.js +313 -0
  521. package/test/unit/conf-loading.test.js +303 -0
  522. package/test/unit/conf-resolution.test.js +996 -0
  523. package/test/unit/coverage-artifact.test.js +221 -0
  524. package/test/{crypto.test.js → unit/crypto.test.js} +1 -1
  525. package/test/unit/cyberia/instance-backup.test.js +183 -0
  526. package/test/unit/cyberia/instance-object-layer-items.test.js +46 -0
  527. package/test/unit/cyberia/publish-workflow.test.js +22 -0
  528. package/test/unit/cyberia/stat-balance.test.js +69 -0
  529. package/test/unit/deploy-env-resolution.test.js +38 -0
  530. package/test/unit/deploy-log-table.test.js +518 -0
  531. package/test/unit/downloader-integrity.test.js +98 -0
  532. package/test/unit/execution-profiles.test.js +219 -0
  533. package/test/unit/file-reference-registry.test.js +141 -0
  534. package/test/unit/fleet-sync-source.test.js +149 -0
  535. package/test/unit/fs-storage-paths.test.js +596 -0
  536. package/test/unit/logger-redaction.test.js +141 -0
  537. package/test/unit/package.test.js +457 -0
  538. package/test/unit/prepare-host.test.js +382 -0
  539. package/test/unit/process-environment.test.js +18 -0
  540. package/test/unit/propagation-message.test.js +78 -0
  541. package/test/unit/redeploy-plan.test.js +24 -0
  542. package/test/unit/release-bump.test.js +474 -0
  543. package/test/unit/ssh-output-redaction.test.js +28 -0
  544. package/test/unit/start-options.test.js +46 -0
  545. package/test/unit/test-tiers.test.js +220 -0
  546. package/test/unit/zip-archive.test.js +142 -0
  547. package/vitest.config.js +66 -0
  548. package/.nycrc +0 -9
  549. package/deploy/lib/logging.sh +0 -96
  550. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +0 -48
  551. package/manifests/mongodb/pv-pvc.yaml +0 -59
  552. package/scripts/coverall-test-cyberia.sh +0 -24
  553. package/scripts/coverall-test.sh +0 -24
  554. package/scripts/link-local-underpost-cli.sh +0 -6
  555. package/src/api/cyberia-instance/cyberia-fallback-default-items.js +0 -63
  556. package/src/api/user/user.build.js +0 -16
  557. package/src/cli/env.js +0 -177
  558. package/src/client/components/core/Blockchain.js +0 -41
  559. package/src/client/components/core/Blog.js +0 -9
  560. package/src/client/components/core/KeyboardAvoidance.js +0 -145
  561. package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +0 -368
  562. package/src/client/public/default/assets/background/dark.jpg +0 -0
  563. package/src/client/public/default/assets/background/white0-min.jpg +0 -0
  564. package/src/client/public/default/assets/background/white0.jpg +0 -0
  565. package/src/client/public/doc/favicon.ico +0 -0
  566. package/src/client/public/doc/sitemap +0 -148
  567. package/src/server/downloader.js +0 -73
  568. package/src/server/environment.js +0 -98
package/bin/index.js CHANGED
@@ -10,22 +10,47 @@
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
- import { shellExec } from '../src/server/process.js';
17
- import { loggerFactory } from '../src/server/logger.js';
17
+ import { shellExec } from '../src/server/runtime/process.js';
18
+ import { cli } from '../src/server/build/execution.js';
19
+ import { loggerFactory } from '../src/server/ops/logger.js';
18
20
  import { generateBesuManifests, deployBesu, removeBesu } from '../src/projects/cyberia/besu-genesis-generator.js';
19
21
  import { DataBaseProviderService } from '../src/db/DataBaseProvider.js';
20
- import { etcHostFactory, loadConfServerJson, normalizeInstanceTopology } from '../src/server/conf.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';
35
+ import {
36
+ deployEnvFilePath,
37
+ etcHostFactory,
38
+ instanceProjectPathFactory,
39
+ loadConfServerJson,
40
+ normalizeInstanceTopology,
41
+ } from '../src/server/runtime/conf.js';
21
42
  import {
22
43
  ObjectLayerEngine,
23
44
  resolveCanonicalCid,
24
45
  pngDirectoryIteratorByObjectLayerType,
25
46
  buildImgFromTile,
26
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';
27
50
  import { getKeyframeDirectionsByCode } from '../src/client/components/cyberia/SharedDefaultsCyberia.js';
28
- 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';
29
54
  import {
30
55
  generateMultiFrame,
31
56
  lookupSemantic,
@@ -45,17 +70,23 @@ import {
45
70
  DefaultCyberiaActions,
46
71
  DefaultCyberiaQuests,
47
72
  ENTITY_TYPE_DEFAULTS,
48
- fillInstanceConfDefaults,
49
- DOCKER_SCRIPTS,
50
- CyberiaDependencies,
51
73
  } from '../src/api/cyberia-server-defaults/cyberia-server-defaults.js';
74
+ import cyberiaCatalog from '../src/projects/cyberia/catalog-cyberia.js';
52
75
 
53
76
  import {
54
77
  DEFAULT_INSTANCE_CODE,
55
78
  ITEM_TYPES as itemTypes,
56
79
  DefaultCyberiaItems,
57
80
  } from '../src/client/components/cyberia/SharedDefaultsCyberia.js';
58
- import { loadDeployCatalog } from '../src/server/catalog.js';
81
+ import { balanceStats, resolveStatBounds, statPolicyActive } from '../src/projects/cyberia/stat-balance.js';
82
+ import { loadDeployCatalog } from '../src/server/build/catalog.js';
83
+ import {
84
+ DEPLOY_MANIFEST_INDENT,
85
+ STAGED_CLI_PACKAGE,
86
+ buildDeployPackageJson,
87
+ deployPackagePathFactory,
88
+ stageCliPackage,
89
+ } from '../src/server/build/package.js';
59
90
 
60
91
  /**
61
92
  * Connect to the project MongoDB instance using the standard env / conf layout.
@@ -93,9 +124,193 @@ async function connectDbForChain({ envPath, mongoHost }) {
93
124
  return { ObjectLayer, host, path };
94
125
  }
95
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
+
96
213
  /** @type {Function} */
97
214
  const logger = loggerFactory(import.meta);
98
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
+
99
314
  const CYBERIA_DOCKER_HOST_ALIASES = ['cyberia-client', 'cyberia-server', 'engine-cyberia'];
100
315
 
101
316
  const installCyberiaDockerHostAliases = () => {
@@ -114,6 +329,7 @@ const installCyberiaDockerHostAliases = () => {
114
329
 
115
330
  try {
116
331
  const program = new Command();
332
+ registerStatCommands(program);
117
333
 
118
334
  /** @type {string} */
119
335
  const version = Underpost.version;
@@ -130,14 +346,52 @@ try {
130
346
  .command('ol [item-id]')
131
347
  .option(
132
348
  '--to-atlas-sprite-sheet [dim]',
133
- '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)',
134
350
  )
135
351
  .option('--show-atlas-sprite-sheet', 'Show consolidated atlas sprite sheet PNG for given item-id')
136
352
  .option(
137
353
  '--import',
138
- '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)',
139
394
  )
140
- .option('--import-types [object-layer-type]', 'Batch import by object layer type e.g. skin,floors or all')
141
395
  .option('--show-frame [direction-frame]', 'View object layer frame for given item-id e.g. 08_0 (default: 08_0)')
142
396
  .option('--generate', 'Generate procedural object layers from semantic item-id (e.g. floor-desert)')
143
397
  .option('--count <count>', 'Shape element count multiplier for --generate (default: 3)', parseFloat)
@@ -147,7 +401,6 @@ try {
147
401
  .option('--density <density>', 'Density factor 0..1 for --generate (default: 0.5)', parseFloat)
148
402
  .option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
149
403
  .option('--mongo-host <mongo-host>', 'Mongo host override')
150
- .option('--storage-file-path <storage-file-path>', 'Storage file path override')
151
404
  .option('--drop', 'Drop existing data before importing')
152
405
  .option('--client-public', 'When used with --drop, also remove static asset folders for dropped items')
153
406
  .option('--git-clean', 'When used with --drop, run underpost clean on the cyberia asset directory')
@@ -160,11 +413,18 @@ try {
160
413
  * @param {string|undefined} itemId - Optional item ID argument.
161
414
  * @param {Object} options - Command options parsed by Commander.
162
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.
163
424
  * @param {boolean|string} options.importTypes - Object layer types to batch import (e.g., 'all', 'skin,floor') or `false`.
164
425
  * @param {boolean|string} options.showFrame - Direction-frame string (e.g., '08_0') or `true` for default.
165
426
  * @param {string} options.envPath - Path to the `.env` file.
166
427
  * @param {string} options.mongoHost - MongoDB host override.
167
- * @param {string} options.storageFilePath - Path to a storage filter JSON file.
168
428
  * @param {boolean|string} options.toAtlasSpriteSheet - Atlas dimension or `true` for auto-calc.
169
429
  * @param {boolean} options.showAtlasSpriteSheet - Whether to display the atlas sprite sheet.
170
430
  * @param {boolean} options.drop - Whether to drop existing data before importing.
@@ -184,11 +444,16 @@ try {
184
444
  itemId,
185
445
  options = {
186
446
  import: false,
447
+ fromDirectory: false,
448
+ minify: false,
449
+ instance: '',
450
+ upscale: DEFAULT_ATLAS_UPSCALE_FACTOR,
187
451
  importTypes: false,
452
+ normalizeStats: false,
453
+ randomStats: false,
188
454
  showFrame: '',
189
455
  envPath: '',
190
456
  mongoHost: '',
191
- storageFilePath: '',
192
457
  toAtlasSpriteSheet: '',
193
458
  showAtlasSpriteSheet: false,
194
459
  drop: false,
@@ -203,6 +468,12 @@ try {
203
468
  density: 0.5,
204
469
  },
205
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
+
206
477
  if (!options.envPath) options.envPath = `./.env`;
207
478
  if (fs.existsSync(options.envPath)) dotenv.config({ path: options.envPath, override: true });
208
479
 
@@ -238,8 +509,21 @@ try {
238
509
  path,
239
510
  });
240
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
+
241
525
  await DataBaseProviderService.load({
242
- 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],
243
527
  host,
244
528
  path,
245
529
  db,
@@ -256,12 +540,47 @@ try {
256
540
  /** @type {import('mongoose').Model} */
257
541
  const Ipfs = DataBaseProviderService.getModel('ipfs', { host, path });
258
542
 
259
- // Idempotent repair, run only before flows that write: collapses legacy
260
- // duplicates and upgrades the data.item.id index to unique so every
261
- // later write has exactly one document to land on. Read-only
262
- // subcommands stay side-effect free findByItemId already resolves the
263
- // same canonical document whether or not duplicates are still present.
264
- 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
+ ) {
265
584
  const { removedIds, indexUpgraded } = await ObjectLayer.ensureUniqueItemIdIndex();
266
585
  if (removedIds.length > 0) logger.warn(`Removed ${removedIds.length} duplicate ObjectLayer document(s)`);
267
586
  if (indexUpgraded) logger.info('Upgraded data.item.id index to unique');
@@ -283,9 +602,9 @@ try {
283
602
  logger.info('Dropping ALL object layer data');
284
603
  }
285
604
 
286
- // 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.
287
607
  const olFilter = isTargetedDrop ? { 'data.item.id': { $in: dropItemIds } } : {};
288
- const atlasFilter = isTargetedDrop ? { 'metadata.itemKey': { $in: dropItemIds } } : {};
289
608
 
290
609
  // Collect data before deletion
291
610
  const olDocs = await ObjectLayer.find(olFilter, {
@@ -296,7 +615,6 @@ try {
296
615
  objectLayerRenderFramesId: 1,
297
616
  atlasSpriteSheetId: 1,
298
617
  }).lean();
299
- const atlasDocs = await AtlasSpriteSheet.find(atlasFilter, { fileId: 1, cid: 1 }).lean();
300
618
 
301
619
  const cidsToUnpin = new Set();
302
620
  const itemIdsToClean = new Set();
@@ -312,34 +630,36 @@ try {
312
630
  if (doc.atlasSpriteSheetId) atlasIds.push(doc.atlasSpriteSheetId);
313
631
  }
314
632
 
315
- const atlasFileIds = atlasDocs.map((a) => a.fileId).filter(Boolean);
316
- for (const atlas of atlasDocs) {
317
- if (atlas.cid) cidsToUnpin.add(atlas.cid);
318
- }
319
-
320
633
  const olCount = olDocs.length;
321
- const atlasCount = atlasDocs.length;
322
634
 
323
635
  // Delete targeted documents
324
636
  if (isTargetedDrop) {
325
637
  const olIds = olDocs.map((d) => d._id);
326
638
  if (olIds.length > 0) await ObjectLayer.deleteMany({ _id: { $in: olIds } });
327
639
  if (renderFrameIds.length > 0) await ObjectLayerRenderFrames.deleteMany({ _id: { $in: renderFrameIds } });
328
- if (atlasIds.length > 0) await AtlasSpriteSheet.deleteMany({ _id: { $in: atlasIds } });
329
640
  } else {
330
641
  await ObjectLayer.deleteMany();
331
642
  await ObjectLayerRenderFrames.deleteMany();
332
- await AtlasSpriteSheet.deleteMany();
333
643
  }
334
644
 
335
645
  const rfCount = renderFrameIds.length;
336
646
 
337
- // Remove only the File documents that were referenced by atlas sprite sheets
338
- let fileCount = 0;
339
- if (atlasFileIds.length > 0) {
340
- const result = await File.deleteMany({ _id: { $in: atlasFileIds } });
341
- fileCount = result.deletedCount || 0;
342
- }
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 } }));
343
663
 
344
664
  // Delete IPFS pin registry records for all collected CIDs
345
665
  if (cidsToUnpin.size > 0) {
@@ -366,7 +686,7 @@ try {
366
686
  `IPFS cleanup: ${unpinCount}/${cidsToUnpin.size} CIDs unpinned, ${mfsCount}/${itemIdsToClean.size} MFS paths removed`,
367
687
  );
368
688
  if (options.gitClean) {
369
- shellExec(`cd src/client/public/cyberia && underpost run clean .`);
689
+ shellExec(`cd src/client/public/cyberia && ${cli()} run clean .`);
370
690
  logger.info('Asset directory cleaned');
371
691
  }
372
692
 
@@ -392,374 +712,166 @@ try {
392
712
  }
393
713
  }
394
714
 
395
- /** @type {Object|null} */
396
- 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
+ });
397
729
 
398
- // ── Handle --import (specific item-id(s)) ─────────────────────
399
- if (options.import) {
400
- if (!itemId) {
401
- logger.error('item-id is required for --import (comma-separated item IDs, e.g. ol hatchet,sword --import)');
402
- 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
+ }
403
770
  }
404
771
 
405
- const itemIds = itemId
406
- .split(',')
407
- .map((id) => id.trim())
408
- .filter(Boolean);
409
- 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
+ }
410
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;
411
800
  for (const currentItemId of itemIds) {
412
- // Search across all asset type directories to find which type contains this item-id
413
- let foundType = null;
414
- let foundFolder = null;
415
- for (const type of Object.keys(itemTypes)) {
416
- const candidateFolder = `./src/client/public/cyberia/assets/${type}/${currentItemId}`;
417
- if (fs.existsSync(candidateFolder) && fs.statSync(candidateFolder).isDirectory()) {
418
- foundType = type;
419
- foundFolder = candidateFolder;
420
- break;
421
- }
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}`);
422
811
  }
812
+ }
813
+ logger.info(`Instance restore done: ${restored}/${itemIds.length} item(s)`);
814
+ }
815
+
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
+ }
423
826
 
424
- if (!foundType) {
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) {
425
841
  logger.error(
426
842
  `Item-id '${currentItemId}' not found in any asset type directory (${Object.keys(itemTypes).join(', ')})`,
427
843
  );
428
844
  continue;
429
845
  }
430
-
431
- logger.info(`Found item '${currentItemId}' in type '${foundType}' at ${foundFolder}`);
846
+ logger.info(`Found item '${currentItemId}' in type '${found.type}' at ${found.folder}`);
432
847
 
433
848
  const { objectLayerRenderFramesData, objectLayerData } =
434
849
  await ObjectLayerEngine.buildObjectLayerDataFromDirectory({
435
- folder: foundFolder,
436
- objectLayerType: foundType,
850
+ folder: found.folder,
851
+ objectLayerType: found.type,
437
852
  objectLayerId: currentItemId,
438
853
  });
854
+ applyStatPolicy(objectLayerData, statPolicy);
439
855
 
440
856
  // Write processed frames back to disk so WebP matches atlas
441
- const srcBasePath = './src/client/public/cyberia/';
442
- const publicBasePath = `./public/${host}${path}`;
443
857
  await ObjectLayerEngine.writeStaticFrameAssets({
444
- basePaths: [srcBasePath, publicBasePath],
445
- itemType: foundType,
858
+ basePaths: ['./src/client/public/cyberia/', `./public/${host}${path}`],
859
+ itemType: found.type,
446
860
  itemId: currentItemId,
447
861
  objectLayerRenderFramesData,
448
862
  objectLayerData,
449
- cellPixelDim: 20,
863
+ cellPixelDim: upscaleFactor,
450
864
  });
451
865
 
452
- // Check if an ObjectLayer with the same item.id already exists (upsert by item ID)
453
- const existingOL = await ObjectLayer.findByItemId(currentItemId);
454
- let objectLayer;
455
-
456
- if (existingOL) {
457
- // ── Cut-over consistency: stage everything in memory before touching the live document ──
458
- logger.info(`ObjectLayer '${currentItemId}' already exists (${existingOL._id}), staging update...`);
459
-
460
- // 1. Prepare staging data entirely in memory (no DB writes yet)
461
- const stagingData = JSON.parse(JSON.stringify(objectLayerData.data));
462
- if (!stagingData.render) stagingData.render = {};
463
- stagingData.render.cid = '';
464
- stagingData.render.metadataCid = '';
465
-
466
- // 2. Generate atlas, pin to IPFS, compute SHA-256 — all in memory
467
- let cutoverReady = false;
468
- let stagingFileDoc = null;
469
- let stagingAtlasDoc = null;
470
- let stagingCid = '';
471
- try {
472
- const itemKey = currentItemId;
473
-
474
- // Generate atlas from in-memory render frames data (plain object, no DB doc needed)
475
- const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
476
- objectLayerRenderFramesData,
477
- itemKey,
478
- 20,
479
- );
480
-
481
- stagingFileDoc = await new File({
482
- name: `${itemKey}-atlas.png`,
483
- data: buffer,
484
- size: buffer.length,
485
- mimetype: 'image/png',
486
- md5: crypto.createHash('md5').update(buffer).digest('hex'),
487
- }).save();
488
-
489
- let importItemCid = '';
490
- let importItemMetadataCid = '';
491
- try {
492
- const ipfsResult = await IpfsClient.addBufferToIpfs(
493
- buffer,
494
- `${itemKey}_atlas_sprite_sheet.png`,
495
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
496
- );
497
- if (ipfsResult) {
498
- importItemCid = ipfsResult.cid;
499
- logger.info(`[staging] Atlas pinned to IPFS – CID: ${importItemCid}`);
500
- try {
501
- await createPinRecord({
502
- cid: importItemCid,
503
- resourceType: 'atlas-sprite-sheet',
504
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
505
- options: { host, path },
506
- });
507
- } catch (prErr) {
508
- logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
509
- }
510
- }
511
- } catch (ipfsError) {
512
- logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
513
- }
514
-
515
- try {
516
- const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
517
- metadata,
518
- `${itemKey}_atlas_sprite_sheet_metadata.json`,
519
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
520
- );
521
- if (metadataIpfsResult) {
522
- importItemMetadataCid = metadataIpfsResult.cid;
523
- logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importItemMetadataCid}`);
524
- try {
525
- await createPinRecord({
526
- cid: importItemMetadataCid,
527
- resourceType: 'atlas-metadata',
528
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
529
- options: { host, path },
530
- });
531
- } catch (prErr) {
532
- logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
533
- }
534
- }
535
- } catch (ipfsError) {
536
- logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
537
- }
538
-
539
- // Persist atlas doc (or update existing one for this itemKey)
540
- stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
541
- if (stagingAtlasDoc) {
542
- if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
543
- stagingAtlasDoc.fileId = stagingFileDoc._id;
544
- stagingAtlasDoc.cid = importItemCid;
545
- stagingAtlasDoc.metadata = metadata;
546
- await stagingAtlasDoc.save();
547
- } else {
548
- stagingAtlasDoc = await new AtlasSpriteSheet({
549
- fileId: stagingFileDoc._id,
550
- cid: importItemCid,
551
- metadata,
552
- }).save();
553
- }
554
-
555
- // Finalize staging data in memory with render CIDs
556
- stagingData.render.cid = importItemCid;
557
- stagingData.render.metadataCid = importItemMetadataCid;
558
-
559
- // Pin data JSON to IPFS
560
- try {
561
- const ipfsDataResult = await IpfsClient.addJsonToIpfs(
562
- stagingData,
563
- `${itemKey}_data.json`,
564
- `/object-layer/${itemKey}/${itemKey}_data.json`,
565
- );
566
- if (ipfsDataResult) {
567
- stagingCid = ipfsDataResult.cid;
568
- logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
569
- try {
570
- await createPinRecord({
571
- cid: stagingCid,
572
- resourceType: 'object-layer-data',
573
- mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
574
- options: { host, path },
575
- });
576
- } catch (prErr) {
577
- logger.warn('[staging] Failed to create data pin record:', prErr.message);
578
- }
579
- }
580
- } catch (ipfsError) {
581
- logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
582
- }
583
-
584
- cutoverReady = true;
585
- logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for cut-over`);
586
- } catch (atlasError) {
587
- logger.error(`[staging] Failed for ${currentItemId}, live document untouched:`, atlasError);
588
- }
589
-
590
- // 3. Atomic cut-over: create new RenderFrames, swap live ObjectLayer in a single update
591
- if (cutoverReady) {
592
- const oldRenderFramesId = existingOL.objectLayerRenderFramesId;
593
-
594
- // Create the new RenderFrames doc (only now touches DB)
595
- const newRenderFrames = await ObjectLayerRenderFrames.create(objectLayerRenderFramesData);
596
-
597
- // Single atomic upsert on the natural key; empty staged CIDs keep their stored value
598
- objectLayer = await ObjectLayer.upsertByItemId({
599
- data: stagingData,
600
- cid: stagingCid,
601
- objectLayerRenderFramesId: newRenderFrames._id,
602
- atlasSpriteSheetId: stagingAtlasDoc._id,
603
- });
604
-
605
- // Clean up old render frames
606
- if (oldRenderFramesId) {
607
- await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
608
- }
609
-
610
- logger.info(`[cut-over] Live document ${objectLayer._id} updated atomically`);
611
- } else {
612
- // Rollback: only File/AtlasSpriteSheet were written, clean those up
613
- if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
614
- logger.warn(`[cut-over] Staging rolled back for ${currentItemId}, live document preserved`);
615
- objectLayer = await ObjectLayer.findByItemId(currentItemId);
616
- }
617
- } else {
618
- // ── New item: stage everything before creating (same cut-over pattern) ──
619
- logger.info(`ObjectLayer '${currentItemId}' is new, staging creation...`);
620
-
621
- const itemKey = currentItemId;
622
- const stagingData = JSON.parse(JSON.stringify(objectLayerData.data));
623
- if (!stagingData.render) stagingData.render = {};
624
- stagingData.render.cid = '';
625
- stagingData.render.metadataCid = '';
626
-
627
- let cutoverReady = false;
628
- let stagingFileDoc = null;
629
- let stagingAtlasDoc = null;
630
- let stagingCid = '';
631
- try {
632
- const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
633
- objectLayerRenderFramesData,
634
- itemKey,
635
- 20,
636
- );
637
-
638
- stagingFileDoc = await new File({
639
- name: `${itemKey}-atlas.png`,
640
- data: buffer,
641
- size: buffer.length,
642
- mimetype: 'image/png',
643
- md5: crypto.createHash('md5').update(buffer).digest('hex'),
644
- }).save();
645
-
646
- let importItemCid = '';
647
- let importItemMetadataCid = '';
648
- try {
649
- const ipfsResult = await IpfsClient.addBufferToIpfs(
650
- buffer,
651
- `${itemKey}_atlas_sprite_sheet.png`,
652
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
653
- );
654
- if (ipfsResult) {
655
- importItemCid = ipfsResult.cid;
656
- logger.info(`[staging] Atlas pinned to IPFS – CID: ${importItemCid}`);
657
- try {
658
- await createPinRecord({
659
- cid: importItemCid,
660
- resourceType: 'atlas-sprite-sheet',
661
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
662
- options: { host, path },
663
- });
664
- } catch (prErr) {
665
- logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
666
- }
667
- }
668
- } catch (ipfsError) {
669
- logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
670
- }
671
-
672
- try {
673
- const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
674
- metadata,
675
- `${itemKey}_atlas_sprite_sheet_metadata.json`,
676
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
677
- );
678
- if (metadataIpfsResult) {
679
- importItemMetadataCid = metadataIpfsResult.cid;
680
- logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importItemMetadataCid}`);
681
- try {
682
- await createPinRecord({
683
- cid: importItemMetadataCid,
684
- resourceType: 'atlas-metadata',
685
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
686
- options: { host, path },
687
- });
688
- } catch (prErr) {
689
- logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
690
- }
691
- }
692
- } catch (ipfsError) {
693
- logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
694
- }
695
-
696
- stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
697
- if (stagingAtlasDoc) {
698
- if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
699
- stagingAtlasDoc.fileId = stagingFileDoc._id;
700
- stagingAtlasDoc.cid = importItemCid;
701
- stagingAtlasDoc.metadata = metadata;
702
- await stagingAtlasDoc.save();
703
- } else {
704
- stagingAtlasDoc = await new AtlasSpriteSheet({
705
- fileId: stagingFileDoc._id,
706
- cid: importItemCid,
707
- metadata,
708
- }).save();
709
- }
710
-
711
- stagingData.render.cid = importItemCid;
712
- stagingData.render.metadataCid = importItemMetadataCid;
713
-
714
- try {
715
- const ipfsDataResult = await IpfsClient.addJsonToIpfs(
716
- stagingData,
717
- `${itemKey}_data.json`,
718
- `/object-layer/${itemKey}/${itemKey}_data.json`,
719
- );
720
- if (ipfsDataResult) {
721
- stagingCid = ipfsDataResult.cid;
722
- logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
723
- try {
724
- await createPinRecord({
725
- cid: stagingCid,
726
- resourceType: 'object-layer-data',
727
- mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
728
- options: { host, path },
729
- });
730
- } catch (prErr) {
731
- logger.warn('[staging] Failed to create data pin record:', prErr.message);
732
- }
733
- }
734
- } catch (ipfsError) {
735
- logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
736
- }
737
-
738
- cutoverReady = true;
739
- logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for creation`);
740
- } catch (atlasError) {
741
- logger.error(`[staging] Failed for ${currentItemId}, no document created:`, atlasError);
742
- }
743
-
744
- if (cutoverReady) {
745
- const newRenderFrames = await ObjectLayerRenderFrames.create(objectLayerRenderFramesData);
746
- objectLayer = await ObjectLayer.upsertByItemId({
747
- data: stagingData,
748
- cid: stagingCid,
749
- objectLayerRenderFramesId: newRenderFrames._id,
750
- atlasSpriteSheetId: stagingAtlasDoc._id,
751
- });
752
- logger.info(`[cut-over] New ObjectLayer ${objectLayer._id} created with all CIDs populated`);
753
- } else {
754
- if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
755
- logger.warn(`[cut-over] Staging failed for ${currentItemId}, no ObjectLayer created`);
756
- continue;
757
- }
758
- }
866
+ const { objectLayer } = await ObjectLayerEngine.persistObjectLayerDocuments({
867
+ ObjectLayer,
868
+ ObjectLayerRenderFrames,
869
+ objectLayerRenderFramesData,
870
+ objectLayerData,
871
+ persistOptions: { upscaleFactor, options: { host, path } },
872
+ });
759
873
 
760
- // Reload final state to include CID and render updates
761
- const finalObjectLayer = await ObjectLayer.findById(objectLayer._id).populate('objectLayerRenderFramesId');
762
- console.log(finalObjectLayer.toObject());
874
+ console.log((await ObjectLayer.findById(objectLayer._id).populate('objectLayerRenderFramesId')).toObject());
763
875
  }
764
876
  }
765
877
 
@@ -794,12 +906,6 @@ try {
794
906
  await pngDirectoryIteratorByObjectLayerType(
795
907
  argItemType,
796
908
  async ({ path: framePath, objectLayerType, objectLayerId, direction, frame }) => {
797
- if (
798
- storage &&
799
- !storage[`src/client/public/cyberia/assets/${objectLayerType}/${objectLayerId}/08/0.png`]
800
- )
801
- return;
802
-
803
909
  // Skip items that already exist in the database (bulk import only)
804
910
  if (isImportAll && existingItemIds.has(objectLayerId)) return;
805
911
 
@@ -814,6 +920,7 @@ try {
814
920
  objectLayerType,
815
921
  objectLayerId,
816
922
  });
923
+ applyStatPolicy(objectLayerData, statPolicy);
817
924
 
818
925
  // Write processed frames back to disk so WebP matches atlas
819
926
  const srcBasePath = './src/client/public/cyberia/';
@@ -824,14 +931,10 @@ try {
824
931
  itemId: objectLayerId,
825
932
  objectLayerRenderFramesData,
826
933
  objectLayerData,
827
- cellPixelDim: 20,
934
+ cellPixelDim: upscaleFactor,
828
935
  });
829
936
 
830
- objectLayers[objectLayerId] = {
831
- ...objectLayerData,
832
- objectLayerRenderFramesData,
833
- _processed: true,
834
- };
937
+ objectLayers[objectLayerId] = { ...objectLayerData, objectLayerRenderFramesData };
835
938
  }
836
939
  },
837
940
  );
@@ -840,369 +943,17 @@ try {
840
943
  for (const objectLayerId of Object.keys(objectLayers)) {
841
944
  const entry = objectLayers[objectLayerId];
842
945
 
843
- // Skip atlas generation when importing all object layers at once (bulk import).
844
- // Individual imports or explicit --to-atlas-sprite-sheet calls will still generate atlases.
845
- const shouldGenerateAtlas = !isImportAll;
846
-
847
- if (shouldGenerateAtlas) {
848
- // Check if an ObjectLayer with the same item.id already exists (upsert by item ID)
849
- const existingOL = await ObjectLayer.findByItemId(objectLayerId);
850
- let objectLayer;
851
-
852
- if (existingOL) {
853
- // ── Cut-over consistency: stage everything in memory before touching the live document ──
854
- logger.info(`ObjectLayer '${objectLayerId}' already exists (${existingOL._id}), staging update...`);
855
-
856
- // 1. Prepare staging data entirely in memory (no DB writes yet)
857
- const stagingData = JSON.parse(JSON.stringify(entry.data));
858
- if (!stagingData.render) stagingData.render = {};
859
- stagingData.render.cid = '';
860
- stagingData.render.metadataCid = '';
861
-
862
- // 2. Generate atlas, pin to IPFS, compute SHA-256 — all in memory
863
- let cutoverReady = false;
864
- let stagingFileDoc = null;
865
- let stagingAtlasDoc = null;
866
- let stagingCid = '';
867
- try {
868
- const itemKey = objectLayerId;
869
-
870
- // Generate atlas from in-memory render frames data (plain object, no DB doc needed)
871
- const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
872
- entry.objectLayerRenderFramesData,
873
- itemKey,
874
- 20,
875
- );
876
-
877
- stagingFileDoc = await new File({
878
- name: `${itemKey}-atlas.png`,
879
- data: buffer,
880
- size: buffer.length,
881
- mimetype: 'image/png',
882
- md5: crypto.createHash('md5').update(buffer).digest('hex'),
883
- }).save();
884
-
885
- let importAtlasCid = '';
886
- let importAtlasMetadataCid = '';
887
- try {
888
- const ipfsResult = await IpfsClient.addBufferToIpfs(
889
- buffer,
890
- `${itemKey}_atlas_sprite_sheet.png`,
891
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
892
- );
893
- if (ipfsResult) {
894
- importAtlasCid = ipfsResult.cid;
895
- logger.info(`[staging] Atlas pinned to IPFS – CID: ${importAtlasCid}`);
896
- try {
897
- await createPinRecord({
898
- cid: importAtlasCid,
899
- resourceType: 'atlas-sprite-sheet',
900
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
901
- options: { host, path },
902
- });
903
- } catch (prErr) {
904
- logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
905
- }
906
- }
907
- } catch (ipfsError) {
908
- logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
909
- }
910
-
911
- try {
912
- const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
913
- metadata,
914
- `${itemKey}_atlas_sprite_sheet_metadata.json`,
915
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
916
- );
917
- if (metadataIpfsResult) {
918
- importAtlasMetadataCid = metadataIpfsResult.cid;
919
- logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importAtlasMetadataCid}`);
920
- try {
921
- await createPinRecord({
922
- cid: importAtlasMetadataCid,
923
- resourceType: 'atlas-metadata',
924
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
925
- options: { host, path },
926
- });
927
- } catch (prErr) {
928
- logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
929
- }
930
- }
931
- } catch (ipfsError) {
932
- logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
933
- }
934
-
935
- stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
936
- if (stagingAtlasDoc) {
937
- if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
938
- stagingAtlasDoc.fileId = stagingFileDoc._id;
939
- stagingAtlasDoc.cid = importAtlasCid;
940
- stagingAtlasDoc.metadata = metadata;
941
- await stagingAtlasDoc.save();
942
- } else {
943
- stagingAtlasDoc = await new AtlasSpriteSheet({
944
- fileId: stagingFileDoc._id,
945
- cid: importAtlasCid,
946
- metadata,
947
- }).save();
948
- }
949
-
950
- // Finalize staging data in memory with render CIDs
951
- stagingData.render.cid = importAtlasCid;
952
- stagingData.render.metadataCid = importAtlasMetadataCid;
953
-
954
- // Pin data JSON to IPFS
955
- try {
956
- const ipfsDataResult = await IpfsClient.addJsonToIpfs(
957
- stagingData,
958
- `${itemKey}_data.json`,
959
- `/object-layer/${itemKey}/${itemKey}_data.json`,
960
- );
961
- if (ipfsDataResult) {
962
- stagingCid = ipfsDataResult.cid;
963
- logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
964
- try {
965
- await createPinRecord({
966
- cid: stagingCid,
967
- resourceType: 'object-layer-data',
968
- mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
969
- options: { host, path },
970
- });
971
- } catch (prErr) {
972
- logger.warn('[staging] Failed to create data pin record:', prErr.message);
973
- }
974
- }
975
- } catch (ipfsError) {
976
- logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
977
- }
978
-
979
- cutoverReady = true;
980
- logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for cut-over`);
981
- } catch (atlasError) {
982
- logger.error(`[staging] Failed for ${objectLayerId}, live document untouched:`, atlasError);
983
- }
984
-
985
- // 3. Atomic cut-over: create new RenderFrames, swap live ObjectLayer in a single update
986
- if (cutoverReady) {
987
- const oldRenderFramesId = existingOL.objectLayerRenderFramesId;
988
- const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
989
-
990
- objectLayer = await ObjectLayer.upsertByItemId({
991
- data: stagingData,
992
- cid: stagingCid,
993
- objectLayerRenderFramesId: newRenderFrames._id,
994
- atlasSpriteSheetId: stagingAtlasDoc._id,
995
- });
996
-
997
- if (oldRenderFramesId) {
998
- await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
999
- }
1000
- logger.info(`[cut-over] Live document ${objectLayer._id} updated atomically`);
1001
- } else {
1002
- if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
1003
- logger.warn(`[cut-over] Staging rolled back for ${objectLayerId}, live document preserved`);
1004
- objectLayer = await ObjectLayer.findByItemId(objectLayerId);
1005
- }
1006
- } else {
1007
- // ── New item: stage everything before creating (same cut-over pattern) ──
1008
- logger.info(`ObjectLayer '${objectLayerId}' is new, staging creation...`);
1009
-
1010
- const itemKey = objectLayerId;
1011
- const stagingData = JSON.parse(JSON.stringify(entry.data));
1012
- if (!stagingData.render) stagingData.render = {};
1013
- stagingData.render.cid = '';
1014
- stagingData.render.metadataCid = '';
1015
-
1016
- let cutoverReady = false;
1017
- let stagingFileDoc = null;
1018
- let stagingAtlasDoc = null;
1019
- let stagingCid = '';
1020
- try {
1021
- const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
1022
- entry.objectLayerRenderFramesData,
1023
- itemKey,
1024
- 20,
1025
- );
1026
-
1027
- stagingFileDoc = await new File({
1028
- name: `${itemKey}-atlas.png`,
1029
- data: buffer,
1030
- size: buffer.length,
1031
- mimetype: 'image/png',
1032
- md5: crypto.createHash('md5').update(buffer).digest('hex'),
1033
- }).save();
1034
-
1035
- let importAtlasCid = '';
1036
- let importAtlasMetadataCid = '';
1037
- try {
1038
- const ipfsResult = await IpfsClient.addBufferToIpfs(
1039
- buffer,
1040
- `${itemKey}_atlas_sprite_sheet.png`,
1041
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
1042
- );
1043
- if (ipfsResult) {
1044
- importAtlasCid = ipfsResult.cid;
1045
- logger.info(`[staging] Atlas pinned to IPFS – CID: ${importAtlasCid}`);
1046
- try {
1047
- await createPinRecord({
1048
- cid: importAtlasCid,
1049
- resourceType: 'atlas-sprite-sheet',
1050
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
1051
- options: { host, path },
1052
- });
1053
- } catch (prErr) {
1054
- logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
1055
- }
1056
- }
1057
- } catch (ipfsError) {
1058
- logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
1059
- }
1060
-
1061
- try {
1062
- const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
1063
- metadata,
1064
- `${itemKey}_atlas_sprite_sheet_metadata.json`,
1065
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
1066
- );
1067
- if (metadataIpfsResult) {
1068
- importAtlasMetadataCid = metadataIpfsResult.cid;
1069
- logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importAtlasMetadataCid}`);
1070
- try {
1071
- await createPinRecord({
1072
- cid: importAtlasMetadataCid,
1073
- resourceType: 'atlas-metadata',
1074
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
1075
- options: { host, path },
1076
- });
1077
- } catch (prErr) {
1078
- logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
1079
- }
1080
- }
1081
- } catch (ipfsError) {
1082
- logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
1083
- }
1084
-
1085
- stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
1086
- if (stagingAtlasDoc) {
1087
- if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
1088
- stagingAtlasDoc.fileId = stagingFileDoc._id;
1089
- stagingAtlasDoc.cid = importAtlasCid;
1090
- stagingAtlasDoc.metadata = metadata;
1091
- await stagingAtlasDoc.save();
1092
- } else {
1093
- stagingAtlasDoc = await new AtlasSpriteSheet({
1094
- fileId: stagingFileDoc._id,
1095
- cid: importAtlasCid,
1096
- metadata,
1097
- }).save();
1098
- }
1099
-
1100
- stagingData.render.cid = importAtlasCid;
1101
- stagingData.render.metadataCid = importAtlasMetadataCid;
1102
-
1103
- try {
1104
- const ipfsDataResult = await IpfsClient.addJsonToIpfs(
1105
- stagingData,
1106
- `${itemKey}_data.json`,
1107
- `/object-layer/${itemKey}/${itemKey}_data.json`,
1108
- );
1109
- if (ipfsDataResult) {
1110
- stagingCid = ipfsDataResult.cid;
1111
- logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
1112
- try {
1113
- await createPinRecord({
1114
- cid: stagingCid,
1115
- resourceType: 'object-layer-data',
1116
- mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
1117
- options: { host, path },
1118
- });
1119
- } catch (prErr) {
1120
- logger.warn('[staging] Failed to create data pin record:', prErr.message);
1121
- }
1122
- }
1123
- } catch (ipfsError) {
1124
- logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
1125
- }
1126
-
1127
- cutoverReady = true;
1128
- logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for creation`);
1129
- } catch (atlasError) {
1130
- logger.error(`[staging] Failed for ${objectLayerId}, no document created:`, atlasError);
1131
- }
1132
-
1133
- if (cutoverReady) {
1134
- const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
1135
- objectLayer = await ObjectLayer.upsertByItemId({
1136
- data: stagingData,
1137
- cid: stagingCid,
1138
- objectLayerRenderFramesId: newRenderFrames._id,
1139
- atlasSpriteSheetId: stagingAtlasDoc._id,
1140
- });
1141
- logger.info(`[cut-over] New ObjectLayer ${objectLayer._id} created with all CIDs populated`);
1142
- } else {
1143
- if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
1144
- logger.warn(`[cut-over] Staging failed for ${objectLayerId}, no ObjectLayer created`);
1145
- continue;
1146
- }
1147
- }
1148
-
1149
- // Reload final state to include CID and render updates
1150
- const finalObjectLayer = await ObjectLayer.findById((objectLayer._id || objectLayer).toString()).populate(
1151
- 'objectLayerRenderFramesId',
1152
- );
1153
- console.log(finalObjectLayer.toObject());
1154
- } else {
1155
- // --import all: skip items that already exist in the database
1156
- if (existingItemIds.has(objectLayerId)) continue;
1157
-
1158
- // --import all: create documents without atlas generation
1159
- const existingOL = await ObjectLayer.findByItemId(objectLayerId);
1160
- let objectLayer;
1161
-
1162
- if (existingOL) {
1163
- logger.info(
1164
- `ObjectLayer '${objectLayerId}' already exists (${existingOL._id}), staging update (atlas skipped)...`,
1165
- );
1166
-
1167
- // ── In-memory staging (no atlas) ──────────────────────
1168
- const stagingData = JSON.parse(JSON.stringify(entry.data));
1169
- if (!stagingData.render) stagingData.render = {};
1170
- stagingData.render.cid = '';
1171
- stagingData.render.metadataCid = '';
1172
-
1173
- // Atomic cut-over: create new RenderFrames, swap live doc, delete old
1174
- const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
1175
- const oldRenderFramesId = existingOL.objectLayerRenderFramesId;
1176
-
1177
- objectLayer = await ObjectLayer.upsertByItemId({
1178
- data: stagingData,
1179
- objectLayerRenderFramesId: newRenderFrames._id,
1180
- });
1181
-
1182
- if (oldRenderFramesId) {
1183
- await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
1184
- }
1185
-
1186
- logger.info(`[cut-over] Live document ${objectLayer._id} updated atomically (atlas skipped)`);
1187
- } else {
1188
- // New item (no atlas for bulk import)
1189
- const stagingData = JSON.parse(JSON.stringify(entry.data));
1190
- if (!stagingData.render) stagingData.render = {};
1191
- stagingData.render.cid = '';
1192
- stagingData.render.metadataCid = '';
1193
-
1194
- const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
1195
- objectLayer = await ObjectLayer.upsertByItemId({
1196
- data: stagingData,
1197
- objectLayerRenderFramesId: newRenderFrames._id,
1198
- });
1199
- }
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
+ });
1200
955
 
1201
- logger.info(
1202
- `ObjectLayer ${existingOL ? 'updated' : 'created'} (atlas skipped for bulk import): ${objectLayerId}`,
1203
- );
1204
- console.log(objectLayer.toObject ? objectLayer.toObject() : objectLayer);
1205
- }
956
+ console.log(objectLayer.toObject());
1206
957
  }
1207
958
  }
1208
959
 
@@ -1222,20 +973,16 @@ try {
1222
973
 
1223
974
  logger.info(`Showing frame for item: ${itemId}, direction: ${direction}, frame: ${frameIndexNum}`);
1224
975
 
1225
- // Find ObjectLayer by item-id
1226
976
  const objectLayer = await ObjectLayer.findByItemId(itemId).populate('objectLayerRenderFramesId');
1227
-
1228
977
  if (!objectLayer) {
1229
978
  logger.error(`ObjectLayer not found for item-id: ${itemId}`);
1230
979
  process.exit(1);
1231
980
  }
1232
-
1233
981
  if (!objectLayer.objectLayerRenderFramesId) {
1234
982
  logger.error(`ObjectLayerRenderFrames not found for item: ${itemId}`);
1235
983
  process.exit(1);
1236
984
  }
1237
985
 
1238
- // Get the keyframe direction name from the numerical direction code
1239
986
  const objectLayerFrameDirections = getKeyframeDirectionsByCode(direction);
1240
987
  if (objectLayerFrameDirections.length === 0) {
1241
988
  logger.error(`Invalid direction code: ${direction}. Valid codes: 08, 18, 02, 12, 04, 14, 06, 16`);
@@ -1259,15 +1006,14 @@ try {
1259
1006
  process.exit(1);
1260
1007
  }
1261
1008
 
1262
- const itemKey = objectLayer.data.item.id;
1263
- const outputPath = `./${itemKey}_${showFrameInput}.png`;
1009
+ const outputPath = `./${objectLayer.data.item.id}_${showFrameInput}.png`;
1264
1010
 
1265
1011
  await buildImgFromTile({
1266
1012
  tile: {
1267
1013
  map_color: objectLayer.objectLayerRenderFramesId.colors,
1268
1014
  frame_matrix: frames[frameIndexNum],
1269
1015
  },
1270
- cellPixelDim: 20,
1016
+ cellPixelDim: upscaleFactor,
1271
1017
  opacityFilter: (x, y, color) => 255,
1272
1018
  imagePath: outputPath,
1273
1019
  });
@@ -1277,158 +1023,90 @@ try {
1277
1023
  }
1278
1024
 
1279
1025
  // ── Handle --to-atlas-sprite-sheet ───────────────────────────────
1280
- if (options.toAtlasSpriteSheet !== undefined) {
1281
- // If toAtlasSpriteSheet is true (flag without value), use null for auto-calc
1282
- // 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) {
1283
1031
  /** @type {number|null} */
1284
- 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;
1285
1036
 
1286
- if (!itemId) {
1287
- logger.error('item-id is required for --to-atlas-sprite-sheet');
1288
- process.exit(1);
1289
- }
1290
-
1291
- if (maxAtlasDim) {
1292
- /** @type {string} */
1037
+ if (maxAtlasDim !== null) {
1293
1038
  const sizeRecommendation =
1294
1039
  maxAtlasDim < 2048
1295
1040
  ? ' (Warning: May be too small for all frames)'
1296
1041
  : maxAtlasDim > 4096
1297
1042
  ? ' (Large size: ensure GPU compatibility)'
1298
1043
  : ' (Recommended size)';
1299
-
1300
- logger.info(
1301
- `Generating atlas sprite sheet for item: ${itemId} with max dimension: ${maxAtlasDim}x${maxAtlasDim}${sizeRecommendation}`,
1302
- );
1303
- } else {
1304
- logger.info(
1305
- `Generating atlas sprite sheet for item: ${itemId} with auto-calculated dimensions (based on frame count)`,
1306
- );
1044
+ logger.info(`Max atlas dimension: ${maxAtlasDim}x${maxAtlasDim}${sizeRecommendation}`);
1307
1045
  }
1308
1046
 
1309
- // Find ObjectLayer by item-id
1310
- const objectLayer = await ObjectLayer.findByItemId(itemId).populate('objectLayerRenderFramesId');
1311
-
1312
- if (!objectLayer) {
1313
- logger.error(`ObjectLayer not found for item-id: ${itemId}`);
1314
- process.exit(1);
1315
- }
1047
+ const selectedItemIds = await selectScopedItemIds({
1048
+ ObjectLayer,
1049
+ itemId,
1050
+ instance: options.instance,
1051
+ host,
1052
+ path,
1053
+ action: '--to-atlas-sprite-sheet',
1054
+ });
1316
1055
 
1317
- const itemKey = objectLayer.data.item.id;
1318
- logger.info(`Found ObjectLayer: ${itemKey} (${objectLayer._id})`);
1319
-
1320
- // Generate atlas sprite sheet
1321
- const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
1322
- objectLayer.objectLayerRenderFramesId,
1323
- itemKey,
1324
- 20, // cellPixelDim
1325
- maxAtlasDim,
1326
- );
1056
+ logger.info(`Atlas rebuild for ${selectedItemIds.length} stored item(s) at ${upscaleFactor}px per cell`);
1057
+ const tally = { rebuilt: 0, skipped: 0, failed: [] };
1327
1058
 
1328
- /** @type {number} */
1329
- const frameCount = Object.values(metadata.frames).reduce((sum, frames) => sum + frames.length, 0);
1330
- logger.info(
1331
- `Atlas generated: ${metadata.atlasWidth}x${metadata.atlasHeight} pixels (${frameCount} frames packed)`,
1332
- );
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;
1071
+ }
1333
1072
 
1334
- // Save to File collection
1335
- const fileDoc = await new File({
1336
- name: `${itemKey}-atlas.png`,
1337
- data: buffer,
1338
- size: buffer.length,
1339
- mimetype: 'image/png',
1340
- md5: crypto.createHash('md5').update(buffer).digest('hex'),
1341
- }).save();
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
+ });
1342
1080
 
1343
- logger.info(`File saved with ID: ${fileDoc._id}`);
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
+ );
1344
1086
 
1345
- // Pin atlas PNG to IPFS
1346
- let toAtlasCid = '';
1347
- let toAtlasMetadataCid = '';
1348
- try {
1349
- const ipfsResult = await IpfsClient.addBufferToIpfs(
1350
- buffer,
1351
- `${itemKey}_atlas_sprite_sheet.png`,
1352
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
1353
- );
1354
- if (ipfsResult) {
1355
- toAtlasCid = ipfsResult.cid;
1356
- logger.info(`Atlas sprite sheet pinned to IPFS – CID: ${toAtlasCid}`);
1357
- try {
1358
- await createPinRecord({
1359
- cid: toAtlasCid,
1360
- resourceType: 'atlas-sprite-sheet',
1361
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
1362
- });
1363
- } catch (e) {
1364
- logger.warn('Failed to create pin record for atlas sprite sheet:', e.message);
1365
- }
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);
1366
1100
  }
1367
- } catch (ipfsError) {
1368
- logger.warn('Failed to add atlas sprite sheet to IPFS:', ipfsError.message);
1369
1101
  }
1370
1102
 
1371
- // Pin atlas metadata JSON to IPFS (fast-json-stable-stringify)
1372
- try {
1373
- const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
1374
- metadata,
1375
- `${itemKey}_atlas_sprite_sheet_metadata.json`,
1376
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
1377
- );
1378
- if (metadataIpfsResult) {
1379
- toAtlasMetadataCid = metadataIpfsResult.cid;
1380
- logger.info(`Atlas metadata pinned to IPFS – CID: ${toAtlasMetadataCid}`);
1381
- try {
1382
- await createPinRecord({
1383
- cid: toAtlasMetadataCid,
1384
- resourceType: 'atlas-metadata',
1385
- mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
1386
- });
1387
- } catch (e) {
1388
- logger.warn('Failed to create pin record for atlas metadata:', e.message);
1389
- }
1390
- }
1391
- } catch (ipfsError) {
1392
- logger.warn('Failed to add atlas metadata to IPFS:', ipfsError.message);
1393
- }
1394
-
1395
- // Check if atlas sprite sheet already exists
1396
- let atlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
1397
-
1398
- if (atlasDoc) {
1399
- // Update existing – remove old File to prevent orphans
1400
- if (atlasDoc.fileId) await File.findByIdAndDelete(atlasDoc.fileId);
1401
- atlasDoc.fileId = fileDoc._id;
1402
- atlasDoc.cid = toAtlasCid;
1403
- atlasDoc.metadata = metadata;
1404
- await atlasDoc.save();
1405
- logger.info(`Updated existing AtlasSpriteSheet document: ${atlasDoc._id}`);
1406
- } else {
1407
- // Create new
1408
- atlasDoc = await new AtlasSpriteSheet({
1409
- fileId: fileDoc._id,
1410
- cid: toAtlasCid,
1411
- metadata,
1412
- }).save();
1413
- 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(',')}`);
1414
1109
  }
1415
-
1416
- // Update ObjectLayer with reference to atlas sprite sheet and render CIDs
1417
- objectLayer.atlasSpriteSheetId = atlasDoc._id;
1418
- if (!objectLayer.data.render) objectLayer.data.render = {};
1419
- objectLayer.data.render.cid = toAtlasCid;
1420
- objectLayer.data.render.metadataCid = toAtlasMetadataCid;
1421
- objectLayer.markModified('data.render');
1422
- await objectLayer.save();
1423
-
1424
- // Compute final SHA-256 and pin object layer data JSON to IPFS
1425
- await ObjectLayerEngine.computeAndSaveFinalSha256({
1426
- objectLayer,
1427
- ipfsClient: IpfsClient,
1428
- createPinRecord,
1429
- });
1430
-
1431
- logger.info(`Atlas sprite sheet completed for item: ${itemKey}`);
1432
1110
  }
1433
1111
 
1434
1112
  // ── Handle --show-atlas-sprite-sheet ─────────────────────────────
@@ -1529,6 +1207,7 @@ try {
1529
1207
 
1530
1208
  // Overwrite the item id in the generated data with the unique variant
1531
1209
  multiFrameResult.objectLayerData.data.item.id = uniqueItemId;
1210
+ applyStatPolicy(multiFrameResult.objectLayerData, statPolicy);
1532
1211
 
1533
1212
  logger.info(
1534
1213
  `Generated ${multiFrameResult.frameCount} frame(s) with ${multiFrameResult.objectLayerRenderFramesData.colors.length} unique colors`,
@@ -1543,7 +1222,7 @@ try {
1543
1222
  itemId: uniqueItemId,
1544
1223
  objectLayerRenderFramesData: multiFrameResult.objectLayerRenderFramesData,
1545
1224
  objectLayerData: multiFrameResult.objectLayerData,
1546
- cellPixelDim: 20,
1225
+ cellPixelDim: upscaleFactor,
1547
1226
  });
1548
1227
 
1549
1228
  logger.info(`Wrote ${writtenFiles.length} asset file(s):`);
@@ -1551,148 +1230,34 @@ try {
1551
1230
  logger.info(` → ${f}`);
1552
1231
  }
1553
1232
 
1554
- // 3. Persist to MongoDB (ObjectLayerRenderFrames + ObjectLayer)
1555
- 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({
1556
1235
  ObjectLayer,
1557
1236
  ObjectLayerRenderFrames,
1558
1237
  objectLayerRenderFramesData: multiFrameResult.objectLayerRenderFramesData,
1559
1238
  objectLayerData: multiFrameResult.objectLayerData,
1560
- createOptions: {
1561
- generateAtlas: false,
1562
- },
1239
+ persistOptions: { upscaleFactor, options: { host, path } },
1563
1240
  });
1564
1241
 
1565
1242
  logger.info(`ObjectLayer persisted to MongoDB: ${objectLayer._id} (item: ${objectLayer.data.item.id})`);
1566
-
1567
- // 4. Generate atlas sprite sheet + pin to IPFS
1568
- let atlasCid = '';
1569
- try {
1570
- const atlasItemKey = objectLayer.data.item.id;
1571
- const populatedObjectLayer = await ObjectLayer.findById(objectLayer._id).populate(
1572
- 'objectLayerRenderFramesId',
1573
- );
1574
-
1575
- const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
1576
- populatedObjectLayer.objectLayerRenderFramesId,
1577
- atlasItemKey,
1578
- 20,
1579
- );
1580
-
1581
- // Save atlas file to File collection
1582
- const fileDoc = await new File({
1583
- name: `${atlasItemKey}-atlas.png`,
1584
- data: buffer,
1585
- size: buffer.length,
1586
- mimetype: 'image/png',
1587
- md5: crypto.createHash('md5').update(buffer).digest('hex'),
1588
- }).save();
1589
-
1590
- // Pin atlas PNG to IPFS + copy into MFS
1591
- let atlasMetadataCid = '';
1592
- try {
1593
- const ipfsResult = await IpfsClient.addBufferToIpfs(
1594
- buffer,
1595
- `${atlasItemKey}_atlas_sprite_sheet.png`,
1596
- `/object-layer/${atlasItemKey}/${atlasItemKey}_atlas_sprite_sheet.png`,
1597
- );
1598
- if (ipfsResult) {
1599
- atlasCid = ipfsResult.cid;
1600
- logger.info(`Atlas sprite sheet pinned to IPFS – CID: ${atlasCid}`);
1601
- try {
1602
- await createPinRecord({
1603
- cid: atlasCid,
1604
- resourceType: 'atlas-sprite-sheet',
1605
- mfsPath: `/object-layer/${atlasItemKey}/${atlasItemKey}_atlas_sprite_sheet.png`,
1606
- options: { host, path },
1607
- });
1608
- } catch (e) {
1609
- logger.warn('Failed to create pin record for atlas sprite sheet:', e.message);
1610
- }
1611
- }
1612
- } catch (ipfsError) {
1613
- logger.warn('Failed to add atlas sprite sheet to IPFS:', ipfsError.message);
1614
- }
1615
-
1616
- // Pin atlas metadata JSON to IPFS (fast-json-stable-stringify)
1617
- try {
1618
- const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
1619
- metadata,
1620
- `${atlasItemKey}_atlas_sprite_sheet_metadata.json`,
1621
- `/object-layer/${atlasItemKey}/${atlasItemKey}_atlas_sprite_sheet_metadata.json`,
1622
- );
1623
- if (metadataIpfsResult) {
1624
- atlasMetadataCid = metadataIpfsResult.cid;
1625
- logger.info(`Atlas metadata pinned to IPFS – CID: ${atlasMetadataCid}`);
1626
- try {
1627
- await createPinRecord({
1628
- cid: atlasMetadataCid,
1629
- resourceType: 'atlas-metadata',
1630
- mfsPath: `/object-layer/${atlasItemKey}/${atlasItemKey}_atlas_sprite_sheet_metadata.json`,
1631
- options: { host, path },
1632
- });
1633
- } catch (e) {
1634
- logger.warn('Failed to create pin record for atlas metadata:', e.message);
1635
- }
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
+ );
1636
1259
  }
1637
- } catch (ipfsError) {
1638
- logger.warn('Failed to add atlas metadata to IPFS:', ipfsError.message);
1639
1260
  }
1640
-
1641
- // Upsert AtlasSpriteSheet document (with CID)
1642
- let atlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': atlasItemKey });
1643
- if (atlasDoc) {
1644
- if (atlasDoc.fileId) await File.findByIdAndDelete(atlasDoc.fileId);
1645
- atlasDoc.fileId = fileDoc._id;
1646
- atlasDoc.cid = atlasCid;
1647
- atlasDoc.metadata = metadata;
1648
- await atlasDoc.save();
1649
- logger.info(`Updated existing AtlasSpriteSheet document: ${atlasDoc._id}`);
1650
- } else {
1651
- atlasDoc = await new AtlasSpriteSheet({
1652
- fileId: fileDoc._id,
1653
- cid: atlasCid,
1654
- metadata,
1655
- }).save();
1656
- logger.info(`Created new AtlasSpriteSheet document: ${atlasDoc._id}`);
1657
- }
1658
-
1659
- // Link atlas to ObjectLayer and set data.render.cid + data.render.metadataCid
1660
- populatedObjectLayer.atlasSpriteSheetId = atlasDoc._id;
1661
- if (!populatedObjectLayer.data.render) populatedObjectLayer.data.render = {};
1662
- populatedObjectLayer.data.render.cid = atlasCid;
1663
- populatedObjectLayer.data.render.metadataCid = atlasMetadataCid;
1664
- populatedObjectLayer.markModified('data.render');
1665
- await populatedObjectLayer.save();
1666
-
1667
- // Also write atlas PNG to both static asset directories
1668
- for (const bp of [srcBasePath, publicBasePath]) {
1669
- const atlasOutputDir = nodePath.join(bp, 'assets', descriptor.itemType, uniqueItemId);
1670
- await fs.ensureDir(atlasOutputDir);
1671
- const atlasOutputPath = nodePath.join(atlasOutputDir, `${atlasItemKey}-atlas.png`);
1672
- await fs.writeFile(atlasOutputPath, buffer);
1673
- logger.info(
1674
- `Atlas sprite sheet generated: ${metadata.atlasWidth}x${metadata.atlasHeight} → ${atlasOutputPath}`,
1675
- );
1676
- }
1677
- } catch (atlasError) {
1678
- logger.error(`Failed to generate atlas for ${uniqueItemId}:`, atlasError);
1679
- }
1680
-
1681
- // 5. Compute final SHA-256, pin OL data JSON to IPFS, create pin records
1682
- try {
1683
- const finalObjectLayer = await ObjectLayer.findById(objectLayer._id).populate('objectLayerRenderFramesId');
1684
- const finalized = await ObjectLayerEngine.computeAndSaveFinalSha256({
1685
- objectLayer: finalObjectLayer,
1686
- ipfsClient: IpfsClient,
1687
- createPinRecord,
1688
- options: { host, path },
1689
- });
1690
- logger.info(`Final SHA-256: ${finalized.sha256}`);
1691
- if (finalized.cid) {
1692
- logger.info(`ObjectLayer data pinned to IPFS – CID: ${finalized.cid}`);
1693
- }
1694
- } catch (finalizeError) {
1695
- logger.error('Failed to finalize SHA-256 / IPFS:', finalizeError);
1696
1261
  }
1697
1262
 
1698
1263
  logger.info(`✓ Generation complete for "${uniqueItemId}" (seed: ${genSeed}, frames: ${genFrameCount})`);
@@ -1721,9 +1286,25 @@ try {
1721
1286
  'When used with --export or --import, only process cyberia-instance.json and cyberia-instance-conf.json',
1722
1287
  )
1723
1288
  .option('--drop', 'Drop all documents associated with the instance code before importing or as a standalone action')
1289
+ .option(
1290
+ '--export-current-fallbackworld',
1291
+ 'Capture the in-memory procedural fallback world as instance [instance-code]: materialize it into MongoDB (maps, conf, actions, quests, missing content defaults) and then export it',
1292
+ )
1293
+ .option(
1294
+ '--keep-fallback-codes',
1295
+ 'With --export-current-fallbackworld, keep the raw fallback-map-* / canonical action-quest codes instead of namespacing them under the instance code',
1296
+ )
1297
+ .option(
1298
+ '--fallback-url <url>',
1299
+ 'With --export-current-fallbackworld, capture the world a running engine currently serves (e.g. http://localhost:4001) instead of regenerating it locally',
1300
+ )
1724
1301
  .option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
1725
1302
  .option('--mongo-host <mongo-host>', 'Mongo host override')
1726
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
+ )
1727
1308
  .option('--publish-build', 'Build instance backup directory with all related maps, entities and object layers')
1728
1309
  .option('--publish-remove', 'Remove published instance from underpostnet/cyberia-instances repository')
1729
1310
  .option('--publish', 'Publish instance in underpostnet/cyberia-instances repository')
@@ -1735,16 +1316,40 @@ try {
1735
1316
  .description('Export/import a Cyberia instance with all related maps, entities and object layers')
1736
1317
  .action(async (instanceCode, options = {}) => {
1737
1318
  if (options.revert) {
1738
- shellExec(`cd /home/dd/cyberia-instances && underpost cmt . reset && underpost run clean .`);
1739
- shellExec(`cd /home/dd/engine/cyberia-server && underpost cmt . reset && underpost run clean .`);
1740
- shellExec(`cd /home/dd/engine/cyberia-client && underpost cmt . reset && underpost run clean .`);
1319
+ Underpost.repo.declareSafeDirectory('/home/dd/cyberia-instances');
1320
+ shellExec(`cd /home/dd/cyberia-instances && ${cli()} cmt . reset && ${cli()} run clean .`);
1321
+ shellExec(`cd /home/dd/engine/cyberia-server && ${cli()} cmt . reset && ${cli()} run clean .`);
1322
+ shellExec(`cd /home/dd/engine/cyberia-client && ${cli()} cmt . reset && ${cli()} run clean .`);
1741
1323
  return;
1742
1324
  }
1325
+ if (options.exportCurrentFallbackworld) {
1326
+ // A capture writes one named instance: no default list, no import in the
1327
+ // same run, and never the reserved code the in-memory world itself uses.
1328
+ if (!instanceCode || instanceCode.includes(',')) {
1329
+ logger.error('--export-current-fallbackworld requires a single [instance-code] to capture the world under');
1330
+ process.exit(1);
1331
+ }
1332
+ if (instanceCode === 'fallback') {
1333
+ logger.error('"fallback" is reserved for the in-memory world — capture it under a different instance code');
1334
+ process.exit(1);
1335
+ }
1336
+ if (options.import !== undefined) {
1337
+ logger.error('--export-current-fallbackworld cannot be combined with --import');
1338
+ process.exit(1);
1339
+ }
1340
+ }
1341
+
1743
1342
  if (!instanceCode) {
1744
- instanceCode = 'amethyst-strata-expansion,FOREST';
1343
+ instanceCode = 'amethyst-strata-expansion,FOREST,TEST';
1745
1344
  logger.warn(`No instance code provided, defaulting to: ${instanceCode}`);
1746
1345
  }
1747
1346
 
1347
+ // An explicitly named env must exist: falling through to the ambient `./.env` resolves the
1348
+ // instance against whatever deployment was loaded last, which is not the one the caller named.
1349
+ if (options.envPath && !fs.existsSync(options.envPath)) {
1350
+ logger.error(`Env file not found: ${options.envPath}`);
1351
+ process.exit(1);
1352
+ }
1748
1353
  if (!options.envPath) options.envPath = `./.env`;
1749
1354
  if (fs.existsSync(options.envPath)) dotenv.config({ path: options.envPath, override: true });
1750
1355
 
@@ -1766,15 +1371,13 @@ try {
1766
1371
  }
1767
1372
 
1768
1373
  if (options.publish || options.publishBuild || options.publishRemove) {
1374
+ // The instances checkout is cloned by the deploy user and driven by root during a deploy,
1375
+ // and git refuses to touch a tree owned by someone else until it is declared safe.
1376
+ Underpost.repo.declareSafeDirectory('/home/dd/cyberia-instances');
1769
1377
  if (options.publishBuild) {
1770
- if (!fs.existsSync('/home/dd/cyberia-instances')) {
1771
- shellExec('cd /home/dd && underpost clone underpostnet/cyberia-instances');
1772
- } else {
1773
- shellExec(`underpost run clean /home/dd/cyberia-instances`);
1774
- shellExec(`cd /home/dd/cyberia-instances && underpost pull . underpostnet/cyberia-instances`, {
1775
- silentOnError: true,
1776
- });
1777
- }
1378
+ if (!fs.existsSync('/home/dd/cyberia-instances'))
1379
+ shellExec(`cd /home/dd && ${cli()} clone underpostnet/cyberia-instances`);
1380
+ else shellExec(`cd /home/dd/cyberia-instances && ${cli()} cmt --switch-repo underpostnet/cyberia-instances`);
1778
1381
 
1779
1382
  fs.mkdirpSync(`/home/dd/cyberia-instances/conf/dd-cyberia`);
1780
1383
  fs.copyFileSync(
@@ -1798,40 +1401,24 @@ try {
1798
1401
  `/home/dd/cyberia-instances/conf/dd-cyberia/conf.volume.json`,
1799
1402
  );
1800
1403
  {
1801
- const catalog = await loadDeployCatalog('dd-cyberia');
1802
- fs.copyFileSync(
1803
- `./engine-private/conf/dd-cyberia/package.json`,
1804
- `/home/dd/cyberia-instances/conf/dd-cyberia/package.json`,
1805
- );
1806
- const originPackageJson = JSON.parse(
1807
- fs.readFileSync(`./engine-private/conf/dd-cyberia/package.json`, 'utf-8'),
1808
- );
1809
- const scriptsOrigin = originPackageJson.scripts;
1810
- const scriptsTarget = JSON.parse(fs.readFileSync(`./package.json`, 'utf-8')).scripts;
1811
- originPackageJson.name = 'cyberia';
1812
- originPackageJson.bin = {
1813
- cyberia: 'bin/index.js',
1814
- };
1815
- originPackageJson.keywords = catalog.keywords;
1816
- originPackageJson.description = catalog.description;
1404
+ // The published manifest is the deploy's, under the product's own identity — one
1405
+ // builder for every generated package.json in the project, so the instances repo
1406
+ // cannot drift from what the deploy and the product CLI declare.
1407
+ const deployPackagePath = deployPackagePathFactory('dd-cyberia');
1817
1408
  fs.writeFileSync(
1818
1409
  `/home/dd/cyberia-instances/conf/dd-cyberia/package.json`,
1819
- JSON.stringify(
1820
- {
1821
- ...originPackageJson,
1822
- scripts: {
1823
- ...scriptsTarget,
1824
- start: scriptsOrigin.start,
1825
- ...DOCKER_SCRIPTS,
1826
- },
1827
- dependencies: {
1828
- ...originPackageJson.dependencies,
1829
- ...CyberiaDependencies,
1830
- },
1831
- },
1410
+ `${JSON.stringify(
1411
+ buildDeployPackageJson({
1412
+ deployId: 'dd-cyberia',
1413
+ enginePackageJson: JSON.parse(fs.readFileSync(`./package.json`, 'utf-8')),
1414
+ catalog: await loadDeployCatalog('dd-cyberia'),
1415
+ currentPackageJson: JSON.parse(fs.readFileSync(deployPackagePath, 'utf-8')),
1416
+ productIdentity: true,
1417
+ }),
1832
1418
  null,
1833
- 2,
1834
- ),
1419
+ DEPLOY_MANIFEST_INDENT,
1420
+ )}\n`,
1421
+ 'utf8',
1835
1422
  );
1836
1423
  }
1837
1424
  fs.copyFileSync(
@@ -1848,7 +1435,11 @@ try {
1848
1435
  );
1849
1436
 
1850
1437
  fs.mkdirpSync(`/home/dd/cyberia-instances/deployments`);
1851
- fs.copySync(`./src/runtime/engine-cyberia`, `/home/dd/cyberia-instances/deployments/engine-cyberia`);
1438
+ // The staged CLI package is a local image-build artifact, not a deployment manifest —
1439
+ // it must never be published into the instances repository.
1440
+ fs.copySync(`./src/runtime/engine-cyberia`, `/home/dd/cyberia-instances/deployments/engine-cyberia`, {
1441
+ filter: (src) => nodePath.basename(src) !== STAGED_CLI_PACKAGE,
1442
+ });
1852
1443
  fs.copySync(
1853
1444
  `./manifests/deployment/dd-cyberia-development/.`,
1854
1445
  `/home/dd/cyberia-instances/deployments/engine-cyberia/.`,
@@ -1863,35 +1454,21 @@ try {
1863
1454
  `./engine-private/conf/dd-cyberia/instances/mmo-server/build/development/.`,
1864
1455
  `/home/dd/cyberia-instances/deployments/cyberia-server/.`,
1865
1456
  );
1866
- fs.removeSync(`/home/dd/cyberia-instances/public/cyberia`);
1867
- fs.mkdirpSync(`/home/dd/cyberia-instances/public/cyberia`);
1868
- for (const assetPath of Object.keys(
1869
- JSON.parse(fs.readFileSync(`./engine-private/conf/dd-cyberia/storage.engine-cyberia.json`, 'utf-8')),
1870
- )) {
1871
- const relativePath = assetPath.replace(/^src\/client\/public\/cyberia\//, '');
1872
- const targetPath = `/home/dd/cyberia-instances/public/cyberia/${relativePath}`;
1873
- fs.mkdirpSync(nodePath.dirname(targetPath));
1874
- logger.info(`Copying asset: ${assetPath} → ${targetPath}`);
1875
- fs.copySync(`./${assetPath}`, targetPath);
1876
- }
1877
-
1878
- // Copy default-items asset folders (src/client/public/cyberia/assets/<type>/<id>/ → public/cyberia/assets/<type>/<id>/)
1879
- for (const entry of DefaultCyberiaItems) {
1880
- const { id, type } = entry.item;
1881
- const srcDir = `src/client/public/cyberia/assets/${type}/${id}`;
1882
- const targetDir = `/home/dd/cyberia-instances/public/cyberia/assets/${type}/${id}`;
1883
- if (fs.existsSync(srcDir)) {
1884
- fs.mkdirpSync(nodePath.dirname(targetDir));
1885
- logger.info(`Copying default-item asset: ${srcDir} → ${targetDir}`);
1886
- fs.copySync(srcDir, targetDir);
1887
- } else {
1888
- logger.warn(`Default-item asset directory not found, skipping: ${srcDir}`);
1889
- }
1890
- }
1891
-
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
+ );
1463
+ // fs.removeSync(`/home/dd/cyberia-instances/public/cyberia`);
1464
+ // fs.mkdirpSync(`/home/dd/cyberia-instances/public/cyberia`);
1892
1465
  fs.mkdirpSync(`/home/dd/cyberia-instances/instances`);
1893
1466
  fs.mkdirpSync(`/home/dd/cyberia-instances/sagas`);
1894
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`);
1895
1472
  fs.copySync(
1896
1473
  `./engine-private/cyberia-instances/${_instanceCode}`,
1897
1474
  `/home/dd/cyberia-instances/instances/${_instanceCode}`,
@@ -1908,6 +1485,19 @@ try {
1908
1485
  '/home/dd/cyberia-instances/conf/dd-cyberia/conf.instances.json',
1909
1486
  );
1910
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
+ );
1911
1501
  const fromN = parseInt(options.fromNCommit) > 0 ? parseInt(options.fromNCommit) : 1;
1912
1502
  const publishMessage =
1913
1503
  shellExec(`node bin cmt --changelog-msg --from-n-commit ${fromN} --changelog-no-hash`, {
@@ -1945,7 +1535,7 @@ try {
1945
1535
  shellExec(`rm -rf /home/dd/cyberia-instances/sagas/${instanceCode}.json`);
1946
1536
  return;
1947
1537
  }
1948
- shellExec(`cd /home/dd/cyberia-instances && underpost push . underpostnet/cyberia-instances`);
1538
+ shellExec(`cd /home/dd/cyberia-instances && ${cli()} push . underpostnet/cyberia-instances`);
1949
1539
  return;
1950
1540
  }
1951
1541
 
@@ -1972,6 +1562,8 @@ try {
1972
1562
  'cyberia-skill',
1973
1563
  'cyberia-entity-type-default',
1974
1564
  'cyberia-saga',
1565
+ 'cyberia-audio',
1566
+ 'cyberia-map-audio-conf',
1975
1567
  'object-layer',
1976
1568
  'object-layer-render-frames',
1977
1569
  'atlas-sprite-sheet',
@@ -1992,6 +1584,8 @@ try {
1992
1584
  const CyberiaSkill = DataBaseProviderService.getModel('cyberia-skill', { host, path });
1993
1585
  const CyberiaEntityTypeDefault = DataBaseProviderService.getModel('cyberia-entity-type-default', { host, path });
1994
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 });
1995
1589
  const ObjectLayer = DataBaseProviderService.getModel('object-layer', { host, path });
1996
1590
  const ObjectLayerRenderFrames = DataBaseProviderService.getModel('object-layer-render-frames', { host, path });
1997
1591
  const AtlasSpriteSheet = DataBaseProviderService.getModel('atlas-sprite-sheet', { host, path });
@@ -2012,8 +1606,8 @@ try {
2012
1606
  atlasMetadata: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
2013
1607
  });
2014
1608
 
2015
- // Canonical pins now carry a single `mfsPath`; `mfsPaths` (plural) is only read
2016
- // 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.
2017
1611
  const collectMfsPaths = (doc = {}) => {
2018
1612
  const paths = new Set();
2019
1613
  if (doc.mfsPath) paths.add(doc.mfsPath);
@@ -2069,10 +1663,9 @@ try {
2069
1663
  ...(entry.mfsPath ? { mfsPath: entry.mfsPath } : {}),
2070
1664
  }));
2071
1665
 
2072
- // Bring the live Ipfs collection in line with the mfsPath-unique model: collapse
2073
- // duplicate mfsPath rows (keeping the most recently updated association) then sync
2074
- // indexes so the legacy {cid,resourceType} unique index is dropped and the new
2075
- // 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.
2076
1669
  const reconcileIpfsRegistryIndexes = async () => {
2077
1670
  let removedDuplicates = 0;
2078
1671
  const duplicateGroups = await Ipfs.aggregate([
@@ -2124,6 +1717,111 @@ try {
2124
1717
  }
2125
1718
  };
2126
1719
 
1720
+ // ── CAPTURE CURRENT FALLBACK WORLD ──────────────────────────────
1721
+ //
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.
1726
+ if (options.exportCurrentFallbackworld) {
1727
+ const { generateFallbackWorld } = await import('../src/api/cyberia-instance/cyberia-fallback-world.js');
1728
+ const { captureFallbackWorld } = await import('../src/api/cyberia-instance/cyberia-fallback-capture.js');
1729
+
1730
+ let world;
1731
+ if (options.fallbackUrl) {
1732
+ // Staged fallback default items live only in the serving engine
1733
+ // process, so a faithful capture of a live world must read it back
1734
+ // over REST instead of regenerating it here.
1735
+ const base = options.fallbackUrl.replace(/\/+$/, '');
1736
+ const worldUrl = base.includes('/fallback-world') ? base : `${base}/api/cyberia-instance/fallback-world`;
1737
+ logger.info('Fetching live fallback world', { url: worldUrl });
1738
+ const response = await fetch(worldUrl);
1739
+ if (!response.ok) {
1740
+ logger.error(`Fallback world fetch failed: ${response.status} ${response.statusText}`, { url: worldUrl });
1741
+ await DataBaseProviderService.getProvider({ host, path }, 'mongoose').close();
1742
+ process.exit(1);
1743
+ }
1744
+ const payload = await response.json();
1745
+ world = payload?.data ?? payload;
1746
+ } else {
1747
+ world = generateFallbackWorld();
1748
+ }
1749
+
1750
+ if (!world?.instance || !Array.isArray(world.maps) || world.maps.length === 0) {
1751
+ logger.error('Fallback world payload has no maps — nothing to capture');
1752
+ await DataBaseProviderService.getProvider({ host, path }, 'mongoose').close();
1753
+ process.exit(1);
1754
+ }
1755
+
1756
+ const capture = await captureFallbackWorld({
1757
+ models: {
1758
+ CyberiaInstance,
1759
+ CyberiaInstanceConf,
1760
+ CyberiaMap,
1761
+ CyberiaAction,
1762
+ CyberiaQuest,
1763
+ CyberiaSkill,
1764
+ CyberiaEntityTypeDefault,
1765
+ CyberiaDialogue,
1766
+ CyberiaMapAudioConf,
1767
+ CyberiaAudio,
1768
+ ObjectLayer,
1769
+ },
1770
+ world,
1771
+ instanceCode,
1772
+ keepFallbackCodes: !!options.keepFallbackCodes,
1773
+ });
1774
+
1775
+ // Sprites are the one thing a capture cannot synthesise: without their
1776
+ // ObjectLayer documents the backup would export atlas-less items and
1777
+ // the restored world would render solid-colour rectangles.
1778
+ if (capture.missingObjectLayerItemIds.length > 0) {
1779
+ logger.error(
1780
+ `Capture aborted: ${capture.missingObjectLayerItemIds.length} referenced item id(s) have no ObjectLayer in MongoDB:`,
1781
+ capture.missingObjectLayerItemIds.join(', '),
1782
+ `— run \`node bin/cyberia ol ${capture.missingObjectLayerItemIds.join(',')} --from-directory --import\` (or ` +
1783
+ '`node bin/cyberia run-workflow import-default-items`) first.',
1784
+ );
1785
+ await DataBaseProviderService.getProvider({ host, path }, 'mongoose').close();
1786
+ process.exit(1);
1787
+ }
1788
+
1789
+ logger.info('Captured fallback world into MongoDB', {
1790
+ code: instanceCode,
1791
+ mapCodes: capture.plan.instance.cyberiaMapCodes,
1792
+ actions: capture.plan.actions.length,
1793
+ quests: capture.plan.quests.length,
1794
+ audioConfs: capture.audio.audioConfs,
1795
+ objectLayerItemIds: capture.plan.itemIds.length,
1796
+ });
1797
+
1798
+ // The capture is only half the command — fall through to the export so
1799
+ // it lands in ./engine-private/cyberia-instances/<instance-code>.
1800
+ if (options.export === undefined) options.export = true;
1801
+ }
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
+
2127
1825
  // ── EXPORT ──────────────────────────────────────────────────────
2128
1826
  if (options.export !== undefined) {
2129
1827
  const instance = await CyberiaInstance.findOne({ code: instanceCode }).lean();
@@ -2139,6 +1837,25 @@ try {
2139
1837
  : `./engine-private/cyberia-instances/${instanceCode}`;
2140
1838
 
2141
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
+ }
2142
1859
  logger.info('Exporting instance', { code: instanceCode, backupDir });
2143
1860
 
2144
1861
  // Helper: export a File document to the files/ directory
@@ -2168,6 +1885,11 @@ try {
2168
1885
  // 1b. Export linked CyberiaInstanceConf (skillRules, equipmentRules, entityDefaults, etc.)
2169
1886
  // If no conf doc exists yet (instance created before auto-upsert logic), create one using
2170
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 });
2171
1893
  let instanceConf =
2172
1894
  (await CyberiaInstanceConf.findOne({ instanceCode }).lean()) ||
2173
1895
  (instance.conf ? await CyberiaInstanceConf.findById(instance.conf).lean() : null);
@@ -2185,10 +1907,10 @@ try {
2185
1907
  instanceConf = created?.toObject ? created.toObject() : created;
2186
1908
  }
2187
1909
  if (instanceConf) {
2188
- // `.lean()` skips Mongoose schema defaults and older docs may predate
2189
- // some fields, so backfill every CyberiaInstanceConfSchema field from
2190
- // the canonical defaults before writing the backup.
2191
- 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));
2192
1914
  fs.writeJsonSync(`${backupDir}/cyberia-instance-conf.json`, instanceConf, { spaces: 2 });
2193
1915
  logger.info('Exported CyberiaInstanceConf', { instanceCode });
2194
1916
  } else {
@@ -2225,6 +1947,50 @@ try {
2225
1947
  }
2226
1948
  logger.info(`Exported ${maps.length} CyberiaMap document(s)`, { codes: maps.map((m) => m.code) });
2227
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
+
2228
1994
  // 3b. Export quests + actions bound to THIS instance's maps (sourceMapCode
2229
1995
  // in the instance's map codes) — only the content tied to this instance.
2230
1996
  // Dialogue codes the actions reference are collected so they travel too.
@@ -2255,103 +2021,51 @@ try {
2255
2021
  logger.info(`Exported ${actions.length} CyberiaAction document(s)`, { codes: actions.map((a) => a.code) });
2256
2022
  }
2257
2023
 
2258
- // 4. Collect all objectLayerItemIds from map entities
2259
- const objectLayerItemIds = new Set();
2260
- for (const map of maps) {
2261
- for (const entity of map.entities || []) {
2262
- for (const itemId of entity.objectLayerItemIds || []) {
2263
- objectLayerItemIds.add(itemId);
2264
- }
2265
- }
2266
- }
2267
-
2268
- // 4b. Add instance-level itemIds ({ id, defaultPlayerInventory }).
2269
- for (const entry of instance.itemIds || []) {
2270
- const id = typeof entry === 'string' ? entry : entry?.id;
2271
- if (id) objectLayerItemIds.add(id);
2272
- }
2273
-
2274
- const contentItemIds = new Set(objectLayerItemIds);
2275
-
2276
- // 4c. Add all itemIds referenced by CyberiaInstanceConf (entityDefaults + skillConfig).
2277
- // This ensures liveItemIds, deadItemIds, dropItemIds, defaultObjectLayers and
2278
- // skill trigger items are included even if no map entity currently uses them.
2279
- if (instanceConf) {
2280
- for (const ed of instanceConf.entityDefaults || []) {
2281
- for (const id of ed.liveItemIds || []) if (id) objectLayerItemIds.add(id);
2282
- for (const id of ed.deadItemIds || []) if (id) objectLayerItemIds.add(id);
2283
- for (const id of ed.dropItemIds || []) if (id) objectLayerItemIds.add(id);
2284
- for (const slot of ed.defaultObjectLayers || []) {
2285
- if (slot.itemId) objectLayerItemIds.add(slot.itemId);
2286
- }
2287
- }
2288
- for (const sc of instanceConf.skillConfig || []) {
2289
- if (sc.triggerItemId) objectLayerItemIds.add(sc.triggerItemId);
2290
- for (const skill of sc.skills || []) {
2291
- if (skill.summonedEntityItemId && !skill.summonedEntityItemId.startsWith('$')) {
2292
- objectLayerItemIds.add(skill.summonedEntityItemId);
2293
- }
2294
- }
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 });
2295
2037
  }
2038
+ logger.info(`Exported ${entityDefaults.length} CyberiaEntityTypeDefault document(s)`, {
2039
+ entityTypes: entityDefaults.map((ed) => ed.entityType),
2040
+ });
2296
2041
  }
2297
2042
 
2298
- // 4d. Export skills whose trigger item belongs to this instance (own model:
2299
- // CyberiaSkill, keyed by triggerItemId). Their summoned-entity items are
2300
- // added to the OL set so those atlases export too. Runs before the
2301
- // dialogue + OL queries so the summoned ids are included.
2302
- if (objectLayerItemIds.size > 0) {
2303
- 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);
2304
2055
  if (skills.length > 0) {
2305
2056
  fs.ensureDirSync(`${backupDir}/cyberia-skills`);
2306
2057
  for (const skill of skills) {
2307
2058
  fs.writeJsonSync(`${backupDir}/cyberia-skills/${encodeURIComponent(skill.triggerItemId)}.json`, skill, {
2308
2059
  spaces: 2,
2309
2060
  });
2310
- for (const def of skill.skills || []) {
2311
- if (def.summonedEntityItemId && !def.summonedEntityItemId.startsWith('$')) {
2312
- objectLayerItemIds.add(def.summonedEntityItemId);
2313
- }
2314
- }
2315
2061
  }
2062
+ for (const summoned of collectSummonedItemIds(skills)) objectLayerItemIds.add(summoned);
2316
2063
  logger.info(`Exported ${skills.length} CyberiaSkill document(s)`, {
2317
2064
  triggerItemIds: skills.map((sk) => sk.triggerItemId),
2318
2065
  });
2319
2066
  }
2320
2067
  }
2321
2068
 
2322
- // 4d-bis. Export entity-type defaults whose item ids belong to this
2323
- // instance's real content (own model: CyberiaEntityTypeDefault). A
2324
- // default is related when any of its live/dead/drop ids or default
2325
- // object-layer ids appears in contentItemIds — map/instance content
2326
- // only, NOT the canonical conf defaults every instance shares (which
2327
- // would spuriously drag in the global seed entity-type-defaults).
2328
- // Matched ids are folded back into objectLayerItemIds so the related
2329
- // atlases + dialogues export too.
2330
- if (contentItemIds.size > 0) {
2331
- const idsForMatch = [...contentItemIds];
2332
- const entityDefaults = await CyberiaEntityTypeDefault.find({
2333
- $or: [
2334
- { liveItemIds: { $in: idsForMatch } },
2335
- { deadItemIds: { $in: idsForMatch } },
2336
- { dropItemIds: { $in: idsForMatch } },
2337
- { 'defaultObjectLayers.itemId': { $in: idsForMatch } },
2338
- ],
2339
- }).lean();
2340
- if (entityDefaults.length > 0) {
2341
- fs.ensureDirSync(`${backupDir}/cyberia-entity-type-defaults`);
2342
- for (const ed of entityDefaults) {
2343
- fs.writeJsonSync(`${backupDir}/cyberia-entity-type-defaults/${ed._id}.json`, ed, { spaces: 2 });
2344
- for (const id of ed.liveItemIds || []) if (id) objectLayerItemIds.add(id);
2345
- for (const id of ed.deadItemIds || []) if (id) objectLayerItemIds.add(id);
2346
- for (const id of ed.dropItemIds || []) if (id) objectLayerItemIds.add(id);
2347
- for (const slot of ed.defaultObjectLayers || []) if (slot.itemId) objectLayerItemIds.add(slot.itemId);
2348
- }
2349
- logger.info(`Exported ${entityDefaults.length} CyberiaEntityTypeDefault document(s)`, {
2350
- entityTypes: entityDefaults.map((ed) => ed.entityType),
2351
- });
2352
- }
2353
- }
2354
-
2355
2069
  // 4e. Export sagas related to this instance. A saga is considered related
2356
2070
  // when its code matches the instance code (direct namespace match), or
2357
2071
  // when its mapCodes or itemIds overlap with the instance's data.
@@ -2359,22 +2073,7 @@ try {
2359
2073
  // conf-default, and skill-summoned item IDs — giving the broadest possible
2360
2074
  // match surface for saga discovery.
2361
2075
  const sagaCodeMatch = instanceCode ? await CyberiaSaga.find({ code: instanceCode }).lean() : [];
2362
- // Disabling overlaps queries for now because they can be very expensive and are not strictly necessary for a backup.
2363
- const sagaMapOverlap = true
2364
- ? []
2365
- : mapCodes.size > 0
2366
- ? await CyberiaSaga.find({ mapCodes: { $in: [...mapCodes] } }).lean()
2367
- : [];
2368
- const sagaItemOverlap = true
2369
- ? []
2370
- : objectLayerItemIds.size > 0
2371
- ? await CyberiaSaga.find({ itemIds: { $in: [...objectLayerItemIds] } }).lean()
2372
- : [];
2373
- const allSagas = [
2374
- ...new Map(
2375
- [...sagaCodeMatch, ...sagaMapOverlap, ...sagaItemOverlap].map((s) => [s._id.toString(), s]),
2376
- ).values(),
2377
- ];
2076
+ const allSagas = [...new Map(sagaCodeMatch.map((s) => [s._id.toString(), s])).values()];
2378
2077
  if (allSagas.length > 0) {
2379
2078
  fs.ensureDirSync(`${backupDir}/cyberia-sagas`);
2380
2079
  for (const saga of allSagas) {
@@ -2486,8 +2185,9 @@ try {
2486
2185
  if (atlas) {
2487
2186
  const atlasExport = newInstance(atlas);
2488
2187
  objectLayerExport.atlasSpriteSheetId = atlas._id;
2489
- if (atlas.fileId) {
2490
- 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));
2491
2191
  }
2492
2192
 
2493
2193
  const atlasFile = atlas.fileId ? await File.findById(atlas.fileId).lean() : null;
@@ -2683,15 +2383,15 @@ try {
2683
2383
 
2684
2384
  logger.info('Importing instance', { code: instanceCode, backupDir });
2685
2385
 
2686
- // Idempotent: a backup restore writes object layers, so collapse any
2687
- // 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.
2688
2388
  const { removedIds, indexUpgraded } = await ObjectLayer.ensureUniqueItemIdIndex();
2689
2389
  if (removedIds.length > 0) logger.warn(`Removed ${removedIds.length} duplicate ObjectLayer document(s)`);
2690
2390
  if (indexUpgraded) logger.info('Upgraded data.item.id index to unique');
2691
2391
 
2692
- // Item ids belonging to this instance (collected from imported object
2693
- // layers + the instance doc) used to backfill missing skills from the
2694
- // 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.
2695
2395
  const importedItemIds = new Set();
2696
2396
 
2697
2397
  // 0. Drop existing documents if --drop is set
@@ -2712,30 +2412,17 @@ try {
2712
2412
  // Query other instances/maps for shared thumbnail exclusion
2713
2413
  const otherInstances = await CyberiaInstance.find({ code: { $ne: instanceCode } }, { thumbnail: 1 }).lean();
2714
2414
 
2715
- // Add instance-level itemIds (may not appear in any map entity)
2716
- for (const entry of existingInstance.itemIds || []) {
2717
- const id = typeof entry === 'string' ? entry : entry?.id;
2718
- if (id) dropOlItemIds.add(id);
2719
- }
2720
-
2721
- // Add conf entityDefaults and skillConfig itemIds (liveItemIds, deadItemIds, dropItemIds, defaultObjectLayers)
2415
+ // Add the item ids the conf's referenced entity-type defaults name.
2722
2416
  const existingConf =
2723
2417
  (await CyberiaInstanceConf.findOne({ instanceCode }).lean()) ||
2724
2418
  (existingInstance.conf ? await CyberiaInstanceConf.findById(existingInstance.conf).lean() : null);
2725
2419
  if (existingConf) {
2726
- for (const ed of existingConf.entityDefaults || []) {
2727
- for (const id of ed.liveItemIds || []) if (id) dropOlItemIds.add(id);
2728
- for (const id of ed.deadItemIds || []) if (id) dropOlItemIds.add(id);
2729
- for (const id of ed.dropItemIds || []) if (id) dropOlItemIds.add(id);
2730
- for (const slot of ed.defaultObjectLayers || []) if (slot.itemId) dropOlItemIds.add(slot.itemId);
2731
- }
2732
- for (const sc of existingConf.skillConfig || []) {
2733
- if (sc.triggerItemId) dropOlItemIds.add(sc.triggerItemId);
2734
- for (const skill of sc.skills || []) {
2735
- if (skill.summonedEntityItemId && !skill.summonedEntityItemId.startsWith('$'))
2736
- dropOlItemIds.add(skill.summonedEntityItemId);
2737
- }
2738
- }
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);
2739
2426
  }
2740
2427
 
2741
2428
  const otherMaps = await CyberiaMap.find(
@@ -2759,6 +2446,12 @@ try {
2759
2446
  const mapResult = await CyberiaMap.deleteMany({ code: { $in: [...dropMapCodes] } });
2760
2447
  logger.info(`Dropped ${mapResult.deletedCount} CyberiaMap document(s)`);
2761
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
+
2762
2455
  // Quests + actions are bound to maps by sourceMapCode, so they drop
2763
2456
  // with this instance's maps — only the content tied to this instance.
2764
2457
  const questResult = await CyberiaQuest.deleteMany({ sourceMapCode: { $in: [...dropMapCodes] } });
@@ -2791,6 +2484,15 @@ try {
2791
2484
  }
2792
2485
  }
2793
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
+
2794
2496
  // Exclude OL item IDs referenced by maps outside this instance
2795
2497
  const sharedOlItemIds = new Set();
2796
2498
  for (const m of otherMaps) {
@@ -2850,19 +2552,17 @@ try {
2850
2552
  if (doc.atlasSpriteSheetId) atlasIds.push(doc.atlasSpriteSheetId);
2851
2553
  }
2852
2554
 
2853
- // Delete AtlasSpriteSheet + referenced File docs
2854
- if (atlasIds.length > 0) {
2855
- const atlasDocs = await AtlasSpriteSheet.find({ _id: { $in: atlasIds } }, { fileId: 1, cid: 1 }).lean();
2856
- const atlasFileIds = atlasDocs.map((a) => a.fileId).filter(Boolean);
2857
- for (const atlas of atlasDocs) {
2858
- if (atlas.cid) cidsToUnpin.add(atlas.cid);
2859
- }
2860
- if (atlasFileIds.length > 0) {
2861
- const fileResult = await File.deleteMany({ _id: { $in: atlasFileIds } });
2862
- logger.info(`Dropped ${fileResult.deletedCount} File document(s) (atlas)`);
2863
- }
2864
- const atlasResult = await AtlasSpriteSheet.deleteMany({ _id: { $in: atlasIds } });
2865
- 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)`);
2866
2566
  }
2867
2567
 
2868
2568
  // Delete RenderFrames
@@ -2919,13 +2619,16 @@ try {
2919
2619
  const confImportPath = `${backupDir}/cyberia-instance-conf.json`;
2920
2620
  let importedConf = null;
2921
2621
  if (fs.existsSync(confImportPath)) {
2922
- // Backfill any missing schema fields so older backups import a
2923
- // complete, playable config into the DB.
2924
- 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
+ );
2925
2628
  if (confData._id) await CyberiaInstanceConf.deleteOne({ _id: confData._id });
2926
2629
  await CyberiaInstanceConf.deleteOne({ instanceCode: confData.instanceCode });
2927
- // Always bump updatedAt so the Go server's version hash changes and
2928
- // 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.
2929
2632
  confData.updatedAt = new Date();
2930
2633
  importedConf = await CyberiaInstanceConf.create(confData);
2931
2634
  logger.info('Imported CyberiaInstanceConf', { instanceCode: confData.instanceCode });
@@ -2933,11 +2636,10 @@ try {
2933
2636
  logger.warn(`CyberiaInstanceConf backup not found: ${confImportPath}`);
2934
2637
  }
2935
2638
 
2936
- // In --conf mode we must NOT delete + recreate the CyberiaInstance because
2937
- // that would overwrite cyberiaMapCodes / portals / itemIds with whatever was
2938
- // in the (possibly stale) backup, effectively removing the live maps and OLs
2939
- // from the instance. Only update the conf ref and bump updatedAt so the Go
2940
- // 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.
2941
2643
  if (importedConf) {
2942
2644
  const result = await CyberiaInstance.updateOne(
2943
2645
  { code: instanceCode },
@@ -3001,6 +2703,7 @@ try {
3001
2703
 
3002
2704
  // 3. Import AtlasSpriteSheet
3003
2705
  const atlasDir = `${backupDir}/atlas-sprite-sheets`;
2706
+ const atlasesWithoutMinify = new Set();
3004
2707
  if (fs.existsSync(atlasDir)) {
3005
2708
  const atlasFiles = fs.readdirSync(atlasDir).filter((f) => f.endsWith('.json'));
3006
2709
  let atlasCount = 0;
@@ -3014,6 +2717,22 @@ try {
3014
2717
  atlasCount++;
3015
2718
  }
3016
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 } });
3017
2736
  }
3018
2737
 
3019
2738
  // 4. Import object layers
@@ -3043,6 +2762,36 @@ try {
3043
2762
  logger.info(`Imported ${olCount} ObjectLayer document(s)`);
3044
2763
  }
3045
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
+
3046
2795
  // 4b. Regenerate static frame PNGs from imported render-frames + object-layer documents.
3047
2796
  // Mirrors the writeStaticFrameAssets call in `ol --import` so src/client/public/cyberia
3048
2797
  // and the public/<host><path> deployment dir are populated even when the cyberia
@@ -3081,7 +2830,7 @@ try {
3081
2830
  itemId,
3082
2831
  objectLayerRenderFramesData,
3083
2832
  objectLayerData: olData,
3084
- cellPixelDim: 20,
2833
+ cellPixelDim: DEFAULT_ATLAS_UPSCALE_FACTOR,
3085
2834
  });
3086
2835
  staticWriteCount += written.length;
3087
2836
  } catch (err) {
@@ -3106,12 +2855,69 @@ try {
3106
2855
  logger.info(`Imported ${mapCount} CyberiaMap document(s)`);
3107
2856
  }
3108
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
+
3109
2912
  // 6. Import CyberiaInstanceConf (skillRules, equipmentRules, entityDefaults, etc.)
3110
2913
  const confImportPath = `${backupDir}/cyberia-instance-conf.json`;
3111
2914
  if (fs.existsSync(confImportPath)) {
3112
- // Backfill any missing schema fields so older backups import a
3113
- // complete, playable config into the DB.
3114
- 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
+ );
3115
2921
  if (confData._id) await CyberiaInstanceConf.deleteOne({ _id: confData._id });
3116
2922
  await CyberiaInstanceConf.deleteOne({ instanceCode: confData.instanceCode });
3117
2923
  await CyberiaInstanceConf.create(confData);
@@ -3124,13 +2930,6 @@ try {
3124
2930
  const instancePath = `${backupDir}/cyberia-instance.json`;
3125
2931
  if (fs.existsSync(instancePath)) {
3126
2932
  const instanceData = fs.readJsonSync(instancePath);
3127
- // Heal legacy shapes against the current model. itemIds migrated from a
3128
- // flat string[] to [{ id, defaultPlayerInventory }] — a raw old backup
3129
- // would fail Mongoose embedded-cast validation, so normalize it here.
3130
- instanceData.itemIds = (instanceData.itemIds || [])
3131
- .map((entry) => (typeof entry === 'string' ? { id: entry, defaultPlayerInventory: false } : entry))
3132
- .filter((entry) => entry && entry.id);
3133
- for (const entry of instanceData.itemIds) importedItemIds.add(entry.id);
3134
2933
  await CyberiaInstance.deleteOne({ code: instanceCode });
3135
2934
  await CyberiaInstance.deleteOne({ _id: instanceData._id });
3136
2935
  await CyberiaInstance.create(instanceData);
@@ -3225,29 +3024,32 @@ try {
3225
3024
  logger.info(`Imported ${skillCount} CyberiaSkill document(s)`);
3226
3025
  }
3227
3026
 
3228
- // 8d-bis. Import CyberiaEntityTypeDefault documents (own model, overwrite
3229
- // 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.
3230
3031
  const entityDefaultsDir = `${backupDir}/cyberia-entity-type-defaults`;
3231
3032
  if (fs.existsSync(entityDefaultsDir)) {
3232
3033
  const entityDefaultFiles = fs.readdirSync(entityDefaultsDir).filter((f) => f.endsWith('.json'));
3233
3034
  let entityDefaultCount = 0;
3234
3035
  for (const file of entityDefaultFiles) {
3235
3036
  const edData = fs.readJsonSync(`${entityDefaultsDir}/${file}`);
3236
- if (!edData.entityType) {
3237
- 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}`);
3238
3039
  continue;
3239
3040
  }
3240
- await CyberiaEntityTypeDefault.deleteMany({
3241
- entityType: edData.entityType,
3242
- liveItemIds: edData.liveItemIds || [],
3243
- });
3244
- if (edData._id) await CyberiaEntityTypeDefault.deleteOne({ _id: edData._id });
3041
+ await CyberiaEntityTypeDefault.deleteOne({ _id: edData._id });
3245
3042
  await CyberiaEntityTypeDefault.create(edData);
3246
3043
  entityDefaultCount++;
3247
3044
  }
3248
3045
  logger.info(`Imported ${entityDefaultCount} CyberiaEntityTypeDefault document(s)`);
3249
3046
  }
3250
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
+
3251
3053
  // 8e. Backfill missing skills from the canonical DefaultSkillConfig. Old
3252
3054
  // backups predate the CyberiaSkill model and ship no skills/ dir, so
3253
3055
  // any instance item that has a canonical skill (e.g. atlas_pistol_mk2,
@@ -3564,30 +3366,17 @@ try {
3564
3366
  // Query other instances for shared thumbnail exclusion
3565
3367
  const otherInstances = await CyberiaInstance.find({ code: { $ne: instanceCode } }, { thumbnail: 1 }).lean();
3566
3368
 
3567
- // Add instance-level itemIds (may not appear in any map entity)
3568
- for (const entry of existingInstance.itemIds || []) {
3569
- const id = typeof entry === 'string' ? entry : entry?.id;
3570
- if (id) dropOlItemIds.add(id);
3571
- }
3572
-
3573
- // Add conf entityDefaults and skillConfig itemIds (liveItemIds, deadItemIds, dropItemIds, defaultObjectLayers)
3369
+ // Add the item ids the conf's referenced entity-type defaults name.
3574
3370
  const existingConf =
3575
3371
  (await CyberiaInstanceConf.findOne({ instanceCode }).lean()) ||
3576
3372
  (existingInstance.conf ? await CyberiaInstanceConf.findById(existingInstance.conf).lean() : null);
3577
3373
  if (existingConf) {
3578
- for (const ed of existingConf.entityDefaults || []) {
3579
- for (const id of ed.liveItemIds || []) if (id) dropOlItemIds.add(id);
3580
- for (const id of ed.deadItemIds || []) if (id) dropOlItemIds.add(id);
3581
- for (const id of ed.dropItemIds || []) if (id) dropOlItemIds.add(id);
3582
- for (const slot of ed.defaultObjectLayers || []) if (slot.itemId) dropOlItemIds.add(slot.itemId);
3583
- }
3584
- for (const sc of existingConf.skillConfig || []) {
3585
- if (sc.triggerItemId) dropOlItemIds.add(sc.triggerItemId);
3586
- for (const skill of sc.skills || []) {
3587
- if (skill.summonedEntityItemId && !skill.summonedEntityItemId.startsWith('$'))
3588
- dropOlItemIds.add(skill.summonedEntityItemId);
3589
- }
3590
- }
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);
3591
3380
  }
3592
3381
 
3593
3382
  const otherMaps = await CyberiaMap.find(
@@ -3608,6 +3397,20 @@ try {
3608
3397
  }
3609
3398
  const mapResult = await CyberiaMap.deleteMany({ code: { $in: [...dropMapCodes] } });
3610
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);
3611
3414
  }
3612
3415
 
3613
3416
  // Exclude OL item IDs referenced by maps outside this instance
@@ -3664,18 +3467,15 @@ try {
3664
3467
  if (doc.atlasSpriteSheetId) atlasIds.push(doc.atlasSpriteSheetId);
3665
3468
  }
3666
3469
 
3667
- if (atlasIds.length > 0) {
3668
- const atlasDocs = await AtlasSpriteSheet.find({ _id: { $in: atlasIds } }, { fileId: 1, cid: 1 }).lean();
3669
- const atlasFileIds = atlasDocs.map((a) => a.fileId).filter(Boolean);
3670
- for (const atlas of atlasDocs) {
3671
- if (atlas.cid) cidsToUnpin.add(atlas.cid);
3672
- }
3673
- if (atlasFileIds.length > 0) {
3674
- const fileResult = await File.deleteMany({ _id: { $in: atlasFileIds } });
3675
- logger.info(`Dropped ${fileResult.deletedCount} File document(s) (atlas)`);
3676
- }
3677
- const atlasResult = await AtlasSpriteSheet.deleteMany({ _id: { $in: atlasIds } });
3678
- 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)`);
3679
3479
  }
3680
3480
 
3681
3481
  if (renderFrameIds.length > 0) {
@@ -3827,6 +3627,130 @@ try {
3827
3627
  }
3828
3628
  });
3829
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
+
3830
3754
  // ── generate-saga: Top-Down PCG guided by LLMs (Semantic Reverse-Engineering) ──
3831
3755
  program
3832
3756
  .command('generate-saga')
@@ -3836,7 +3760,7 @@ try {
3836
3760
  )
3837
3761
  .option('--import <file>', 'Load a previously generated payload file (the shape --out writes) into the database')
3838
3762
  .option('--model <model>', 'Gemini model id (default: gemma-4-26b-a4b-it)')
3839
- .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))
3840
3764
  .option('--thinking-level <level>', 'Gemini thinking level: low | medium | high (default: high)')
3841
3765
  .option(
3842
3766
  '--lore-path <path>',
@@ -4792,6 +4716,26 @@ try {
4792
4716
 
4793
4717
  const runner = program.command('run-workflow').description('Run a Cyberia script from the "scripts" directory');
4794
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
+
4795
4739
  runner
4796
4740
  .command('import-default-items')
4797
4741
  .option('--dev', 'Force development environment (loads .env.development for IPFS localhost, etc.)')
@@ -4830,21 +4774,83 @@ try {
4830
4774
  shellExec(
4831
4775
  `node bin/cyberia generate-saga --import engine-private/cyberia-sagas/${sagaCode}.json${devFlag}${mongoHostFlag}`,
4832
4776
  );
4833
- shellExec(`node bin/cyberia ol ${DefaultCyberiaItems.map((e) => e.item.id)} --import${devFlag}${mongoHostFlag}`);
4834
- shellExec(`node bin/cyberia run-workflow seed-skills${devFlag}${mongoHostFlag}`);
4835
- shellExec(`node bin/cyberia run-workflow seed-entities${devFlag}${mongoHostFlag}`);
4836
- shellExec(`node bin/cyberia run-workflow seed-dialogues${devFlag}${mongoHostFlag}`);
4837
- shellExec(`node bin/cyberia run-workflow seed-actions-quests${devFlag}${mongoHostFlag}`);
4838
- shellExec(`node bin/cyberia client-hints ${instanceHintsCode} --seed-defaults${devFlag}${mongoHostFlag}`);
4777
+ // shellExec(`node bin/cyberia ol ${DefaultCyberiaItems.map((e) => e.item.id)} --import${devFlag}${mongoHostFlag}`);
4778
+ // shellExec(`node bin/cyberia run-workflow seed-skills${devFlag}${mongoHostFlag}`);
4779
+ // shellExec(`node bin/cyberia run-workflow seed-entities${devFlag}${mongoHostFlag}`);
4780
+ // shellExec(`node bin/cyberia run-workflow seed-dialogues${devFlag}${mongoHostFlag}`);
4781
+ // shellExec(`node bin/cyberia run-workflow seed-actions-quests${devFlag}${mongoHostFlag}`);
4782
+ // shellExec(`node bin/cyberia client-hints ${instanceHintsCode} --seed-defaults${devFlag}${mongoHostFlag}`);
4839
4783
  shellExec(`node bin/cyberia instance ${sagaCode} --import${devFlag}`);
4840
4784
  shellExec(`node bin/cyberia instance FOREST --import${devFlag}`);
4785
+ shellExec(`node bin/cyberia instance TEST --import${devFlag}`);
4786
+ });
4787
+
4788
+ runner
4789
+ .command('stage-cli')
4790
+ .option('--output-path <output-path>', "Build context to stage the package in (default: '.')")
4791
+ .description('Packs this engine checkout as underpost-cli.tgz for a runtime image build context')
4792
+ .action((options) => stageCliPackage(options.outputPath || '.'));
4793
+
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
+ });
4841
4835
  });
4842
4836
 
4843
- runner.command('sync-src').action(() => {
4844
- fs.copyFileSync('./cyberia-server/README.md', './src/client/public/cyberia-docs/CYBERIA-SERVER.md');
4845
- fs.copyFileSync('./cyberia-server/Dockerfile', './src/runtime/cyberia-server/Dockerfile');
4846
- fs.copyFileSync('./cyberia-client/README.md', './src/client/public/cyberia-docs/CYBERIA-CLIENT.md');
4847
- fs.copyFileSync('./cyberia-client/Dockerfile', './src/runtime/cyberia-client/Dockerfile');
4837
+ runner.command('setup-workspace').action(() => {
4838
+ shellExec(`node bin fs src/client/public/cyberia --tracked --pull --deploy-id dd-cyberia`);
4839
+ shellExec(`node bin/deploy.js cyberia`);
4840
+ if (!fs.existsSync('./cyberia-server')) shellExec(`${cli()} clone underpostnet/cyberia-server`);
4841
+ if (!fs.existsSync('./cyberia-client')) shellExec(`${cli()} clone underpostnet/cyberia-client`);
4842
+ });
4843
+
4844
+ runner.command('e2e-build').action(() => {
4845
+ shellExec(`node bin run build-cluster-deployment-manifests`);
4846
+ shellExec(`node bin/cyberia run-workflow build-manifest`);
4847
+ shellExec(`node bin/cyberia run-workflow publish --dry-run`);
4848
+ shellExec(`npm run security`);
4849
+ shellExec(`sudo rm -rf ./conf.dd*.js`);
4850
+ });
4851
+
4852
+ runner.command('cluster').action(() => {
4853
+ shellExec(`node bin run cluster --runtime-image express --deploy-id dd-cyberia --instance-id mmo-server --dev`);
4848
4854
  });
4849
4855
 
4850
4856
  runner
@@ -4941,7 +4947,7 @@ try {
4941
4947
  .option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
4942
4948
  .option('--mongo-host <mongo-host>', 'Mongo host override')
4943
4949
  .option('--dev', 'Force development environment')
4944
- .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')
4945
4951
  .action(async (options = {}) => {
4946
4952
  if (!options.envPath) options.envPath = `./.env`;
4947
4953
  if (fs.existsSync(options.envPath)) dotenv.config({ path: options.envPath, override: true });
@@ -4984,30 +4990,37 @@ try {
4984
4990
  'cyberia-entity-type-default',
4985
4991
  'cyberia-client-hints',
4986
4992
  'cyberia-saga',
4993
+ 'cyberia-audio',
4994
+ 'cyberia-map-audio-conf',
4987
4995
  ];
4988
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
+
4989
5005
  await DataBaseProviderService.load({ apis: [...cyberiaCollections, 'file'], host, path, db });
4990
5006
 
4991
5007
  const File = DataBaseProviderService.getModel('file', { host, path });
4992
5008
 
4993
- // Thumbnails/previews on instances/maps are File _id references; collect
4994
- // them before dropping so the backing File documents don't leak as orphans.
4995
- const thumbnailFileIds = new Set();
4996
- for (const api of ['cyberia-instance', 'cyberia-map']) {
5009
+ const fileIds = new Set();
5010
+ for (const { api, fields } of fileReferences) {
4997
5011
  const Model = DataBaseProviderService.getModel(api, { host, path });
4998
5012
  const docs = await Model.find(
4999
- { $or: [{ thumbnail: { $ne: null } }, { preview: { $ne: null } }] },
5000
- { thumbnail: 1, preview: 1 },
5013
+ { $or: fields.map((field) => ({ [field]: { $ne: null } })) },
5014
+ Object.fromEntries(fields.map((field) => [field, 1])),
5001
5015
  ).lean();
5002
5016
  for (const doc of docs) {
5003
- if (doc.thumbnail) thumbnailFileIds.add(doc.thumbnail.toString());
5004
- if (doc.preview) thumbnailFileIds.add(doc.preview.toString());
5017
+ for (const field of fields) if (doc[field]) fileIds.add(doc[field].toString());
5005
5018
  }
5006
5019
  }
5007
5020
 
5008
- if (thumbnailFileIds.size > 0) {
5009
- const result = await File.deleteMany({ _id: { $in: [...thumbnailFileIds] } });
5010
- 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)`);
5011
5024
  }
5012
5025
 
5013
5026
  for (const api of cyberiaCollections) {
@@ -5029,6 +5042,44 @@ try {
5029
5042
  shellExec(`gh workflow run ${id}.cd.yml -R underpostnet/${id} -f job=deploy`);
5030
5043
  });
5031
5044
 
5045
+ runner.command('cp-assets').action(() => {
5046
+ for (const assetPath of Object.keys(
5047
+ JSON.parse(fs.readFileSync(`./engine-private/conf/dd-cyberia/storage.engine-cyberia.json`, 'utf-8')),
5048
+ )) {
5049
+ const relativePath = assetPath.replace(/^src\/client\/public\/cyberia\//, '');
5050
+ const targetPath = `/home/dd/cyberia-instances/public/cyberia/${relativePath}`;
5051
+ fs.mkdirpSync(nodePath.dirname(targetPath));
5052
+ logger.info(`Copying asset: ${assetPath} → ${targetPath}`);
5053
+ fs.copySync(`./${assetPath}`, targetPath);
5054
+ }
5055
+
5056
+ // Copy default-items asset folders (src/client/public/cyberia/assets/<type>/<id>/ → public/cyberia/assets/<type>/<id>/)
5057
+ for (const entry of DefaultCyberiaItems) {
5058
+ const { id, type } = entry.item;
5059
+ const srcDir = `src/client/public/cyberia/assets/${type}/${id}`;
5060
+ const targetDir = `/home/dd/cyberia-instances/public/cyberia/assets/${type}/${id}`;
5061
+ if (fs.existsSync(srcDir)) {
5062
+ fs.mkdirpSync(nodePath.dirname(targetDir));
5063
+ logger.info(`Copying default-item asset: ${srcDir} → ${targetDir}`);
5064
+ fs.copySync(srcDir, targetDir);
5065
+ } else {
5066
+ logger.warn(`Default-item asset directory not found, skipping: ${srcDir}`);
5067
+ }
5068
+ }
5069
+ });
5070
+
5071
+ runner
5072
+ .command('sync-cluster')
5073
+ .option('--build')
5074
+ .action((options) => {
5075
+ shellExec(`node bin/build dd-cyberia --update-private`);
5076
+ shellExec(`node bin/build dd-core --update-private`);
5077
+ if (options.build) return;
5078
+ shellExec(
5079
+ `node bin wireguard --sync --repo-engine underpostnet/engine-test-cyberia --repo-engine-private underpostnet/engine-private`,
5080
+ );
5081
+ });
5082
+
5032
5083
  runner
5033
5084
  .command('test')
5034
5085
  .option('--n-con <connections>', 'Number of concurrent WebSocket connections')
@@ -5039,7 +5090,7 @@ try {
5039
5090
  CYBERIA_LOAD_CONNECTIONS=${options.nCon ?? 40} \
5040
5091
  CYBERIA_LOAD_TAP_FREQUENCY=${options.tapFreq ?? 5} \
5041
5092
  CYBERIA_LOAD_DURATION_MS=${options.duration ?? 1000 * 60 * 30} \
5042
- c8 mocha test/cyberia-load.test.js`);
5093
+ node bin test cyberia --grep 'Cyberia load'`);
5043
5094
  });
5044
5095
 
5045
5096
  runner
@@ -5049,46 +5100,49 @@ c8 mocha test/cyberia-load.test.js`);
5049
5100
  // no funca
5050
5101
  if (options.loadTar) {
5051
5102
  for (const imageId of dockerImageIds)
5052
- if (imageId === id || id === '.') shellExec(`docker load -i ./${imageId}-dev_v3.3.0.tar`);
5103
+ if (imageId === id || id === '.') shellExec(`docker load -i ./${imageId}-dev_v3.3.76.tar`);
5053
5104
  return;
5054
5105
  }
5055
5106
  switch (id) {
5056
5107
  case 'engine-cyberia':
5108
+ stageCliPackage(`./src/runtime/engine-cyberia`);
5057
5109
  shellExec(`
5058
5110
  node bin/build dd-cyberia --conf
5059
5111
  node bin/build dd-cyberia --update-private
5060
5112
  node bin image --path src/runtime/engine-cyberia \
5061
5113
  --docker-compose --pull-base --build \
5062
5114
  --dockerfile-name Dockerfile.dev \
5063
- --image-name engine-cyberia-dev:v3.3.0 \
5115
+ --image-name engine-cyberia-dev:v3.3.76 \
5064
5116
  --image-out-path .
5065
5117
  `);
5066
5118
  break;
5067
5119
 
5068
5120
  case 'cyberia-server':
5121
+ stageCliPackage(`./cyberia-server`);
5069
5122
  shellExec(`
5070
5123
  cp -f src/runtime/cyberia-server/Dockerfile.dev cyberia-server/Dockerfile.dev
5071
5124
  node bin image --path cyberia-server \
5072
5125
  --docker-compose --pull-base --build \
5073
5126
  --dockerfile-name Dockerfile.dev \
5074
- --image-name cyberia-server-dev:v3.3.0 \
5127
+ --image-name cyberia-server-dev:v3.3.76 \
5075
5128
  --image-out-path .
5076
5129
  `);
5077
5130
  break;
5078
5131
  case 'cyberia-client':
5132
+ stageCliPackage(`./cyberia-client`);
5079
5133
  shellExec(`
5080
5134
  cp -f src/runtime/cyberia-client/Dockerfile.dev cyberia-client/Dockerfile.dev
5081
5135
  node bin image --path cyberia-client \
5082
5136
  --docker-compose --pull-base --build \
5083
5137
  --dockerfile-name Dockerfile.dev \
5084
- --image-name cyberia-client-dev:v3.3.0 \
5138
+ --image-name cyberia-client-dev:v3.3.76 \
5085
5139
  --image-out-path .
5086
5140
  `);
5087
5141
  break;
5088
5142
  }
5089
5143
  });
5090
5144
 
5091
- for (const [cmd, action] of Object.entries(DOCKER_SCRIPTS))
5145
+ for (const [cmd, action] of Object.entries(cyberiaCatalog.packageScripts))
5092
5146
  runner.command(cmd).action(() => {
5093
5147
  if (cmd === 'docker:up' || cmd === 'docker:up:build' || cmd === 'docker:restart') {
5094
5148
  const { aliases, changed } = installCyberiaDockerHostAliases();
@@ -5247,9 +5301,9 @@ node bin image --path cyberia-client \
5247
5301
 
5248
5302
  const CyberiaSkill = DataBaseProviderService.getModel('cyberia-skill', { host, path });
5249
5303
 
5250
- // Upsert each skill record keyed by triggerItemId — full record (logic
5251
- // event keys + expanded skills metadata), unlike the instance-conf
5252
- // 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.
5253
5307
  let upserted = 0;
5254
5308
  for (const sk of DefaultSkillConfig) {
5255
5309
  await CyberiaSkill.findOneAndUpdate(
@@ -5272,6 +5326,10 @@ node bin image --path cyberia-client \
5272
5326
  .command('seed-entities')
5273
5327
  .option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
5274
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
+ )
5275
5333
  .option('--dev', 'Force development environment')
5276
5334
  .description('Upsert ENTITY_TYPE_DEFAULTS into the cyberia-entity-type-default collection (idempotent)')
5277
5335
  .action(async (options) => {
@@ -5303,7 +5361,12 @@ node bin image --path cyberia-client \
5303
5361
 
5304
5362
  logger.info('seed-entities', { deployId, host, path });
5305
5363
 
5306
- 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
+ });
5307
5370
 
5308
5371
  const CyberiaEntityTypeDefault = DataBaseProviderService.getModel('cyberia-entity-type-default', { host, path });
5309
5372
 
@@ -5322,8 +5385,9 @@ node bin image --path cyberia-client \
5322
5385
  // (across entity types, or within one type at different specificity). Every
5323
5386
  // entry is upserted, idempotently, by its exact (entityType, liveItemIds) key.
5324
5387
  let upserted = 0;
5388
+ const seededIds = [];
5325
5389
  for (const ed of ENTITY_TYPE_DEFAULTS) {
5326
- await CyberiaEntityTypeDefault.findOneAndUpdate(
5390
+ const doc = await CyberiaEntityTypeDefault.findOneAndUpdate(
5327
5391
  { entityType: ed.entityType, liveItemIds: ed.liveItemIds || [] },
5328
5392
  {
5329
5393
  $set: {
@@ -5331,12 +5395,14 @@ node bin image --path cyberia-client \
5331
5395
  liveItemIds: ed.liveItemIds || [],
5332
5396
  deadItemIds: ed.deadItemIds || [],
5333
5397
  dropItemIds: ed.dropItemIds || [],
5334
- defaultObjectLayers: ed.defaultObjectLayers || [],
5398
+ inventoryItemsIds: ed.inventoryItemsIds || [],
5399
+ overrideItemsIdsState: ed.overrideItemsIdsState || [],
5335
5400
  behavior: ed.behavior || '',
5336
5401
  },
5337
5402
  },
5338
- { upsert: true },
5403
+ { upsert: true, returnDocument: 'after' },
5339
5404
  );
5405
+ if (doc?._id) seededIds.push(doc._id);
5340
5406
  upserted++;
5341
5407
  }
5342
5408
 
@@ -5345,6 +5411,23 @@ node bin image --path cyberia-client \
5345
5411
  ENTITY_TYPE_DEFAULTS.map((e) => `${e.entityType} → [${(e.liveItemIds || []).join(', ')}]`),
5346
5412
  );
5347
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
+
5348
5431
  await DataBaseProviderService.getProvider({ host, path }, 'mongoose').close();
5349
5432
  });
5350
5433
 
@@ -5368,26 +5451,6 @@ node bin image --path cyberia-client \
5368
5451
  'skin-vivid',
5369
5452
  'skin-natural',
5370
5453
  'skin-shaved',
5371
- // 'resource-desert-petal',
5372
- // 'resource-desert-stone',
5373
- // 'resource-desert-polygon',
5374
- // 'resource-desert-thread',
5375
- // 'resource-grass-petal',
5376
- // 'resource-grass-stone',
5377
- // 'resource-grass-polygon',
5378
- // 'resource-grass-thread',
5379
- // 'resource-water-petal',
5380
- // 'resource-water-stone',
5381
- // 'resource-water-polygon',
5382
- // 'resource-water-thread',
5383
- // 'resource-stone-petal',
5384
- // 'resource-stone-stone',
5385
- // 'resource-stone-polygon',
5386
- // 'resource-stone-thread',
5387
- // 'resource-lava-petal',
5388
- // 'resource-lava-stone',
5389
- // 'resource-lava-polygon',
5390
- // 'resource-lava-thread',
5391
5454
  ];
5392
5455
 
5393
5456
  const baseSeed = options.seed || 'example';
@@ -5412,10 +5475,10 @@ node bin image --path cyberia-client \
5412
5475
  // Instance id → project root. Single source of truth for the workloads this
5413
5476
  // workflow builds: both the k8s manifests and the status page artifacts each
5414
5477
  // project ships are resolved from this list plus conf.instances.json.
5415
- const CYBERIA_INSTANCE_PROJECTS = [
5416
- { id: 'mmo-client', rootPath: './cyberia-client' },
5417
- { id: 'mmo-server', rootPath: './cyberia-server' },
5418
- ];
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'];
5419
5482
  const CYBERIA_CONF_INSTANCES_PATH = './engine-private/conf/dd-cyberia/conf.instances.json';
5420
5483
  const CYBERIA_CONF_SSR_PATH = './engine-private/conf/dd-cyberia/conf.ssr.json';
5421
5484
  // Copy shared by the server and the client for a given status code. A status
@@ -5508,10 +5571,9 @@ node bin image --path cyberia-client \
5508
5571
  const nodeFlag = options.nodeName ? ` --node-name ${options.nodeName}` : '';
5509
5572
 
5510
5573
  // ── Dynamically resolve instance codes from conf.instances.json ──────
5511
- // Read all cyberia-server runtime instances and collect their
5512
- // multiInstance variant codes. These are used to update the
5513
- // INSTANCE_CODES label in Dockerfile.dev so the dev image
5514
- // 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.
5515
5577
  //
5516
5578
  // Only codes that have an on-disk instance backup directory are
5517
5579
  // included. The saga file is optional — the Dockerfile's for loop
@@ -5573,14 +5635,12 @@ node bin image --path cyberia-client \
5573
5635
  }
5574
5636
 
5575
5637
  // ── Update catalog-cyberia.js privateConfPaths ───────────────────────
5576
- // The array block in privateConfPaths is bounded by /** INSTANCE_CODES */
5577
- // markers (valid JS comments here). Replace everything between them with
5578
- // the resolved per-code paths. These are synced by syncPrivateConf, which
5579
- // copies each entry from `./engine-private/<path>` so they must match the
5580
- // LOCAL engine-private layout (`cyberia-instances/<code>`,
5581
- // `cyberia-sagas/<code>.json`), not the published cyberia-instances repo
5582
- // (which uses `instances/` + `sagas/`). Only emit paths that exist on disk
5583
- // 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.
5584
5644
  const catalogPath = './src/projects/cyberia/catalog-cyberia.js';
5585
5645
  try {
5586
5646
  const catalogContent = fs.readFileSync(catalogPath, 'utf8');
@@ -5613,11 +5673,11 @@ node bin image --path cyberia-client \
5613
5673
  // embed each document declared under an instance's `customStatusPages`,
5614
5674
  // so the artifact has to exist at its `hostPath` by manifest time.
5615
5675
  const statusPagesBuilt = [];
5616
- for (const { id, rootPath } of CYBERIA_INSTANCE_PROJECTS) {
5676
+ for (const id of CYBERIA_INSTANCE_IDS) {
5617
5677
  const instance = confInstancesEntries.find((entry) => entry.id === id);
5618
5678
  for (const page of instance?.customStatusPages || []) {
5619
5679
  if (!page?.status || !page?.hostPath) continue;
5620
- const outputPath = nodePath.normalize(`${rootPath}/${page.hostPath}`);
5680
+ const outputPath = nodePath.normalize(`${instanceProjectPathFactory(instance)}/${page.hostPath}`);
5621
5681
  if (buildCyberiaStatusPage({ status: page.status, outputPath, dev: isDev }))
5622
5682
  statusPagesBuilt.push({ instance: id, status: page.status, outputPath });
5623
5683
  }
@@ -5630,24 +5690,36 @@ node bin image --path cyberia-client \
5630
5690
  // ── Build dev manifests (always --kind --dev) ────────────────────────
5631
5691
  {
5632
5692
  const flags = `--kind --dev${nodeFlag}`;
5633
- for (const { id, rootPath } of CYBERIA_INSTANCE_PROJECTS)
5634
- 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}`);
5635
5695
  }
5636
5696
  // ── Build prod manifests (--kubeadm, no --dev) ───────────────────────
5637
5697
  if (!isDev) {
5638
5698
  const flags = `--kubeadm${nodeFlag}`;
5639
- for (const { id, rootPath } of CYBERIA_INSTANCE_PROJECTS)
5640
- 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}`);
5641
5701
  }
5642
5702
 
5643
5703
  // Copy canonical doc sources into the generated project READMEs.
5644
5704
  // Edit the canonical sources; never hand-edit these generated outputs.
5645
- fs.copySync('./deploy/lib', './cyberia-server/deploy/lib', { overwrite: true });
5646
- fs.copySync('./deploy/lib', './cyberia-client/deploy/lib', { overwrite: true });
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`.
5709
+ for (const project of ['cyberia-client', 'cyberia-server']) {
5710
+ const scripts = `./deploy/${project}`;
5711
+ // A tree assembled before these scripts were packaged does not carry them; mirroring is
5712
+ // what publishes them, so say so and continue rather than failing the whole manifest build.
5713
+ if (!fs.existsSync(scripts)) {
5714
+ logger.warn(`[build-manifest] No deploy scripts to mirror for ${project}`, { path: scripts });
5715
+ continue;
5716
+ }
5717
+ fs.removeSync(`./${project}/deploy`);
5718
+ fs.copySync('./deploy/lib', `./${project}/deploy/lib`);
5719
+ fs.copySync(scripts, `./${project}/deploy/${project}`);
5720
+ }
5647
5721
  fs.copyFileSync('./src/client/public/cyberia-docs/CYBERIA-CLIENT.md', './cyberia-client/README.md');
5648
- fs.copySync('./deploy/cyberia-client', './cyberia-client/deploy');
5649
5722
  fs.copyFileSync('./src/client/public/cyberia-docs/CYBERIA-SERVER.md', './cyberia-server/README.md');
5650
- fs.copySync('./deploy/cyberia-server', './cyberia-server/deploy');
5651
5723
  fs.copyFileSync(
5652
5724
  './.github/workflows/cyberia-client.cd.yml',
5653
5725
  './cyberia-client/.github/workflows/cyberia-client.cd.yml',
@@ -5657,7 +5729,14 @@ node bin image --path cyberia-client \
5657
5729
  './cyberia-server/.github/workflows/cyberia-server.cd.yml',
5658
5730
  );
5659
5731
  shellExec('cp -a ./engine-private/conf/dd-cyberia/docker-compose/cyberia/. ./src/runtime/engine-cyberia/');
5660
- shellExec('node bin/cyberia.js instance --publish-build');
5732
+ // Scope the publish to the deployment this workflow builds: read
5733
+ // dd-cyberia's own environment, not the working-tree `./.env`.
5734
+ shellExec(
5735
+ `node bin/cyberia.js instance --publish-build --env-path ${deployEnvFilePath(
5736
+ 'dd-cyberia',
5737
+ isDev ? 'development' : 'production',
5738
+ )}`,
5739
+ );
5661
5740
  logger.info(`run-workflow build-manifest complete (${isDev ? 'dev' : 'prod'})`);
5662
5741
  });
5663
5742
 
@@ -5668,6 +5747,7 @@ node bin image --path cyberia-client \
5668
5747
  if (options.dryRun) {
5669
5748
  shellExec('node bin cmt --log --unpush cyberia-server');
5670
5749
  shellExec('node bin cmt --log --unpush cyberia-client');
5750
+ shellExec('node bin cmt --log --unpush cyberia-audio');
5671
5751
  shellExec('node bin cmt --log --unpush');
5672
5752
  shellExec('node bin cmt --log --unpush ../cyberia-instances');
5673
5753
  } else {
@@ -5680,6 +5760,9 @@ node bin image --path cyberia-client \
5680
5760
  shellExec('node bin push cyberia-client underpostnet/cyberia-client', {
5681
5761
  silentOnError: true,
5682
5762
  });
5763
+ shellExec('node bin push cyberia-audio underpostnet/cyberia-audio', {
5764
+ silentOnError: true,
5765
+ });
5683
5766
  shellExec('node bin run template-deploy', {
5684
5767
  silentOnError: true,
5685
5768
  });
@@ -5778,16 +5861,24 @@ node bin image --path cyberia-client \
5778
5861
 
5779
5862
  await program.parseAsync();
5780
5863
  } catch (error) {
5781
- // ONLY reroute on the explicit passthrough sentinel. Any other thrown
5782
- // error (subprocess non-zero from shellExec's fail-fast default, CLI
5783
- // parse errors, missing modules) must propagate as a non-zero process
5784
- // exit so GitHub Actions / CI parents observe the failure. Without this
5785
- // guard, a genuine build failure was being silently rerouted into the
5786
- // underpost CLI and then masked behind a misleading "unknown command"
5787
- // 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.
5788
5867
  if (error && error.message === 'Trigger underpost passthrough') {
5789
- process.argv = process.argv.filter((c) => c !== 'underpost');
5790
- logger.warn('Rerouting to underpost cli...');
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
+ ];
5881
+ if (!process.argv.includes('--plain')) logger.info('Rerouting to underpost cli...');
5791
5882
  try {
5792
5883
  await underpostProgram.parseAsync();
5793
5884
  } catch (err) {