cyberia 3.1.3 → 3.2.5

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 (208) hide show
  1. package/.env.example +0 -2
  2. package/.github/workflows/engine-cyberia.cd.yml +10 -8
  3. package/.github/workflows/engine-cyberia.ci.yml +12 -29
  4. package/.github/workflows/ghpkg.ci.yml +4 -4
  5. package/.github/workflows/npmpkg.ci.yml +28 -11
  6. package/.github/workflows/publish.ci.yml +21 -2
  7. package/.github/workflows/pwa-microservices-template-page.cd.yml +4 -5
  8. package/.github/workflows/pwa-microservices-template-test.ci.yml +3 -3
  9. package/.github/workflows/release.cd.yml +13 -8
  10. package/CHANGELOG.md +433 -1
  11. package/CLI-HELP.md +57 -7
  12. package/Dockerfile +4 -2
  13. package/README.md +347 -22
  14. package/bin/build.js +5 -2
  15. package/bin/cyberia.js +1789 -112
  16. package/bin/deploy.js +177 -124
  17. package/bin/file.js +3 -0
  18. package/bin/index.js +1789 -112
  19. package/conf.js +64 -8
  20. package/deployment.yaml +92 -20
  21. package/hardhat/hardhat.config.js +13 -13
  22. package/hardhat/ignition/modules/ObjectLayerToken.js +1 -1
  23. package/hardhat/package-lock.json +2554 -5859
  24. package/hardhat/package.json +13 -22
  25. package/hardhat/scripts/deployObjectLayerToken.js +1 -1
  26. package/hardhat/test/ObjectLayerToken.js +4 -2
  27. package/hardhat/types/ethers-contracts/ObjectLayerToken.ts +690 -0
  28. package/hardhat/types/ethers-contracts/common.ts +92 -0
  29. package/hardhat/types/ethers-contracts/factories/ObjectLayerToken__factory.ts +1055 -0
  30. package/hardhat/types/ethers-contracts/factories/index.ts +4 -0
  31. package/hardhat/types/ethers-contracts/hardhat.d.ts +47 -0
  32. package/hardhat/types/ethers-contracts/index.ts +6 -0
  33. package/jsdoc.dd-cyberia.json +64 -55
  34. package/jsdoc.json +64 -55
  35. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -4
  36. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +5 -4
  37. package/manifests/deployment/dd-cyberia-development/deployment.yaml +92 -20
  38. package/manifests/deployment/dd-cyberia-development/proxy.yaml +54 -18
  39. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  40. package/manifests/deployment/dd-test-development/deployment.yaml +88 -74
  41. package/manifests/deployment/dd-test-development/proxy.yaml +13 -4
  42. package/manifests/deployment/playwright/deployment.yaml +1 -1
  43. package/nodemon.json +1 -1
  44. package/package.json +22 -16
  45. package/proxy.yaml +54 -18
  46. package/scripts/rhel-grpc-setup.sh +56 -0
  47. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +44 -0
  48. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +16 -0
  49. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +5 -0
  50. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +80 -7
  51. package/src/api/cyberia-dialogue/cyberia-dialogue.controller.js +93 -0
  52. package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +36 -0
  53. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +29 -0
  54. package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +51 -0
  55. package/src/api/cyberia-entity/cyberia-entity.controller.js +74 -0
  56. package/src/api/cyberia-entity/cyberia-entity.model.js +24 -0
  57. package/src/api/cyberia-entity/cyberia-entity.router.js +27 -0
  58. package/src/api/cyberia-entity/cyberia-entity.service.js +42 -0
  59. package/src/api/cyberia-instance/cyberia-fallback-world.js +368 -0
  60. package/src/api/cyberia-instance/cyberia-instance.controller.js +92 -0
  61. package/src/api/cyberia-instance/cyberia-instance.model.js +84 -0
  62. package/src/api/cyberia-instance/cyberia-instance.router.js +63 -0
  63. package/src/api/cyberia-instance/cyberia-instance.service.js +191 -0
  64. package/src/api/cyberia-instance/cyberia-portal-connector.js +486 -0
  65. package/src/api/cyberia-instance-conf/cyberia-instance-conf.controller.js +74 -0
  66. package/src/api/cyberia-instance-conf/cyberia-instance-conf.defaults.js +413 -0
  67. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +228 -0
  68. package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +27 -0
  69. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +42 -0
  70. package/src/api/cyberia-map/cyberia-map.controller.js +79 -0
  71. package/src/api/cyberia-map/cyberia-map.model.js +30 -0
  72. package/src/api/cyberia-map/cyberia-map.router.js +40 -0
  73. package/src/api/cyberia-map/cyberia-map.service.js +74 -0
  74. package/src/api/file/file.ref.json +18 -0
  75. package/src/api/ipfs/ipfs.controller.js +4 -25
  76. package/src/api/ipfs/ipfs.model.js +43 -34
  77. package/src/api/ipfs/ipfs.router.js +8 -13
  78. package/src/api/ipfs/ipfs.service.js +54 -102
  79. package/src/api/object-layer/README.md +347 -22
  80. package/src/api/object-layer/object-layer.router.js +30 -0
  81. package/src/api/object-layer/object-layer.service.js +114 -31
  82. package/src/api/user/user.service.js +8 -7
  83. package/src/cli/cluster.js +7 -7
  84. package/src/cli/db.js +710 -827
  85. package/src/cli/deploy.js +151 -93
  86. package/src/cli/env.js +29 -0
  87. package/src/cli/fs.js +5 -2
  88. package/src/cli/index.js +48 -2
  89. package/src/cli/kubectl.js +211 -0
  90. package/src/cli/release.js +284 -0
  91. package/src/cli/repository.js +438 -75
  92. package/src/cli/run.js +195 -35
  93. package/src/cli/secrets.js +73 -0
  94. package/src/cli/test.js +3 -3
  95. package/src/client/Cryptokoyn.index.js +3 -4
  96. package/src/client/CyberiaPortal.index.js +3 -4
  97. package/src/client/Default.index.js +3 -4
  98. package/src/client/Itemledger.index.js +3 -4
  99. package/src/client/Underpost.index.js +3 -4
  100. package/src/client/components/core/AppStore.js +69 -0
  101. package/src/client/components/core/CalendarCore.js +2 -2
  102. package/src/client/components/core/DropDown.js +137 -17
  103. package/src/client/components/core/Keyboard.js +2 -2
  104. package/src/client/components/core/LogIn.js +2 -2
  105. package/src/client/components/core/LogOut.js +2 -2
  106. package/src/client/components/core/Modal.js +0 -1
  107. package/src/client/components/core/Panel.js +0 -1
  108. package/src/client/components/core/PanelForm.js +19 -19
  109. package/src/client/components/core/SocketIo.js +82 -29
  110. package/src/client/components/core/SocketIoHandler.js +75 -0
  111. package/src/client/components/core/Stream.js +143 -95
  112. package/src/client/components/core/Webhook.js +40 -7
  113. package/src/client/components/cryptokoyn/AppStoreCryptokoyn.js +5 -0
  114. package/src/client/components/cryptokoyn/LogInCryptokoyn.js +3 -3
  115. package/src/client/components/cryptokoyn/LogOutCryptokoyn.js +2 -2
  116. package/src/client/components/cryptokoyn/MenuCryptokoyn.js +3 -3
  117. package/src/client/components/cryptokoyn/SocketIoCryptokoyn.js +3 -51
  118. package/src/client/components/cyberia/InstanceEngineCyberia.js +700 -0
  119. package/src/client/components/cyberia/MapEngineCyberia.js +1359 -2
  120. package/src/client/components/cyberia/ObjectLayerEngineModal.js +17 -6
  121. package/src/client/components/cyberia/ObjectLayerEngineViewer.js +92 -54
  122. package/src/client/components/cyberia-portal/AppStoreCyberiaPortal.js +5 -0
  123. package/src/client/components/cyberia-portal/CommonCyberiaPortal.js +216 -30
  124. package/src/client/components/cyberia-portal/LogInCyberiaPortal.js +3 -3
  125. package/src/client/components/cyberia-portal/LogOutCyberiaPortal.js +2 -2
  126. package/src/client/components/cyberia-portal/MenuCyberiaPortal.js +40 -7
  127. package/src/client/components/cyberia-portal/RoutesCyberiaPortal.js +4 -0
  128. package/src/client/components/cyberia-portal/SocketIoCyberiaPortal.js +3 -49
  129. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +4 -0
  130. package/src/client/components/default/AppStoreDefault.js +5 -0
  131. package/src/client/components/default/LogInDefault.js +3 -3
  132. package/src/client/components/default/LogOutDefault.js +2 -2
  133. package/src/client/components/default/MenuDefault.js +5 -5
  134. package/src/client/components/default/SocketIoDefault.js +3 -51
  135. package/src/client/components/itemledger/AppStoreItemledger.js +5 -0
  136. package/src/client/components/itemledger/LogInItemledger.js +3 -3
  137. package/src/client/components/itemledger/LogOutItemledger.js +2 -2
  138. package/src/client/components/itemledger/MenuItemledger.js +3 -3
  139. package/src/client/components/itemledger/SocketIoItemledger.js +3 -51
  140. package/src/client/components/underpost/AppStoreUnderpost.js +5 -0
  141. package/src/client/components/underpost/LogInUnderpost.js +3 -3
  142. package/src/client/components/underpost/LogOutUnderpost.js +2 -2
  143. package/src/client/components/underpost/MenuUnderpost.js +5 -5
  144. package/src/client/components/underpost/SocketIoUnderpost.js +3 -51
  145. package/src/client/services/core/core.service.js +20 -8
  146. package/src/client/services/cyberia-dialogue/cyberia-dialogue.service.js +105 -0
  147. package/src/client/services/cyberia-entity/cyberia-entity.management.js +57 -0
  148. package/src/client/services/cyberia-entity/cyberia-entity.service.js +105 -0
  149. package/src/client/services/cyberia-instance/cyberia-instance.management.js +194 -0
  150. package/src/client/services/cyberia-instance/cyberia-instance.service.js +122 -0
  151. package/src/client/services/cyberia-instance-conf/cyberia-instance-conf.service.js +105 -0
  152. package/src/client/services/cyberia-map/cyberia-map.management.js +193 -0
  153. package/src/client/services/cyberia-map/cyberia-map.service.js +126 -0
  154. package/src/client/services/instance/instance.management.js +2 -2
  155. package/src/client/services/ipfs/ipfs.service.js +3 -23
  156. package/src/client/services/object-layer/object-layer.management.js +3 -3
  157. package/src/client/services/object-layer/object-layer.service.js +21 -0
  158. package/src/client/services/user/user.management.js +2 -2
  159. package/src/client/ssr/pages/CyberiaServerMetrics.js +1 -1
  160. package/src/grpc/cyberia/OFF_CHAIN_ECONOMY.md +305 -0
  161. package/src/grpc/cyberia/README.md +326 -0
  162. package/src/grpc/cyberia/grpc-server.js +530 -0
  163. package/src/index.js +24 -1
  164. package/src/runtime/express/Dockerfile +4 -0
  165. package/src/runtime/express/Express.js +18 -1
  166. package/src/runtime/lampp/Dockerfile +13 -2
  167. package/src/runtime/lampp/Lampp.js +27 -4
  168. package/src/runtime/wp/Dockerfile +68 -0
  169. package/src/runtime/wp/Wp.js +639 -0
  170. package/src/server/auth.js +24 -1
  171. package/src/server/backup.js +37 -9
  172. package/src/server/client-build-docs.js +9 -2
  173. package/src/server/client-build.js +31 -31
  174. package/src/server/client-formatted.js +109 -57
  175. package/src/server/conf.js +24 -9
  176. package/src/server/cron.js +25 -23
  177. package/src/server/dns.js +2 -1
  178. package/src/server/ipfs-client.js +24 -1
  179. package/src/server/object-layer.js +149 -108
  180. package/src/server/peer.js +8 -0
  181. package/src/server/runtime.js +25 -1
  182. package/src/server/semantic-layer-generator-floor.js +359 -0
  183. package/src/server/semantic-layer-generator-skin.js +1294 -0
  184. package/src/server/semantic-layer-generator.js +116 -555
  185. package/src/server/start.js +2 -2
  186. package/src/ws/IoInterface.js +1 -10
  187. package/src/ws/IoServer.js +14 -33
  188. package/src/ws/core/channels/core.ws.chat.js +65 -20
  189. package/src/ws/core/channels/core.ws.mailer.js +113 -32
  190. package/src/ws/core/channels/core.ws.stream.js +90 -31
  191. package/src/ws/core/core.ws.connection.js +12 -33
  192. package/src/ws/core/core.ws.emit.js +10 -26
  193. package/src/ws/core/core.ws.server.js +25 -58
  194. package/src/ws/default/channels/default.ws.main.js +53 -12
  195. package/src/ws/default/default.ws.connection.js +26 -13
  196. package/src/ws/default/default.ws.server.js +30 -12
  197. package/src/client/components/cryptokoyn/CommonCryptokoyn.js +0 -29
  198. package/src/client/components/cryptokoyn/ElementsCryptokoyn.js +0 -38
  199. package/src/client/components/cyberia-portal/ElementsCyberiaPortal.js +0 -38
  200. package/src/client/components/default/ElementsDefault.js +0 -38
  201. package/src/client/components/itemledger/CommonItemledger.js +0 -29
  202. package/src/client/components/itemledger/ElementsItemledger.js +0 -38
  203. package/src/client/components/underpost/CommonUnderpost.js +0 -29
  204. package/src/client/components/underpost/ElementsUnderpost.js +0 -38
  205. package/src/ws/core/management/core.ws.chat.js +0 -8
  206. package/src/ws/core/management/core.ws.mailer.js +0 -16
  207. package/src/ws/core/management/core.ws.stream.js +0 -8
  208. package/src/ws/default/management/default.ws.main.js +0 -8
package/bin/cyberia.js CHANGED
@@ -27,8 +27,8 @@ import {
27
27
  pngDirectoryIteratorByObjectLayerType,
28
28
  getKeyFramesDirectionsFromNumberFolderDirection,
29
29
  buildImgFromTile,
30
- itemTypes,
31
30
  } from '../src/server/object-layer.js';
31
+ import { ITEM_TYPES as itemTypes } from '../src/api/cyberia-instance-conf/cyberia-instance-conf.defaults.js';
32
32
  import { AtlasSpriteSheetGenerator } from '../src/server/atlas-sprite-sheet-generator.js';
