@underpostnet/cyberia 3.3.73 → 3.3.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/.env.example +17 -5
  2. package/.github/workflows/coverall.cyberia.ci.yml +10 -0
  3. package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
  4. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
  5. package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
  6. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
  7. package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
  8. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
  9. package/.github/workflows/engine-cyberia.ci.yml +1 -1
  10. package/.github/workflows/publish.cyberia.ci.yml +44 -0
  11. package/AGENTS.md +61 -12
  12. package/CHANGELOG.md +159 -161
  13. package/CLI-HELP.md +20 -13
  14. package/Dockerfile +1 -1
  15. package/Dockerfile.dev +1 -1
  16. package/Dockerfile.test +2 -2
  17. package/README.md +28 -28
  18. package/bin/build.js +96 -21
  19. package/bin/cyberia.js +1247 -1373
  20. package/bin/index.js +1247 -1373
  21. package/compose.env +17 -5
  22. package/conf.js +7 -4
  23. package/deploy/cyberia-client/deploy.sh +10 -11
  24. package/deploy/cyberia-client/package.sh +0 -1
  25. package/deploy/cyberia-client/state.sh +3 -2
  26. package/deploy/cyberia-server/deploy.sh +10 -11
  27. package/deploy/cyberia-server/package.sh +0 -1
  28. package/deploy/cyberia-server/state.sh +3 -2
  29. package/deploy/dd-cyberia/deploy.sh +5 -5
  30. package/deploy/dd-cyberia/init.sh +15 -14
  31. package/deploy/dd-cyberia/package.sh +0 -1
  32. package/deploy/dd-cyberia/state.sh +2 -2
  33. package/deploy/dd-cyberia/sync-deploy.sh +139 -75
  34. package/deploy/lib/config.sh +29 -0
  35. package/deploy/lib/github-actions-logging.sh +92 -76
  36. package/deploy/lib/host.sh +28 -19
  37. package/deploy/lib/state.sh +4 -1
  38. package/deploy/pwa-microservices-template/deploy.sh +10 -8
  39. package/deploy/release/deploy.sh +0 -2
  40. package/deployment.yaml +1 -1
  41. package/docker-compose.yml +23 -17
  42. package/docs/coverage/cyberia/api/cyberia-instance/cyberia-fallback-capture.js.html +1591 -0
  43. package/docs/coverage/cyberia/api/cyberia-instance/index.html +116 -0
  44. package/docs/coverage/cyberia/api/cyberia-server-defaults/cyberia-server-defaults.js.html +4138 -0
  45. package/docs/coverage/cyberia/api/cyberia-server-defaults/index.html +116 -0
  46. package/docs/coverage/cyberia/api/object-layer/index.html +116 -0
  47. package/docs/coverage/cyberia/api/object-layer/object-layer.model.js.html +1393 -0
  48. package/docs/coverage/cyberia/base.css +362 -0
  49. package/docs/coverage/cyberia/block-navigation.js +82 -0
  50. package/docs/coverage/cyberia/favicon.png +0 -0
  51. package/docs/coverage/cyberia/index.html +161 -0
  52. package/docs/coverage/cyberia/prettify.css +101 -0
  53. package/docs/coverage/cyberia/prettify.js +937 -0
  54. package/docs/coverage/cyberia/projects/cyberia/atlas-sprite-sheet-generator.js.html +1315 -0
  55. package/docs/coverage/cyberia/projects/cyberia/index.html +176 -0
  56. package/docs/coverage/cyberia/projects/cyberia/instance-backup.js.html +715 -0
  57. package/docs/coverage/cyberia/projects/cyberia/instance-data.js.html +2182 -0
  58. package/docs/coverage/cyberia/projects/cyberia/shape-generator.js.html +3265 -0
  59. package/docs/coverage/cyberia/projects/cyberia/stat-balance.js.html +328 -0
  60. package/docs/coverage/cyberia/sort-arrow-sprite.png +0 -0
  61. package/docs/coverage/cyberia/sorter.js +205 -0
  62. package/hardhat/package-lock.json +2 -2
  63. package/hardhat/package.json +1 -1
  64. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  65. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
  66. package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
  67. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  68. package/manifests/deployment/mongo-express/deployment.yaml +46 -27
  69. package/manifests/deployment/mongo-express/kustomization.yaml +9 -0
  70. package/manifests/deployment/mongo-express/mongo-express-nodeport.yaml +18 -0
  71. package/manifests/deployment/mongo-express/service.yaml +16 -0
  72. package/manifests/deployment/mongo-express-no-auth/disable-mongodb-auth.yaml +16 -0
  73. package/manifests/deployment/mongo-express-no-auth/kustomization.yaml +16 -0
  74. package/package.json +19 -17
  75. package/scripts/test-monitor.sh +3 -3
  76. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +14 -9
  77. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +36 -5
  78. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +5 -0
  79. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +88 -184
  80. package/src/api/cyberia-action/cyberia-action.router.js +2 -2
  81. package/src/api/cyberia-action/cyberia-action.service.js +3 -4
  82. package/src/api/cyberia-audio/cyberia-audio.controller.js +6 -0
  83. package/src/api/cyberia-audio/cyberia-audio.model.js +61 -0
  84. package/src/api/cyberia-audio/cyberia-audio.router.js +21 -0
  85. package/src/api/cyberia-audio/cyberia-audio.service.js +220 -0
  86. package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -3
  87. package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +10 -19
  88. package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +12 -34
  89. package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +15 -32
  90. package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +1 -1
  91. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
  92. package/src/api/cyberia-entity/cyberia-entity.model.js +2 -0
  93. package/src/api/cyberia-entity/cyberia-entity.service.js +1 -1
  94. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +8 -2
  95. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +31 -20
  96. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +16 -1
  97. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +466 -3
  98. package/src/api/cyberia-instance/cyberia-fallback-capture.js +87 -51
  99. package/src/api/cyberia-instance/cyberia-fallback-world.js +21 -40
  100. package/src/api/cyberia-instance/cyberia-instance-boot.service.js +5 -5
  101. package/src/api/cyberia-instance/cyberia-instance-items.js +98 -0
  102. package/src/api/cyberia-instance/cyberia-instance-map.service.js +34 -141
  103. package/src/api/cyberia-instance/cyberia-instance.controller.js +1 -4
  104. package/src/api/cyberia-instance/cyberia-instance.model.js +0 -14
  105. package/src/api/cyberia-instance/cyberia-instance.router.js +3 -16
  106. package/src/api/cyberia-instance/cyberia-instance.service.js +3 -43
  107. package/src/api/cyberia-instance/cyberia-world-generator.js +8 -13
  108. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +49 -75
  109. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +74 -1
  110. package/src/api/cyberia-map/cyberia-map.service.js +6 -2
  111. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.controller.js +31 -0
  112. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.model.js +56 -0
  113. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.router.js +33 -0
  114. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +189 -0
  115. package/src/api/cyberia-quest/cyberia-quest.router.js +3 -3
  116. package/src/api/cyberia-quest/cyberia-quest.service.js +3 -3
  117. package/src/api/cyberia-quest-progress/cyberia-quest-progress.model.js +1 -1
  118. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +403 -188
  119. package/src/api/cyberia-skill/cyberia-skill.model.js +6 -6
  120. package/src/api/file/file.ref.js +153 -0
  121. package/src/api/file/file.ref.json +11 -2
  122. package/src/api/object-layer/object-layer.model.js +47 -12
  123. package/src/api/object-layer/object-layer.router.js +10 -3
  124. package/src/api/object-layer/object-layer.service.js +113 -120
  125. package/src/cli/app.js +12 -1
  126. package/src/cli/client.js +2 -2
  127. package/src/cli/cluster.js +80 -18
  128. package/src/cli/deploy.js +215 -34
  129. package/src/cli/docker-compose.js +1 -1
  130. package/src/cli/fs-selection.js +115 -0
  131. package/src/cli/fs.js +158 -342
  132. package/src/cli/index.js +22 -10
  133. package/src/cli/release.js +3 -1
  134. package/src/cli/repository.js +35 -9
  135. package/src/cli/run.js +80 -199
  136. package/src/cli/test.js +4 -0
  137. package/src/cli/wireguard.js +32 -31
  138. package/src/client/components/core/Badge.js +0 -1
  139. package/src/client/components/core/CssCore.js +8 -1
  140. package/src/client/components/core/Docs.js +77 -100
  141. package/src/client/components/core/Modal.js +77 -23
  142. package/src/client/components/core/ToolTip.js +15 -0
  143. package/src/client/components/cryptokoyn/AppShellCryptokoyn.js +0 -2
  144. package/src/client/components/cyberia/ActionEngineCyberia.js +12 -36
  145. package/src/client/components/cyberia/EntityEngineCyberia.js +492 -121
  146. package/src/client/components/cyberia/InstanceEngineCyberia.js +129 -160
  147. package/src/client/components/cyberia/InstanceSelectionView.js +7 -1
  148. package/src/client/components/cyberia/MapEngineCyberia.js +175 -69
  149. package/src/client/components/cyberia/ObjectLayerEngine.js +1011 -97
  150. package/src/client/components/cyberia/ObjectLayerEngineModal.js +81 -155
  151. package/src/client/components/cyberia/ObjectLayerEngineViewer.js +113 -98
  152. package/src/client/components/cyberia/SharedDefaultsCyberia.js +215 -56
  153. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +0 -34
  154. package/src/client/components/cyberia-portal/CssCyberiaPortal.js +1 -1
  155. package/src/client/components/cyberia-portal/MainBodyCyberiaPortal.js +712 -76
  156. package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +0 -4
  157. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +0 -4
  158. package/src/client/components/default/AppShellDefault.js +1 -4
  159. package/src/client/components/itemledger/AppShellItemledger.js +0 -2
  160. package/src/client/components/underpost/AppShellUnderpost.js +1 -1
  161. package/src/client/public/cyberia-docs/ARCHITECTURE.md +15 -15
  162. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +280 -24
  163. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +51 -29
  164. package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +52 -53
  165. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +16 -11
  166. package/src/client/public/cyberia-docs/CYBERIA.md +28 -28
  167. package/src/client/public/cyberia-docs/ENTITY-PROFILE.md +11 -5
  168. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  169. package/src/client/public/cyberia-docs/STATS-PROGRESSION.md +217 -0
  170. package/src/client/public/cyberia-docs/WHITE-PAPER.md +20 -12
  171. package/src/client/services/cyberia-audio/cyberia-audio.service.js +99 -0
  172. package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +38 -0
  173. package/src/client/services/cyberia-instance/cyberia-instance.management.js +5 -4
  174. package/src/client/services/cyberia-instance/cyberia-instance.service.js +0 -40
  175. package/src/client/services/cyberia-map/cyberia-map.management.js +5 -4
  176. package/src/client/services/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +138 -0
  177. package/src/client/services/object-layer/object-layer.management.js +6 -10
  178. package/src/client/services/object-layer/object-layer.service.js +3 -1
  179. package/src/client/ssr/views/Cyberia404.js +3 -3
  180. package/src/client-builder/client-build-docs.js +52 -52
  181. package/src/client-builder/client-build.js +67 -44
  182. package/src/client-builder/client-bundle.js +347 -0
  183. package/src/db/mongo/MongoExpress.js +187 -0
  184. package/src/grpc/cyberia/grpc-server.js +5 -7
  185. package/src/index.js +1 -1
  186. package/src/projects/cyberia/atlas-sprite-sheet-generator.js +143 -146
  187. package/src/projects/cyberia/atlas-sprite-sheet-store.js +391 -0
  188. package/src/projects/cyberia/besu-genesis-generator.js +5 -14
  189. package/src/projects/cyberia/boot-contract-fixtures.js +32 -0
  190. package/src/projects/cyberia/catalog-cyberia.js +3 -2
  191. package/src/projects/cyberia/gemini-client.js +3 -3
  192. package/src/projects/cyberia/generate-saga.js +4 -11
  193. package/src/projects/cyberia/hot-reload-trigger.js +17 -28
  194. package/src/projects/cyberia/instance-backup.js +210 -0
  195. package/src/projects/cyberia/instance-data.js +177 -257
  196. package/src/projects/cyberia/map-preview-generator.js +32 -68
  197. package/src/projects/cyberia/object-layer.js +152 -319
  198. package/src/projects/cyberia/seed-audio.js +139 -0
  199. package/src/projects/cyberia/semantic-layer-generator-skin.js +2 -8
  200. package/src/projects/cyberia/semantic-layer-generator.js +2 -18
  201. package/src/projects/cyberia/stat-balance.js +81 -0
  202. package/src/projects/cyberia/stat-commands.js +23 -0
  203. package/src/projects/cyberia/stat-contract-generator.js +101 -0
  204. package/src/runtime/cyberia-client/Dockerfile +8 -4
  205. package/src/runtime/cyberia-client/Dockerfile.dev +5 -2
  206. package/src/runtime/cyberia-server/Dockerfile +9 -2
  207. package/src/runtime/cyberia-server/Dockerfile.dev +8 -1
  208. package/src/runtime/engine-cyberia/Dockerfile +1 -1
  209. package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
  210. package/src/runtime/engine-cyberia/Dockerfile.test +2 -2
  211. package/src/runtime/engine-cyberia/compose.env +17 -5
  212. package/src/runtime/engine-cyberia/docker-compose.yml +23 -17
  213. package/src/server/build/coverage.js +113 -42
  214. package/src/server/build/package.js +55 -12
  215. package/src/server/build/testing.js +59 -9
  216. package/src/server/network/middlewares.js +8 -2
  217. package/src/server/network/underpost-gateway.js +113 -14
  218. package/src/server/ops/logger.js +70 -35
  219. package/src/server/runtime/conf.js +40 -9
  220. package/src/server/runtime/start.js +11 -2
  221. package/src/server/storage/data-query.js +16 -0
  222. package/src/server/storage/downloader.js +62 -14
  223. package/src/server/storage/zip.js +153 -0
  224. package/test/integration/app/cyberia/atlas-sprite-sheet-store.test.js +402 -0
  225. package/test/integration/app/cyberia/atlas-sprite-sheet.test.js +86 -0
  226. package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +34 -0
  227. package/test/integration/app/cyberia/cyberia-entity-type-default.test.js +842 -0
  228. package/test/integration/app/cyberia/cyberia-instance-conf-coerce.test.js +53 -0
  229. package/test/integration/app/cyberia/cyberia-instance-conf-defaults.test.js +16 -22
  230. package/test/integration/app/cyberia/cyberia-instance-items.test.js +90 -0
  231. package/test/integration/app/cyberia/cyberia-load.test.js +6 -7
  232. package/test/integration/app/cyberia/cyberia-map-audio-conf.test.js +256 -0
  233. package/test/integration/app/cyberia/cyberia-stats.test.js +150 -0
  234. package/test/integration/app/cyberia/fallback-world-capture.test.js +87 -2
  235. package/test/integration/app/cyberia/object-layer-item-selection.test.js +56 -0
  236. package/test/integration/app/cyberia/object-layer-natural-key.test.js +32 -0
  237. package/test/integration/app/cyberia/seed-audio.test.js +275 -0
  238. package/test/integration/infra/2-network/wireguard-cli.test.js +33 -9
  239. package/test/integration/infra/2-network/wireguard-edge.test.js +58 -18
  240. package/test/integration/infra/3-cluster/mongo-express-deploy.test.js +156 -0
  241. package/test/integration/infra/4-ingress/api-cross-origin.test.js +79 -0
  242. package/test/integration/infra/4-ingress/underpost-ingress.test.js +1 -1
  243. package/test/unit/catalog.test.js +13 -2
  244. package/test/unit/client-build-docs.test.js +97 -70
  245. package/test/unit/client-bundle.test.js +313 -0
  246. package/test/unit/conf-resolution.test.js +58 -7
  247. package/test/unit/coverage-artifact.test.js +120 -29
  248. package/test/unit/cyberia/instance-backup.test.js +183 -0
  249. package/test/unit/cyberia/instance-data.test.js +314 -0
  250. package/test/unit/cyberia/instance-object-layer-items.test.js +46 -0
  251. package/test/unit/cyberia/publish-workflow.test.js +22 -0
  252. package/test/unit/cyberia/stat-balance.test.js +69 -0
  253. package/test/unit/deploy-legacy-gateway-sweep.test.js +63 -0
  254. package/test/unit/deploy-log-table.test.js +76 -5
  255. package/test/unit/downloader-integrity.test.js +98 -0
  256. package/test/unit/file-reference-registry.test.js +141 -0
  257. package/test/unit/fs-storage-paths.test.js +596 -0
  258. package/test/unit/gateway-pod-fetch.test.js +95 -0
  259. package/test/unit/letsencrypt-issuer.test.js +101 -0
  260. package/test/unit/logger-redaction.test.js +29 -0
  261. package/test/unit/package.test.js +70 -16
  262. package/test/unit/prepare-host.test.js +84 -1
  263. package/test/unit/release-bump.test.js +3 -1
  264. package/test/unit/start-options.test.js +12 -0
  265. package/test/unit/test-tiers.test.js +27 -0
  266. package/test/unit/zip-archive.test.js +142 -0
  267. package/vitest.config.js +11 -3
  268. package/src/api/cyberia-instance/cyberia-fallback-default-items.js +0 -63
  269. package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +0 -368
