@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,402 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { Types } from 'mongoose';
3
+
4
+ const models = {};
5
+
6
+ vi.mock('../../../../src/db/DataBaseProvider.js', () => ({
7
+ DataBaseProviderService: { getModel: (name) => models[name] },
8
+ }));
9
+ vi.mock('../../../../src/api/ipfs/ipfs.service.js', () => ({ createPinRecord: async () => ({}) }));
10
+ vi.mock('../../../../src/projects/cyberia/ipfs-client.js', () => ({
11
+ IpfsClient: { addBufferToIpfs: async () => null, addJsonToIpfs: async () => null },
12
+ }));
13
+
14
+ const { AtlasSpriteSheetStore } = await import('../../../../src/projects/cyberia/atlas-sprite-sheet-store.js');
15
+
16
+ const colors = [
17
+ [255, 0, 0, 255],
18
+ [0, 255, 0, 255],
19
+ ];
20
+ const frame = [
21
+ [0, 1],
22
+ [null, 0],
23
+ ];
24
+ const renderFrames = (frames) => ({ colors, frame_duration: 250, frames });
25
+ const twoFrames = renderFrames({ down_idle: [frame], up_idle: [frame] });
26
+
27
+ /** Minimal File collection: the store only upserts, reads and bulk-deletes by _id. */
28
+ class FakeFiles {
29
+ constructor() {
30
+ this.docs = new Map();
31
+ }
32
+ async updateOne({ _id }, { $setOnInsert }) {
33
+ const key = String(_id);
34
+ if (!this.docs.has(key)) this.docs.set(key, { ...$setOnInsert });
35
+ return { acknowledged: true };
36
+ }
37
+ async deleteMany({ _id: { $in } }) {
38
+ for (const id of $in) this.docs.delete(String(id));
39
+ return { deletedCount: $in.length };
40
+ }
41
+ async findById(id) {
42
+ return this.docs.get(String(id)) ?? null;
43
+ }
44
+ get size() {
45
+ return this.docs.size;
46
+ }
47
+ has(id) {
48
+ return this.docs.has(String(id));
49
+ }
50
+ }
51
+
52
+ /** Minimal AtlasSpriteSheet collection keyed by metadata.itemKey. */
53
+ class FakeAtlases {
54
+ constructor() {
55
+ this.doc = null;
56
+ }
57
+ findOne() {
58
+ return Promise.resolve(this.doc);
59
+ }
60
+ async updateOne(_filter, { $set }) {
61
+ Object.assign(this.doc, $set);
62
+ return { acknowledged: true };
63
+ }
64
+ seed(fields) {
65
+ this.doc = {
66
+ _id: new Types.ObjectId(),
67
+ ...fields,
68
+ set(next) {
69
+ Object.assign(this, next);
70
+ },
71
+ save: async () => this.doc,
72
+ };
73
+ return this.doc;
74
+ }
75
+ }
76
+
77
+ const newAtlasModel = (files) => {
78
+ const atlases = new FakeAtlases();
79
+ function AtlasSpriteSheet(fields) {
80
+ Object.assign(this, fields);
81
+ this.set = (next) => Object.assign(this, next);
82
+ this.save = async () => {
83
+ atlases.doc = this;
84
+ return this;
85
+ };
86
+ }
87
+ AtlasSpriteSheet.findOne = () => atlases.findOne();
88
+ AtlasSpriteSheet.updateOne = (filter, update) => atlases.updateOne(filter, update);
89
+ AtlasSpriteSheet.store = atlases;
90
+ models.AtlasSpriteSheet = AtlasSpriteSheet;
91
+ models.File = files;
92
+ return AtlasSpriteSheet;
93
+ };
94
+
95
+ describe('atlas sprite sheet store', () => {
96
+ let files;
97
+
98
+ beforeEach(() => {
99
+ files = new FakeFiles();
100
+ newAtlasModel(files);
101
+ });
102
+
103
+ it('stores both renders, the idle still, and describes the minified one', async () => {
104
+ const { atlasDoc } = await AtlasSpriteSheetStore.persist({
105
+ itemKey: 'hatchet',
106
+ objectLayerRenderFrames: twoFrames,
107
+ upscaleFactor: 20,
108
+ });
109
+
110
+ expect(atlasDoc.metadata.cellPixelDim).toBe(1);
111
+ expect(atlasDoc.metadata.upscaleFactor).toBe(20);
112
+ const ids = [atlasDoc.fileId, atlasDoc.minifyFileId, atlasDoc.idlePreviewFileId].map(String);
113
+ expect(new Set(ids).size).toBe(3);
114
+ expect(files.size).toBe(3);
115
+ for (const id of ids) expect(files.has(id)).toBe(true);
116
+ });
117
+
118
+ it('leaves the same item untouched when persist reruns on unchanged frames', async () => {
119
+ const renders = (doc) => [doc.fileId, doc.minifyFileId, doc.idlePreviewFileId].map(String);
120
+ const first = await AtlasSpriteSheetStore.persist({ itemKey: 'hatchet', objectLayerRenderFrames: twoFrames });
121
+
122
+ const second = await AtlasSpriteSheetStore.persist({ itemKey: 'hatchet', objectLayerRenderFrames: twoFrames });
123
+
124
+ expect(renders(second.atlasDoc)).toEqual(renders(first.atlasDoc));
125
+ expect(files.size).toBe(3);
126
+ });
127
+
128
+ it('deletes the File it replaces when the upscale factor changes', async () => {
129
+ const first = await AtlasSpriteSheetStore.persist({
130
+ itemKey: 'hatchet',
131
+ objectLayerRenderFrames: twoFrames,
132
+ upscaleFactor: 4,
133
+ });
134
+ const firstFileId = String(first.atlasDoc.fileId);
135
+ const firstMinifyFileId = String(first.atlasDoc.minifyFileId);
136
+ const firstIdlePreviewFileId = String(first.atlasDoc.idlePreviewFileId);
137
+
138
+ const second = await AtlasSpriteSheetStore.persist({
139
+ itemKey: 'hatchet',
140
+ objectLayerRenderFrames: twoFrames,
141
+ upscaleFactor: 8,
142
+ });
143
+
144
+ expect(String(second.atlasDoc.fileId)).not.toBe(firstFileId);
145
+ expect(files.has(firstFileId)).toBe(false);
146
+ // The minified render does not move with the upscale factor; the still, cut from the
147
+ // human-resolution render, does.
148
+ expect(String(second.atlasDoc.minifyFileId)).toBe(firstMinifyFileId);
149
+ expect(String(second.atlasDoc.idlePreviewFileId)).not.toBe(firstIdlePreviewFileId);
150
+ expect(files.has(firstIdlePreviewFileId)).toBe(false);
151
+ expect(files.size).toBe(3);
152
+ });
153
+
154
+ it('cuts the still from the down-idle frame, and from any frame when there is none', async () => {
155
+ const { atlasDoc } = await AtlasSpriteSheetStore.persist({
156
+ itemKey: 'hatchet',
157
+ objectLayerRenderFrames: twoFrames,
158
+ });
159
+ expect(atlasDoc.idlePreviewFileId).toBeTruthy();
160
+
161
+ const onlyUp = renderFrames({ up_idle: [frame] });
162
+ const { atlasDoc: upOnly } = await AtlasSpriteSheetStore.persist({
163
+ itemKey: 'hatchet',
164
+ objectLayerRenderFrames: onlyUp,
165
+ });
166
+ expect(upOnly.idlePreviewFileId).toBeTruthy();
167
+ });
168
+
169
+ it('fills a missing still from the render the atlas already holds, then is a no-op', async () => {
170
+ const atlases = models.AtlasSpriteSheet.store;
171
+ await AtlasSpriteSheetStore.persist({ itemKey: 'hatchet', objectLayerRenderFrames: twoFrames });
172
+ const expected = String(atlases.doc.idlePreviewFileId);
173
+ // An atlas written before the still existed: the field is empty and the File is gone.
174
+ await models.AtlasSpriteSheet.updateOne({}, { $set: { idlePreviewFileId: null } });
175
+ files.docs.delete(expected);
176
+
177
+ const filled = await AtlasSpriteSheetStore.syncIdlePreview({ itemKey: 'hatchet' });
178
+ expect(filled.status).toBe('updated');
179
+ expect(String(atlases.doc.idlePreviewFileId)).toBe(expected);
180
+ expect(files.has(expected)).toBe(true);
181
+
182
+ const again = await AtlasSpriteSheetStore.syncIdlePreview({ itemKey: 'hatchet' });
183
+ expect(again.status).toBe('unchanged');
184
+ expect(files.size).toBe(3);
185
+ });
186
+
187
+ it('reports a missing atlas or render instead of inventing a still', async () => {
188
+ expect((await AtlasSpriteSheetStore.syncIdlePreview({ itemKey: 'nothing' })).status).toBe('missing');
189
+ });
190
+
191
+ it('reports a missing atlas instead of creating one', async () => {
192
+ const result = await AtlasSpriteSheetStore.syncMinifyRender({
193
+ itemKey: 'ghost',
194
+ objectLayerRenderFrames: twoFrames,
195
+ });
196
+ expect(result).toEqual({ status: 'missing' });
197
+ expect(files.size).toBe(0);
198
+ });
199
+
200
+ it('fills a missing minified render and touches no other attribute', async () => {
201
+ const atlases = models.AtlasSpriteSheet.store;
202
+ await AtlasSpriteSheetStore.persist({ itemKey: 'hatchet', objectLayerRenderFrames: twoFrames });
203
+ const fileIdBefore = atlases.doc.fileId;
204
+ const metadataBefore = atlases.doc.metadata;
205
+
206
+ await models.AtlasSpriteSheet.updateOne({}, { $set: { minifyFileId: null } });
207
+ files.docs.delete(String(atlases.doc.minifyFileId));
208
+
209
+ const result = await AtlasSpriteSheetStore.syncMinifyRender({
210
+ itemKey: 'hatchet',
211
+ objectLayerRenderFrames: twoFrames,
212
+ });
213
+
214
+ expect(result.status).toBe('updated');
215
+ expect(atlases.doc.minifyFileId).toBeTruthy();
216
+ expect(atlases.doc.fileId).toBe(fileIdBefore);
217
+ expect(atlases.doc.metadata).toBe(metadataBefore);
218
+ });
219
+
220
+ it('is a no-op when the minified render already matches', async () => {
221
+ const atlases = models.AtlasSpriteSheet.store;
222
+ await AtlasSpriteSheetStore.persist({ itemKey: 'hatchet', objectLayerRenderFrames: twoFrames });
223
+ const before = atlases.doc.minifyFileId;
224
+
225
+ const result = await AtlasSpriteSheetStore.syncMinifyRender({
226
+ itemKey: 'hatchet',
227
+ objectLayerRenderFrames: twoFrames,
228
+ });
229
+
230
+ expect(result.status).toBe('unchanged');
231
+ expect(String(atlases.doc.minifyFileId)).toBe(String(before));
232
+ expect(files.size).toBe(3);
233
+ });
234
+
235
+ it('refuses to write when the render frames moved the atlas layout', async () => {
236
+ const atlases = models.AtlasSpriteSheet.store;
237
+ await AtlasSpriteSheetStore.persist({ itemKey: 'hatchet', objectLayerRenderFrames: twoFrames });
238
+ const before = atlases.doc.minifyFileId;
239
+
240
+ const result = await AtlasSpriteSheetStore.syncMinifyRender({
241
+ itemKey: 'hatchet',
242
+ objectLayerRenderFrames: renderFrames({ down_idle: [frame, frame, frame], up_idle: [frame] }),
243
+ });
244
+
245
+ expect(result).toEqual({ status: 'stale' });
246
+ expect(String(atlases.doc.minifyFileId)).toBe(String(before));
247
+ });
248
+ });
249
+
250
+ /**
251
+ * Multi-document collections, because a drop is about more than one item. Supports the shapes the
252
+ * store queries with: `$or`, `$in`, `$regex` and the `metadata.itemKey` dot path.
253
+ */
254
+ const fakeCollection = (docs) => {
255
+ const read = (doc, path) => path.split('.').reduce((value, part) => value?.[part], doc);
256
+ const matches = (doc, filter) => {
257
+ if (filter.$or) return filter.$or.some((clause) => matches(doc, clause));
258
+ return Object.entries(filter).every(([field, condition]) => {
259
+ const value = read(doc, field);
260
+ if (condition && typeof condition === 'object' && '$in' in condition)
261
+ return value !== undefined && value !== null && condition.$in.map(String).includes(String(value));
262
+ if (condition && typeof condition === 'object' && '$regex' in condition)
263
+ return typeof value === 'string' && condition.$regex.test(value);
264
+ return String(value) === String(condition);
265
+ });
266
+ };
267
+ return {
268
+ get docs() {
269
+ return docs;
270
+ },
271
+ find(filter) {
272
+ return { lean: async () => docs.filter((doc) => matches(doc, filter)) };
273
+ },
274
+ async deleteMany(filter = {}) {
275
+ const removing = new Set(docs.filter((doc) => matches(doc, filter)).map((doc) => String(doc._id)));
276
+ const before = docs.length;
277
+ docs = docs.filter((doc) => !removing.has(String(doc._id)));
278
+ return { deletedCount: before - docs.length };
279
+ },
280
+ };
281
+ };
282
+
283
+ const atlasDoc = (itemKey, suffix) => ({
284
+ _id: new Types.ObjectId(),
285
+ cid: `cid-${itemKey}`,
286
+ fileId: `${itemKey}-atlas-${suffix}`,
287
+ minifyFileId: `${itemKey}-minify-${suffix}`,
288
+ idlePreviewFileId: `${itemKey}-idle-${suffix}`,
289
+ metadata: { itemKey },
290
+ });
291
+
292
+ const renderFile = (id, itemKey, role) => ({ _id: id, name: `${itemKey}-${role}.png` });
293
+
294
+ describe('purging an atlas', () => {
295
+ let atlases;
296
+ let files;
297
+
298
+ beforeEach(() => {
299
+ atlases = fakeCollection([atlasDoc('hatchet', 1), atlasDoc('sword', 1)]);
300
+ files = fakeCollection([
301
+ renderFile('hatchet-atlas-1', 'hatchet', 'atlas'),
302
+ renderFile('hatchet-minify-1', 'hatchet', 'minify'),
303
+ renderFile('hatchet-idle-1', 'hatchet', 'idle'),
304
+ renderFile('sword-atlas-1', 'sword', 'atlas'),
305
+ renderFile('sword-minify-1', 'sword', 'minify'),
306
+ renderFile('sword-idle-1', 'sword', 'idle'),
307
+ ]);
308
+ models.AtlasSpriteSheet = atlases;
309
+ models.File = files;
310
+ });
311
+
312
+ it('takes every render of the item, not only the human-resolution one', async () => {
313
+ // Regression: the CLI drop named `fileId` alone, so every minified render the client
314
+ // downloads stayed in the File collection with nothing left pointing at it.
315
+ const result = await AtlasSpriteSheetStore.purge({ itemKeys: ['hatchet'] });
316
+
317
+ expect(result).toEqual({ atlases: 1, files: 3, cids: ['cid-hatchet'] });
318
+ expect(files.docs.map((doc) => doc._id)).toEqual(['sword-atlas-1', 'sword-minify-1', 'sword-idle-1']);
319
+ expect(atlases.docs.map((doc) => doc.metadata.itemKey)).toEqual(['sword']);
320
+ });
321
+
322
+ it('drops an atlas selected by its document id as readily as by its item key', async () => {
323
+ const linked = atlases.docs.find((doc) => doc.metadata.itemKey === 'sword');
324
+
325
+ const result = await AtlasSpriteSheetStore.purge({ atlasIds: [linked._id] });
326
+
327
+ expect(result.atlases).toBe(1);
328
+ expect(result.files).toBe(3);
329
+ expect(atlases.docs).toHaveLength(1);
330
+ });
331
+
332
+ it('empties the collection when asked for all of it', async () => {
333
+ const result = await AtlasSpriteSheetStore.purge({ all: true });
334
+
335
+ expect(result.atlases).toBe(2);
336
+ expect(result.files).toBe(6);
337
+ expect(files.docs).toHaveLength(0);
338
+ });
339
+
340
+ it('deletes nothing when no selector is given', async () => {
341
+ expect(await AtlasSpriteSheetStore.purge({})).toEqual({ atlases: 0, files: 0, cids: [] });
342
+ expect(atlases.docs).toHaveLength(2);
343
+ expect(files.docs).toHaveLength(6);
344
+ });
345
+
346
+ it('reports zeros on a rerun instead of failing', async () => {
347
+ await AtlasSpriteSheetStore.purge({ itemKeys: ['hatchet'] });
348
+ expect(await AtlasSpriteSheetStore.purge({ itemKeys: ['hatchet'] })).toEqual({
349
+ atlases: 0,
350
+ files: 0,
351
+ cids: [],
352
+ });
353
+ });
354
+
355
+ it('keeps a render a second atlas of the same item still points at', async () => {
356
+ atlases.docs.push({ ...atlasDoc('hatchet', 1), _id: new Types.ObjectId() });
357
+ const first = atlases.docs.find((doc) => doc.metadata.itemKey === 'hatchet');
358
+
359
+ const result = await AtlasSpriteSheetStore.purge({ atlasIds: [first._id] });
360
+
361
+ expect(result.files).toBe(0);
362
+ expect(files.docs.map((doc) => doc._id)).toContain('hatchet-minify-1');
363
+ });
364
+ });
365
+
366
+ describe('pruning renders left behind by an earlier drop', () => {
367
+ it('removes the renders no atlas points at and keeps the ones in use', async () => {
368
+ const atlases = fakeCollection([atlasDoc('sword', 1)]);
369
+ const files = fakeCollection([
370
+ renderFile('hatchet-minify-1', 'hatchet', 'minify'),
371
+ renderFile('hatchet-idle-1', 'hatchet', 'idle'),
372
+ renderFile('sword-atlas-1', 'sword', 'atlas'),
373
+ renderFile('sword-minify-1', 'sword', 'minify'),
374
+ renderFile('sword-idle-1', 'sword', 'idle'),
375
+ { _id: 'thumbnail-1', name: 'map-thumbnail.png' },
376
+ ]);
377
+ models.AtlasSpriteSheet = atlases;
378
+ models.File = files;
379
+
380
+ const removed = await AtlasSpriteSheetStore.pruneOrphanRenders({});
381
+
382
+ expect(removed).toBe(2);
383
+ expect(files.docs.map((doc) => doc._id)).toEqual([
384
+ 'sword-atlas-1',
385
+ 'sword-minify-1',
386
+ 'sword-idle-1',
387
+ 'thumbnail-1',
388
+ ]);
389
+ });
390
+
391
+ it('is a no-op on a collection that leaked nothing', async () => {
392
+ models.AtlasSpriteSheet = fakeCollection([atlasDoc('sword', 1)]);
393
+ models.File = fakeCollection([
394
+ renderFile('sword-atlas-1', 'sword', 'atlas'),
395
+ renderFile('sword-minify-1', 'sword', 'minify'),
396
+ renderFile('sword-idle-1', 'sword', 'idle'),
397
+ ]);
398
+
399
+ expect(await AtlasSpriteSheetStore.pruneOrphanRenders({})).toBe(0);
400
+ expect(models.File.docs).toHaveLength(3);
401
+ });
402
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Jimp } from 'jimp';
3
+ import { AtlasSpriteSheetGenerator as Atlas } from '../../../../src/projects/cyberia/atlas-sprite-sheet-generator.js';
4
+
5
+ const colors = [
6
+ [255, 0, 0, 255],
7
+ [0, 255, 0, 255],
8
+ ];
9
+ const frame = [
10
+ [0, 1],
11
+ [null, 0],
12
+ ];
13
+
14
+ describe('Cyberia atlas generation', () => {
15
+ it('keeps source pixels and frame positions without square padding', async () => {
16
+ const source = { colors, frames: { down_idle: [frame], up_idle: [frame], down_walking: [frame] } };
17
+ const { minifyBuffer, metadata } = await Atlas.generateAtlas(source, 'test');
18
+ const image = await Jimp.read(minifyBuffer);
19
+ expect(metadata.cellPixelDim).toBe(1);
20
+ expect([image.bitmap.width, image.bitmap.height]).toEqual([4, 4]);
21
+ for (const frames of Object.values(metadata.frames)) {
22
+ const { x, y, width, height } = frames[0];
23
+ expect([width, height]).toEqual([2, 2]);
24
+ expect(image.getPixelColor(x, y)).toBe(0xff0000ff);
25
+ expect(image.getPixelColor(x + 1, y)).toBe(0x00ff00ff);
26
+ expect(image.getPixelColor(x, y + 1)).toBe(0);
27
+ }
28
+ });
29
+
30
+ it('fits 26 character frames without scaling each source pixel', async () => {
31
+ const pixels = Array.from({ length: 25 }, () => Array(25).fill(0));
32
+ const { minifyBuffer, metadata } = await Atlas.generateAtlas(
33
+ { colors, frames: { down_idle: Array(26).fill(pixels) } },
34
+ 'character',
35
+ 1,
36
+ );
37
+ expect([metadata.atlasWidth, metadata.atlasHeight]).toEqual([250, 75]);
38
+ expect((await Jimp.read(minifyBuffer)).bitmap.data.byteLength).toBe(75000);
39
+ expect(metadata.frames.down_idle).toHaveLength(26);
40
+ });
41
+
42
+ it('describes the minified render, whatever the upscale factor', async () => {
43
+ const source = { colors, frames: { down_idle: [frame], up_idle: [frame] } };
44
+ const { metadata } = await Atlas.generateAtlas(source, 'scaled', 20);
45
+ expect(metadata.cellPixelDim).toBe(1);
46
+ expect(metadata.upscaleFactor).toBe(20);
47
+ expect(metadata.frames.down_idle[0].width).toBe(2);
48
+ });
49
+
50
+ it('renders the upscaled atlas as an exact multiple of the minified one', async () => {
51
+ const source = { colors, frames: { down_idle: [frame], up_idle: [frame] } };
52
+ const { buffer, minifyBuffer, metadata } = await Atlas.generateAtlas(source, 'scaled', 3);
53
+ const minified = await Jimp.read(minifyBuffer);
54
+ const upscaled = await Jimp.read(buffer);
55
+
56
+ expect([upscaled.bitmap.width, upscaled.bitmap.height]).toEqual([
57
+ metadata.atlasWidth * 3,
58
+ metadata.atlasHeight * 3,
59
+ ]);
60
+ for (let y = 0; y < minified.bitmap.height; y++) {
61
+ for (let x = 0; x < minified.bitmap.width; x++) {
62
+ expect(upscaled.getPixelColor(x * 3, y * 3)).toBe(minified.getPixelColor(x, y));
63
+ expect(upscaled.getPixelColor(x * 3 + 2, y * 3 + 2)).toBe(minified.getPixelColor(x, y));
64
+ }
65
+ }
66
+ });
67
+
68
+ it('rejects clipped frames and invalid dimensions', async () => {
69
+ expect(() => Atlas.packFramesGrid([{ width: 5, height: 1 }], 4)).toThrow('exceed');
70
+ expect(() =>
71
+ Atlas.packFramesGrid(
72
+ [
73
+ { width: 4, height: 3 },
74
+ { width: 4, height: 3 },
75
+ ],
76
+ 4,
77
+ ),
78
+ ).toThrow('exceed');
79
+ await expect(Atlas.generateAtlas({ colors, frames: { down_idle: [frame] } }, 'invalid', 0)).rejects.toThrow(
80
+ 'pixel scale',
81
+ );
82
+ await expect(Atlas.generateAtlas({ colors, frames: { down_idle: [frame] } }, 'invalid', 1, 8192)).rejects.toThrow(
83
+ '4096',
84
+ );
85
+ });
86
+ });
@@ -27,4 +27,38 @@ describe('rerouted plain reads stay machine-readable', () => {
27
27
  },
28
28
  60000,
29
29
  );
30
+
31
+ it.skipIf(!fs.existsSync(new URL(CYBERIA_CLI, repoRoot)))(
32
+ 'keeps an argument whose value is the CLI name through the reroute',
33
+ () => {
34
+ // Regression: the reroute dropped every argv token equal to `underpost`, so an option value
35
+ // that happens to be the CLI name was removed and the parse failed on a missing argument.
36
+ // A redundant name can only precede the command; everything after it is an argument.
37
+ let output = '';
38
+ try {
39
+ output = execFileSync(
40
+ process.execPath,
41
+ [
42
+ CYBERIA_CLI,
43
+ 'fs',
44
+ 'src/client/public/underpost',
45
+ '--deploy-id',
46
+ 'dd-cyberia',
47
+ '--pull',
48
+ '--tracked',
49
+ '--storage-id',
50
+ 'underpost',
51
+ ],
52
+ { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
53
+ );
54
+ } catch (error) {
55
+ output = `${error.stdout ?? ''}${error.stderr ?? ''}`;
56
+ }
57
+
58
+ // Whatever the run goes on to do, it must not fail on the argument it was given.
59
+ expect(output).to.not.include('argument missing');
60
+ expect(output).to.include('Rerouting to underpost cli');
61
+ },
62
+ 60000,
63
+ );
30
64
  });