@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
@@ -0,0 +1,314 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ buildCyberiaMmoInstanceEnv,
4
+ getInstanceModels,
5
+ parseRgba,
6
+ toEntityMsg,
7
+ toMapMsg,
8
+ toInstanceMsg,
9
+ toObjectLayerMsg,
10
+ toActionMsg,
11
+ toQuestMsg,
12
+ toInstanceConfig,
13
+ buildFallbackConfig,
14
+ pingData,
15
+ objectLayerQueryFilter,
16
+ fetchObjectLayerBatch,
17
+ fetchObjectLayer,
18
+ fetchMapData,
19
+ fetchObjectLayerManifest,
20
+ fetchFullInstance,
21
+ skillDocsToConfig,
22
+ entityTypeDefaultDocsToConfig,
23
+ mergeEntityDefaults,
24
+ itemTypesOf,
25
+ } from '../../../src/projects/cyberia/instance-data.js';
26
+ import {
27
+ CYBERIA_INSTANCE_CONF_DEFAULTS,
28
+ DEFAULT_DEAD_ITEM_ID,
29
+ ENTITY_TYPE_DEFAULTS,
30
+ } from '../../../src/api/cyberia-server-defaults/cyberia-server-defaults.js';
31
+ import { DEFAULT_INSTANCE_CODE } from '../../../src/client/components/cyberia/SharedDefaultsCyberia.js';
32
+
33
+ const lean = (value) => ({ lean: async () => value, populate: () => lean(value) });
34
+
35
+ describe('instance env', () => {
36
+ it('derives the server and client variables from the variant runtime', () => {
37
+ const server = buildCyberiaMmoInstanceEnv({
38
+ instance: { runtime: 'cyberia-server', path: '/forest', instanceCode: 'FOREST' },
39
+ env: { PORT: '4001' },
40
+ });
41
+ expect(server).toEqual({ PORT: '4001', INSTANCE_CODE: 'FOREST', CYBERIA_BASE_PATH: '/forest' });
42
+
43
+ const client = buildCyberiaMmoInstanceEnv({ instance: { runtime: 'cyberia-client', path: '/' } });
44
+ expect(client).toEqual({
45
+ CYBERIA_INSTANCE_CODE: DEFAULT_INSTANCE_CODE,
46
+ CYBERIA_DEFAULT_INSTANCE: DEFAULT_INSTANCE_CODE,
47
+ CYBERIA_BASE_PATH: '/',
48
+ });
49
+ });
50
+
51
+ it('leaves a runtime it does not know alone', () => {
52
+ expect(buildCyberiaMmoInstanceEnv({ instance: { runtime: 'nodejs', path: '/x' }, env: { A: '1' } })).toEqual({
53
+ A: '1',
54
+ });
55
+ expect(buildCyberiaMmoInstanceEnv({})).toEqual({});
56
+ });
57
+
58
+ it('names the context when no database provider was loaded for it', () => {
59
+ expect(() => getInstanceModels({ host: 'nowhere.test', path: '/' })).toThrow('nowhere.test');
60
+ });
61
+ });
62
+
63
+ describe('wire converters', () => {
64
+ it('parses css colours into wire components and treats anything else as transparent', () => {
65
+ expect(parseRgba('rgba(10, 20, 30, 0.5)')).toEqual({ r: 10, g: 20, b: 30, a: 128 });
66
+ expect(parseRgba('rgb(1,2,3)')).toEqual({ r: 1, g: 2, b: 3, a: 255 });
67
+ expect(parseRgba('')).toEqual({ r: 0, g: 0, b: 0, a: 0 });
68
+ expect(parseRgba('#ff0000')).toEqual({ r: 0, g: 0, b: 0, a: 0 });
69
+ });
70
+
71
+ it('fills every entity, map and instance field an empty document leaves out', () => {
72
+ const entity = toEntityMsg({});
73
+ expect(entity).toMatchObject({ entityType: 'floor', level: 0, dimX: 1, dimY: 1, objectLayerItemIds: [] });
74
+ expect(toEntityMsg({ level: 3, color: 'rgb(9,8,7)' })).toMatchObject({ level: 3, colorR: 9, colorB: 7 });
75
+
76
+ const map = toMapMsg({ _id: 'm1', entities: [{ entityType: 'bot' }] });
77
+ expect(map).toMatchObject({ mongoId: 'm1', gridX: 16, cellWidth: 32 });
78
+ expect(map.entities[0].entityType).toBe('bot');
79
+
80
+ const instance = toInstanceMsg({ _id: 'i1', portals: [{ sourceMapCode: 'a' }] });
81
+ expect(instance).toMatchObject({ mongoId: 'i1', topologyMode: 'hybrid', mapCodes: [] });
82
+ expect(instance.portals[0]).toMatchObject({ sourceMapCode: 'a', portalMode: 'inter-portal', targetCellX: 0 });
83
+ expect(instance.playerSpawn).toEqual({ sourceMapCode: '', sourceCellX: 0, sourceCellY: 0, random: false });
84
+ });
85
+
86
+ it('carries an object layer with its ledger and render, defaulting the ledger to off-chain', () => {
87
+ const msg = toObjectLayerMsg({ _id: 'o1', data: { item: { id: 'hatchet', type: 'weapon', activable: 1 } } });
88
+ expect(msg).toMatchObject({
89
+ mongoId: 'o1',
90
+ item: { id: 'hatchet', type: 'weapon', description: '', activable: true },
91
+ ledger: { type: 'OFF_CHAIN', address: '', tokenId: '' },
92
+ render: { cid: '', metadataCid: '' },
93
+ sha256: '',
94
+ cid: '',
95
+ });
96
+ expect(toObjectLayerMsg({ _id: 'o2' }).item.id).toBe('');
97
+ });
98
+
99
+ it('carries vendor, craft and quest content with their defaults', () => {
100
+ const action = toActionMsg({
101
+ code: 'shop',
102
+ shopItems: [{ itemId: 'hatchet' }],
103
+ craftRecipes: [{ outputItems: [{ itemId: 'plank' }], ingredients: [{ itemId: 'wood-1', qty: 2 }] }],
104
+ questDialogueCodes: [{ questCode: 'q1' }],
105
+ });
106
+ expect(action.shopItems[0]).toEqual({ itemId: 'hatchet', priceItemId: 'coin', priceQty: 1 });
107
+ expect(action.craftRecipes[0]).toEqual({
108
+ outputItems: [{ itemId: 'plank', qty: 1 }],
109
+ ingredients: [{ itemId: 'wood-1', qty: 2 }],
110
+ craftTimeMs: 0,
111
+ });
112
+ expect(action.questDialogueCodes[0]).toEqual({ questCode: 'q1', dialogCode: '' });
113
+ expect(toActionMsg({})).toMatchObject({ code: '', storageSlots: 0, shopItems: [] });
114
+
115
+ const quest = toQuestMsg({
116
+ code: 'q1',
117
+ steps: [{ id: 's1', objectives: [{ type: 'collect', itemId: 'wood-1' }] }],
118
+ rewards: [{ itemId: 'coin' }],
119
+ });
120
+ expect(quest.steps[0].objectives[0]).toEqual({ type: 'collect', itemId: 'wood-1', quantity: 1 });
121
+ expect(quest.rewards[0]).toEqual({ itemId: 'coin', quantity: 1 });
122
+ expect(toQuestMsg({})).toMatchObject({ code: '', steps: [], unlocksQuestCodes: [] });
123
+ });
124
+ });
125
+
126
+ describe('instance config', () => {
127
+ it('is the canonical defaults for a world without a conf', () => {
128
+ expect(toInstanceConfig(null)).toEqual(buildFallbackConfig());
129
+ expect(buildFallbackConfig()).toEqual(CYBERIA_INSTANCE_CONF_DEFAULTS);
130
+ expect(buildFallbackConfig()).not.toBe(CYBERIA_INSTANCE_CONF_DEFAULTS);
131
+ });
132
+
133
+ it('keeps every field a conf sets and fills the rest from the defaults', () => {
134
+ const config = toInstanceConfig({
135
+ tickRate: 5,
136
+ economyRules: { portalFee: 9 },
137
+ skillRules: { projectileWidth: 3, doppelgangerSpawnChance: 0.2 },
138
+ equipmentRules: { requireSkin: false },
139
+ lifeRegenChance: 7,
140
+ });
141
+ const fb = CYBERIA_INSTANCE_CONF_DEFAULTS;
142
+ expect(config.tickRate).toBe(5);
143
+ expect(config.snapshotRate).toBe(fb.snapshotRate);
144
+ expect(config.economyRules).toEqual({ ...fb.economyRules, portalFee: 9 });
145
+ expect(config.skillRules).toEqual({ ...fb.skillRules, projectileWidth: 3, doppelgangerSpawnChance: 0.2 });
146
+ expect(config.equipmentRules).toEqual({ ...fb.equipmentRules, requireSkin: false });
147
+ expect(config.lifeRegenChance).toBe(fb.lifeRegenChance);
148
+ expect(config.skillConfig).toEqual([]);
149
+ expect(config.entityDefaults.map(({ entityType }) => entityType)).toEqual(
150
+ ENTITY_TYPE_DEFAULTS.map(({ entityType }) => entityType),
151
+ );
152
+ });
153
+
154
+ it('shapes skill and entity-type documents for the wire and drops the ones without a key', () => {
155
+ expect(skillDocsToConfig([{ triggerItemId: 'gun', skills: [{ logicEventId: 'projectile' }] }, {}, null])).toEqual([
156
+ {
157
+ triggerItemId: 'gun',
158
+ skills: [{ logicEventId: 'projectile', name: '', description: '', summonedEntityItemId: '' }],
159
+ },
160
+ ]);
161
+ expect(
162
+ entityTypeDefaultDocsToConfig([{ entityType: 'bot', liveItemIds: ['x'] }, { liveItemIds: ['y'] }]),
163
+ ).toMatchObject([{ entityType: 'bot', liveItemIds: ['x'], deadItemIds: [] }]);
164
+ });
165
+
166
+ it('completes a partial set of entity defaults with the canonical types it does not cover', () => {
167
+ const merged = mergeEntityDefaults([{ entityType: 'bot', liveItemIds: ['ghost'] }], { ghost: 'skin' });
168
+ const bots = merged.filter(({ entityType }) => entityType === 'bot');
169
+ expect(bots).toHaveLength(1);
170
+ expect(bots[0].liveItemIds).toEqual(['ghost']);
171
+ expect(merged.some(({ entityType }) => entityType === 'player')).toBe(true);
172
+ expect(itemTypesOf([{ data: { item: { id: 'ghost', type: 'skin' } } }, { data: {} }, null])).toEqual({
173
+ ghost: 'skin',
174
+ });
175
+ });
176
+ });
177
+
178
+ describe('transport-agnostic fetchers', () => {
179
+ const ol = (id, type = 'skin') => ({ _id: `ol-${id}`, sha256: `sha-${id}`, data: { item: { id, type } } });
180
+
181
+ it('answers a ping with the server clock', () => {
182
+ expect(pingData().serverTimeMs).toBeGreaterThan(0);
183
+ });
184
+
185
+ it('filters object layers by item type only when one is asked for', async () => {
186
+ expect(objectLayerQueryFilter('')).toEqual({});
187
+ expect(objectLayerQueryFilter('skin')).toEqual({ 'data.item.type': 'skin' });
188
+ const filters = [];
189
+ const models = { ObjectLayer: { find: (filter) => (filters.push(filter), lean([ol('anon')])) } };
190
+ const batch = await fetchObjectLayerBatch(models, 'skin');
191
+ expect(filters).toEqual([{ 'data.item.type': 'skin' }]);
192
+ expect(batch.map(({ item }) => item.id)).toEqual(['anon']);
193
+ });
194
+
195
+ it('resolves one object layer by item id, or null', async () => {
196
+ const models = { ObjectLayer: { findOne: ({ 'data.item.id': id }) => lean(id === 'anon' ? ol('anon') : null) } };
197
+ expect((await fetchObjectLayer(models, 'anon')).mongoId).toBe('ol-anon');
198
+ expect(await fetchObjectLayer(models, 'ghost')).toBeNull();
199
+ });
200
+
201
+ it('lists the id and digest of every object layer', async () => {
202
+ const models = { ObjectLayer: { find: () => lean([ol('anon'), { _id: 'x' }]) } };
203
+ expect(await fetchObjectLayerManifest(models)).toEqual({
204
+ entries: [
205
+ { itemId: 'anon', sha256: 'sha-anon' },
206
+ { itemId: '', sha256: '' },
207
+ ],
208
+ });
209
+ });
210
+
211
+ it('serves a map and records which instance serves it, surviving a registry failure', async () => {
212
+ const registry = [];
213
+ const models = {
214
+ CyberiaMap: { findOne: ({ code }) => lean(code === 'forest-1' ? { _id: 'm1', code } : null) },
215
+ GlobalMapCodeRegistry: {
216
+ findOneAndUpdate: (filter, update) => {
217
+ registry.push({ filter, update });
218
+ return Promise.reject(new Error('registry down'));
219
+ },
220
+ },
221
+ };
222
+ expect(await fetchMapData(models, { mapCode: 'ghost' })).toBeNull();
223
+ expect(await fetchMapData(models, { mapCode: 'forest-1' })).toMatchObject({ map: { code: 'forest-1' } });
224
+ expect(registry).toEqual([]);
225
+ const served = await fetchMapData(models, { mapCode: 'forest-1', instanceCode: 'FOREST' });
226
+ expect(served.map.mongoId).toBe('m1');
227
+ expect(registry).toEqual([
228
+ { filter: { mapCode: 'forest-1' }, update: { instanceCode: 'FOREST', status: 'active' } },
229
+ ]);
230
+ await new Promise((resolve) => setImmediate(resolve));
231
+ });
232
+ });
233
+
234
+ describe('full world load', () => {
235
+ const ol = (id, type = 'skin') => ({ _id: `ol-${id}`, sha256: `sha-${id}`, data: { item: { id, type } } });
236
+
237
+ it('serves the procedural world, with the canonical content, when the instance is unknown', async () => {
238
+ const queries = [];
239
+ const models = {
240
+ CyberiaInstance: { findOne: () => lean(null) },
241
+ ObjectLayer: { find: (filter) => (queries.push(filter), lean([ol('anon'), ol(DEFAULT_DEAD_ITEM_ID)])) },
242
+ };
243
+ const world = await fetchFullInstance(models, '');
244
+ expect(world.instance.seed).toBe('default');
245
+ expect(world.maps.length).toBeGreaterThan(0);
246
+ expect(world.maps[0].entities[0]).toHaveProperty('colorA');
247
+ expect(world.objectLayers.map(({ item }) => item.id)).toEqual(['anon', DEFAULT_DEAD_ITEM_ID]);
248
+ expect(world.config.skillConfig.map(({ triggerItemId }) => triggerItemId)).toContain('atlas_pistol_mk2');
249
+ expect(world.actions.length).toBeGreaterThan(0);
250
+ expect(world.quests.length).toBeGreaterThan(0);
251
+ expect(world.version).toMatch(/^fallback-[0-9a-f]{64}$/);
252
+ // Every canonical item is asked for, so anything a player can pick up resolves an atlas.
253
+ const wanted = queries[0]['data.item.id'].$in;
254
+ expect(wanted).toContain('anon');
255
+ expect(wanted).toContain(DEFAULT_DEAD_ITEM_ID);
256
+
257
+ const again = await fetchFullInstance(models, '');
258
+ expect(again.version).toBe(world.version);
259
+ });
260
+
261
+ it('loads a stored world from its conf, maps, content and the skills its items trigger', async () => {
262
+ const conf = { _id: 'c1', instanceCode: 'FOREST', tickRate: 7, entityTypeDefaults: [], updatedAt: 't1' };
263
+ const models = {
264
+ CyberiaInstance: {
265
+ findOne: () => lean({ _id: 'i1', code: 'FOREST', cyberiaMapCodes: ['forest-1'], conf, updatedAt: 't0' }),
266
+ },
267
+ CyberiaMap: {
268
+ find: () => lean([{ _id: 'm1', code: 'forest-1', entities: [{ objectLayerItemIds: ['atlas_pistol_mk2'] }] }]),
269
+ },
270
+ CyberiaEntityTypeDefault: { find: () => lean([]) },
271
+ CyberiaAction: {
272
+ find: () => lean([{ code: 'shop', sourceMapCode: 'forest-1', shopItems: [{ itemId: 'hatchet' }] }]),
273
+ },
274
+ CyberiaQuest: { find: () => lean([{ code: 'q1', sourceMapCode: 'forest-1' }]) },
275
+ CyberiaSkill: { find: () => lean([]) },
276
+ ObjectLayer: {
277
+ find: ({ 'data.item.id': { $in } }) =>
278
+ lean(
279
+ ['atlas_pistol_mk2', 'hatchet', 'atlas_pistol_mk2_bullet']
280
+ .filter((id) => $in.includes(id))
281
+ .map((id) => ol(id)),
282
+ ),
283
+ },
284
+ };
285
+ const world = await fetchFullInstance(models, 'FOREST');
286
+ expect(world.instance.code).toBe('FOREST');
287
+ expect(world.config.tickRate).toBe(7);
288
+ // The map places the pistol and the vendor sells the hatchet: both trigger skills this world runs.
289
+ const triggers = world.config.skillConfig.map(({ triggerItemId }) => triggerItemId);
290
+ expect(triggers).toContain('atlas_pistol_mk2');
291
+ expect(triggers).toContain('hatchet');
292
+ // What the skill summons needs an atlas too.
293
+ expect(world.objectLayers.map(({ item }) => item.id)).toContain('atlas_pistol_mk2_bullet');
294
+ expect(world.objectLayers.map(({ item }) => item.id)).toContain('hatchet');
295
+ expect(world.actions[0].shopItems[0].itemId).toBe('hatchet');
296
+ expect(world.quests[0].code).toBe('q1');
297
+ expect(world.version).toMatch(/^[0-9a-f]{64}$/);
298
+ });
299
+
300
+ it('runs a world whose conf reference is gone on the canonical defaults', async () => {
301
+ const models = {
302
+ CyberiaInstance: { findOne: () => lean({ _id: 'i1', code: 'BARE', cyberiaMapCodes: [], conf: null }) },
303
+ CyberiaInstanceConf: { findOne: () => lean(null) },
304
+ CyberiaEntityTypeDefault: { find: () => lean([]) },
305
+ ObjectLayer: { find: () => lean([]) },
306
+ };
307
+ const world = await fetchFullInstance(models, 'BARE');
308
+ expect(world.config.tickRate).toBe(CYBERIA_INSTANCE_CONF_DEFAULTS.tickRate);
309
+ expect(world.maps).toEqual([]);
310
+ expect(world.actions).toEqual([]);
311
+ expect(world.quests).toEqual([]);
312
+ expect(world.objectLayers).toEqual([]);
313
+ });
314
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { fetchInstanceObjectLayerItemIds } from '../../../src/projects/cyberia/instance-data.js';
3
+
4
+ const lean = (value) => ({ lean: async () => value, populate: () => lean(value) });
5
+
6
+ const buildModels = ({ instance, maps = [], storedItemIds = [] }) => ({
7
+ CyberiaInstance: { findOne: () => lean(instance) },
8
+ CyberiaInstanceConf: { findOne: () => lean({ instanceCode: instance?.code, entityDefaults: [] }) },
9
+ CyberiaMap: { find: () => lean(maps) },
10
+ CyberiaEntityTypeDefault: { find: () => lean([]) },
11
+ CyberiaAction: { find: () => lean([]) },
12
+ CyberiaQuest: { find: () => lean([]) },
13
+ CyberiaSkill: { find: () => lean([]) },
14
+ ObjectLayer: {
15
+ find: (filter) => {
16
+ const wanted = new Set(filter['data.item.id'].$in);
17
+ return lean(storedItemIds.filter((id) => wanted.has(id)).map((id) => ({ _id: id, data: { item: { id } } })));
18
+ },
19
+ },
20
+ });
21
+
22
+ describe('instance object layer item ids', () => {
23
+ it('collects the item ids the instance maps place, limited to stored documents', async () => {
24
+ const models = buildModels({
25
+ instance: { _id: 'i1', code: 'FOREST', cyberiaMapCodes: ['forest-1'] },
26
+ maps: [
27
+ {
28
+ code: 'forest-1',
29
+ entities: [{ objectLayerItemIds: ['anon', 'hatchet'] }, { objectLayerItemIds: ['hatchet', '$runtime'] }],
30
+ },
31
+ ],
32
+ storedItemIds: ['anon', 'hatchet', 'unrelated'],
33
+ });
34
+ const itemIds = await fetchInstanceObjectLayerItemIds(models, 'FOREST');
35
+ expect(itemIds).toContain('anon');
36
+ expect(itemIds).toContain('hatchet');
37
+ expect(itemIds).not.toContain('unrelated');
38
+ expect(itemIds).not.toContain('$runtime');
39
+ expect(new Set(itemIds).size).toBe(itemIds.length);
40
+ });
41
+
42
+ it('fails on an unknown instance code instead of returning the fallback world', async () => {
43
+ const models = buildModels({ instance: null });
44
+ await expect(fetchInstanceObjectLayerItemIds(models, 'GHOST')).rejects.toThrow('"GHOST" not found');
45
+ });
46
+ });
@@ -0,0 +1,22 @@
1
+ import { expect } from 'chai';
2
+ import fs from 'fs-extra';
3
+ // Named import: js-yaml's ESM build exports no default.
4
+ import { load } from 'js-yaml';
5
+
6
+ describe('the cyberia publish workflow', () => {
7
+ it('rewrites the published manifest after the install and before every publish', () => {
8
+ // The rewrite is the publish step's alone: a checkout that installed the published shape
9
+ // would run the engine source against a second engine in node_modules.
10
+ const workflow = load(fs.readFileSync('./.github/workflows/publish.cyberia.ci.yml', 'utf8'));
11
+
12
+ for (const [job, { steps }] of Object.entries(workflow.jobs)) {
13
+ const commands = steps.map(({ run }) => `${run ?? ''}`);
14
+ const restore = commands.findIndex((command) => command.includes('publishedProductPackageJson'));
15
+ const install = commands.findIndex((command) => command.trim().startsWith('npm ci'));
16
+ const publish = commands.findIndex((command) => command.includes('npm publish'));
17
+
18
+ expect(restore, `${job}: restores the published manifest`).to.be.greaterThan(install);
19
+ expect(restore, `${job}: restores it before publishing`).to.be.lessThan(publish);
20
+ }
21
+ });
22
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { balanceStats, resolveStatBounds, statPolicyActive } from '../../../src/projects/cyberia/stat-balance.js';
3
+ import {
4
+ STAT_TYPE_BOUNDS,
5
+ STAT_TYPES,
6
+ statBoundsForType,
7
+ } from '../../../src/client/components/cyberia/SharedDefaultsCyberia.js';
8
+
9
+ const wild = { effect: 60, resistance: -40, agility: 3, range: 25, intelligence: 9, utility: -2 };
10
+ const lcg = (seed) => () => (seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 4294967296;
11
+
12
+ describe('stat balancing policy', () => {
13
+ it('is inert without normalize or random', () => {
14
+ expect(statPolicyActive({})).toBe(false);
15
+ expect(balanceStats({ stats: wild, itemType: 'weapon' })).toEqual(wild);
16
+ });
17
+
18
+ it('clamps into the semantic bounds of the item type', () => {
19
+ expect(balanceStats({ stats: wild, itemType: 'weapon', policy: { normalize: true } })).toEqual({
20
+ effect: 20,
21
+ resistance: -10,
22
+ agility: 3,
23
+ range: 10,
24
+ intelligence: 5,
25
+ utility: 0,
26
+ });
27
+ expect(balanceStats({ stats: wild, itemType: 'skin', policy: { normalize: true } })).toEqual({
28
+ effect: 1,
29
+ resistance: 0,
30
+ agility: 3,
31
+ range: 0,
32
+ intelligence: 2,
33
+ utility: 0,
34
+ });
35
+ });
36
+
37
+ it('keeps the contract bounds for a type without semantic bounds', () => {
38
+ expect(statBoundsForType('unknown')).toEqual(Object.fromEntries(STAT_TYPES.map((key) => [key, [-100, 100]])));
39
+ expect(balanceStats({ stats: wild, itemType: 'unknown', policy: { normalize: true } })).toEqual(wild);
40
+ });
41
+
42
+ it('is idempotent and deterministic', () => {
43
+ const once = balanceStats({ stats: wild, itemType: 'weapon', policy: { normalize: true } });
44
+ expect(balanceStats({ stats: once, itemType: 'weapon', policy: { normalize: true } })).toEqual(once);
45
+ const policy = { normalize: true, random: true, min: 0, max: 8 };
46
+ const first = balanceStats({ stats: wild, itemType: 'weapon', policy, random: lcg(7) });
47
+ expect(balanceStats({ stats: wild, itemType: 'weapon', policy, random: lcg(7) })).toEqual(first);
48
+ for (const key of STAT_TYPES) {
49
+ const [lo, hi] = resolveStatBounds('weapon', policy)[key];
50
+ expect(first[key]).toBeGreaterThanOrEqual(lo);
51
+ expect(first[key]).toBeLessThanOrEqual(hi);
52
+ }
53
+ });
54
+
55
+ it('lets min and max narrow the semantic range and pins a request outside it', () => {
56
+ expect(resolveStatBounds('weapon', { normalize: true, min: 12, max: 30 }).effect).toEqual([12, 20]);
57
+ expect(resolveStatBounds('weapon', { normalize: true, min: 50 }).effect).toEqual([20, 20]);
58
+ expect(resolveStatBounds('weapon', { normalize: true, max: -50 }).resistance).toEqual([-10, -10]);
59
+ expect(resolveStatBounds('weapon', { min: -20, max: 20 }).effect).toEqual([-20, 20]);
60
+ expect(() => resolveStatBounds('weapon', { min: 5, max: 1 })).toThrow('minimum');
61
+ expect(() => resolveStatBounds('weapon', { min: -101 })).toThrow();
62
+ });
63
+
64
+ it('declares every semantic bound inside the contract and in order', () => {
65
+ for (const bounds of Object.values(STAT_TYPE_BOUNDS)) {
66
+ for (const [lo, hi] of Object.values(bounds)) expect(lo <= hi && lo >= -100 && hi <= 100).toBe(true);
67
+ }
68
+ });
69
+ });
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ import { expect } from 'chai';
4
+ import shell from 'shelljs';
5
+ import { vi } from 'vitest';
6
+ import UnderpostDeploy from '../../src/cli/deploy.js';
7
+
8
+ describe('deploy legacy Gateway API sweep', () => {
9
+ let commands;
10
+ let listed;
11
+
12
+ beforeEach(() => {
13
+ commands = [];
14
+ listed = { Gateway: '', ClientTrafficPolicy: '' };
15
+ vi.spyOn(shell, 'exec').mockImplementation((command) => {
16
+ commands.push(command);
17
+ const kind = command.match(/^kubectl get (\w+) /)?.[1];
18
+ const stdout = kind ? listed[kind] : '';
19
+ return { code: 0, stdout, stderr: '', toString: () => stdout };
20
+ });
21
+ });
22
+
23
+ afterEach(() => vi.restoreAllMocks());
24
+
25
+ const deletes = () => commands.filter((command) => command.includes('kubectl delete'));
26
+
27
+ it('lists each kind once and deletes nothing on a namespace that was already migrated', () => {
28
+ // Regression: the sweep deleted `Gateway <host>`, `Gateway undefined` and both
29
+ // `-http3` policies blind, once per host — two dozen no-op round-trips per sync.
30
+ listed.Gateway = 'gateway.gateway.networking.k8s.io/dd-prototype-production\n';
31
+ listed.ClientTrafficPolicy = 'clienttrafficpolicy.gateway.envoyproxy.io/dd-prototype-production-http3\n';
32
+ const removed = UnderpostDeploy.API.sweepLegacyGatewayObjects({
33
+ hosts: ['healthcare.nexodev.org', 'www.bymyelectrics.com', 'bymyelectrics.com'],
34
+ namespace: 'default',
35
+ });
36
+ expect(removed).to.deep.equal({ gateways: [], policies: [] });
37
+ expect(commands.filter((command) => command.startsWith('kubectl get ')).length).to.equal(2);
38
+ expect(deletes()).to.deep.equal([]);
39
+ });
40
+
41
+ it('removes only the per-host and unresolved-host objects that still exist', () => {
42
+ listed.Gateway = [
43
+ 'gateway.gateway.networking.k8s.io/dd-prototype-production',
44
+ 'gateway.gateway.networking.k8s.io/healthcare.nexodev.org',
45
+ 'gateway.gateway.networking.k8s.io/undefined',
46
+ 'gateway.gateway.networking.k8s.io/other-deploy-production',
47
+ ].join('\n');
48
+ listed.ClientTrafficPolicy = 'clienttrafficpolicy.gateway.envoyproxy.io/undefined-http3\n';
49
+ const removed = UnderpostDeploy.API.sweepLegacyGatewayObjects({
50
+ hosts: ['healthcare.nexodev.org', 'www.bymyelectrics.com'],
51
+ namespace: 'default',
52
+ });
53
+ expect(removed).to.deep.equal({
54
+ gateways: ['healthcare.nexodev.org', 'undefined'],
55
+ policies: ['undefined-http3'],
56
+ });
57
+ expect(deletes()).to.deep.equal([
58
+ 'sudo kubectl delete Gateway healthcare.nexodev.org -n default --ignore-not-found',
59
+ 'sudo kubectl delete Gateway undefined -n default --ignore-not-found',
60
+ 'sudo kubectl delete ClientTrafficPolicy undefined-http3 -n default --ignore-not-found',
61
+ ]);
62
+ });
63
+ });
@@ -85,7 +85,7 @@ const stream = [
85
85
  let workDir;
86
86
  let streamPath;
87
87
 
88
- const runQuiet = ({ tty = false, plain = false, ci = false, exitCode = 0 } = {}) => {
88
+ const runQuiet = ({ tty = false, plain = false, ci = false, debug = false, exitCode = 0 } = {}) => {
89
89
  const command = `exit ${exitCode}`;
90
90
  const script = [
91
91
  'status=0',
@@ -106,6 +106,8 @@ const runQuiet = ({ tty = false, plain = false, ci = false, exitCode = 0 } = {})
106
106
  delete env.GITHUB_ACTIONS;
107
107
  delete env.RUN_QUIET_CI;
108
108
  if (ci) env.RUN_QUIET_CI = 'github';
109
+ delete env.RUN_QUIET_DEBUG;
110
+ if (debug) env.RUN_QUIET_DEBUG = '1';
109
111
 
110
112
  const [file, args] = tty ? ['script', ['-qec', `bash ${scriptPath}`, '/dev/null']] : ['bash', [scriptPath]];
111
113
  return execFileSync(file, args, { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
@@ -128,7 +130,7 @@ describe('deploy failure propagation (run_quiet exit status)', () => {
128
130
  * Runs the helper against an arbitrary stream and reports the exit status rather than
129
131
  * throwing, so a failing step is an assertion rather than a caught exception.
130
132
  */
131
- const runWith = (lines, exitCode = 0) => {
133
+ const runWith = (lines, exitCode = 0, mode = { RUN_QUIET_PLAIN: '1' }) => {
132
134
  const logPath = path.join(failDir, 'stream.log');
133
135
  fs.writeFileSync(logPath, `${lines.join('\n')}\n`);
134
136
  const scriptPath = path.join(failDir, 'run.sh');
@@ -141,9 +143,12 @@ describe('deploy failure propagation (run_quiet exit status)', () => {
141
143
  'echo REACHED_NEXT_STEP',
142
144
  ].join('\n'),
143
145
  );
144
- const env = { ...process.env, RUN_QUIET_PLAIN: '1' };
146
+ const env = { ...process.env };
145
147
  delete env.GITHUB_ACTIONS;
146
148
  delete env.RUN_QUIET_CI;
149
+ delete env.RUN_QUIET_PLAIN;
150
+ delete env.RUN_QUIET_DEBUG;
151
+ Object.assign(env, mode);
147
152
  try {
148
153
  const stdout = execFileSync('bash', [scriptPath], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
149
154
  return { status: 0, stdout, stderr: '' };
@@ -158,6 +163,41 @@ describe('deploy failure propagation (run_quiet exit status)', () => {
158
163
 
159
164
  afterEach(() => fs.removeSync(failDir));
160
165
 
166
+ // The failure report names two files and prints nothing out of them, so what the step captured
167
+ // is read back from the file rather than from the transcript.
168
+ const errorLog = (result) => {
169
+ const named = /error trace:\s+(\S+)/.exec(result.stderr);
170
+ expect(named, 'the failure report names an error trace').to.not.equal(null);
171
+ return fs.readFileSync(named[1], 'utf8');
172
+ };
173
+
174
+ it('latches a fatal runtime state in every rendering, including the debug stream', () => {
175
+ // The latch is parsed, not rendered. Turning the display off must not turn it off with it, or
176
+ // an audit run reports success on a deployment that never came up.
177
+ const podFatal = 'Target pod: dd-test-blue | Pod status: Running | Runtime status: error';
178
+
179
+ for (const [name, mode] of [
180
+ ['plain', { RUN_QUIET_PLAIN: '1' }],
181
+ ['ci', { RUN_QUIET_CI: 'github' }],
182
+ ['debug', { RUN_QUIET_DEBUG: '1' }],
183
+ ]) {
184
+ const result = runWith([podFatal], 0, mode);
185
+ expect(result.status, name).to.equal(1);
186
+ expect(result.stderr, name).to.include('reported a fatal runtime state but exited 0');
187
+ expect(result.stdout, name).to.not.include('REACHED_NEXT_STEP');
188
+ }
189
+ });
190
+
191
+ it('latches a fatal runtime state reported only by a monitor event, in the debug stream too', () => {
192
+ // The event arrives as a multi-line JSON block, so the state machine that reads it has to run
193
+ // in a mode that renders none of it.
194
+ const emitted = emit('08:11:33', 'runtime', 'runtime_error', 'error').split('\n');
195
+ const result = runWith(emitted, 0, { RUN_QUIET_DEBUG: '1' });
196
+
197
+ expect(result.status).to.equal(1);
198
+ expect(result.stdout, 'debug shows the event it latched on').to.include('"status": "error"');
199
+ });
200
+
161
201
  it('propagates a non-zero command status so `set -e` stops the deployment', () => {
162
202
  const result = runWith([podLine(POD_A, 'Running', 'running-deployment')], 7);
163
203
  expect(result.status).to.equal(7);
@@ -184,7 +224,10 @@ describe('deploy failure propagation (run_quiet exit status)', () => {
184
224
  expect(result.status).to.equal(1);
185
225
  expect(result.stdout).to.not.include('REACHED_NEXT_STEP');
186
226
  expect(result.stderr).to.include('fatal runtime state but exited 0');
187
- expect(result.stderr).to.include(POD_A);
227
+ // The pod name is captured output: a CI transcript is readable by anyone with the job log,
228
+ // so the latch detail belongs in the error log and nowhere else.
229
+ expect(result.stderr).to.not.include(POD_A);
230
+ expect(errorLog(result)).to.include(`pod ${POD_A}: runtime status=error`);
188
231
  });
189
232
 
190
233
  it('fails on a fatal runtime status carried by a monitor event rather than a pod row', () => {
@@ -196,7 +239,8 @@ describe('deploy failure propagation (run_quiet exit status)', () => {
196
239
  0,
197
240
  );
198
241
  expect(result.status).to.equal(1);
199
- expect(result.stderr).to.include('runtime event: status=error');
242
+ expect(result.stderr).to.not.include('status=error');
243
+ expect(errorLog(result)).to.include('runtime event: status=error');
200
244
  });
201
245
 
202
246
  it('never hands off to the traffic switch on a pod reporting the fatal state', () => {
@@ -318,6 +362,33 @@ describe('deploy log table (run_quiet filter)', () => {
318
362
  expect(out).to.not.include(CHATTER_LINE);
319
363
  });
320
364
 
365
+ it('streams every line verbatim in debug mode, rendering nothing of its own', () => {
366
+ const out = runQuiet({ debug: true });
367
+
368
+ // What the other modes consume is exactly what an operator opened this session to read: the
369
+ // monitor's own JSON, and the report lines the table replaces.
370
+ expect(out).to.include('deploy-monitor');
371
+ expect(out).to.include('"phase": "kubernetes"');
372
+ expect(out).to.include('Target pod: ');
373
+ expect(out).to.include(READY_LINE);
374
+ expect(out).to.include(CHATTER_LINE);
375
+
376
+ // Nothing is rendered on top of the stream: no table, no CI section, no hand-off.
377
+ expect(out).to.not.include('POD NAME');
378
+ expect(out).to.not.include('refresh #');
379
+ expect(out).to.not.include('::group::');
380
+ expect(out).to.not.include('▶ Switch traffic');
381
+ });
382
+
383
+ it('keeps debug mode a passthrough even where a CI log would fold frames', () => {
384
+ // Debug is the operator asking for the stream, so it wins over the rendering the destination
385
+ // would otherwise get: a collapsed section hides exactly what the run is being watched for.
386
+ const out = runQuiet({ debug: true, ci: true });
387
+
388
+ expect(out).to.not.include('::group::');
389
+ expect(out).to.include('deploy-monitor');
390
+ });
391
+
321
392
  it('falls back to plain rows on a terminal when RUN_QUIET_PLAIN is set', function () {
322
393
  if (!hasScript) return this.skip();
323
394
  const out = runQuiet({ tty: true, plain: true });