@@ -8,6 +8,7 @@ import {
8
8
  captureFallbackWorld,
9
9
  captureMapCode,
10
10
  } from '../../../../src/api/cyberia-instance/cyberia-fallback-capture.js';
11
+ import { fallbackAudioConfig } from '../../../../src/projects/cyberia/seed-audio.js';
11
12
  import {
12
13
  DefaultCyberiaActions,
13
14
  DefaultCyberiaQuests,
@@ -73,7 +74,13 @@ class FakeModel {
73
74
  }
74
75
  }
75
76
 
76
- const buildModels = ({ objectLayerItemIds = [], seeded = {} } = {}) => ({
77
+ // Every asset the fallback audio configuration binds, as `cyberia audio --import` would leave them.
78
+ const AUDIO_CONFIG = fallbackAudioConfig();
79
+ const importedAudioCodes = () => [
80
+ ...new Set([...AUDIO_CONFIG.events.map((e) => e.audioCode), ...AUDIO_CONFIG.maps.map((m) => m.defaultMusic)]),
81
+ ];
82
+
83
+ const buildModels = ({ objectLayerItemIds = [], audioCodes = importedAudioCodes(), seeded = {} } = {}) => ({
77
84
  CyberiaInstance: new FakeModel(seeded.instances),
78
85
  CyberiaInstanceConf: new FakeModel(seeded.confs),
79
86
  CyberiaMap: new FakeModel(seeded.maps),
@@ -82,6 +89,8 @@ const buildModels = ({ objectLayerItemIds = [], seeded = {} } = {}) => ({
82
89
  CyberiaSkill: new FakeModel(seeded.skills),
83
90
  CyberiaEntityTypeDefault: new FakeModel(seeded.entityTypeDefaults),
84
91
  CyberiaDialogue: new FakeModel(seeded.dialogues),
92
+ CyberiaMapAudioConf: new FakeModel(seeded.audioConfs),
93
+ CyberiaAudio: new FakeModel(audioCodes.map((code) => ({ code }))),
85
94
  ObjectLayer: new FakeModel(objectLayerItemIds.map((id) => ({ data: { item: { id } } }))),
86
95
  });
87
96
 
@@ -166,7 +175,9 @@ describe('planFallbackCapture', () => {
166
175
  it('stamps the instance code onto a fully defaulted conf document', () => {
167
176
  expect(plan.conf.instanceCode).to.equal(INSTANCE_CODE);
168
177
  expect(plan.conf.tickRate).to.equal(world.config.tickRate);
169
- expect(plan.conf.entityDefaults).to.be.an('array').that.is.not.empty;
178
+ // The captured conf references no entity-type default: the fallback world runs on the
179
+ // canonical set, and referencing nothing is what says so.
180
+ expect(plan.conf.entityDefaults).to.deep.equal([]);
170
181
  expect(plan.conf.equipmentRules).to.be.an('object');
171
182
  });
172
183
 
@@ -251,3 +262,77 @@ describe('captureFallbackWorld', () => {
251
262
  expect(result.missingObjectLayerItemIds).to.deep.equal(['coin']);
252
263
  });
253
264
  });
265
+
266
+ describe('captureFallbackWorld — audio configuration', () => {
267
+ const world = generateFallbackWorld();
268
+ const plan = planFallbackCapture({ world, instanceCode: INSTANCE_CODE });
269
+
270
+ const run = async (overrides = {}) => {
271
+ const models = buildModels({ objectLayerItemIds: plan.itemIds, ...overrides });
272
+ const result = await captureFallbackWorld({
273
+ models,
274
+ world,
275
+ instanceCode: INSTANCE_CODE,
276
+ keepFallbackCodes: !!overrides.keepFallbackCodes,
277
+ });
278
+ return { models, result };
279
+ };
280
+
281
+ it('writes one configuration per captured map, under the captured map code', async () => {
282
+ const { models, result } = await run();
283
+ // The client asks for audio by map code, so a namespaced capture needs its own documents.
284
+ expect(result.audio.audioConfs).to.equal(AUDIO_CONFIG.maps.length);
285
+ expect(models.CyberiaMapAudioConf.docs.map((d) => d.mapCode)).to.deep.equal(
286
+ AUDIO_CONFIG.maps.map((m) => captureMapCode(m.mapCode, INSTANCE_CODE)),
287
+ );
288
+ for (const doc of models.CyberiaMapAudioConf.docs) {
289
+ expect(doc.mapCode).to.not.include('fallback-map-');
290
+ expect(plan.instance.cyberiaMapCodes).to.include(doc.mapCode);
291
+ expect(doc.events.map((e) => e.logicEventId)).to.deep.equal(AUDIO_CONFIG.events.map((e) => e.logicEventId));
292
+ expect(doc.settings).to.deep.equal(AUDIO_CONFIG.settings);
293
+ }
294
+ expect(result.audio.missingAudioCodes).to.deep.equal([]);
295
+ });
296
+
297
+ it('binds a logic event to an audio code, carrying no asset of its own', async () => {
298
+ const { models } = await run();
299
+ const [doc] = models.CyberiaMapAudioConf.docs;
300
+ const projectile = doc.events.find((e) => e.logicEventId === 'projectile');
301
+ expect(projectile.audioCode).to.equal('shoot');
302
+ expect(projectile).to.not.have.property('audioId');
303
+ expect(doc.defaultMusic).to.equal(AUDIO_CONFIG.maps[0].defaultMusic);
304
+ });
305
+
306
+ it('leaves a code with no imported asset unbound, and reports it', async () => {
307
+ const { models, result } = await run({ audioCodes: importedAudioCodes().filter((code) => code !== 'shoot') });
308
+ expect(result.audio.missingAudioCodes).to.deep.equal(['shoot']);
309
+ for (const doc of models.CyberiaMapAudioConf.docs) {
310
+ expect(doc.events.some((e) => e.audioCode === 'shoot')).to.equal(false);
311
+ }
312
+ });
313
+
314
+ it('clears a default music the world has not imported', async () => {
315
+ const { models, result } = await run({ audioCodes: [] });
316
+ expect(result.audio.missingAudioCodes).to.deep.equal(importedAudioCodes().sort());
317
+ for (const doc of models.CyberiaMapAudioConf.docs) {
318
+ expect(doc.defaultMusic).to.equal('');
319
+ expect(doc.events).to.deep.equal([]);
320
+ }
321
+ });
322
+
323
+ it('writes the canonical map codes under keepFallbackCodes', async () => {
324
+ const { models } = await run({ keepFallbackCodes: true });
325
+ expect(models.CyberiaMapAudioConf.docs.map((d) => d.mapCode)).to.deep.equal(
326
+ AUDIO_CONFIG.maps.map((m) => m.mapCode),
327
+ );
328
+ });
329
+
330
+ it('is idempotent — a second capture converges on the same configuration', async () => {
331
+ const models = buildModels({ objectLayerItemIds: plan.itemIds });
332
+ await captureFallbackWorld({ models, world, instanceCode: INSTANCE_CODE });
333
+ const first = JSON.stringify(models.CyberiaMapAudioConf.docs);
334
+ await captureFallbackWorld({ models, world, instanceCode: INSTANCE_CODE });
335
+ expect(models.CyberiaMapAudioConf.docs.length).to.equal(AUDIO_CONFIG.maps.length);
336
+ expect(JSON.stringify(models.CyberiaMapAudioConf.docs)).to.equal(first);
337
+ });
338
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ObjectLayerEngine } from '../../../../src/projects/cyberia/object-layer.js';
3
+
4
+ const select = (storedItemIds, requestedItemIds) =>
5
+ ObjectLayerEngine.selectStoredItemIds({ storedItemIds, requestedItemIds });
6
+
7
+ describe('ol stored item selection', () => {
8
+ it('takes every stored item when the command gives no item-id', () => {
9
+ expect(select(['hatchet', 'sword'])).toEqual({ itemIds: ['hatchet', 'sword'], missingItemIds: [] });
10
+ });
11
+
12
+ it('keeps only the requested items that the collection holds', () => {
13
+ expect(select(['hatchet', 'sword'], ['sword', 'ghost'])).toEqual({
14
+ itemIds: ['sword'],
15
+ missingItemIds: ['ghost'],
16
+ });
17
+ });
18
+
19
+ it('removes blanks and duplicates from both sides', () => {
20
+ expect(select(['hatchet', 'hatchet', null, ' sword '], [' hatchet ', 'hatchet', ''])).toEqual({
21
+ itemIds: ['hatchet'],
22
+ missingItemIds: [],
23
+ });
24
+ expect(select([undefined, ''], [])).toEqual({ itemIds: [], missingItemIds: [] });
25
+ });
26
+ });
27
+
28
+ const rebuild = (options) => ObjectLayerEngine.selectAtlasRebuild(options);
29
+
30
+ describe('ol atlas rebuild selection', () => {
31
+ it('rebuilds when --to-atlas-sprite-sheet is given, with or without a dimension', () => {
32
+ expect(rebuild({ toAtlasSpriteSheet: true })).toBe(true);
33
+ expect(rebuild({ toAtlasSpriteSheet: '2048' })).toBe(true);
34
+ expect(rebuild({ toAtlasSpriteSheet: true, instance: 'TEST' })).toBe(true);
35
+ });
36
+
37
+ it('treats a bare --upscale as the rebuild request', () => {
38
+ expect(rebuild({ upscale: 20 })).toBe(true);
39
+ expect(rebuild({ upscale: 20, instance: 'TEST' })).toBe(true);
40
+ });
41
+
42
+ it('keeps --upscale a modifier next to another action', () => {
43
+ expect(rebuild({ upscale: 20, import: true })).toBe(false);
44
+ expect(rebuild({ upscale: 20, minify: true })).toBe(false);
45
+ expect(rebuild({ upscale: 20, generate: true })).toBe(false);
46
+ expect(rebuild({ upscale: 20, importTypes: 'skin' })).toBe(false);
47
+ expect(rebuild({ upscale: 20, drop: true })).toBe(false);
48
+ expect(rebuild({ upscale: 20, showFrame: '08_0' })).toBe(false);
49
+ expect(rebuild({ upscale: 20, showAtlasSpriteSheet: true })).toBe(false);
50
+ });
51
+
52
+ it('stays out of the way when neither flag is given', () => {
53
+ expect(rebuild({})).toBe(false);
54
+ expect(rebuild({ minify: true, instance: 'TEST' })).toBe(false);
55
+ });
56
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { DataQuery } from '../../../../src/server/storage/data-query.js';
3
+
4
+ const byItem = (key) => DataQuery.naturalKeyFilter('data.item.id', key);
5
+ const byCode = (key) => DataQuery.naturalKeyFilter('code', key);
6
+
7
+ describe('natural key addressing', () => {
8
+ it('addresses an object layer by its item id', () => {
9
+ expect(byItem('anon')).toEqual({ 'data.item.id': 'anon' });
10
+ expect(byItem('wood-drop-1')).toEqual({ 'data.item.id': 'wood-drop-1' });
11
+ });
12
+
13
+ it('addresses an instance or a map by its code', () => {
14
+ expect(byCode('TEST')).toEqual({ code: 'TEST' });
15
+ expect(byCode('TEST-map-0')).toEqual({ code: 'TEST-map-0' });
16
+ });
17
+
18
+ it('matches either field when the key is shaped like a document id', () => {
19
+ const key = '6a8fcb760faca8157bde4210';
20
+ expect(byItem(key)).toEqual({ $or: [{ 'data.item.id': key }, { _id: key }] });
21
+ });
22
+
23
+ it('keeps a twelve-character item id out of the document id branch', () => {
24
+ expect(byItem('wood-drop-12')).toEqual({ 'data.item.id': 'wood-drop-12' });
25
+ expect(byItem('atlas_pistol')).toEqual({ 'data.item.id': 'atlas_pistol' });
26
+ });
27
+
28
+ it('treats a non-hex string of document id length as a natural key', () => {
29
+ const key = 'zzzzzzzzzzzzzzzzzzzzzzzz';
30
+ expect(byItem(key)).toEqual({ 'data.item.id': key });
31
+ });
32
+ });
@@ -0,0 +1,275 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import fs from 'fs-extra';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+ import { DataBaseProviderService } from '../../../../src/db/DataBaseProvider.js';
7
+ import { CyberiaAudioModel } from '../../../../src/api/cyberia-audio/cyberia-audio.model.js';
8
+ import { CyberiaAudioService } from '../../../../src/api/cyberia-audio/cyberia-audio.service.js';
9
+ import { CyberiaMapAudioConfModel } from '../../../../src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.model.js';
10
+ import { FileModel } from '../../../../src/api/file/file.model.js';
11
+ import { FileService } from '../../../../src/api/file/file.service.js';
12
+ import { getFallbackMapCodes } from '../../../../src/api/cyberia-instance/cyberia-fallback-world.js';
13
+ import {
14
+ audioConfigForMaps, fallbackAudioConfig, hasAudioRenderer, loadAudioRenderer, prepareFallbackAudio,
15
+ seedFallbackAudio, seedInstanceAudio,
16
+ } from '../../../../src/projects/cyberia/seed-audio.js';
17
+ import {
18
+ DEFAULT_AUDIO_BANK, DEFAULT_AUDIO_BINDINGS, buildAudioEventBindings,
19
+ } from '../../../../src/api/cyberia-server-defaults/cyberia-server-defaults.js';
20
+ import {
21
+ AUDIO_BUS_MUSIC, AUDIO_BUS_SFX, AUDIO_LOGIC_ID_BUSES, isCanonicalAudioLogicId,
22
+ } from '../../../../src/client/components/cyberia/SharedDefaultsCyberia.js';
23
+
24
+ // Producing WAV bytes needs the cyberia-audio renderer, which lives in a sibling repository this
25
+ // one does not contain — so it is absent on a fresh checkout and in CI. The cases below that need
26
+ // real bytes are skipped there; everything that checks a rule rather than a recording still runs.
27
+ const itRecording = it.skipIf(!hasAudioRenderer());
28
+
29
+ describe('fallback audio pipeline', () => {
30
+ let directory;
31
+ let recordsPath;
32
+ let audio;
33
+ let files;
34
+ let maps;
35
+ let instances;
36
+ const options = { host: 'audio-test', path: '/' };
37
+
38
+ beforeEach(() => {
39
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cyberia-seed-audio-'));
40
+ recordsPath = path.join(directory, 'records');
41
+ audio = new Map(); files = new Map(); maps = new Map(); instances = new Map();
42
+ const CyberiaInstanceModel = {
43
+ findOne: ({ code }) => ({
44
+ select: () => ({ lean: async () => (instances.has(code) ? { cyberiaMapCodes: instances.get(code) } : null) }),
45
+ }),
46
+ };
47
+ vi.spyOn(DataBaseProviderService, 'getModel').mockImplementation((name) => {
48
+ if (name.toLowerCase() === 'cyberiaaudio') return CyberiaAudioModel;
49
+ if (name.toLowerCase() === 'cyberiamapaudioconf') return CyberiaMapAudioConfModel;
50
+ if (name.toLowerCase() === 'file') return FileModel;
51
+ if (name.toLowerCase() === 'cyberiainstance') return CyberiaInstanceModel;
52
+ if (name === 'Document') return { findOne: async () => null };
53
+ if (name === 'User') return {};
54
+ throw new Error(`Unexpected model: ${name}`);
55
+ });
56
+ vi.spyOn(CyberiaAudioModel, 'findOne').mockImplementation(async ({ code }) => audio.get(code) ?? null);
57
+ vi.spyOn(CyberiaAudioModel, 'exists').mockImplementation(async ({ code }) => audio.has(code));
58
+ vi.spyOn(CyberiaAudioModel, 'findOneAndUpdate').mockImplementation(async ({ code }, { $set }) => {
59
+ const doc = new CyberiaAudioModel({ ...audio.get(code)?.toObject(), ...$set });
60
+ await doc.validate(); audio.set(code, doc); return doc;
61
+ });
62
+ vi.spyOn(FileModel, 'findOneAndUpdate').mockImplementation(async ({ _id }, { $set }) => {
63
+ const doc = new FileModel({ _id, ...$set });
64
+ await doc.validate(); files.set(String(_id), doc); return doc;
65
+ });
66
+ vi.spyOn(FileModel, 'findOne').mockImplementation(async ({ _id }) => files.get(String(_id)) ?? null);
67
+ vi.spyOn(FileModel, 'findByIdAndDelete').mockImplementation(async (id) => {
68
+ const doc = files.get(String(id)) ?? null;
69
+ files.delete(String(id));
70
+ return doc;
71
+ });
72
+ vi.spyOn(CyberiaMapAudioConfModel, 'findOne').mockImplementation(async ({ mapCode }) => maps.get(mapCode) ?? null);
73
+ vi.spyOn(CyberiaMapAudioConfModel.prototype, 'save').mockImplementation(async function () {
74
+ await this.validate(); maps.set(this.mapCode, this); return this;
75
+ });
76
+ });
77
+
78
+ afterEach(() => { vi.restoreAllMocks(); fs.removeSync(directory); });
79
+
80
+ itRecording('generates deterministic WAVs, seeds twice, and serves the same bytes through the generic file service', async () => {
81
+ const config = await prepareFallbackAudio({ recordsPath });
82
+ expect(config.maps.map(({ mapCode }) => mapCode)).toEqual(getFallbackMapCodes());
83
+ const digest = () => Object.fromEntries(config.assets.map((code) => [code,
84
+ createHash('sha256').update(fs.readFileSync(path.join(recordsPath, `${code}.wav`))).digest('hex'),
85
+ ]));
86
+ const hashes = digest();
87
+ await seedFallbackAudio({ recordsPath }, options);
88
+ const ids = [...audio.values()].map(({ fileId }) => String(fileId));
89
+ await prepareFallbackAudio({ recordsPath });
90
+ expect(digest()).toEqual(hashes);
91
+ await seedFallbackAudio({ recordsPath }, options);
92
+ // The vocabulary is the configuration's own asset list, so adding a cue never leaves a
93
+ // hard-coded count behind to correct.
94
+ expect(audio.size).toBe(config.assets.length);
95
+ expect(files.size).toBe(config.assets.length);
96
+ expect(maps.size).toBe(getFallbackMapCodes().length);
97
+ expect([...audio.values()].map(({ fileId }) => String(fileId))).toEqual(ids);
98
+ for (const map of maps.values()) {
99
+ expect(new Set(map.events.map(({ logicEventId }) => logicEventId)).size).toBe(config.events.length);
100
+ for (const event of map.events) expect(audio.has(event.audioCode)).toBe(true);
101
+ }
102
+ for (const code of config.assets) {
103
+ const fileId = String(audio.get(code).fileId);
104
+ const res = { set: vi.fn() };
105
+ const bytes = await FileService.get({ path: `/blob/${fileId}`, params: { id: fileId } }, res, options);
106
+ expect(bytes).toEqual(fs.readFileSync(path.join(recordsPath, `${code}.wav`)));
107
+ expect(res.set).toHaveBeenCalledWith('Content-Type', 'audio/wav');
108
+ }
109
+ }, 20000);
110
+
111
+ itRecording('converges on re-seed: a binding the bank no longer declares is dropped', async () => {
112
+ // The failure this prevents: a renamed cue leaves its old binding behind, the client asks the
113
+ // engine for an asset nothing declares, and the map keeps a dangling name forever.
114
+ await prepareFallbackAudio({ recordsPath });
115
+ await seedFallbackAudio({ recordsPath }, options);
116
+ const config = fallbackAudioConfig();
117
+ const [mapCode] = config.maps.map(({ mapCode: code }) => code);
118
+
119
+ const stale = maps.get(mapCode);
120
+ stale.events.push({ logicEventId: 'player-hit', audioCode: 'coin' });
121
+ expect(stale.events).toHaveLength(config.events.length + 1);
122
+
123
+ await seedFallbackAudio({ recordsPath }, options);
124
+ for (const map of maps.values()) {
125
+ expect(map.events).toHaveLength(config.events.length);
126
+ expect(map.events.some(({ logicEventId }) => logicEventId === 'player-hit')).toBe(false);
127
+ expect(map.events.map(({ logicEventId }) => logicEventId)).toEqual(
128
+ config.events.map(({ logicEventId }) => logicEventId),
129
+ );
130
+ }
131
+ }, 20000);
132
+
133
+ itRecording('reuses a file left by an interrupted metadata write and repairs a missing file', async () => {
134
+ await prepareFallbackAudio({ recordsPath });
135
+ const record = { wavPath: path.join(recordsPath, 'coin.wav'), manifestPath: path.join(recordsPath, 'coin.json') };
136
+ CyberiaAudioModel.findOneAndUpdate.mockRejectedValueOnce(new Error('interrupted'));
137
+ await expect(CyberiaAudioService.importRecord(record, options)).rejects.toThrow('interrupted');
138
+ expect(files.size).toBe(1);
139
+ const first = await CyberiaAudioService.importRecord(record, options);
140
+ expect(files.size).toBe(1);
141
+ files.clear();
142
+ const repaired = await CyberiaAudioService.importRecord(record, options);
143
+ expect(String(repaired.fileId)).toBe(String(first.fileId));
144
+ expect(files.size).toBe(1);
145
+ }, 15000);
146
+
147
+ itRecording('replaces the blob when a render changes, and leaves no orphan behind', async () => {
148
+ const { dispatch } = await loadAudioRenderer();
149
+ await prepareFallbackAudio({ recordsPath });
150
+ const record = { wavPath: path.join(recordsPath, 'coin.wav'), manifestPath: path.join(recordsPath, 'coin.json') };
151
+
152
+ const first = await CyberiaAudioService.importRecord(record, options);
153
+ expect(files.size).toBe(1);
154
+ // Unchanged bytes re-derive the same id: re-importing a bank must not churn File documents.
155
+ const again = await CyberiaAudioService.importRecord(record, options);
156
+ expect(String(again.fileId)).toBe(String(first.fileId));
157
+ expect(files.size).toBe(1);
158
+
159
+ // A changed render has to land on a new id — the client fetches and caches the WAV by
160
+ // fileId, so reusing it would serve the old sound forever.
161
+ await dispatch('sfx', 'coin', { sampleRate: 22050, volume: 0.5 }, { play: false, record: true, path: record.wavPath });
162
+ const changed = await CyberiaAudioService.importRecord(record, options);
163
+ expect(String(changed.fileId)).not.toBe(String(first.fileId));
164
+ // And the blob it replaced is gone rather than left for clean-fs to sweep.
165
+ expect(files.has(String(first.fileId))).toBe(false);
166
+ expect(files.size).toBe(1);
167
+ expect(String(audio.get('coin').fileId)).toBe(String(changed.fileId));
168
+ }, 20000);
169
+
170
+ itRecording('drops the blob with the asset it belongs to', async () => {
171
+ await prepareFallbackAudio({ recordsPath });
172
+ await CyberiaAudioService.importRecord(
173
+ { wavPath: path.join(recordsPath, 'coin.wav'), manifestPath: path.join(recordsPath, 'coin.json') },
174
+ options,
175
+ );
176
+ expect(files.size).toBe(1);
177
+ expect(await CyberiaAudioService.deleteBackingFiles([audio.get('coin')], options)).toBe(1);
178
+ expect(files.size).toBe(0);
179
+ }, 15000);
180
+
181
+ it('registers its File reference so the orphan sweep can see it', () => {
182
+ // `underpost db clean-fs` deletes every File no registered field points at. An unregistered
183
+ // fileId is not an untracked reference — it is a blob the sweep actively deletes.
184
+ const refs = fs.readJsonSync('./src/api/file/file.ref.json');
185
+ expect(refs.find(({ api }) => api === 'cyberia-audio')?.model).toEqual({ fileId: true });
186
+ for (const { api, model } of refs) {
187
+ for (const [field, tracked] of Object.entries(model)) {
188
+ expect(tracked, `${api}.${field} must be tracked`).toBe(true);
189
+ }
190
+ }
191
+ });
192
+
193
+ it('rejects malformed data before writing a file', async () => {
194
+ fs.ensureDirSync(recordsPath);
195
+ const wavPath = path.join(recordsPath, 'coin.wav');
196
+ const manifestPath = path.join(recordsPath, 'coin.json');
197
+ fs.writeFileSync(wavPath, 'RIFF');
198
+ fs.writeJsonSync(manifestPath, { id: 'coin', author: 'test', version: '1', duration: 0.5 });
199
+ await expect(CyberiaAudioService.importRecord({ wavPath, manifestPath }, options)).rejects.toThrow('Invalid WAV');
200
+ expect(files.size).toBe(0);
201
+ });
202
+
203
+ itRecording('scores every map of an instance alike, and converges on re-seed', async () => {
204
+ instances.set('nexus', ['nexus-hub', 'nexus-deep', 'nexus-vault', 'nexus-rim', 'nexus-gate']);
205
+ await prepareFallbackAudio({ recordsPath });
206
+ await seedInstanceAudio({ instanceCode: 'nexus', recordsPath }, options);
207
+
208
+ // One bed for the whole world: what a map sounds like at rest is the world's identity, and
209
+ // the events are what make a moment sound different.
210
+ const expected = audioConfigForMaps(instances.get('nexus'));
211
+ expect(expected.maps.map(({ defaultMusic }) => defaultMusic)).toEqual(Array(5).fill('exploration'));
212
+ expect([...maps.keys()]).toEqual(instances.get('nexus'));
213
+ for (const { mapCode, defaultMusic } of expected.maps) {
214
+ expect(maps.get(mapCode).defaultMusic).toBe(defaultMusic);
215
+ expect(maps.get(mapCode).events).toHaveLength(expected.events.length);
216
+ }
217
+
218
+ const stale = maps.get('nexus-hub');
219
+ stale.events.push({ logicEventId: 'player-hit', audioCode: 'coin' });
220
+ await seedInstanceAudio({ instanceCode: 'nexus', recordsPath }, options);
221
+ expect(maps.get('nexus-hub').events).toHaveLength(expected.events.length);
222
+ expect(maps.size).toBe(instances.get('nexus').length);
223
+ }, 20000);
224
+
225
+ it('refuses an instance it cannot score', async () => {
226
+ await expect(seedInstanceAudio({ instanceCode: 'absent', recordsPath }, options))
227
+ .rejects.toThrow('cyberia-instance not found for code="absent"');
228
+ instances.set('empty', []);
229
+ await expect(seedInstanceAudio({ instanceCode: 'empty', recordsPath }, options))
230
+ .rejects.toThrow('declares no maps');
231
+ });
232
+
233
+ it('sources its whole vocabulary from the centralized defaults', () => {
234
+ const config = fallbackAudioConfig();
235
+ // The seed declares nothing of its own: the bank, the bindings and the vocabulary they use
236
+ // all come from cyberia-server-defaults and SharedDefaultsCyberia.
237
+ expect(config.assets).toEqual(DEFAULT_AUDIO_BANK.map(({ code }) => code));
238
+ expect(config.events).toEqual(buildAudioEventBindings());
239
+ for (const { logicEventId, audioCode } of DEFAULT_AUDIO_BINDINGS) {
240
+ expect(isCanonicalAudioLogicId(logicEventId)).toBe(true);
241
+ expect(config.assets).toContain(audioCode);
242
+ }
243
+ // Routing is derived from the registry, never restated per binding.
244
+ for (const { logicEventId, settings } of config.events) {
245
+ expect(settings.bus).toBe(AUDIO_LOGIC_ID_BUSES[logicEventId] ?? AUDIO_BUS_SFX);
246
+ expect(settings.crossfadeMs > 0).toBe(settings.bus === AUDIO_BUS_MUSIC);
247
+ }
248
+ });
249
+
250
+ it('binds the portal charge as a bed and the jump as a one-shot', () => {
251
+ const config = fallbackAudioConfig();
252
+ const cooldown = config.events.find(({ logicEventId }) => logicEventId === 'portal-cooldown');
253
+ const portal = config.events.find(({ logicEventId }) => logicEventId === 'portal');
254
+ // The charge is held for as long as the player waits, so it loops; the jump sounds once.
255
+ expect(cooldown).toMatchObject({ audioCode: 'portal-cooldown', settings: { bus: AUDIO_BUS_MUSIC, loop: true } });
256
+ expect(portal).toMatchObject({ audioCode: 'portal', settings: { bus: AUDIO_BUS_SFX, loop: false } });
257
+ });
258
+
259
+ it('keeps the semantic skill vocabulary and makes bus routing a binding setting', () => {
260
+ const config = fallbackAudioConfig();
261
+ expect(config.events.find(({ logicEventId }) => logicEventId === 'projectile').audioCode).toBe('shoot');
262
+ // One impact cue for any collision, and the interface has a voice of its own.
263
+ expect(config.events.find(({ logicEventId }) => logicEventId === 'hit').audioCode).toBe('hit');
264
+ expect(config.events.find(({ logicEventId }) => logicEventId === 'ui-click').audioCode).toBe('ui-click');
265
+ expect(config.events.some(({ logicEventId }) => logicEventId === 'player-hit')).toBe(false);
266
+ expect(config.assets).toContain('hit');
267
+ expect(config.assets).not.toContain('player-hit');
268
+ expect(config.events.find(({ logicEventId }) => logicEventId === 'coin_drop_or_transaction').audioCode).toBe('coin');
269
+ expect(config.events.find(({ logicEventId }) => logicEventId === 'victory').settings.loop).toBe(false);
270
+ expect(new Set(config.maps.map(({ defaultMusic }) => defaultMusic))).toEqual(new Set(['exploration']));
271
+ for (const settings of [{ bus: 'MASTER' }, { volume: 2 }, { pan: -1 }, { pitch: 0 }, { crossfadeMs: Infinity }]) {
272
+ expect(new CyberiaMapAudioConfModel({ mapCode: 'test', settings }).validateSync()).toBeDefined();
273
+ }
274
+ });
275
+ });
@@ -876,10 +876,12 @@ describe('edge host provisioning', () => {
876
876
  });
877
877
 
878
878
  it.skipIf(!shipsCyberiaPackageScript)(
879
- 'installs the deploy manifest before the CLI is used, and again on what the switch landed',
879
+ 'installs the checkout manifest before the CLI is used, and the deploy manifest on what the switch landed',
880
880
  () => {
881
881
  // Regression: a node whose install no longer matched its manifest could not load its own
882
- // CLI, so the first step of the sync died before the step that repairs the tree.
882
+ // CLI, so the first step of the sync died before the step that repairs the tree. The
883
+ // bootstrap installs the checkout's own manifest because that is the one matching the code
884
+ // already in the tree; the deploy manifest can only be installed once its switch lands it.
883
885
  const previousUser = process.env.GITHUB_USERNAME;
884
886
  const previousToken = process.env.GITHUB_TOKEN;
885
887
  process.env.GITHUB_USERNAME = 'fixture-org';
@@ -892,20 +894,25 @@ describe('edge host provisioning', () => {
892
894
  const commands = steps.map(({ command }) => command);
893
895
  const packageStep = 'bash ./deploy/dd-cyberia/package.sh';
894
896
 
895
- expect(commands[0]).to.equal(packageStep);
896
- expect(steps[0].halt, 'a tree predating the script must still reach the switch').to.not.equal(true);
897
+ expect(commands[0]).to.equal('git checkout -- package.json; npm install');
898
+ expect(steps[0].halt, 'a tree that cannot be restored must still reach the switch').to.not.equal(true);
897
899
  expect(commands).to.not.include('npm link --force');
898
900
  const last = commands.lastIndexOf(packageStep);
901
+ expect(last).to.equal(commands.indexOf(packageStep));
899
902
  expect(last).to.be.greaterThan(commands.findIndex((command) => command.includes('./engine-private')));
900
903
  expect(last).to.be.lessThan(commands.findIndex((command) => command.includes('underpost-event')));
901
904
  expect(steps[last].halt).to.equal(true);
902
905
 
903
- // The monorepo belongs to no deploy, so it carries no package step at all.
904
- expect(
905
- UnderpostWireguard.API.syncCommands({ repoEngine: 'fixture-org/engine', nodeRole: 'control' }),
906
- ).to.satisfy((monorepo) =>
907
- monorepo.every(({ command }) => !command.includes('package.sh') && !/<[a-z-]+>/.test(command)),
906
+ // The monorepo belongs to no deploy, so its post-switch install falls back to the
907
+ // checkout's own manifest rather than being dropped.
908
+ const monorepo = UnderpostWireguard.API.syncCommands({
909
+ repoEngine: 'fixture-org/engine',
910
+ nodeRole: 'control',
911
+ }).map(({ command }) => command);
912
+ expect(monorepo.every((command) => !command.includes('package.sh') && !/<[a-z-]+>/.test(command))).to.equal(
913
+ true,
908
914
  );
915
+ expect(monorepo).to.include('npm install');
909
916
  } finally {
910
917
  if (previousUser === undefined) delete process.env.GITHUB_USERNAME;
911
918
  else process.env.GITHUB_USERNAME = previousUser;
@@ -915,6 +922,23 @@ describe('edge host provisioning', () => {
915
922
  },
916
923
  );
917
924
 
925
+ it('enters the CLI through Node, never through the link its own steps invalidate', () => {
926
+ // Regression: the global `underpost` is a symlink onto `bin/index.js` in the checkout the
927
+ // sync restores, so `run clean` rewrote that file's mode and every later step died on
928
+ // `Permission denied`.
929
+ const previous = process.env.GITHUB_USERNAME;
930
+ process.env.GITHUB_USERNAME = 'fixture-org';
931
+ try {
932
+ for (const nodeRole of ['control', 'worker', 'hub'])
933
+ expect(UnderpostWireguard.API.syncCommands({ nodeRole })).to.satisfy((steps) =>
934
+ steps.every(({ command }) => !/(^|[\s;&|])underpost\s/.test(command)),
935
+ );
936
+ } finally {
937
+ if (previous === undefined) delete process.env.GITHUB_USERNAME;
938
+ else process.env.GITHUB_USERNAME = previous;
939
+ }
940
+ });
941
+
918
942
  it('never writes a blank token onto a node', () => {
919
943
  const previous = process.env.GITHUB_TOKEN;
920
944
  delete process.env.GITHUB_TOKEN;