33
33
  import {
34
34
  generateFrame,
@@ -42,6 +42,11 @@ import { program as underpostProgram } from '../src/cli/index.js';
42
42
  import crypto from 'crypto';
43
43
  import nodePath from 'path';
44
44
  import Underpost from '../src/index.js';
45
+ import {
46
+ DefaultCyberiaItems,
47
+ DefaultSkillConfig,
48
+ DefaultCyberiaDialogues,
49
+ } from '../src/client/components/cyberia-portal/CommonCyberiaPortal.js';
45
50
 
46
51
  /**
47
52
  * Connect to the project MongoDB instance using the standard env / conf layout.
@@ -103,7 +108,11 @@ try {
103
108
  'Convert object layers to atlas sprite sheets, specify dimension (default: auto-calculated based on frame count)',
104
109
  )
105
110
  .option('--show-atlas-sprite-sheet', 'Show consolidated atlas sprite sheet PNG for given item-id')
106
- .option('--import [object-layer-type]', 'Commas separated object layer types e.g. skin,floors')
111
+ .option(
112
+ '--import',
113
+ 'Import specific item-id(s) passed as comma-separated command argument (e.g. ol hatchet,sword --import)',
114
+ )
115
+ .option('--import-types [object-layer-type]', 'Batch import by object layer type e.g. skin,floors or all')
107
116
  .option('--show-frame [direction-frame]', 'View object layer frame for given item-id e.g. 08_0 (default: 08_0)')
108
117
  .option('--generate', 'Generate procedural object layers from semantic item-id (e.g. floor-desert)')
109
118
  .option('--count <count>', 'Shape element count multiplier for --generate (default: 3)', parseFloat)
@@ -115,6 +124,9 @@ try {
115
124
  .option('--mongo-host <mongo-host>', 'Mongo host override')
116
125
  .option('--storage-file-path <storage-file-path>', 'Storage file path override')
117
126
  .option('--drop', 'Drop existing data before importing')
127
+ .option('--client-public', 'When used with --drop, also remove static asset folders for dropped items')
128
+ .option('--git-clean', 'When used with --drop, run underpost clean on the cyberia asset directory')
129
+ .option('--dev', 'Force development environment (loads .env.development for IPFS localhost, etc.)')
118
130
  .action(
119
131
  /**
120
132
  * Main action handler for the `ol` command.
@@ -122,7 +134,8 @@ try {
122
134
  *
123
135
  * @param {string|undefined} itemId - Optional item ID argument.
124
136
  * @param {Object} options - Command options parsed by Commander.
125
- * @param {boolean|string} options.import - Object layer types to import (e.g., 'all', 'skin,floor') or `false`.
137
+ * @param {boolean} options.import - Import specific item-id(s) from the command argument (comma-separated).
138
+ * @param {boolean|string} options.importTypes - Object layer types to batch import (e.g., 'all', 'skin,floor') or `false`.
126
139
  * @param {boolean|string} options.showFrame - Direction-frame string (e.g., '08_0') or `true` for default.
127
140
  * @param {string} options.envPath - Path to the `.env` file.
128
141
  * @param {string} options.mongoHost - MongoDB host override.
@@ -130,6 +143,9 @@ try {
130
143
  * @param {boolean|string} options.toAtlasSpriteSheet - Atlas dimension or `true` for auto-calc.
131
144
  * @param {boolean} options.showAtlasSpriteSheet - Whether to display the atlas sprite sheet.
132
145
  * @param {boolean} options.drop - Whether to drop existing data before importing.
146
+ * @param {boolean} options.clientPublic - Also remove static asset folders when dropping.
147
+ * @param {boolean} options.gitClean - Run underpost clean on the cyberia asset directory when dropping.
148
+ * @param {boolean} options.dev - Force development environment.
133
149
  * @param {boolean} options.generate - Whether to run procedural generation for the item-id.
134
150
  * @param {number} options.count - Shape element count multiplier for generation.
135
151
  * @param {string} options.seed - Deterministic seed string for generation.
@@ -143,12 +159,17 @@ try {
143
159
  itemId,
144
160
  options = {
145
161
  import: false,
162
+ importTypes: false,
146
163
  showFrame: '',
147
164
  envPath: '',
148
165
  mongoHost: '',
149
166
  storageFilePath: '',
150
167
  toAtlasSpriteSheet: '',
151
168
  showAtlasSpriteSheet: false,
169
+ drop: false,
170
+ clientPublic: false,
171
+ gitClean: false,
172
+ dev: false,
152
173
  generate: false,
153
174
  count: 3,
154
175
  seed: '',
@@ -160,6 +181,14 @@ try {
160
181
  if (!options.envPath) options.envPath = `./.env`;
161
182
  if (fs.existsSync(options.envPath)) dotenv.config({ path: options.envPath, override: true });
162
183
 
184
+ // --dev: force development environment (IPFS localhost, etc.)
185
+ if (options.dev && process.env.DEFAULT_DEPLOY_ID) {
186
+ const deployDevEnvPath = `./engine-private/conf/${process.env.DEFAULT_DEPLOY_ID}/.env.development`;
187
+ if (fs.existsSync(deployDevEnvPath)) {
188
+ dotenv.config({ path: deployDevEnvPath, override: true });
189
+ }
190
+ }
191
+
163
192
  /** @type {string} */
164
193
  const deployId = process.env.DEFAULT_DEPLOY_ID;
165
194
  /** @type {string} */
@@ -171,7 +200,11 @@ try {
171
200
  const confServer = loadConfServerJson(confServerPath, { resolve: true });
172
201
  const { db } = confServer[host][path];
173
202
 
174
- db.host = options.mongoHost ? options.mongoHost : db.host.replace('127.0.0.1', 'mongodb-0.mongodb-service');
203
+ db.host = options.mongoHost
204
+ ? options.mongoHost
205
+ : options.dev
206
+ ? db.host
207
+ : db.host.replace('127.0.0.1', 'mongodb-0.mongodb-service');
175
208
 
176
209
  logger.info('env', {
177
210
  env: options.envPath,
@@ -197,23 +230,519 @@ try {
197
230
  const AtlasSpriteSheet = DataBaseProvider.instance[`${host}${path}`].mongoose.models.AtlasSpriteSheet;
198
231
  /** @type {import('mongoose').Model} */
199
232
  const File = DataBaseProvider.instance[`${host}${path}`].mongoose.models.File;
233
+ /** @type {import('mongoose').Model} */
234
+ const Ipfs = DataBaseProvider.instance[`${host}${path}`].mongoose.models.Ipfs;
200
235
 
201
236
  if (options.drop) {
202
- await ObjectLayer.deleteMany();
203
- await ObjectLayerRenderFrames.deleteMany();
204
- shellExec(`cd src/client/public/cyberia && underpost run clean .`);
237
+ // Parse comma-separated item IDs for targeted drop; if none provided, drop everything
238
+ const dropItemIds = itemId
239
+ ? itemId
240
+ .split(',')
241
+ .map((id) => id.trim())
242
+ .filter(Boolean)
243
+ : null;
244
+ const isTargetedDrop = dropItemIds && dropItemIds.length > 0;
245
+
246
+ if (isTargetedDrop) {
247
+ logger.info(`Targeted drop for item(s): ${dropItemIds.join(', ')}`);
248
+ } else {
249
+ logger.info('Dropping ALL object layer data');
250
+ }
251
+
252
+ // Build query filter: targeted or all
253
+ const olFilter = isTargetedDrop ? { 'data.item.id': { $in: dropItemIds } } : {};
254
+ const atlasFilter = isTargetedDrop ? { 'metadata.itemKey': { $in: dropItemIds } } : {};
255
+
256
+ // Collect data before deletion
257
+ const olDocs = await ObjectLayer.find(olFilter, {
258
+ cid: 1,
259
+ 'data.item.id': 1,
260
+ 'data.item.type': 1,
261
+ 'data.render': 1,
262
+ objectLayerRenderFramesId: 1,
263
+ atlasSpriteSheetId: 1,
264
+ }).lean();
265
+ const atlasDocs = await AtlasSpriteSheet.find(atlasFilter, { fileId: 1, cid: 1 }).lean();
266
+
267
+ const cidsToUnpin = new Set();
268
+ const itemIdsToClean = new Set();
269
+ const renderFrameIds = [];
270
+ const atlasIds = [];
271
+
272
+ for (const doc of olDocs) {
273
+ if (doc.cid) cidsToUnpin.add(doc.cid);
274
+ if (doc.data?.render?.cid) cidsToUnpin.add(doc.data.render.cid);
275
+ if (doc.data?.render?.metadataCid) cidsToUnpin.add(doc.data.render.metadataCid);
276
+ if (doc.data?.item?.id) itemIdsToClean.add(doc.data.item.id);
277
+ if (doc.objectLayerRenderFramesId) renderFrameIds.push(doc.objectLayerRenderFramesId);
278
+ if (doc.atlasSpriteSheetId) atlasIds.push(doc.atlasSpriteSheetId);
279
+ }
280
+
281
+ const atlasFileIds = atlasDocs.map((a) => a.fileId).filter(Boolean);
282
+ for (const atlas of atlasDocs) {
283
+ if (atlas.cid) cidsToUnpin.add(atlas.cid);
284
+ }
285
+
286
+ const olCount = olDocs.length;
287
+ const atlasCount = atlasDocs.length;
288
+
289
+ // Delete targeted documents
290
+ if (isTargetedDrop) {
291
+ const olIds = olDocs.map((d) => d._id);
292
+ if (olIds.length > 0) await ObjectLayer.deleteMany({ _id: { $in: olIds } });
293
+ if (renderFrameIds.length > 0) await ObjectLayerRenderFrames.deleteMany({ _id: { $in: renderFrameIds } });
294
+ if (atlasIds.length > 0) await AtlasSpriteSheet.deleteMany({ _id: { $in: atlasIds } });
295
+ } else {
296
+ await ObjectLayer.deleteMany();
297
+ await ObjectLayerRenderFrames.deleteMany();
298
+ await AtlasSpriteSheet.deleteMany();
299
+ }
300
+
301
+ const rfCount = renderFrameIds.length;
302
+
303
+ // Remove only the File documents that were referenced by atlas sprite sheets
304
+ let fileCount = 0;
305
+ if (atlasFileIds.length > 0) {
306
+ const result = await File.deleteMany({ _id: { $in: atlasFileIds } });
307
+ fileCount = result.deletedCount || 0;
308
+ }
309
+
310
+ // Delete IPFS pin registry records for all collected CIDs
311
+ if (cidsToUnpin.size > 0) {
312
+ const ipfsResult = await Ipfs.deleteMany({ cid: { $in: [...cidsToUnpin] } });
313
+ logger.info(`Dropped ${ipfsResult.deletedCount} Ipfs pin record(s)`);
314
+ }
315
+
316
+ // Unpin CIDs from IPFS Cluster + Kubo and remove MFS directories
317
+ let unpinCount = 0;
318
+ let mfsCount = 0;
319
+ for (const cid of cidsToUnpin) {
320
+ const ok = await IpfsClient.unpinCid(cid);
321
+ if (ok) unpinCount++;
322
+ }
323
+ for (const itemKey of itemIdsToClean) {
324
+ const ok = await IpfsClient.removeMfsPath(`/object-layer/${itemKey}`);
325
+ if (ok) mfsCount++;
326
+ }
327
+
328
+ logger.info(
329
+ `Dropped: ${olCount} ObjectLayer, ${rfCount} RenderFrames, ${atlasCount} AtlasSpriteSheet, ${fileCount} File (atlas)`,
330
+ );
331
+ logger.info(
332
+ `IPFS cleanup: ${unpinCount}/${cidsToUnpin.size} CIDs unpinned, ${mfsCount}/${itemIdsToClean.size} MFS paths removed`,
333
+ );
334
+ if (options.gitClean) {
335
+ shellExec(`cd src/client/public/cyberia && underpost run clean .`);
336
+ logger.info('Asset directory cleaned');
337
+ }
338
+
339
+ // --client-public: remove static asset folders for dropped items
340
+ if (options.clientPublic) {
341
+ const srcBase = './src/client/public/cyberia/assets';
342
+ const publicBase = `./public/${host}${path}/assets`;
343
+ let removedCount = 0;
344
+ for (const doc of olDocs) {
345
+ const docItemId = doc.data?.item?.id;
346
+ const docItemType = doc.data?.item?.type;
347
+ if (!docItemId || !docItemType) continue;
348
+ for (const base of [srcBase, publicBase]) {
349
+ const folder = `${base}/${docItemType}/${docItemId}`;
350
+ if (fs.existsSync(folder)) {
351
+ fs.removeSync(folder);
352
+ removedCount++;
353
+ logger.info(`Removed static folder: ${folder}`);
354
+ }
355
+ }
356
+ }
357
+ logger.info(`Static asset cleanup: ${removedCount} folder(s) removed`);
358
+ }
205
359
  }
206
360
 
207
361
  /** @type {Object|null} */
208
362
  const storage = options.storageFilePath ? JSON.parse(fs.readFileSync(options.storageFilePath, 'utf8')) : null;
209
363
 
210
- // ── Handle --import ──────────────────────────────────────────────
364
+ // ── Handle --import (specific item-id(s)) ─────────────────────
211
365
  if (options.import) {
366
+ if (!itemId) {
367
+ logger.error('item-id is required for --import (comma-separated item IDs, e.g. ol hatchet,sword --import)');
368
+ process.exit(1);
369
+ }
370
+
371
+ const itemIds = itemId
372
+ .split(',')
373
+ .map((id) => id.trim())
374
+ .filter(Boolean);
375
+ logger.info(`Importing specific item(s): ${itemIds.join(', ')}`);
376
+
377
+ for (const currentItemId of itemIds) {
378
+ // Search across all asset type directories to find which type contains this item-id
379
+ let foundType = null;
380
+ let foundFolder = null;
381
+ for (const type of Object.keys(itemTypes)) {
382
+ const candidateFolder = `./src/client/public/cyberia/assets/${type}/${currentItemId}`;
383
+ if (fs.existsSync(candidateFolder) && fs.statSync(candidateFolder).isDirectory()) {
384
+ foundType = type;
385
+ foundFolder = candidateFolder;
386
+ break;
387
+ }
388
+ }
389
+
390
+ if (!foundType) {
391
+ logger.error(
392
+ `Item-id '${currentItemId}' not found in any asset type directory (${Object.keys(itemTypes).join(', ')})`,
393
+ );
394
+ continue;
395
+ }
396
+
397
+ logger.info(`Found item '${currentItemId}' in type '${foundType}' at ${foundFolder}`);
398
+
399
+ const { objectLayerRenderFramesData, objectLayerData } =
400
+ await ObjectLayerEngine.buildObjectLayerDataFromDirectory({
401
+ folder: foundFolder,
402
+ objectLayerType: foundType,
403
+ objectLayerId: currentItemId,
404
+ });
405
+
406
+ // Write processed frames back to disk so WebP matches atlas
407
+ const srcBasePath = './src/client/public/cyberia/';
408
+ const publicBasePath = `./public/${host}${path}`;
409
+ await ObjectLayerEngine.writeStaticFrameAssets({
410
+ basePaths: [srcBasePath, publicBasePath],
411
+ itemType: foundType,
412
+ itemId: currentItemId,
413
+ objectLayerRenderFramesData,
414
+ objectLayerData,
415
+ cellPixelDim: 20,
416
+ });
417
+
418
+ // Check if an ObjectLayer with the same item.id already exists (upsert by item ID)
419
+ const existingOL = await ObjectLayer.findOne({ 'data.item.id': currentItemId });
420
+ let objectLayer;
421
+
422
+ if (existingOL) {
423
+ // ── Cut-over consistency: stage everything in memory before touching the live document ──
424
+ logger.info(`ObjectLayer '${currentItemId}' already exists (${existingOL._id}), staging update...`);
425
+
426
+ // 1. Prepare staging data entirely in memory (no DB writes yet)
427
+ const stagingData = JSON.parse(JSON.stringify(objectLayerData.data));
428
+ if (!stagingData.render) stagingData.render = {};
429
+ stagingData.render.cid = '';
430
+ stagingData.render.metadataCid = '';
431
+
432
+ // 2. Generate atlas, pin to IPFS, compute SHA-256 — all in memory
433
+ let cutoverReady = false;
434
+ let stagingFileDoc = null;
435
+ let stagingAtlasDoc = null;
436
+ let stagingCid = '';
437
+ let stagingSha256 = '';
438
+ try {
439
+ const itemKey = currentItemId;
440
+
441
+ // Generate atlas from in-memory render frames data (plain object, no DB doc needed)
442
+ const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
443
+ objectLayerRenderFramesData,
444
+ itemKey,
445
+ 20,
446
+ );
447
+
448
+ stagingFileDoc = await new File({
449
+ name: `${itemKey}-atlas.png`,
450
+ data: buffer,
451
+ size: buffer.length,
452
+ mimetype: 'image/png',
453
+ md5: crypto.createHash('md5').update(buffer).digest('hex'),
454
+ }).save();
455
+
456
+ let importItemCid = '';
457
+ let importItemMetadataCid = '';
458
+ try {
459
+ const ipfsResult = await IpfsClient.addBufferToIpfs(
460
+ buffer,
461
+ `${itemKey}_atlas_sprite_sheet.png`,
462
+ `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
463
+ );
464
+ if (ipfsResult) {
465
+ importItemCid = ipfsResult.cid;
466
+ logger.info(`[staging] Atlas pinned to IPFS – CID: ${importItemCid}`);
467
+ try {
468
+ await createPinRecord({
469
+ cid: importItemCid,
470
+ resourceType: 'atlas-sprite-sheet',
471
+ mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
472
+ options: { host, path },
473
+ });
474
+ } catch (prErr) {
475
+ logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
476
+ }
477
+ }
478
+ } catch (ipfsError) {
479
+ logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
480
+ }
481
+
482
+ try {
483
+ const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
484
+ metadata,
485
+ `${itemKey}_atlas_sprite_sheet_metadata.json`,
486
+ `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
487
+ );
488
+ if (metadataIpfsResult) {
489
+ importItemMetadataCid = metadataIpfsResult.cid;
490
+ logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importItemMetadataCid}`);
491
+ try {
492
+ await createPinRecord({
493
+ cid: importItemMetadataCid,
494
+ resourceType: 'atlas-metadata',
495
+ mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
496
+ options: { host, path },
497
+ });
498
+ } catch (prErr) {
499
+ logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
500
+ }
501
+ }
502
+ } catch (ipfsError) {
503
+ logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
504
+ }
505
+
506
+ // Persist atlas doc (or update existing one for this itemKey)
507
+ stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
508
+ if (stagingAtlasDoc) {
509
+ if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
510
+ stagingAtlasDoc.fileId = stagingFileDoc._id;
511
+ stagingAtlasDoc.cid = importItemCid;
512
+ stagingAtlasDoc.metadata = metadata;
513
+ await stagingAtlasDoc.save();
514
+ } else {
515
+ stagingAtlasDoc = await new AtlasSpriteSheet({
516
+ fileId: stagingFileDoc._id,
517
+ cid: importItemCid,
518
+ metadata,
519
+ }).save();
520
+ }
521
+
522
+ // Finalize staging data in memory with render CIDs
523
+ stagingData.render.cid = importItemCid;
524
+ stagingData.render.metadataCid = importItemMetadataCid;
525
+
526
+ // Pin data JSON to IPFS (compute final SHA-256 in memory)
527
+ stagingSha256 = ObjectLayerEngine.computeSha256(stagingData);
528
+ try {
529
+ const ipfsDataResult = await IpfsClient.addJsonToIpfs(
530
+ stagingData,
531
+ `${itemKey}_data.json`,
532
+ `/object-layer/${itemKey}/${itemKey}_data.json`,
533
+ );
534
+ if (ipfsDataResult) {
535
+ stagingCid = ipfsDataResult.cid;
536
+ logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
537
+ try {
538
+ await createPinRecord({
539
+ cid: stagingCid,
540
+ resourceType: 'object-layer-data',
541
+ mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
542
+ options: { host, path },
543
+ });
544
+ } catch (prErr) {
545
+ logger.warn('[staging] Failed to create data pin record:', prErr.message);
546
+ }
547
+ }
548
+ } catch (ipfsError) {
549
+ logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
550
+ }
551
+
552
+ cutoverReady = true;
553
+ logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for cut-over`);
554
+ } catch (atlasError) {
555
+ logger.error(`[staging] Failed for ${currentItemId}, live document untouched:`, atlasError);
556
+ }
557
+
558
+ // 3. Atomic cut-over: create new RenderFrames, swap live ObjectLayer in a single update
559
+ if (cutoverReady) {
560
+ const oldRenderFramesId = existingOL.objectLayerRenderFramesId;
561
+
562
+ // Create the new RenderFrames doc (only now touches DB)
563
+ const newRenderFrames = await ObjectLayerRenderFrames.create(objectLayerRenderFramesData);
564
+
565
+ // Single atomic update of the live document
566
+ await ObjectLayer.findByIdAndUpdate(existingOL._id, {
567
+ data: stagingData,
568
+ sha256: stagingSha256,
569
+ cid: stagingCid,
570
+ objectLayerRenderFramesId: newRenderFrames._id,
571
+ atlasSpriteSheetId: stagingAtlasDoc._id,
572
+ });
573
+
574
+ // Clean up old render frames
575
+ if (oldRenderFramesId) {
576
+ await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
577
+ }
578
+
579
+ logger.info(`[cut-over] Live document ${existingOL._id} updated atomically`);
580
+ } else {
581
+ // Rollback: only File/AtlasSpriteSheet were written, clean those up
582
+ if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
583
+ logger.warn(`[cut-over] Staging rolled back for ${currentItemId}, live document preserved`);
584
+ }
585
+
586
+ objectLayer = await ObjectLayer.findById(existingOL._id);
587
+ } else {
588
+ // ── New item: stage everything before creating (same cut-over pattern) ──
589
+ logger.info(`ObjectLayer '${currentItemId}' is new, staging creation...`);
590
+
591
+ const itemKey = currentItemId;
592
+ const stagingData = JSON.parse(JSON.stringify(objectLayerData.data));
593
+ if (!stagingData.render) stagingData.render = {};
594
+ stagingData.render.cid = '';
595
+ stagingData.render.metadataCid = '';
596
+
597
+ let cutoverReady = false;
598
+ let stagingFileDoc = null;
599
+ let stagingAtlasDoc = null;
600
+ let stagingCid = '';
601
+ let stagingSha256 = '';
602
+ try {
603
+ const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
604
+ objectLayerRenderFramesData,
605
+ itemKey,
606
+ 20,
607
+ );
608
+
609
+ stagingFileDoc = await new File({
610
+ name: `${itemKey}-atlas.png`,
611
+ data: buffer,
612
+ size: buffer.length,
613
+ mimetype: 'image/png',
614
+ md5: crypto.createHash('md5').update(buffer).digest('hex'),
615
+ }).save();
616
+
617
+ let importItemCid = '';
618
+ let importItemMetadataCid = '';
619
+ try {
620
+ const ipfsResult = await IpfsClient.addBufferToIpfs(
621
+ buffer,
622
+ `${itemKey}_atlas_sprite_sheet.png`,
623
+ `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
624
+ );
625
+ if (ipfsResult) {
626
+ importItemCid = ipfsResult.cid;
627
+ logger.info(`[staging] Atlas pinned to IPFS – CID: ${importItemCid}`);
628
+ try {
629
+ await createPinRecord({
630
+ cid: importItemCid,
631
+ resourceType: 'atlas-sprite-sheet',
632
+ mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
633
+ options: { host, path },
634
+ });
635
+ } catch (prErr) {
636
+ logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
637
+ }
638
+ }
639
+ } catch (ipfsError) {
640
+ logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
641
+ }
642
+
643
+ try {
644
+ const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
645
+ metadata,
646
+ `${itemKey}_atlas_sprite_sheet_metadata.json`,
647
+ `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
648
+ );
649
+ if (metadataIpfsResult) {
650
+ importItemMetadataCid = metadataIpfsResult.cid;
651
+ logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importItemMetadataCid}`);
652
+ try {
653
+ await createPinRecord({
654
+ cid: importItemMetadataCid,
655
+ resourceType: 'atlas-metadata',
656
+ mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
657
+ options: { host, path },
658
+ });
659
+ } catch (prErr) {
660
+ logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
661
+ }
662
+ }
663
+ } catch (ipfsError) {
664
+ logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
665
+ }
666
+
667
+ stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
668
+ if (stagingAtlasDoc) {
669
+ if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
670
+ stagingAtlasDoc.fileId = stagingFileDoc._id;
671
+ stagingAtlasDoc.cid = importItemCid;
672
+ stagingAtlasDoc.metadata = metadata;
673
+ await stagingAtlasDoc.save();
674
+ } else {
675
+ stagingAtlasDoc = await new AtlasSpriteSheet({
676
+ fileId: stagingFileDoc._id,
677
+ cid: importItemCid,
678
+ metadata,
679
+ }).save();
680
+ }
681
+
682
+ stagingData.render.cid = importItemCid;
683
+ stagingData.render.metadataCid = importItemMetadataCid;
684
+
685
+ stagingSha256 = ObjectLayerEngine.computeSha256(stagingData);
686
+ try {
687
+ const ipfsDataResult = await IpfsClient.addJsonToIpfs(
688
+ stagingData,
689
+ `${itemKey}_data.json`,
690
+ `/object-layer/${itemKey}/${itemKey}_data.json`,
691
+ );
692
+ if (ipfsDataResult) {
693
+ stagingCid = ipfsDataResult.cid;
694
+ logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
695
+ try {
696
+ await createPinRecord({
697
+ cid: stagingCid,
698
+ resourceType: 'object-layer-data',
699
+ mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
700
+ options: { host, path },
701
+ });
702
+ } catch (prErr) {
703
+ logger.warn('[staging] Failed to create data pin record:', prErr.message);
704
+ }
705
+ }
706
+ } catch (ipfsError) {
707
+ logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
708
+ }
709
+
710
+ cutoverReady = true;
711
+ logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for creation`);
712
+ } catch (atlasError) {
713
+ logger.error(`[staging] Failed for ${currentItemId}, no document created:`, atlasError);
714
+ }
715
+
716
+ if (cutoverReady) {
717
+ const newRenderFrames = await ObjectLayerRenderFrames.create(objectLayerRenderFramesData);
718
+ objectLayer = await ObjectLayer.create({
719
+ data: stagingData,
720
+ sha256: stagingSha256,
721
+ cid: stagingCid,
722
+ objectLayerRenderFramesId: newRenderFrames._id,
723
+ atlasSpriteSheetId: stagingAtlasDoc._id,
724
+ });
725
+ logger.info(`[cut-over] New ObjectLayer ${objectLayer._id} created with all CIDs populated`);
726
+ } else {
727
+ if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
728
+ logger.warn(`[cut-over] Staging failed for ${currentItemId}, no ObjectLayer created`);
729
+ continue;
730
+ }
731
+ }
732
+
733
+ // Reload final state to include CID and render updates
734
+ const finalObjectLayer = await ObjectLayer.findById(objectLayer._id).populate('objectLayerRenderFramesId');
735
+ console.log(finalObjectLayer.toObject());
736
+ }
737
+ }
738
+
739
+ // ── Handle --import-types (batch by type) ────────────────────────
740
+ if (options.importTypes) {
212
741
  /** @type {boolean} */
213
- const isImportAll = options.import === 'all';
742
+ const isImportAll = options.importTypes === 'all';
214
743
 
215
744
  /** @type {string[]} */
216
- const argItemTypes = isImportAll ? Object.keys(itemTypes) : options.import.split(',');
745
+ const argItemTypes = isImportAll ? Object.keys(itemTypes) : options.importTypes.split(',');
217
746
 
218
747
  /**
219
748
  * Accumulated object layer data keyed by objectLayerId.
@@ -221,6 +750,19 @@ try {
221
750
  */
222
751
  const objectLayers = {};
223
752
 
753
+ // When importing all types, pre-fetch existing item IDs so we can skip them entirely
754
+ /** @type {Set<string>} */
755
+ const existingItemIds = new Set();
756
+ if (isImportAll) {
757
+ const existingDocs = await ObjectLayer.find({}, { 'data.item.id': 1 }).lean();
758
+ for (const doc of existingDocs) {
759
+ if (doc.data?.item?.id) existingItemIds.add(doc.data.item.id);
760
+ }
761
+ if (existingItemIds.size > 0) {
762
+ logger.info(`Skipping ${existingItemIds.size} existing item(s): ${[...existingItemIds].join(', ')}`);
763
+ }
764
+ }
765
+
224
766
  for (const argItemType of argItemTypes) {
225
767
  await pngDirectoryIteratorByObjectLayerType(
226
768
  argItemType,
@@ -231,6 +773,9 @@ try {
231
773
  )
232
774
  return;
233
775
 
776
+ // Skip items that already exist in the database (bulk import only)
777
+ if (isImportAll && existingItemIds.has(objectLayerId)) return;
778
+
234
779
  console.log(framePath, { objectLayerType, objectLayerId, direction, frame });
235
780
 
236
781
  // On first encounter of an objectLayerId, build its data from the asset directory
@@ -243,6 +788,18 @@ try {
243
788
  objectLayerId,
244
789
  });
245
790
 
791
+ // Write processed frames back to disk so WebP matches atlas
792
+ const srcBasePath = './src/client/public/cyberia/';
793
+ const publicBasePath = `./public/${host}${path}`;
794
+ await ObjectLayerEngine.writeStaticFrameAssets({
795
+ basePaths: [srcBasePath, publicBasePath],
796
+ itemType: objectLayerType,
797
+ itemId: objectLayerId,
798
+ objectLayerRenderFramesData,
799
+ objectLayerData,
800
+ cellPixelDim: 20,
801
+ });
802
+
246
803
  objectLayers[objectLayerId] = {
247
804
  ...objectLayerData,
248
805
  objectLayerRenderFramesData,
@@ -261,116 +818,375 @@ try {
261
818
  const shouldGenerateAtlas = !isImportAll;
262
819
 
263
820
  if (shouldGenerateAtlas) {
264
- // Use the createObjectLayerDocuments which handles atlas generation
265
- // Since we're in CLI context without a full Express req/res, we build a minimal
266
- // atlas generation flow using AtlasSpriteSheetGenerator directly after creation.
267
- const { objectLayer } = await ObjectLayerEngine.createObjectLayerDocuments({
268
- ObjectLayer,
269
- ObjectLayerRenderFrames,
270
- objectLayerRenderFramesData: entry.objectLayerRenderFramesData,
271
- objectLayerData: { data: entry.data },
272
- createOptions: {
273
- generateAtlas: false,
274
- },
275
- });
821
+ // Check if an ObjectLayer with the same item.id already exists (upsert by item ID)
822
+ const existingOL = await ObjectLayer.findOne({ 'data.item.id': objectLayerId });
823
+ let objectLayer;
824
+
825
+ if (existingOL) {
826
+ // ── Cut-over consistency: stage everything in memory before touching the live document ──
827
+ logger.info(`ObjectLayer '${objectLayerId}' already exists (${existingOL._id}), staging update...`);
828
+
829
+ // 1. Prepare staging data entirely in memory (no DB writes yet)
830
+ const stagingData = JSON.parse(JSON.stringify(entry.data));
831
+ if (!stagingData.render) stagingData.render = {};
832
+ stagingData.render.cid = '';
833
+ stagingData.render.metadataCid = '';
834
+
835
+ // 2. Generate atlas, pin to IPFS, compute SHA-256 — all in memory
836
+ let cutoverReady = false;
837
+ let stagingFileDoc = null;
838
+ let stagingAtlasDoc = null;
839
+ let stagingCid = '';
840
+ let stagingSha256 = '';
841
+ try {
842
+ const itemKey = objectLayerId;
276
843
 
277
- // Generate atlas sprite sheet for individual imports
278
- try {
279
- const itemKey = objectLayer.data.item.id;
280
- const populatedObjectLayer = await ObjectLayer.findById(objectLayer._id).populate(
281
- 'objectLayerRenderFramesId',
282
- );
844
+ // Generate atlas from in-memory render frames data (plain object, no DB doc needed)
845
+ const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
846
+ entry.objectLayerRenderFramesData,
847
+ itemKey,
848
+ 20,
849
+ );
283
850
 
284
- const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
285
- populatedObjectLayer.objectLayerRenderFramesId,
286
- itemKey,
287
- 20,
288
- );
851
+ stagingFileDoc = await new File({
852
+ name: `${itemKey}-atlas.png`,
853
+ data: buffer,
854
+ size: buffer.length,
855
+ mimetype: 'image/png',
856
+ md5: crypto.createHash('md5').update(buffer).digest('hex'),
857
+ }).save();
289
858
 
290
- const fileDoc = await new File({
291
- name: `${itemKey}-atlas.png`,
292
- data: buffer,
293
- size: buffer.length,
294
- mimetype: 'image/png',
295
- md5: crypto.createHash('md5').update(buffer).digest('hex'),
296
- }).save();
859
+ let importAtlasCid = '';
860
+ let importAtlasMetadataCid = '';
861
+ try {
862
+ const ipfsResult = await IpfsClient.addBufferToIpfs(
863
+ buffer,
864
+ `${itemKey}_atlas_sprite_sheet.png`,
865
+ `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
866
+ );
867
+ if (ipfsResult) {
868
+ importAtlasCid = ipfsResult.cid;
869
+ logger.info(`[staging] Atlas pinned to IPFS – CID: ${importAtlasCid}`);
870
+ try {
871
+ await createPinRecord({
872
+ cid: importAtlasCid,
873
+ resourceType: 'atlas-sprite-sheet',
874
+ mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
875
+ options: { host, path },
876
+ });
877
+ } catch (prErr) {
878
+ logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
879
+ }
880
+ }
881
+ } catch (ipfsError) {
882
+ logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
883
+ }
297
884
 
298
- // Pin atlas PNG to IPFS
299
- let importAtlasCid = '';
300
- let importAtlasMetadataCid = '';
301
- try {
302
- const ipfsResult = await IpfsClient.addBufferToIpfs(
303
- buffer,
304
- `${itemKey}_atlas_sprite_sheet.png`,
305
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
306
- );
307
- if (ipfsResult) {
308
- importAtlasCid = ipfsResult.cid;
309
- logger.info(`Atlas sprite sheet pinned to IPFS – CID: ${importAtlasCid}`);
885
+ try {
886
+ const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
887
+ metadata,
888
+ `${itemKey}_atlas_sprite_sheet_metadata.json`,
889
+ `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
890
+ );
891
+ if (metadataIpfsResult) {
892
+ importAtlasMetadataCid = metadataIpfsResult.cid;
893
+ logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importAtlasMetadataCid}`);
894
+ try {
895
+ await createPinRecord({
896
+ cid: importAtlasMetadataCid,
897
+ resourceType: 'atlas-metadata',
898
+ mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
899
+ options: { host, path },
900
+ });
901
+ } catch (prErr) {
902
+ logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
903
+ }
904
+ }
905
+ } catch (ipfsError) {
906
+ logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
310
907
  }
311
- } catch (ipfsError) {
312
- logger.warn('Failed to add atlas sprite sheet to IPFS:', ipfsError.message);
908
+
909
+ stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
910
+ if (stagingAtlasDoc) {
911
+ if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
912
+ stagingAtlasDoc.fileId = stagingFileDoc._id;
913
+ stagingAtlasDoc.cid = importAtlasCid;
914
+ stagingAtlasDoc.metadata = metadata;
915
+ await stagingAtlasDoc.save();
916
+ } else {
917
+ stagingAtlasDoc = await new AtlasSpriteSheet({
918
+ fileId: stagingFileDoc._id,
919
+ cid: importAtlasCid,
920
+ metadata,
921
+ }).save();
922
+ }
923
+
924
+ // Finalize staging data in memory with render CIDs
925
+ stagingData.render.cid = importAtlasCid;
926
+ stagingData.render.metadataCid = importAtlasMetadataCid;
927
+
928
+ // Pin data JSON to IPFS (compute final SHA-256 in memory)
929
+ stagingSha256 = ObjectLayerEngine.computeSha256(stagingData);
930
+ try {
931
+ const ipfsDataResult = await IpfsClient.addJsonToIpfs(
932
+ stagingData,
933
+ `${itemKey}_data.json`,
934
+ `/object-layer/${itemKey}/${itemKey}_data.json`,
935
+ );
936
+ if (ipfsDataResult) {
937
+ stagingCid = ipfsDataResult.cid;
938
+ logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
939
+ try {
940
+ await createPinRecord({
941
+ cid: stagingCid,
942
+ resourceType: 'object-layer-data',
943
+ mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
944
+ options: { host, path },
945
+ });
946
+ } catch (prErr) {
947
+ logger.warn('[staging] Failed to create data pin record:', prErr.message);
948
+ }
949
+ }
950
+ } catch (ipfsError) {
951
+ logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
952
+ }
953
+
954
+ cutoverReady = true;
955
+ logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for cut-over`);
956
+ } catch (atlasError) {
957
+ logger.error(`[staging] Failed for ${objectLayerId}, live document untouched:`, atlasError);
313
958
  }
314
959
 
315
- // Pin atlas metadata JSON to IPFS (fast-json-stable-stringify)
316
- try {
317
- const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
318
- metadata,
319
- `${itemKey}_atlas_sprite_sheet_metadata.json`,
320
- `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
321
- );
322
- if (metadataIpfsResult) {
323
- importAtlasMetadataCid = metadataIpfsResult.cid;
324
- logger.info(`Atlas metadata pinned to IPFS – CID: ${importAtlasMetadataCid}`);
960
+ // 3. Atomic cut-over: create new RenderFrames, swap live ObjectLayer in a single update
961
+ if (cutoverReady) {
962
+ const oldRenderFramesId = existingOL.objectLayerRenderFramesId;
963
+ const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
964
+
965
+ await ObjectLayer.findByIdAndUpdate(existingOL._id, {
966
+ data: stagingData,
967
+ sha256: stagingSha256,
968
+ cid: stagingCid,
969
+ objectLayerRenderFramesId: newRenderFrames._id,
970
+ atlasSpriteSheetId: stagingAtlasDoc._id,
971
+ });
972
+
973
+ if (oldRenderFramesId) {
974
+ await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
325
975
  }
326
- } catch (ipfsError) {
327
- logger.warn('Failed to add atlas metadata to IPFS:', ipfsError.message);
976
+ logger.info(`[cut-over] Live document ${existingOL._id} updated atomically`);
977
+ } else {
978
+ if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
979
+ logger.warn(`[cut-over] Staging rolled back for ${objectLayerId}, live document preserved`);
328
980
  }
329
981
 
330
- let atlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
982
+ objectLayer = await ObjectLayer.findById(existingOL._id);
983
+ } else {
984
+ // ── New item: stage everything before creating (same cut-over pattern) ──
985
+ logger.info(`ObjectLayer '${objectLayerId}' is new, staging creation...`);
986
+
987
+ const itemKey = objectLayerId;
988
+ const stagingData = JSON.parse(JSON.stringify(entry.data));
989
+ if (!stagingData.render) stagingData.render = {};
990
+ stagingData.render.cid = '';
991
+ stagingData.render.metadataCid = '';
992
+
993
+ let cutoverReady = false;
994
+ let stagingFileDoc = null;
995
+ let stagingAtlasDoc = null;
996
+ let stagingCid = '';
997
+ let stagingSha256 = '';
998
+ try {
999
+ const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
1000
+ entry.objectLayerRenderFramesData,
1001
+ itemKey,
1002
+ 20,
1003
+ );
331
1004
 
332
- if (atlasDoc) {
333
- atlasDoc.fileId = fileDoc._id;
334
- atlasDoc.cid = importAtlasCid;
335
- atlasDoc.metadata = metadata;
336
- await atlasDoc.save();
337
- logger.info(`Updated existing AtlasSpriteSheet document: ${atlasDoc._id}`);
338
- } else {
339
- atlasDoc = await new AtlasSpriteSheet({
340
- fileId: fileDoc._id,
341
- cid: importAtlasCid,
342
- metadata,
1005
+ stagingFileDoc = await new File({
1006
+ name: `${itemKey}-atlas.png`,
1007
+ data: buffer,
1008
+ size: buffer.length,
1009
+ mimetype: 'image/png',
1010
+ md5: crypto.createHash('md5').update(buffer).digest('hex'),
343
1011
  }).save();
344
- logger.info(`Created new AtlasSpriteSheet document: ${atlasDoc._id}`);
345
- }
346
1012
 
347
- populatedObjectLayer.atlasSpriteSheetId = atlasDoc._id;
348
- if (!populatedObjectLayer.data.render) populatedObjectLayer.data.render = {};
349
- populatedObjectLayer.data.render.cid = importAtlasCid;
350
- populatedObjectLayer.data.render.metadataCid = importAtlasMetadataCid;
351
- populatedObjectLayer.markModified('data.render');
352
- await populatedObjectLayer.save();
1013
+ let importAtlasCid = '';
1014
+ let importAtlasMetadataCid = '';
1015
+ try {
1016
+ const ipfsResult = await IpfsClient.addBufferToIpfs(
1017
+ buffer,
1018
+ `${itemKey}_atlas_sprite_sheet.png`,
1019
+ `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
1020
+ );
1021
+ if (ipfsResult) {
1022
+ importAtlasCid = ipfsResult.cid;
1023
+ logger.info(`[staging] Atlas pinned to IPFS – CID: ${importAtlasCid}`);
1024
+ try {
1025
+ await createPinRecord({
1026
+ cid: importAtlasCid,
1027
+ resourceType: 'atlas-sprite-sheet',
1028
+ mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
1029
+ options: { host, path },
1030
+ });
1031
+ } catch (prErr) {
1032
+ logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
1033
+ }
1034
+ }
1035
+ } catch (ipfsError) {
1036
+ logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
1037
+ }
353
1038
 
354
- logger.info(`Atlas sprite sheet completed for item: ${itemKey}`);
355
- } catch (atlasError) {
356
- logger.error(`Failed to generate atlas for ${objectLayerId}:`, atlasError);
1039
+ try {
1040
+ const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
1041
+ metadata,
1042
+ `${itemKey}_atlas_sprite_sheet_metadata.json`,
1043
+ `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
1044
+ );
1045
+ if (metadataIpfsResult) {
1046
+ importAtlasMetadataCid = metadataIpfsResult.cid;
1047
+ logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importAtlasMetadataCid}`);
1048
+ try {
1049
+ await createPinRecord({
1050
+ cid: importAtlasMetadataCid,
1051
+ resourceType: 'atlas-metadata',
1052
+ mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
1053
+ options: { host, path },
1054
+ });
1055
+ } catch (prErr) {
1056
+ logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
1057
+ }
1058
+ }
1059
+ } catch (ipfsError) {
1060
+ logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
1061
+ }
1062
+
1063
+ stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
1064
+ if (stagingAtlasDoc) {
1065
+ if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
1066
+ stagingAtlasDoc.fileId = stagingFileDoc._id;
1067
+ stagingAtlasDoc.cid = importAtlasCid;
1068
+ stagingAtlasDoc.metadata = metadata;
1069
+ await stagingAtlasDoc.save();
1070
+ } else {
1071
+ stagingAtlasDoc = await new AtlasSpriteSheet({
1072
+ fileId: stagingFileDoc._id,
1073
+ cid: importAtlasCid,
1074
+ metadata,
1075
+ }).save();
1076
+ }
1077
+
1078
+ stagingData.render.cid = importAtlasCid;
1079
+ stagingData.render.metadataCid = importAtlasMetadataCid;
1080
+
1081
+ stagingSha256 = ObjectLayerEngine.computeSha256(stagingData);
1082
+ try {
1083
+ const ipfsDataResult = await IpfsClient.addJsonToIpfs(
1084
+ stagingData,
1085
+ `${itemKey}_data.json`,
1086
+ `/object-layer/${itemKey}/${itemKey}_data.json`,
1087
+ );
1088
+ if (ipfsDataResult) {
1089
+ stagingCid = ipfsDataResult.cid;
1090
+ logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
1091
+ try {
1092
+ await createPinRecord({
1093
+ cid: stagingCid,
1094
+ resourceType: 'object-layer-data',
1095
+ mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
1096
+ options: { host, path },
1097
+ });
1098
+ } catch (prErr) {
1099
+ logger.warn('[staging] Failed to create data pin record:', prErr.message);
1100
+ }
1101
+ }
1102
+ } catch (ipfsError) {
1103
+ logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
1104
+ }
1105
+
1106
+ cutoverReady = true;
1107
+ logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for creation`);
1108
+ } catch (atlasError) {
1109
+ logger.error(`[staging] Failed for ${objectLayerId}, no document created:`, atlasError);
1110
+ }
1111
+
1112
+ if (cutoverReady) {
1113
+ const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
1114
+ objectLayer = await ObjectLayer.create({
1115
+ data: stagingData,
1116
+ sha256: stagingSha256,
1117
+ cid: stagingCid,
1118
+ objectLayerRenderFramesId: newRenderFrames._id,
1119
+ atlasSpriteSheetId: stagingAtlasDoc._id,
1120
+ });
1121
+ logger.info(`[cut-over] New ObjectLayer ${objectLayer._id} created with all CIDs populated`);
1122
+ } else {
1123
+ if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
1124
+ logger.warn(`[cut-over] Staging failed for ${objectLayerId}, no ObjectLayer created`);
1125
+ continue;
1126
+ }
357
1127
  }
358
1128
 
359
- console.log(objectLayer);
1129
+ // Reload final state to include CID and render updates
1130
+ const finalObjectLayer = await ObjectLayer.findById((objectLayer._id || objectLayer).toString()).populate(
1131
+ 'objectLayerRenderFramesId',
1132
+ );
1133
+ console.log(finalObjectLayer.toObject());
360
1134
  } else {
1135
+ // --import all: skip items that already exist in the database
1136
+ if (existingItemIds.has(objectLayerId)) continue;
1137
+
361
1138
  // --import all: create documents without atlas generation
362
- const { objectLayer } = await ObjectLayerEngine.createObjectLayerDocuments({
363
- ObjectLayer,
364
- ObjectLayerRenderFrames,
365
- objectLayerRenderFramesData: entry.objectLayerRenderFramesData,
366
- objectLayerData: { data: entry.data },
367
- createOptions: {
368
- generateAtlas: false,
369
- },
370
- });
1139
+ const existingOL = await ObjectLayer.findOne({ 'data.item.id': objectLayerId });
1140
+ let objectLayer;
371
1141
 
372
- logger.info(`ObjectLayer created (atlas skipped for bulk import): ${objectLayerId}`);
373
- console.log(objectLayer);
1142
+ if (existingOL) {
1143
+ logger.info(
1144
+ `ObjectLayer '${objectLayerId}' already exists (${existingOL._id}), staging update (atlas skipped)...`,
1145
+ );
1146
+
1147
+ // ── In-memory staging (no atlas) ──────────────────────
1148
+ const stagingData = JSON.parse(JSON.stringify(entry.data));
1149
+ if (!stagingData.render) stagingData.render = {};
1150
+ stagingData.render.cid = '';
1151
+ stagingData.render.metadataCid = '';
1152
+ const stagingSha256 = ObjectLayerEngine.computeSha256(stagingData);
1153
+
1154
+ // Atomic cut-over: create new RenderFrames, swap live doc, delete old
1155
+ const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
1156
+ const oldRenderFramesId = existingOL.objectLayerRenderFramesId;
1157
+
1158
+ await ObjectLayer.findByIdAndUpdate(existingOL._id, {
1159
+ data: stagingData,
1160
+ sha256: stagingSha256,
1161
+ objectLayerRenderFramesId: newRenderFrames._id,
1162
+ });
1163
+
1164
+ if (oldRenderFramesId) {
1165
+ await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
1166
+ }
1167
+
1168
+ objectLayer = await ObjectLayer.findById(existingOL._id);
1169
+ logger.info(`[cut-over] Live document ${existingOL._id} updated atomically (atlas skipped)`);
1170
+ } else {
1171
+ // New item: create with sha256 populated (no atlas for bulk import)
1172
+ const stagingData = JSON.parse(JSON.stringify(entry.data));
1173
+ if (!stagingData.render) stagingData.render = {};
1174
+ stagingData.render.cid = '';
1175
+ stagingData.render.metadataCid = '';
1176
+ const stagingSha256 = ObjectLayerEngine.computeSha256(stagingData);
1177
+
1178
+ const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
1179
+ objectLayer = await ObjectLayer.create({
1180
+ data: stagingData,
1181
+ sha256: stagingSha256,
1182
+ objectLayerRenderFramesId: newRenderFrames._id,
1183
+ });
1184
+ }
1185
+
1186
+ logger.info(
1187
+ `ObjectLayer ${existingOL ? 'updated' : 'created'} (atlas skipped for bulk import): ${objectLayerId}`,
1188
+ );
1189
+ console.log(objectLayer.toObject ? objectLayer.toObject() : objectLayer);
374
1190
  }
375
1191
  }
376
1192
  }
@@ -527,6 +1343,15 @@ try {
527
1343
  if (ipfsResult) {
528
1344
  toAtlasCid = ipfsResult.cid;
529
1345
  logger.info(`Atlas sprite sheet pinned to IPFS – CID: ${toAtlasCid}`);
1346
+ try {
1347
+ await createPinRecord({
1348
+ cid: toAtlasCid,
1349
+ resourceType: 'atlas-sprite-sheet',
1350
+ mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
1351
+ });
1352
+ } catch (e) {
1353
+ logger.warn('Failed to create pin record for atlas sprite sheet:', e.message);
1354
+ }
530
1355
  }
531
1356
  } catch (ipfsError) {
532
1357
  logger.warn('Failed to add atlas sprite sheet to IPFS:', ipfsError.message);
@@ -542,6 +1367,15 @@ try {
542
1367
  if (metadataIpfsResult) {
543
1368
  toAtlasMetadataCid = metadataIpfsResult.cid;
544
1369
  logger.info(`Atlas metadata pinned to IPFS – CID: ${toAtlasMetadataCid}`);
1370
+ try {
1371
+ await createPinRecord({
1372
+ cid: toAtlasMetadataCid,
1373
+ resourceType: 'atlas-metadata',
1374
+ mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
1375
+ });
1376
+ } catch (e) {
1377
+ logger.warn('Failed to create pin record for atlas metadata:', e.message);
1378
+ }
545
1379
  }
546
1380
  } catch (ipfsError) {
547
1381
  logger.warn('Failed to add atlas metadata to IPFS:', ipfsError.message);
@@ -551,7 +1385,8 @@ try {
551
1385
  let atlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
552
1386
 
553
1387
  if (atlasDoc) {
554
- // Update existing
1388
+ // Update existing – remove old File to prevent orphans
1389
+ if (atlasDoc.fileId) await File.findByIdAndDelete(atlasDoc.fileId);
555
1390
  atlasDoc.fileId = fileDoc._id;
556
1391
  atlasDoc.cid = toAtlasCid;
557
1392
  atlasDoc.metadata = metadata;
@@ -575,6 +1410,13 @@ try {
575
1410
  objectLayer.markModified('data.render');
576
1411
  await objectLayer.save();
577
1412
 
1413
+ // Compute final SHA-256 and pin object layer data JSON to IPFS
1414
+ await ObjectLayerEngine.computeAndSaveFinalSha256({
1415
+ objectLayer,
1416
+ ipfsClient: IpfsClient,
1417
+ createPinRecord,
1418
+ });
1419
+
578
1420
  logger.info(`Atlas sprite sheet completed for item: ${itemKey}`);
579
1421
  }
580
1422
 
@@ -745,6 +1587,16 @@ try {
745
1587
  if (ipfsResult) {
746
1588
  atlasCid = ipfsResult.cid;
747
1589
  logger.info(`Atlas sprite sheet pinned to IPFS – CID: ${atlasCid}`);
1590
+ try {
1591
+ await createPinRecord({
1592
+ cid: atlasCid,
1593
+ resourceType: 'atlas-sprite-sheet',
1594
+ mfsPath: `/object-layer/${atlasItemKey}/${atlasItemKey}_atlas_sprite_sheet.png`,
1595
+ options: { host, path },
1596
+ });
1597
+ } catch (e) {
1598
+ logger.warn('Failed to create pin record for atlas sprite sheet:', e.message);
1599
+ }
748
1600
  }
749
1601
  } catch (ipfsError) {
750
1602
  logger.warn('Failed to add atlas sprite sheet to IPFS:', ipfsError.message);
@@ -760,6 +1612,16 @@ try {
760
1612
  if (metadataIpfsResult) {
761
1613
  atlasMetadataCid = metadataIpfsResult.cid;
762
1614
  logger.info(`Atlas metadata pinned to IPFS – CID: ${atlasMetadataCid}`);
1615
+ try {
1616
+ await createPinRecord({
1617
+ cid: atlasMetadataCid,
1618
+ resourceType: 'atlas-metadata',
1619
+ mfsPath: `/object-layer/${atlasItemKey}/${atlasItemKey}_atlas_sprite_sheet_metadata.json`,
1620
+ options: { host, path },
1621
+ });
1622
+ } catch (e) {
1623
+ logger.warn('Failed to create pin record for atlas metadata:', e.message);
1624
+ }
763
1625
  }
764
1626
  } catch (ipfsError) {
765
1627
  logger.warn('Failed to add atlas metadata to IPFS:', ipfsError.message);
@@ -768,6 +1630,7 @@ try {
768
1630
  // Upsert AtlasSpriteSheet document (with CID)
769
1631
  let atlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': atlasItemKey });
770
1632
  if (atlasDoc) {
1633
+ if (atlasDoc.fileId) await File.findByIdAndDelete(atlasDoc.fileId);
771
1634
  atlasDoc.fileId = fileDoc._id;
772
1635
  atlasDoc.cid = atlasCid;
773
1636
  atlasDoc.metadata = metadata;
@@ -811,7 +1674,6 @@ try {
811
1674
  objectLayer: finalObjectLayer,
812
1675
  ipfsClient: IpfsClient,
813
1676
  createPinRecord,
814
- userId: undefined, // CLI context has no authenticated user
815
1677
  options: { host, path },
816
1678
  });
817
1679
  logger.info(`Final SHA-256: ${finalized.sha256}`);
@@ -838,6 +1700,641 @@ try {
838
1700
  )
839
1701
  .description('Object layer management');
840
1702
 
1703
+ // ── instance: Cyberia instance backup / restore ─────────────────────────
1704
+ program
1705
+ .command('instance [instance-code]')
1706
+ .option('--export [path]', 'Export instance and related documents to a backup directory')
1707
+ .option('--import [path]', 'Import instance and related documents from a backup directory (preserveUUID, upsert)')
1708
+ .option('--drop', 'Drop existing instance, maps and object layers before importing')
1709
+ .option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
1710
+ .option('--mongo-host <mongo-host>', 'Mongo host override')
1711
+ .option('--dev', 'Force development environment')
1712
+ .description('Export/import a Cyberia instance with all related maps, entities and object layers')
1713
+ .action(async (instanceCode, options = {}) => {
1714
+ if (!instanceCode) {
1715
+ logger.error('instance-code argument is required');
1716
+ process.exit(1);
1717
+ }
1718
+
1719
+ if (!options.envPath) options.envPath = `./.env`;
1720
+ if (fs.existsSync(options.envPath)) dotenv.config({ path: options.envPath, override: true });
1721
+
1722
+ if (options.dev && process.env.DEFAULT_DEPLOY_ID) {
1723
+ const deployDevEnvPath = `./engine-private/conf/${process.env.DEFAULT_DEPLOY_ID}/.env.development`;
1724
+ if (fs.existsSync(deployDevEnvPath)) {
1725
+ dotenv.config({ path: deployDevEnvPath, override: true });
1726
+ }
1727
+ }
1728
+
1729
+ const deployId = process.env.DEFAULT_DEPLOY_ID;
1730
+ const host = process.env.DEFAULT_DEPLOY_HOST;
1731
+ const path = process.env.DEFAULT_DEPLOY_PATH;
1732
+
1733
+ const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
1734
+ if (!fs.existsSync(confServerPath)) {
1735
+ logger.error(`Server config not found: ${confServerPath}`);
1736
+ process.exit(1);
1737
+ }
1738
+ const confServer = loadConfServerJson(confServerPath, { resolve: true });
1739
+ const { db } = confServer[host][path];
1740
+
1741
+ db.host = options.mongoHost
1742
+ ? options.mongoHost
1743
+ : options.dev
1744
+ ? db.host
1745
+ : db.host.replace('127.0.0.1', 'mongodb-0.mongodb-service');
1746
+
1747
+ logger.info('instance env', { env: options.envPath, deployId, host, path, db });
1748
+
1749
+ await DataBaseProvider.load({
1750
+ apis: [
1751
+ 'cyberia-instance',
1752
+ 'cyberia-map',
1753
+ 'cyberia-entity',
1754
+ 'object-layer',
1755
+ 'object-layer-render-frames',
1756
+ 'atlas-sprite-sheet',
1757
+ 'file',
1758
+ 'ipfs',
1759
+ ],
1760
+ host,
1761
+ path,
1762
+ db,
1763
+ });
1764
+
1765
+ const dbModels = DataBaseProvider.instance[`${host}${path}`].mongoose.models;
1766
+ const CyberiaInstance = dbModels.CyberiaInstance;
1767
+ const CyberiaMap = dbModels.CyberiaMap;
1768
+ const ObjectLayer = dbModels.ObjectLayer;
1769
+ const ObjectLayerRenderFrames = dbModels.ObjectLayerRenderFrames;
1770
+ const AtlasSpriteSheet = dbModels.AtlasSpriteSheet;
1771
+ const File = dbModels.File;
1772
+ const Ipfs = dbModels.Ipfs;
1773
+
1774
+ // ── EXPORT ──────────────────────────────────────────────────────
1775
+ if (options.export !== undefined) {
1776
+ const instance = await CyberiaInstance.findOne({ code: instanceCode }).lean();
1777
+ if (!instance) {
1778
+ logger.error(`CyberiaInstance with code "${instanceCode}" not found`);
1779
+ await DataBaseProvider.instance[`${host}${path}`].mongoose.close();
1780
+ process.exit(1);
1781
+ }
1782
+
1783
+ const backupDir =
1784
+ typeof options.export === 'string' && options.export
1785
+ ? options.export
1786
+ : `./engine-private/cyberia-instances/${instanceCode}`;
1787
+
1788
+ fs.ensureDirSync(backupDir);
1789
+ logger.info('Exporting instance', { code: instanceCode, backupDir });
1790
+
1791
+ fs.ensureDirSync(`${backupDir}/files`);
1792
+
1793
+ // Helper: export a File document to the files/ directory
1794
+ const exportFileDoc = async (fileId, fileKey) => {
1795
+ if (!fileId) return;
1796
+ const file = await File.findById(fileId).lean();
1797
+ if (!file) return;
1798
+ const fileExport = { ...file };
1799
+ // Handle both Node.js Buffer and BSON Binary types from .lean()
1800
+ if (fileExport.data) {
1801
+ const buf = Buffer.isBuffer(fileExport.data)
1802
+ ? fileExport.data
1803
+ : Buffer.from(fileExport.data.buffer || fileExport.data);
1804
+ fileExport.data = { $base64: buf.toString('base64') };
1805
+ }
1806
+ fs.writeJsonSync(`${backupDir}/files/${fileKey}.json`, fileExport, { spaces: 2 });
1807
+ };
1808
+
1809
+ // 1. Save instance document + thumbnail
1810
+ fs.writeJsonSync(`${backupDir}/cyberia-instance.json`, instance, { spaces: 2 });
1811
+ if (instance.thumbnail) {
1812
+ await exportFileDoc(instance.thumbnail, `thumb-instance-${instanceCode}`);
1813
+ }
1814
+ logger.info('Exported CyberiaInstance', { code: instanceCode });
1815
+
1816
+ // 2. Collect all map codes (instance maps + portal targets)
1817
+ const mapCodes = new Set(instance.cyberiaMapCodes || []);
1818
+ for (const portal of instance.portals || []) {
1819
+ if (portal.sourceMapCode) mapCodes.add(portal.sourceMapCode);
1820
+ if (portal.targetMapCode) mapCodes.add(portal.targetMapCode);
1821
+ }
1822
+
1823
+ // 3. Export maps + thumbnails
1824
+ const maps = await CyberiaMap.find({ code: { $in: [...mapCodes] } }).lean();
1825
+ fs.ensureDirSync(`${backupDir}/maps`);
1826
+ for (const map of maps) {
1827
+ fs.writeJsonSync(`${backupDir}/maps/${map.code}.json`, map, { spaces: 2 });
1828
+ if (map.thumbnail) {
1829
+ await exportFileDoc(map.thumbnail, `thumb-map-${map.code}`);
1830
+ }
1831
+ }
1832
+ logger.info(`Exported ${maps.length} CyberiaMap document(s)`, { codes: maps.map((m) => m.code) });
1833
+
1834
+ // 4. Collect all objectLayerItemIds from map entities
1835
+ const objectLayerItemIds = new Set();
1836
+ for (const map of maps) {
1837
+ for (const entity of map.entities || []) {
1838
+ for (const itemId of entity.objectLayerItemIds || []) {
1839
+ objectLayerItemIds.add(itemId);
1840
+ }
1841
+ }
1842
+ }
1843
+
1844
+ // 5. Export object layers with related render frames, atlas, files, and IPFS records
1845
+ if (objectLayerItemIds.size > 0) {
1846
+ const objectLayers = await ObjectLayer.find({
1847
+ 'data.item.id': { $in: [...objectLayerItemIds] },
1848
+ }).lean();
1849
+
1850
+ fs.ensureDirSync(`${backupDir}/object-layers`);
1851
+ fs.ensureDirSync(`${backupDir}/render-frames`);
1852
+ fs.ensureDirSync(`${backupDir}/atlas-sprite-sheets`);
1853
+ fs.ensureDirSync(`${backupDir}/ipfs`);
1854
+
1855
+ const allCids = new Set();
1856
+
1857
+ for (const ol of objectLayers) {
1858
+ const fileName = ol.data?.item?.id || ol._id.toString();
1859
+ fs.writeJsonSync(`${backupDir}/object-layers/${fileName}.json`, ol, { spaces: 2 });
1860
+
1861
+ // Export ObjectLayerRenderFrames
1862
+ if (ol.objectLayerRenderFramesId) {
1863
+ const rf = await ObjectLayerRenderFrames.findById(ol.objectLayerRenderFramesId).lean();
1864
+ if (rf) {
1865
+ fs.writeJsonSync(`${backupDir}/render-frames/${fileName}.json`, rf, { spaces: 2 });
1866
+ }
1867
+ }
1868
+
1869
+ // Export AtlasSpriteSheet + its File
1870
+ if (ol.atlasSpriteSheetId) {
1871
+ const atlas = await AtlasSpriteSheet.findById(ol.atlasSpriteSheetId).lean();
1872
+ if (atlas) {
1873
+ fs.writeJsonSync(`${backupDir}/atlas-sprite-sheets/${fileName}.json`, atlas, { spaces: 2 });
1874
+ if (atlas.fileId) {
1875
+ await exportFileDoc(atlas.fileId, `atlas-${fileName}`);
1876
+ }
1877
+ if (atlas.cid) allCids.add(atlas.cid);
1878
+ }
1879
+ }
1880
+
1881
+ // Collect CIDs for IPFS pin records
1882
+ if (ol.cid) allCids.add(ol.cid);
1883
+ if (ol.data?.render?.cid) allCids.add(ol.data.render.cid);
1884
+ if (ol.data?.render?.metadataCid) allCids.add(ol.data.render.metadataCid);
1885
+ }
1886
+
1887
+ // Export IPFS pin records for all collected CIDs
1888
+ if (allCids.size > 0) {
1889
+ const ipfsDocs = await Ipfs.find({ cid: { $in: [...allCids] } }).lean();
1890
+ if (ipfsDocs.length > 0) {
1891
+ fs.writeJsonSync(`${backupDir}/ipfs/pins.json`, ipfsDocs, { spaces: 2 });
1892
+ logger.info(`Exported ${ipfsDocs.length} Ipfs pin record(s)`);
1893
+ }
1894
+ }
1895
+
1896
+ logger.info(`Exported ${objectLayers.length} ObjectLayer document(s)`, {
1897
+ itemIds: [...objectLayerItemIds],
1898
+ });
1899
+ } else {
1900
+ logger.info('No ObjectLayer references found in map entities');
1901
+ }
1902
+
1903
+ logger.info('Instance export completed', { backupDir });
1904
+ }
1905
+
1906
+ // ── IMPORT ──────────────────────────────────────────────────────
1907
+ if (options.import !== undefined) {
1908
+ const backupDir =
1909
+ typeof options.import === 'string' && options.import
1910
+ ? options.import
1911
+ : `./engine-private/cyberia-instances/${instanceCode}`;
1912
+
1913
+ if (!fs.existsSync(backupDir)) {
1914
+ logger.error(`Backup directory not found: ${backupDir}`);
1915
+ await DataBaseProvider.instance[`${host}${path}`].mongoose.close();
1916
+ process.exit(1);
1917
+ }
1918
+
1919
+ logger.info('Importing instance', { code: instanceCode, backupDir });
1920
+
1921
+ // 0. Drop existing documents if --drop is set
1922
+ if (options.drop) {
1923
+ const existingInstance = await CyberiaInstance.findOne({ code: instanceCode }).lean();
1924
+ if (existingInstance) {
1925
+ const dropMapCodes = new Set(existingInstance.cyberiaMapCodes || []);
1926
+ for (const portal of existingInstance.portals || []) {
1927
+ if (portal.sourceMapCode) dropMapCodes.add(portal.sourceMapCode);
1928
+ if (portal.targetMapCode) dropMapCodes.add(portal.targetMapCode);
1929
+ }
1930
+
1931
+ // Collect thumbnail File IDs to drop
1932
+ const thumbFileIds = [];
1933
+ if (existingInstance.thumbnail) thumbFileIds.push(existingInstance.thumbnail);
1934
+
1935
+ // Query other instances/maps for shared thumbnail exclusion
1936
+ const otherInstances = await CyberiaInstance.find({ code: { $ne: instanceCode } }, { thumbnail: 1 }).lean();
1937
+
1938
+ if (dropMapCodes.size > 0) {
1939
+ const dropMaps = await CyberiaMap.find({ code: { $in: [...dropMapCodes] } }).lean();
1940
+ const dropOlItemIds = new Set();
1941
+ for (const map of dropMaps) {
1942
+ if (map.thumbnail) thumbFileIds.push(map.thumbnail);
1943
+ for (const entity of map.entities || []) {
1944
+ for (const itemId of entity.objectLayerItemIds || []) {
1945
+ dropOlItemIds.add(itemId);
1946
+ }
1947
+ }
1948
+ }
1949
+
1950
+ // Exclude OL item IDs referenced by maps outside this instance
1951
+ const otherMaps = await CyberiaMap.find(
1952
+ { code: { $nin: [...dropMapCodes] } },
1953
+ { 'entities.objectLayerItemIds': 1, thumbnail: 1 },
1954
+ ).lean();
1955
+ const sharedOlItemIds = new Set();
1956
+ for (const m of otherMaps) {
1957
+ for (const entity of m.entities || []) {
1958
+ for (const itemId of entity.objectLayerItemIds || []) {
1959
+ if (dropOlItemIds.has(itemId)) sharedOlItemIds.add(itemId);
1960
+ }
1961
+ }
1962
+ }
1963
+ for (const shared of sharedOlItemIds) dropOlItemIds.delete(shared);
1964
+ if (sharedOlItemIds.size > 0) {
1965
+ logger.info(`Preserved ${sharedOlItemIds.size} ObjectLayer(s) shared with other maps`);
1966
+ }
1967
+
1968
+ // Exclude thumbnail File IDs referenced by other instances or maps
1969
+ const otherMapThumbs = otherMaps.map((m) => m.thumbnail?.toString()).filter(Boolean);
1970
+ const otherInstThumbs = otherInstances.map((i) => i.thumbnail?.toString()).filter(Boolean);
1971
+ const sharedThumbIds = new Set([...otherMapThumbs, ...otherInstThumbs]);
1972
+ for (let i = thumbFileIds.length - 1; i >= 0; i--) {
1973
+ if (sharedThumbIds.has(thumbFileIds[i].toString())) thumbFileIds.splice(i, 1);
1974
+ }
1975
+
1976
+ if (dropOlItemIds.size > 0) {
1977
+ // Gather ObjectLayers to collect related doc IDs and CIDs
1978
+ const olDocs = await ObjectLayer.find(
1979
+ { 'data.item.id': { $in: [...dropOlItemIds] } },
1980
+ {
1981
+ cid: 1,
1982
+ 'data.item.id': 1,
1983
+ 'data.render': 1,
1984
+ objectLayerRenderFramesId: 1,
1985
+ atlasSpriteSheetId: 1,
1986
+ },
1987
+ ).lean();
1988
+
1989
+ const cidsToUnpin = new Set();
1990
+ const renderFrameIds = [];
1991
+ const atlasIds = [];
1992
+ const itemKeysToClean = new Set();
1993
+
1994
+ for (const doc of olDocs) {
1995
+ if (doc.cid) cidsToUnpin.add(doc.cid);
1996
+ if (doc.data?.render?.cid) cidsToUnpin.add(doc.data.render.cid);
1997
+ if (doc.data?.render?.metadataCid) cidsToUnpin.add(doc.data.render.metadataCid);
1998
+ if (doc.data?.item?.id) itemKeysToClean.add(doc.data.item.id);
1999
+ if (doc.objectLayerRenderFramesId) renderFrameIds.push(doc.objectLayerRenderFramesId);
2000
+ if (doc.atlasSpriteSheetId) atlasIds.push(doc.atlasSpriteSheetId);
2001
+ }
2002
+
2003
+ // Delete AtlasSpriteSheet + referenced File docs
2004
+ if (atlasIds.length > 0) {
2005
+ const atlasDocs = await AtlasSpriteSheet.find(
2006
+ { _id: { $in: atlasIds } },
2007
+ { fileId: 1, cid: 1 },
2008
+ ).lean();
2009
+ const atlasFileIds = atlasDocs.map((a) => a.fileId).filter(Boolean);
2010
+ for (const atlas of atlasDocs) {
2011
+ if (atlas.cid) cidsToUnpin.add(atlas.cid);
2012
+ }
2013
+ if (atlasFileIds.length > 0) {
2014
+ const fileResult = await File.deleteMany({ _id: { $in: atlasFileIds } });
2015
+ logger.info(`Dropped ${fileResult.deletedCount} File document(s) (atlas)`);
2016
+ }
2017
+ const atlasResult = await AtlasSpriteSheet.deleteMany({ _id: { $in: atlasIds } });
2018
+ logger.info(`Dropped ${atlasResult.deletedCount} AtlasSpriteSheet document(s)`);
2019
+ }
2020
+
2021
+ // Delete RenderFrames
2022
+ if (renderFrameIds.length > 0) {
2023
+ const rfResult = await ObjectLayerRenderFrames.deleteMany({ _id: { $in: renderFrameIds } });
2024
+ logger.info(`Dropped ${rfResult.deletedCount} ObjectLayerRenderFrames document(s)`);
2025
+ }
2026
+
2027
+ // Delete IPFS pin records
2028
+ if (cidsToUnpin.size > 0) {
2029
+ const ipfsResult = await Ipfs.deleteMany({ cid: { $in: [...cidsToUnpin] } });
2030
+ logger.info(`Dropped ${ipfsResult.deletedCount} Ipfs pin record(s)`);
2031
+ }
2032
+
2033
+ // Unpin CIDs from IPFS Kubo + Cluster and remove MFS paths
2034
+ let unpinCount = 0;
2035
+ for (const cid of cidsToUnpin) {
2036
+ const ok = await IpfsClient.unpinCid(cid);
2037
+ if (ok) unpinCount++;
2038
+ }
2039
+ let mfsCount = 0;
2040
+ for (const itemKey of itemKeysToClean) {
2041
+ const ok = await IpfsClient.removeMfsPath(`/object-layer/${itemKey}`);
2042
+ if (ok) mfsCount++;
2043
+ }
2044
+ logger.info(
2045
+ `IPFS cleanup: ${unpinCount}/${cidsToUnpin.size} CIDs unpinned, ${mfsCount}/${itemKeysToClean.size} MFS paths removed`,
2046
+ );
2047
+
2048
+ const olResult = await ObjectLayer.deleteMany({ 'data.item.id': { $in: [...dropOlItemIds] } });
2049
+ logger.info(`Dropped ${olResult.deletedCount} ObjectLayer document(s)`);
2050
+ }
2051
+
2052
+ const mapResult = await CyberiaMap.deleteMany({ code: { $in: [...dropMapCodes] } });
2053
+ logger.info(`Dropped ${mapResult.deletedCount} CyberiaMap document(s)`);
2054
+ }
2055
+
2056
+ // Drop thumbnail File documents (instance + maps), excluding shared ones
2057
+ if (thumbFileIds.length > 0) {
2058
+ const thumbResult = await File.deleteMany({ _id: { $in: thumbFileIds } });
2059
+ logger.info(`Dropped ${thumbResult.deletedCount} File document(s) (thumbnails)`);
2060
+ }
2061
+
2062
+ await CyberiaInstance.deleteOne({ code: instanceCode });
2063
+ logger.info('Dropped CyberiaInstance', { code: instanceCode });
2064
+ } else {
2065
+ logger.info('No existing instance to drop', { code: instanceCode });
2066
+ }
2067
+ }
2068
+
2069
+ // 1. Import File documents first (atlas PNG + thumbnail dependencies)
2070
+ const filesDir = `${backupDir}/files`;
2071
+ if (fs.existsSync(filesDir)) {
2072
+ const fileFiles = fs.readdirSync(filesDir).filter((f) => f.endsWith('.json'));
2073
+ let fileCount = 0;
2074
+ for (const f of fileFiles) {
2075
+ const fileData = fs.readJsonSync(`${filesDir}/${f}`);
2076
+ // Restore base64-encoded Buffer (handle both $base64 and { type: 'Buffer', data: [...] })
2077
+ if (fileData.data) {
2078
+ if (fileData.data.$base64) {
2079
+ fileData.data = Buffer.from(fileData.data.$base64, 'base64');
2080
+ } else if (fileData.data.type === 'Buffer' && Array.isArray(fileData.data.data)) {
2081
+ fileData.data = Buffer.from(fileData.data.data);
2082
+ }
2083
+ }
2084
+ // preserveUUID: delete any existing doc with this _id then create with exact _id
2085
+ await File.deleteOne({ _id: fileData._id });
2086
+ await File.create(fileData);
2087
+ fileCount++;
2088
+ }
2089
+ logger.info(`Imported ${fileCount} File document(s)`);
2090
+ }
2091
+
2092
+ // 2. Import ObjectLayerRenderFrames
2093
+ const rfDir = `${backupDir}/render-frames`;
2094
+ if (fs.existsSync(rfDir)) {
2095
+ const rfFiles = fs.readdirSync(rfDir).filter((f) => f.endsWith('.json'));
2096
+ let rfCount = 0;
2097
+ for (const f of rfFiles) {
2098
+ const rfData = fs.readJsonSync(`${rfDir}/${f}`);
2099
+ if (rfData._id) {
2100
+ await ObjectLayerRenderFrames.deleteOne({ _id: rfData._id });
2101
+ await ObjectLayerRenderFrames.create(rfData);
2102
+ rfCount++;
2103
+ }
2104
+ }
2105
+ logger.info(`Imported ${rfCount} ObjectLayerRenderFrames document(s)`);
2106
+ }
2107
+
2108
+ // 3. Import AtlasSpriteSheet
2109
+ const atlasDir = `${backupDir}/atlas-sprite-sheets`;
2110
+ if (fs.existsSync(atlasDir)) {
2111
+ const atlasFiles = fs.readdirSync(atlasDir).filter((f) => f.endsWith('.json'));
2112
+ let atlasCount = 0;
2113
+ for (const f of atlasFiles) {
2114
+ const atlasData = fs.readJsonSync(`${atlasDir}/${f}`);
2115
+ await AtlasSpriteSheet.deleteOne({ _id: atlasData._id });
2116
+ await AtlasSpriteSheet.create(atlasData);
2117
+ atlasCount++;
2118
+ }
2119
+ logger.info(`Imported ${atlasCount} AtlasSpriteSheet document(s)`);
2120
+ }
2121
+
2122
+ // 4. Import object layers
2123
+ const olDir = `${backupDir}/object-layers`;
2124
+ if (fs.existsSync(olDir)) {
2125
+ const olFiles = fs.readdirSync(olDir).filter((f) => f.endsWith('.json'));
2126
+ let olCount = 0;
2127
+ for (const file of olFiles) {
2128
+ const olData = fs.readJsonSync(`${olDir}/${file}`);
2129
+ await ObjectLayer.deleteOne({ _id: olData._id });
2130
+ await ObjectLayer.create(olData);
2131
+ olCount++;
2132
+ }
2133
+ logger.info(`Imported ${olCount} ObjectLayer document(s)`);
2134
+ }
2135
+
2136
+ // 5. Import IPFS pin records and re-pin CIDs
2137
+ const ipfsFile = `${backupDir}/ipfs/pins.json`;
2138
+ if (fs.existsSync(ipfsFile)) {
2139
+ const ipfsDocs = fs.readJsonSync(ipfsFile);
2140
+ let ipfsCount = 0;
2141
+ const pinnedCids = new Set();
2142
+ for (const doc of ipfsDocs) {
2143
+ await Ipfs.deleteOne({ _id: doc._id });
2144
+ await Ipfs.create(doc);
2145
+ ipfsCount++;
2146
+ if (doc.cid) pinnedCids.add(doc.cid);
2147
+ }
2148
+ logger.info(`Imported ${ipfsCount} Ipfs pin record(s)`);
2149
+
2150
+ // Re-pin CIDs to IPFS Kubo + Cluster
2151
+ let repinCount = 0;
2152
+ for (const cid of pinnedCids) {
2153
+ const ok = await IpfsClient.pinCid(cid);
2154
+ if (ok) repinCount++;
2155
+ }
2156
+ logger.info(`IPFS re-pin: ${repinCount}/${pinnedCids.size} CIDs pinned`);
2157
+ }
2158
+
2159
+ // 6. Import maps (preserveUUID: delete by code then create with exact _id)
2160
+ const mapsDir = `${backupDir}/maps`;
2161
+ if (fs.existsSync(mapsDir)) {
2162
+ const mapFiles = fs.readdirSync(mapsDir).filter((f) => f.endsWith('.json'));
2163
+ let mapCount = 0;
2164
+ for (const file of mapFiles) {
2165
+ const mapData = fs.readJsonSync(`${mapsDir}/${file}`);
2166
+ // Remove any existing map with this code (may have different _id)
2167
+ await CyberiaMap.deleteOne({ code: mapData.code });
2168
+ // Also remove if an old doc with this _id exists
2169
+ await CyberiaMap.deleteOne({ _id: mapData._id });
2170
+ await CyberiaMap.create(mapData);
2171
+ mapCount++;
2172
+ }
2173
+ logger.info(`Imported ${mapCount} CyberiaMap document(s)`);
2174
+ }
2175
+
2176
+ // 7. Import instance (preserveUUID: delete by code then create with exact _id)
2177
+ const instancePath = `${backupDir}/cyberia-instance.json`;
2178
+ if (fs.existsSync(instancePath)) {
2179
+ const instanceData = fs.readJsonSync(instancePath);
2180
+ await CyberiaInstance.deleteOne({ code: instanceCode });
2181
+ await CyberiaInstance.deleteOne({ _id: instanceData._id });
2182
+ await CyberiaInstance.create(instanceData);
2183
+ logger.info('Imported CyberiaInstance', { code: instanceCode });
2184
+ } else {
2185
+ logger.warn(`Instance file not found: ${instancePath}`);
2186
+ }
2187
+
2188
+ logger.info('Instance import completed', { backupDir });
2189
+ }
2190
+
2191
+ // ── DROP (standalone) ───────────────────────────────────────────
2192
+ if (options.drop && options.import === undefined) {
2193
+ const existingInstance = await CyberiaInstance.findOne({ code: instanceCode }).lean();
2194
+ if (existingInstance) {
2195
+ const dropMapCodes = new Set(existingInstance.cyberiaMapCodes || []);
2196
+ for (const portal of existingInstance.portals || []) {
2197
+ if (portal.sourceMapCode) dropMapCodes.add(portal.sourceMapCode);
2198
+ if (portal.targetMapCode) dropMapCodes.add(portal.targetMapCode);
2199
+ }
2200
+
2201
+ // Collect thumbnail File IDs to drop
2202
+ const thumbFileIds = [];
2203
+ if (existingInstance.thumbnail) thumbFileIds.push(existingInstance.thumbnail);
2204
+
2205
+ // Query other instances for shared thumbnail exclusion
2206
+ const otherInstances = await CyberiaInstance.find({ code: { $ne: instanceCode } }, { thumbnail: 1 }).lean();
2207
+
2208
+ if (dropMapCodes.size > 0) {
2209
+ const dropMaps = await CyberiaMap.find({ code: { $in: [...dropMapCodes] } }).lean();
2210
+ const dropOlItemIds = new Set();
2211
+ for (const map of dropMaps) {
2212
+ if (map.thumbnail) thumbFileIds.push(map.thumbnail);
2213
+ for (const entity of map.entities || []) {
2214
+ for (const itemId of entity.objectLayerItemIds || []) {
2215
+ dropOlItemIds.add(itemId);
2216
+ }
2217
+ }
2218
+ }
2219
+
2220
+ // Exclude OL item IDs referenced by maps outside this instance
2221
+ const otherMaps = await CyberiaMap.find(
2222
+ { code: { $nin: [...dropMapCodes] } },
2223
+ { 'entities.objectLayerItemIds': 1, thumbnail: 1 },
2224
+ ).lean();
2225
+ const sharedOlItemIds = new Set();
2226
+ for (const m of otherMaps) {
2227
+ for (const entity of m.entities || []) {
2228
+ for (const itemId of entity.objectLayerItemIds || []) {
2229
+ if (dropOlItemIds.has(itemId)) sharedOlItemIds.add(itemId);
2230
+ }
2231
+ }
2232
+ }
2233
+ for (const shared of sharedOlItemIds) dropOlItemIds.delete(shared);
2234
+ if (sharedOlItemIds.size > 0) {
2235
+ logger.info(`Preserved ${sharedOlItemIds.size} ObjectLayer(s) shared with other maps`);
2236
+ }
2237
+
2238
+ // Exclude thumbnail File IDs referenced by other instances or maps
2239
+ const otherMapThumbs = otherMaps.map((m) => m.thumbnail?.toString()).filter(Boolean);
2240
+ const otherInstThumbs = otherInstances.map((i) => i.thumbnail?.toString()).filter(Boolean);
2241
+ const sharedThumbIds = new Set([...otherMapThumbs, ...otherInstThumbs]);
2242
+ for (let i = thumbFileIds.length - 1; i >= 0; i--) {
2243
+ if (sharedThumbIds.has(thumbFileIds[i].toString())) thumbFileIds.splice(i, 1);
2244
+ }
2245
+
2246
+ if (dropOlItemIds.size > 0) {
2247
+ const olDocs = await ObjectLayer.find(
2248
+ { 'data.item.id': { $in: [...dropOlItemIds] } },
2249
+ {
2250
+ cid: 1,
2251
+ 'data.item.id': 1,
2252
+ 'data.render': 1,
2253
+ objectLayerRenderFramesId: 1,
2254
+ atlasSpriteSheetId: 1,
2255
+ },
2256
+ ).lean();
2257
+
2258
+ const cidsToUnpin = new Set();
2259
+ const renderFrameIds = [];
2260
+ const atlasIds = [];
2261
+ const itemKeysToClean = new Set();
2262
+
2263
+ for (const doc of olDocs) {
2264
+ if (doc.cid) cidsToUnpin.add(doc.cid);
2265
+ if (doc.data?.render?.cid) cidsToUnpin.add(doc.data.render.cid);
2266
+ if (doc.data?.render?.metadataCid) cidsToUnpin.add(doc.data.render.metadataCid);
2267
+ if (doc.data?.item?.id) itemKeysToClean.add(doc.data.item.id);
2268
+ if (doc.objectLayerRenderFramesId) renderFrameIds.push(doc.objectLayerRenderFramesId);
2269
+ if (doc.atlasSpriteSheetId) atlasIds.push(doc.atlasSpriteSheetId);
2270
+ }
2271
+
2272
+ if (atlasIds.length > 0) {
2273
+ const atlasDocs = await AtlasSpriteSheet.find({ _id: { $in: atlasIds } }, { fileId: 1, cid: 1 }).lean();
2274
+ const atlasFileIds = atlasDocs.map((a) => a.fileId).filter(Boolean);
2275
+ for (const atlas of atlasDocs) {
2276
+ if (atlas.cid) cidsToUnpin.add(atlas.cid);
2277
+ }
2278
+ if (atlasFileIds.length > 0) {
2279
+ const fileResult = await File.deleteMany({ _id: { $in: atlasFileIds } });
2280
+ logger.info(`Dropped ${fileResult.deletedCount} File document(s) (atlas)`);
2281
+ }
2282
+ const atlasResult = await AtlasSpriteSheet.deleteMany({ _id: { $in: atlasIds } });
2283
+ logger.info(`Dropped ${atlasResult.deletedCount} AtlasSpriteSheet document(s)`);
2284
+ }
2285
+
2286
+ if (renderFrameIds.length > 0) {
2287
+ const rfResult = await ObjectLayerRenderFrames.deleteMany({ _id: { $in: renderFrameIds } });
2288
+ logger.info(`Dropped ${rfResult.deletedCount} ObjectLayerRenderFrames document(s)`);
2289
+ }
2290
+
2291
+ if (cidsToUnpin.size > 0) {
2292
+ const ipfsResult = await Ipfs.deleteMany({ cid: { $in: [...cidsToUnpin] } });
2293
+ logger.info(`Dropped ${ipfsResult.deletedCount} Ipfs pin record(s)`);
2294
+ }
2295
+
2296
+ let unpinCount = 0;
2297
+ for (const cid of cidsToUnpin) {
2298
+ const ok = await IpfsClient.unpinCid(cid);
2299
+ if (ok) unpinCount++;
2300
+ }
2301
+ let mfsCount = 0;
2302
+ for (const itemKey of itemKeysToClean) {
2303
+ const ok = await IpfsClient.removeMfsPath(`/object-layer/${itemKey}`);
2304
+ if (ok) mfsCount++;
2305
+ }
2306
+ logger.info(
2307
+ `IPFS cleanup: ${unpinCount}/${cidsToUnpin.size} CIDs unpinned, ${mfsCount}/${itemKeysToClean.size} MFS paths removed`,
2308
+ );
2309
+
2310
+ const olResult = await ObjectLayer.deleteMany({ 'data.item.id': { $in: [...dropOlItemIds] } });
2311
+ logger.info(`Dropped ${olResult.deletedCount} ObjectLayer document(s)`);
2312
+ }
2313
+
2314
+ const mapResult = await CyberiaMap.deleteMany({ code: { $in: [...dropMapCodes] } });
2315
+ logger.info(`Dropped ${mapResult.deletedCount} CyberiaMap document(s)`);
2316
+ }
2317
+
2318
+ // Drop thumbnail File documents (instance + maps), excluding shared ones
2319
+ if (thumbFileIds.length > 0) {
2320
+ const thumbResult = await File.deleteMany({ _id: { $in: thumbFileIds } });
2321
+ logger.info(`Dropped ${thumbResult.deletedCount} File document(s) (thumbnails)`);
2322
+ }
2323
+
2324
+ await CyberiaInstance.deleteOne({ code: instanceCode });
2325
+ logger.info('Dropped CyberiaInstance', { code: instanceCode });
2326
+ } else {
2327
+ logger.info('No existing instance to drop', { code: instanceCode });
2328
+ }
2329
+ }
2330
+
2331
+ if (options.export === undefined && options.import === undefined && !options.drop) {
2332
+ logger.error('Specify --export, --import, or --drop flag');
2333
+ }
2334
+
2335
+ await DataBaseProvider.instance[`${host}${path}`].mongoose.close();
2336
+ });
2337
+
841
2338
  // ── chain: Hyperledger Besu / ERC-1155 lifecycle commands ────────────────
842
2339
  const chain = program.command('chain').description('Hyperledger Besu chain & ERC-1155 ObjectLayerToken lifecycle');
843
2340
 
@@ -1054,7 +2551,7 @@ try {
1054
2551
  // Use a Hardhat script via inline JS to call registerObjectLayer
1055
2552
  const registerScript = `
1056
2553
  import hre from 'hardhat';
1057
- const { ethers } = hre;
2554
+ const { ethers } = await hre.network.connect();
1058
2555
  async function main() {
1059
2556
  const [deployer] = await ethers.getSigners();
1060
2557
  const token = await ethers.getContractAt('ObjectLayerToken', '${contractAddress}');
@@ -1105,7 +2602,7 @@ try {
1105
2602
 
1106
2603
  const mintScript = `
1107
2604
  import hre from 'hardhat';
1108
- const { ethers } = hre;
2605
+ const { ethers } = await hre.network.connect();
1109
2606
  async function main() {
1110
2607
  const token = await ethers.getContractAt('ObjectLayerToken', '${contractAddress}');
1111
2608
  const tx = await token.mint('${options.to}', ${options.tokenId}, ${options.amount}, '0x');
@@ -1142,7 +2639,7 @@ try {
1142
2639
  const statusScript = `
1143
2640
  import hre from 'hardhat';
1144
2641
  import { readFileSync } from 'fs';
1145
- const { ethers } = hre;
2642
+ const { ethers } = await hre.network.connect();
1146
2643
  async function main() {
1147
2644
  const provider = ethers.provider;
1148
2645
  const network = await provider.getNetwork();
@@ -1204,7 +2701,7 @@ try {
1204
2701
 
1205
2702
  const pauseScript = `
1206
2703
  import hre from 'hardhat';
1207
- const { ethers } = hre;
2704
+ const { ethers } = await hre.network.connect();
1208
2705
  async function main() {
1209
2706
  const token = await ethers.getContractAt('ObjectLayerToken', '${deployment.address}');
1210
2707
  const tx = await token.pause();
@@ -1237,7 +2734,7 @@ try {
1237
2734
 
1238
2735
  const unpauseScript = `
1239
2736
  import hre from 'hardhat';
1240
- const { ethers } = hre;
2737
+ const { ethers } = await hre.network.connect();
1241
2738
  async function main() {
1242
2739
  const token = await ethers.getContractAt('ObjectLayerToken', '${deployment.address}');
1243
2740
  const tx = await token.unpause();
@@ -1396,7 +2893,7 @@ try {
1396
2893
 
1397
2894
  const balanceScript = `
1398
2895
  import hre from 'hardhat';
1399
- const { ethers } = hre;
2896
+ const { ethers } = await hre.network.connect();
1400
2897
  async function main() {
1401
2898
  const token = await ethers.getContractAt('ObjectLayerToken', '${contractAddress}');
1402
2899
  const balance = await token.balanceOf('${options.address}', ${options.tokenId});
@@ -1453,7 +2950,7 @@ try {
1453
2950
 
1454
2951
  const transferScript = `
1455
2952
  import hre from 'hardhat';
1456
- const { ethers } = hre;
2953
+ const { ethers } = await hre.network.connect();
1457
2954
  async function main() {
1458
2955
  const [signer] = await ethers.getSigners();
1459
2956
  const token = await ethers.getContractAt('ObjectLayerToken', '${contractAddress}');
@@ -1509,7 +3006,7 @@ try {
1509
3006
 
1510
3007
  const burnScript = `
1511
3008
  import hre from 'hardhat';
1512
- const { ethers } = hre;
3009
+ const { ethers } = await hre.network.connect();
1513
3010
  async function main() {
1514
3011
  const token = await ethers.getContractAt('ObjectLayerToken', '${contractAddress}');
1515
3012
  const tx = await token.burn('${options.address}', ${options.tokenId}, ${options.amount});
@@ -1620,7 +3117,7 @@ try {
1620
3117
 
1621
3118
  const batchScript = `
1622
3119
  import hre from 'hardhat';
1623
- const { ethers } = hre;
3120
+ const { ethers } = await hre.network.connect();
1624
3121
  async function main() {
1625
3122
  const [deployer] = await ethers.getSigners();
1626
3123
  const token = await ethers.getContractAt('ObjectLayerToken', '${contractAddress}');
@@ -1653,6 +3150,186 @@ try {
1653
3150
  }
1654
3151
  });
1655
3152
 
3153
+ const runner = program.command('run-workflow').description('Run a Cyberia script from the "scripts" directory');
3154
+
3155
+ runner
3156
+ .command('import-default-items')
3157
+ .option('--dev', 'Force development environment (loads .env.development for IPFS localhost, etc.)')
3158
+ .description('Import default Object Layer items, skill config, and dialogues into MongoDB')
3159
+ .action(async (options) => {
3160
+ const devFlag = options.dev ? ' --dev' : '';
3161
+ shellExec(`node bin/cyberia ol ${DefaultCyberiaItems.map((e) => e.item.id)} --import${devFlag}`);
3162
+ shellExec(`node bin/cyberia run-workflow seed-skill-config${devFlag}`);
3163
+ shellExec(`node bin/cyberia run-workflow seed-dialogues${devFlag}`);
3164
+ });
3165
+
3166
+ runner
3167
+ .command('seed-skill-config')
3168
+ .option('--instance-code <code>', 'CyberiaInstance code to update (default: $INSTANCE_CODE env or "default")')
3169
+ .option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
3170
+ .option('--mongo-host <mongo-host>', 'Mongo host override')
3171
+ .option('--dev', 'Force development environment')
3172
+ .description('Upsert default skillConfig entries into a CyberiaInstance document')
3173
+ .action(async (options) => {
3174
+ if (!options.envPath) options.envPath = `./.env`;
3175
+ if (fs.existsSync(options.envPath)) dotenv.config({ path: options.envPath, override: true });
3176
+
3177
+ if (options.dev && process.env.DEFAULT_DEPLOY_ID) {
3178
+ const devEnvPath = `./engine-private/conf/${process.env.DEFAULT_DEPLOY_ID}/.env.development`;
3179
+ if (fs.existsSync(devEnvPath)) dotenv.config({ path: devEnvPath, override: true });
3180
+ }
3181
+
3182
+ const deployId = process.env.DEFAULT_DEPLOY_ID;
3183
+ const host = process.env.DEFAULT_DEPLOY_HOST;
3184
+ const path = process.env.DEFAULT_DEPLOY_PATH;
3185
+ const instanceCode = options.instanceCode || process.env.INSTANCE_CODE || 'default';
3186
+
3187
+ const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
3188
+ if (!fs.existsSync(confServerPath)) {
3189
+ logger.error(`Server config not found: ${confServerPath}`);
3190
+ process.exit(1);
3191
+ }
3192
+ const confServer = loadConfServerJson(confServerPath, { resolve: true });
3193
+ const { db } = confServer[host][path];
3194
+
3195
+ db.host = options.mongoHost
3196
+ ? options.mongoHost
3197
+ : options.dev
3198
+ ? db.host
3199
+ : db.host.replace('127.0.0.1', 'mongodb-0.mongodb-service');
3200
+
3201
+ logger.info('seed-skill-config', { instanceCode, deployId, host, path, db });
3202
+
3203
+ await DataBaseProvider.load({ apis: ['cyberia-instance', 'cyberia-instance-conf'], host, path, db });
3204
+
3205
+ const CyberiaInstance = DataBaseProvider.instance[`${host}${path}`].mongoose.models.CyberiaInstance;
3206
+ const CyberiaInstanceConf = DataBaseProvider.instance[`${host}${path}`].mongoose.models.CyberiaInstanceConf;
3207
+
3208
+ const instance = await CyberiaInstance.findOne({ code: instanceCode }).lean();
3209
+
3210
+ if (!instance) {
3211
+ logger.info(
3212
+ `CyberiaInstance "${instanceCode}" not found — seeding skillConfig into conf using fallback defaults. ` +
3213
+ `To link to a live instance, create or import it with: node bin/cyberia instance ${instanceCode} --import`,
3214
+ );
3215
+ }
3216
+
3217
+ // Always upsert the conf with DefaultSkillConfig — idempotent regardless of instance existence.
3218
+ const conf = await CyberiaInstanceConf.findOneAndUpdate(
3219
+ { instanceCode },
3220
+ { $set: { skillConfig: DefaultSkillConfig } },
3221
+ { upsert: true, returnDocument: 'after' },
3222
+ );
3223
+
3224
+ // If a live instance exists, ensure its conf ref is linked.
3225
+ if (instance && (!instance.conf || String(instance.conf) !== String(conf._id))) {
3226
+ await CyberiaInstance.findByIdAndUpdate(instance._id, { conf: conf._id });
3227
+ }
3228
+
3229
+ logger.info(
3230
+ `skillConfig seeded for instance "${instanceCode}" (${DefaultSkillConfig.length} entries)`,
3231
+ DefaultSkillConfig.map((e) => `${e.triggerItemId} → [${e.logicEventIds.join(', ')}]`),
3232
+ );
3233
+
3234
+ await DataBaseProvider.instance[`${host}${path}`].mongoose.close();
3235
+ });
3236
+
3237
+ runner
3238
+ .command('seed-dialogues')
3239
+ .option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
3240
+ .option('--mongo-host <mongo-host>', 'Mongo host override')
3241
+ .option('--dev', 'Force development environment')
3242
+ .description('Upsert DefaultCyberiaDialogues into the cyberia-dialogue collection (idempotent)')
3243
+ .action(async (options) => {
3244
+ if (!options.envPath) options.envPath = `./.env`;
3245
+ if (fs.existsSync(options.envPath)) dotenv.config({ path: options.envPath, override: true });
3246
+
3247
+ if (options.dev && process.env.DEFAULT_DEPLOY_ID) {
3248
+ const devEnvPath = `./engine-private/conf/${process.env.DEFAULT_DEPLOY_ID}/.env.development`;
3249
+ if (fs.existsSync(devEnvPath)) dotenv.config({ path: devEnvPath, override: true });
3250
+ }
3251
+
3252
+ const deployId = process.env.DEFAULT_DEPLOY_ID;
3253
+ const host = process.env.DEFAULT_DEPLOY_HOST;
3254
+ const path = process.env.DEFAULT_DEPLOY_PATH;
3255
+
3256
+ const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
3257
+ if (!fs.existsSync(confServerPath)) {
3258
+ logger.error(`Server config not found: ${confServerPath}`);
3259
+ process.exit(1);
3260
+ }
3261
+ const confServer = loadConfServerJson(confServerPath, { resolve: true });
3262
+ const { db } = confServer[host][path];
3263
+
3264
+ db.host = options.mongoHost
3265
+ ? options.mongoHost
3266
+ : options.dev
3267
+ ? db.host
3268
+ : db.host.replace('127.0.0.1', 'mongodb-0.mongodb-service');
3269
+
3270
+ logger.info('seed-dialogues', { deployId, host, path, db });
3271
+
3272
+ await DataBaseProvider.load({ apis: ['cyberia-dialogue'], host, path, db });
3273
+
3274
+ const CyberiaDialogue = DataBaseProvider.instance[`${host}${path}`].mongoose.models.CyberiaDialogue;
3275
+
3276
+ // Upsert each dialogue record keyed by (itemId, order) — idempotent.
3277
+ let upserted = 0;
3278
+ for (const dlg of DefaultCyberiaDialogues) {
3279
+ await CyberiaDialogue.findOneAndUpdate(
3280
+ { itemId: dlg.itemId, order: dlg.order },
3281
+ { $set: { speaker: dlg.speaker, text: dlg.text, mood: dlg.mood } },
3282
+ { upsert: true },
3283
+ );
3284
+ upserted++;
3285
+ }
3286
+
3287
+ logger.info(`seed-dialogues: ${upserted} dialogue records upserted`);
3288
+
3289
+ await DataBaseProvider.instance[`${host}${path}`].mongoose.close();
3290
+ });
3291
+
3292
+ runner
3293
+ .command('generate-semantic-examples')
3294
+ .option('--seed <seed>', 'Base seed string (each type gets a unique suffix appended)', 'example')
3295
+ .option('--frame-count <frameCount>', 'Number of frames to generate per item (default: 4)', parseInt)
3296
+ .option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
3297
+ .option('--dev', 'Force development environment')
3298
+ .description('Generate one procedural example of every registered semantic prefix')
3299
+ .action(async (options) => {
3300
+ const SEMANTIC_TYPES = [
3301
+ 'floor-desert',
3302
+ 'floor-grass',
3303
+ // 'floor-water',
3304
+ // 'floor-stone',
3305
+ // 'floor-lava',
3306
+ 'skin-random',
3307
+ // 'skin-dark',
3308
+ // 'skin-light',
3309
+ // 'skin-vivid',
3310
+ // 'skin-natural',
3311
+ 'skin-shaved',
3312
+ ];
3313
+
3314
+ const baseSeed = options.seed || 'example';
3315
+ const frameCount = options.frameCount || 2;
3316
+ const envFlag = options.envPath ? ` --env-path ${options.envPath}` : '';
3317
+ const devFlag = options.dev ? ' --dev' : '';
3318
+
3319
+ logger.info(
3320
+ `Generating ${SEMANTIC_TYPES.length} semantic examples (seed base: "${baseSeed}", frames: ${frameCount})`,
3321
+ );
3322
+
3323
+ for (const prefix of SEMANTIC_TYPES) {
3324
+ const seed = `${baseSeed}-${prefix}`;
3325
+ const cmd = `node bin/cyberia ol ${prefix} --generate --seed ${seed} --frame-count ${frameCount}${envFlag}${devFlag}`;
3326
+ logger.info(` → ${cmd}`);
3327
+ shellExec(cmd);
3328
+ }
3329
+
3330
+ logger.info('All semantic examples generated.');
3331
+ });
3332
+
1656
3333
  if (underpostProgram.commands.find((c) => c._name == process.argv[2]))
1657
3334
  throw new Error('Trigger underpost passthrough');
1658
3335