@underpostnet/cyberia 3.2.80 → 3.3.0

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 (162) hide show
  1. package/.env.example +50 -15
  2. package/.github/workflows/coverall.cyberia.ci.yml +87 -0
  3. package/.github/workflows/cyberia-client.cd.yml +2 -7
  4. package/.github/workflows/cyberia-server.cd.yml +2 -7
  5. package/.github/workflows/docker-image.cyberia-client.ci.yml +4 -4
  6. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +4 -4
  7. package/.github/workflows/docker-image.cyberia-server.ci.yml +4 -4
  8. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +4 -4
  9. package/.github/workflows/docker-image.engine-cyberia.ci.yml +3 -3
  10. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +3 -3
  11. package/.github/workflows/engine-cyberia.cd.yml +3 -67
  12. package/.github/workflows/ghpkg.ci.yml +7 -1
  13. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  14. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  15. package/.github/workflows/release.cd.yml +1 -9
  16. package/CHANGELOG.md +291 -1
  17. package/CLI-HELP.md +174 -23
  18. package/Dockerfile +1 -1
  19. package/Dockerfile.dev +1 -1
  20. package/Dockerfile.test +1 -1
  21. package/bin/build.js +7 -5
  22. package/bin/cyberia.js +272 -98
  23. package/bin/deploy.js +19 -17
  24. package/bin/index.js +272 -98
  25. package/compose.env +50 -15
  26. package/conf.js +2 -0
  27. package/deploy/dd-cyberia/deploy.sh +42 -0
  28. package/deploy/dd-cyberia/init.sh +63 -0
  29. package/deploy/dd-cyberia/sync-deploy.sh +194 -0
  30. package/deploy/lib/logging.sh +96 -0
  31. package/deploy/pwa-microservices-template/deploy.sh +72 -0
  32. package/deploy/release/deploy.sh +62 -0
  33. package/deployment.yaml +1 -210
  34. package/docker-compose.yml +90 -85
  35. package/hardhat/package-lock.json +139 -131
  36. package/hardhat/package.json +4 -4
  37. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
  38. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  39. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
  40. package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -210
  41. package/manifests/deployment/dd-cyberia-development/gateway.yaml +80 -0
  42. package/manifests/deployment/dd-cyberia-development/httproute.yaml +504 -0
  43. package/manifests/deployment/dd-cyberia-development/proxy.yaml +12 -12
  44. package/manifests/deployment/dd-cyberia-development/pv-pvc.yaml +0 -82
  45. package/manifests/deployment/dd-cyberia-development/traffic-service.yaml +121 -0
  46. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  47. package/manifests/deployment/playwright/deployment.yaml +1 -1
  48. package/manifests/mongodb/kustomization.yaml +4 -1
  49. package/manifests/mongodb/statefulset.yaml +4 -0
  50. package/manifests/mongodb/storage-class.yaml +9 -2
  51. package/nginx.conf +86 -16
  52. package/package.json +2 -2
  53. package/proxy.yaml +12 -12
  54. package/pv-pvc.yaml +0 -82
  55. package/scripts/audit-selinux.sh +64 -0
  56. package/scripts/coverall-test-cyberia.sh +24 -0
  57. package/scripts/coverall-test.sh +24 -0
  58. package/scripts/gpu-diag.sh +0 -0
  59. package/scripts/ip-info.sh +0 -0
  60. package/scripts/k3s-node-setup.sh +18 -15
  61. package/scripts/kubeadm-node-setup.sh +12 -23
  62. package/scripts/link-local-underpost-cli.sh +0 -0
  63. package/scripts/lxd-vm-setup.sh +0 -0
  64. package/scripts/maas-nat-firewalld.sh +0 -0
  65. package/scripts/nat-iptables.sh +12 -4
  66. package/scripts/rhel-grpc-setup.sh +0 -0
  67. package/scripts/rocky-kickstart.sh +25 -9
  68. package/scripts/test-monitor.sh +4 -3
  69. package/src/api/cyberia-action/cyberia-action.model.js +1 -0
  70. package/src/api/cyberia-instance/cyberia-fallback-default-items.js +63 -0
  71. package/src/api/cyberia-instance/cyberia-fallback-world.js +49 -15
  72. package/src/api/cyberia-instance/cyberia-instance-map.service.js +8 -12
  73. package/src/api/cyberia-instance/cyberia-instance.controller.js +3 -0
  74. package/src/api/cyberia-instance/cyberia-instance.router.js +13 -0
  75. package/src/api/cyberia-instance/cyberia-instance.service.js +43 -0
  76. package/src/api/cyberia-instance/cyberia-portal-connector.js +7 -5
  77. package/src/api/cyberia-instance/cyberia-random-source.js +80 -0
  78. package/src/api/cyberia-instance/cyberia-world-generator.js +4 -3
  79. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +1 -0
  80. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +87 -1
  81. package/src/api/object-layer/object-layer.model.js +204 -3
  82. package/src/api/object-layer/object-layer.service.js +1 -14
  83. package/src/cli/baremetal.js +1 -2
  84. package/src/cli/cloud-init.js +1 -1
  85. package/src/cli/cluster.js +786 -96
  86. package/src/cli/db.js +11 -4
  87. package/src/cli/deploy.js +1698 -177
  88. package/src/cli/docker-compose.js +19 -178
  89. package/src/cli/env.js +1 -1
  90. package/src/cli/image.js +15 -7
  91. package/src/cli/index.js +245 -44
  92. package/src/cli/ipfs.js +82 -11
  93. package/src/cli/lxd.js +1 -1
  94. package/src/cli/monitor.js +2 -2
  95. package/src/cli/release.js +57 -22
  96. package/src/cli/repository.js +12 -10
  97. package/src/cli/run.js +2195 -427
  98. package/src/cli/secrets.js +969 -0
  99. package/src/cli/ssh.js +206 -105
  100. package/src/cli/system.js +26 -13
  101. package/src/cli/test.js +1 -1
  102. package/src/cli/vultr.js +583 -0
  103. package/src/cli/wireguard.js +2125 -0
  104. package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +368 -0
  105. package/src/client/components/cyberia/InstanceSelectionView.js +11 -8
  106. package/src/client/components/cyberia/SharedDefaultsCyberia.js +5 -0
  107. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +33 -0
  108. package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +4 -0
  109. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +4 -0
  110. package/src/client/public/cyberia-docs/ACTION-SYSTEM.md +106 -39
  111. package/src/client/public/cyberia-docs/ARCHITECTURE.md +35 -3
  112. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +44 -7
  113. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +1 -1
  114. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  115. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  116. package/src/client/services/cyberia-instance/cyberia-instance.service.js +40 -0
  117. package/src/client/services/object-layer/object-layer.management.js +4 -4
  118. package/src/client-builder/client-build.js +102 -13
  119. package/src/client-builder/ssr.js +27 -73
  120. package/src/db/mongo/MongoBootstrap.js +295 -54
  121. package/src/db/mongo/MongooseDB.js +51 -32
  122. package/src/index.js +25 -1
  123. package/src/projects/cyberia/besu-genesis-generator.js +3 -2
  124. package/src/projects/cyberia/catalog-cyberia.js +5 -0
  125. package/src/projects/cyberia/generate-saga.js +14 -23
  126. package/src/projects/cyberia/hot-reload-trigger.js +3 -3
  127. package/src/projects/cyberia/instance-data.js +63 -3
  128. package/src/projects/cyberia/object-layer.js +11 -21
  129. package/src/projects/underpost/catalog-underpost.js +4 -1
  130. package/src/runtime/cyberia-client/Dockerfile +1 -1
  131. package/src/runtime/cyberia-client/Dockerfile.dev +1 -1
  132. package/src/runtime/cyberia-server/Dockerfile +1 -1
  133. package/src/runtime/cyberia-server/Dockerfile.dev +1 -1
  134. package/src/runtime/engine-cyberia/Dockerfile +1 -1
  135. package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
  136. package/src/runtime/engine-cyberia/Dockerfile.test +1 -1
  137. package/src/runtime/engine-cyberia/compose.env +50 -15
  138. package/src/runtime/engine-cyberia/docker-compose.yml +90 -85
  139. package/src/runtime/engine-cyberia/nginx.conf +86 -16
  140. package/src/server/backup.js +1 -1
  141. package/src/server/conf.js +1216 -168
  142. package/src/server/cri.js +70 -0
  143. package/src/server/cron.js +249 -51
  144. package/src/server/dns.js +100 -6
  145. package/src/server/environment.js +98 -0
  146. package/src/server/forward-proxy.js +549 -0
  147. package/src/server/middlewares.js +56 -1
  148. package/src/server/process.js +0 -1
  149. package/src/server/selinux.js +185 -0
  150. package/src/server/systemd.js +205 -0
  151. package/src/server/underpost-compression.js +186 -0
  152. package/src/server/underpost-gateway.js +1083 -0
  153. package/src/server/underpost-ingress.js +380 -0
  154. package/test/cluster-instances.test.js +435 -0
  155. package/test/cyberia-instance-conf-defaults.test.js +1 -0
  156. package/test/deploy-node-placement.test.js +45 -0
  157. package/test/instance-traffic-plan.test.js +710 -0
  158. package/test/selinux.test.js +71 -0
  159. package/test/sops-secret-store.test.js +612 -0
  160. package/test/underpost-gateway.test.js +510 -0
  161. package/test/underpost-ingress.test.js +305 -0
  162. package/test/wireguard-edge.test.js +1177 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Swappable random source for procedural world generation.
3
+ *
4
+ * The world generator and portal connector draw all randomness through
5
+ * `nextRandom()` instead of `Math.random()` directly. By default it IS
6
+ * `Math.random`, so live/editor generation stays random. The fallback world
7
+ * temporarily installs a seeded PRNG so the SAME layout is produced on every
8
+ * call — the instance-map `/static` POIs and the `/preview` image are built by
9
+ * independent HTTP requests (and survive server restarts), so they must agree
10
+ * on where every entity sits.
11
+ *
12
+ * @module src/api/cyberia-instance/cyberia-random-source.js
13
+ */
14
+
15
+ let source = Math.random;
16
+
17
+ /** Draw the next float in [0, 1). Routes through the installed source. */
18
+ const nextRandom = () => source();
19
+
20
+ /**
21
+ * mulberry32 — a small, fast, well-distributed seeded PRNG. Deterministic:
22
+ * the same seed always yields the same sequence.
23
+ * @param {number} seed 32-bit unsigned integer
24
+ * @returns {() => number}
25
+ */
26
+ function mulberry32(seed) {
27
+ let a = seed >>> 0;
28
+ return function () {
29
+ a |= 0;
30
+ a = (a + 0x6d2b79f5) | 0;
31
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
32
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
33
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
34
+ };
35
+ }
36
+
37
+ /** Derive a stable 32-bit seed from a string (FNV-1a). */
38
+ function hashSeed(str) {
39
+ let h = 0x811c9dc5;
40
+ for (let i = 0; i < String(str).length; i++) {
41
+ h ^= String(str).charCodeAt(i);
42
+ h = Math.imul(h, 0x01000193);
43
+ }
44
+ return h >>> 0;
45
+ }
46
+
47
+ /**
48
+ * Install a deterministic seeded source. Accepts a number or a string
49
+ * (hashed to a 32-bit seed). Call `resetRandomSource()` to restore Math.random.
50
+ * @param {number|string} seed
51
+ */
52
+ function seedRandomSource(seed) {
53
+ const s = typeof seed === 'number' ? seed >>> 0 : hashSeed(seed);
54
+ source = mulberry32(s);
55
+ }
56
+
57
+ /** Restore the default `Math.random` source. */
58
+ function resetRandomSource() {
59
+ source = Math.random;
60
+ }
61
+
62
+ /**
63
+ * Run `fn` with a deterministic seeded source, restoring the previous source
64
+ * afterward even if `fn` throws. Returns `fn`'s result.
65
+ * @template T
66
+ * @param {number|string} seed
67
+ * @param {() => T} fn
68
+ * @returns {T}
69
+ */
70
+ function withSeededRandom(seed, fn) {
71
+ const prev = source;
72
+ seedRandomSource(seed);
73
+ try {
74
+ return fn();
75
+ } finally {
76
+ source = prev;
77
+ }
78
+ }
79
+
80
+ export { nextRandom, seedRandomSource, resetRandomSource, withSeededRandom, hashSeed };
@@ -23,6 +23,7 @@ import {
23
23
  } from '../cyberia-server-defaults/cyberia-server-defaults.js';
24
24
 
25
25
  import { DefaultCyberiaItems, ITEM_TYPES } from '../../client/components/cyberia/SharedDefaultsCyberia.js';
26
+ import { nextRandom } from './cyberia-random-source.js';
26
27
 
27
28
  import { PORTAL_MODES, PORTAL_MODE_COLOR_KEY, EXTRA_PORTAL_MODES } from './cyberia-portal-connector.js';
28
29
 
@@ -51,7 +52,7 @@ const findColor = (colors, key) => colors.find((c) => c.key === key);
51
52
  * @param {number} max
52
53
  * @returns {number}
53
54
  */
54
- const randInt = (min, max) => min + Math.floor(Math.random() * (max - min + 1));
55
+ const randInt = (min, max) => min + Math.floor(nextRandom() * (max - min + 1));
55
56
 
56
57
  // ── Entity count ranges ──────────────────────────────────────────────────────
57
58
  // [min, max] — actual count is random within range on each generation call.
@@ -438,7 +439,7 @@ function generatePortalEntities(mapDims, colors, opts = {}) {
438
439
  // First portal is always inter-portal (reserved for the ring topology);
439
440
  // extra portals get a random non-ring subtype.
440
441
  const subtype =
441
- i === 0 ? PORTAL_MODES.INTER_PORTAL : EXTRA_PORTAL_MODES[Math.floor(Math.random() * EXTRA_PORTAL_MODES.length)];
442
+ i === 0 ? PORTAL_MODES.INTER_PORTAL : EXTRA_PORTAL_MODES[Math.floor(nextRandom() * EXTRA_PORTAL_MODES.length)];
442
443
  const portal = generatePortalEntity(mapDims, colors, opts.grid, subtype);
443
444
  if (portal) entities.push(portal);
444
445
  }
@@ -484,7 +485,7 @@ function generateBots(mapDims, colors, opts = {}) {
484
485
 
485
486
  // const skin = BOT_SKIN_POOL[Math.floor(Math.random() * BOT_SKIN_POOL.length)];
486
487
  const skin = 'kishins';
487
- const hasWeapon = Math.random() < BOT_WEAPON_CHANCE;
488
+ const hasWeapon = nextRandom() < BOT_WEAPON_CHANCE;
488
489
  const itemIds = hasWeapon ? [skin, 'atlas_pistol_mk2'] : [skin];
489
490
 
490
491
  entities.push({
@@ -148,6 +148,7 @@ const CyberiaInstanceConfSchema = new Schema(
148
148
  // ── Player defaults ──────────────────────────────────────────────
149
149
  defaultPlayerWidth: { type: Number, default: D.defaultPlayerWidth },
150
150
  defaultPlayerHeight: { type: Number, default: D.defaultPlayerHeight },
151
+ playerBaseSpeed: { type: Number, default: D.playerBaseSpeed },
151
152
  playerBaseLifeRegenMin: { type: Number, default: D.playerBaseLifeRegenMin },
152
153
  playerBaseLifeRegenMax: { type: Number, default: D.playerBaseLifeRegenMax },
153
154
  sumStatsLimit: { type: Number, default: D.sumStatsLimit },
@@ -591,6 +591,76 @@ export const DefaultCyberiaActions = [
591
591
  dialogCode: 'default-agent',
592
592
  questDialogueCodes: [{ questCode: 'bounty-quest-alpha', dialogCode: 'quest-talk-agent' }],
593
593
  },
594
+ {
595
+ // Shop capability: an action carrying shopItems is a vendor. The client
596
+ // surfaces a Shop tab for it and the simulation validates every purchase
597
+ // against this catalog (price item + quantity).
598
+ code: 'loc-fallback-map-0-18-16',
599
+ label: 'Punk',
600
+ sourceMapCode: 'fallback-map-0',
601
+ sourceCellX: 18,
602
+ sourceCellY: 16,
603
+ dialogCode: 'default-punk',
604
+ shopItems: [{ itemId: 'tim-knife', priceItemId: 'coin', priceQty: 10 }],
605
+ },
606
+ {
607
+ // Assembler capability: an action carrying craftRecipes is a fabrication
608
+ // terminal. The client surfaces an Assembly tab for it and the simulation
609
+ // validates every synthesis against these recipes (ingredients → outputs).
610
+ // Every id here is obtainable inside the fallback world — wood from the
611
+ // resource nodes, coin from field drops — so the loop is playable on a
612
+ // fresh spawn with no seeding.
613
+ code: 'loc-fallback-map-0-15-16',
614
+ label: 'Eiri',
615
+ sourceMapCode: 'fallback-map-0',
616
+ sourceCellX: 15,
617
+ sourceCellY: 16,
618
+ dialogCode: 'default-eiri',
619
+ craftRecipes: [
620
+ {
621
+ outputItems: [{ itemId: 'hatchet', qty: 1 }],
622
+ ingredients: [
623
+ { itemId: 'wood-drop-1', qty: 2 },
624
+ { itemId: 'coin', qty: 5 },
625
+ ],
626
+ craftTimeMs: 3000,
627
+ },
628
+ {
629
+ outputItems: [{ itemId: 'tim-knife', qty: 1 }],
630
+ ingredients: [
631
+ { itemId: 'wood-drop-2', qty: 1 },
632
+ { itemId: 'wood-drop-1', qty: 1 },
633
+ { itemId: 'coin', qty: 10 },
634
+ ],
635
+ craftTimeMs: 5000,
636
+ },
637
+ {
638
+ // Multi-output: one blade breaks down into three component stacks, so
639
+ // the assembly UI and the arrival flights are exercised with more than
640
+ // a single result slot. Closes the loop with Punk's shop — buy a knife,
641
+ // salvage it back into parts.
642
+ outputItems: [
643
+ { itemId: 'wood-drop-1', qty: 2 },
644
+ { itemId: 'wood-drop-2', qty: 1 },
645
+ { itemId: 'coin', qty: 5 },
646
+ ],
647
+ ingredients: [{ itemId: 'tim-knife', qty: 1 }],
648
+ craftTimeMs: 2000,
649
+ },
650
+ ],
651
+ },
652
+ {
653
+ // Storage capability: an action carrying storageSlots is a personal vault.
654
+ // The client surfaces a Storage tab whose square grid is sized from the
655
+ // capacity (25 → 5x5) and the simulation owns the contents.
656
+ code: 'loc-fallback-map-0-12-22',
657
+ label: 'Kaneki',
658
+ sourceMapCode: 'fallback-map-0',
659
+ sourceCellX: 12,
660
+ sourceCellY: 22,
661
+ dialogCode: 'default-kaneki',
662
+ storageSlots: 25,
663
+ },
594
664
  {
595
665
  code: 'loc-fallback-map-0-15-22',
596
666
  label: 'Lain',
@@ -839,7 +909,6 @@ export const ENTITY_TYPE_DEFAULTS = Object.freeze([
839
909
  { itemId: 'anon', active: true, quantity: 1 },
840
910
  { itemId: 'atlas_pistol_mk2', active: true, quantity: 1 },
841
911
  { itemId: 'coin', active: false, quantity: 0 },
842
- { itemId: 'eiri', active: false, quantity: 1 },
843
912
  ],
844
913
  },
845
914
  {
@@ -868,6 +937,19 @@ export const ENTITY_TYPE_DEFAULTS = Object.freeze([
868
937
  { entityType: ENTITY_TYPES.bot, liveItemIds: ['wason'], deadItemIds: [DEFAULT_DEAD_ITEM_ID], behavior: 'provider' },
869
938
  { entityType: ENTITY_TYPES.bot, liveItemIds: ['alex'], deadItemIds: [DEFAULT_DEAD_ITEM_ID], behavior: 'provider' },
870
939
  { entityType: ENTITY_TYPES.bot, liveItemIds: ['agent'], deadItemIds: [DEFAULT_DEAD_ITEM_ID], behavior: 'provider' },
940
+ { entityType: ENTITY_TYPES.bot, liveItemIds: ['punk'], deadItemIds: [DEFAULT_DEAD_ITEM_ID], behavior: 'provider' },
941
+ {
942
+ entityType: ENTITY_TYPES.bot,
943
+ liveItemIds: ['eiri'],
944
+ deadItemIds: [DEFAULT_DEAD_ITEM_ID],
945
+ behavior: 'provider-static',
946
+ },
947
+ {
948
+ entityType: ENTITY_TYPES.bot,
949
+ liveItemIds: ['kaneki'],
950
+ deadItemIds: [DEFAULT_DEAD_ITEM_ID],
951
+ behavior: 'provider-static',
952
+ },
871
953
  {
872
954
  entityType: ENTITY_TYPES.bot,
873
955
  liveItemIds: ['lain'],
@@ -954,6 +1036,10 @@ export const CYBERIA_INSTANCE_CONF_DEFAULTS = {
954
1036
  defaultPlayerHeight: 2,
955
1037
  playerBaseLifeRegenMin: 0.5,
956
1038
  playerBaseLifeRegenMax: 1.5,
1039
+ // Movement speed for the player entity only, in grid cells per second. Bots,
1040
+ // projectiles and every other entity keep entityBaseSpeed. 0 falls back to
1041
+ // entityBaseSpeed.
1042
+ playerBaseSpeed: 8,
957
1043
  sumStatsLimit: 500,
958
1044
  maxActiveLayers: 4,
959
1045
  initialLifeFraction: 1.0,
@@ -3,6 +3,8 @@
3
3
  * @module src/api/object-layer/object-layer.model.js
4
4
  * @namespace CyberiaObjectLayerModel
5
5
  */
6
+ import crypto from 'crypto';
7
+ import stringify from 'fast-json-stable-stringify';
6
8
  import { Schema, model } from 'mongoose';
7
9
  /**
8
10
  * @typedef {Object} Stats
@@ -132,8 +134,19 @@ const ObjectLayerSchema = new Schema(
132
134
  toObject: { virtuals: true },
133
135
  },
134
136
  );
135
- // Index for faster querying
136
- ObjectLayerSchema.index({ 'data.item.id': 1 });
137
+ /**
138
+ * Name of the `data.item.id` index. Kept stable across the non-unique → unique
139
+ * upgrade so {@link ObjectLayerModel.ensureUniqueItemIdIndex} can detect and
140
+ * replace the legacy definition instead of leaving two overlapping indexes.
141
+ * @memberof CyberiaObjectLayerModel
142
+ */
143
+ const ITEM_ID_INDEX_NAME = 'data.item.id_1';
144
+ // `data.item.id` is the natural key: exactly one document per item id.
145
+ // autoIndex runs at model-compile time, so on a collection that still holds
146
+ // legacy duplicates (or a legacy non-unique index) this build fails and is
147
+ // logged by the model's `error` listener. ensureUniqueItemIdIndex() then
148
+ // dedupes and rebuilds it, after which autoIndex agrees and stays quiet.
149
+ ObjectLayerSchema.index({ 'data.item.id': 1 }, { name: ITEM_ID_INDEX_NAME, unique: true });
137
150
  ObjectLayerSchema.index({ 'data.item.type': 1 });
138
151
  // Add text index for searchable fields
139
152
  ObjectLayerSchema.index(
@@ -158,6 +171,186 @@ ObjectLayerSchema.pre('save', function () {
158
171
  }
159
172
  // cid (object layer data JSON) and data.render.cid (atlas PNG) are optional – default to ''
160
173
  });
174
+
175
+ /**
176
+ * Computes the canonical SHA-256 of an object layer `data` sub-document using
177
+ * deterministic JSON serialisation. Single source of truth for the hash;
178
+ * `ObjectLayerEngine.computeSha256` delegates here.
179
+ *
180
+ * @param {Object} data - The `data` sub-document (item, stats, ledger, render).
181
+ * @returns {string} Hex-encoded SHA-256 hash.
182
+ * @memberof CyberiaObjectLayerModel
183
+ */
184
+ const computeObjectLayerSha256 = (data) => crypto.createHash('sha256').update(stringify(data)).digest('hex');
185
+
186
+ const isMergeableObject = (value) => {
187
+ if (value === null || typeof value !== 'object') return false;
188
+ const proto = Object.getPrototypeOf(value);
189
+ return proto === Object.prototype || proto === null;
190
+ };
191
+
192
+ /**
193
+ * An incoming attribute only wins when it actually carries a value. `null`,
194
+ * `undefined` and `''` mean "not provided by this writer" — importers emit them
195
+ * whenever a stage is skipped or degraded (IPFS unreachable, atlas not
196
+ * generated, saga metadata with no render yet), and they must never erase a
197
+ * value another writer already persisted. `false` and `0` are real values.
198
+ *
199
+ * @param {*} value - Candidate attribute value.
200
+ * @returns {boolean} Whether the value should override the stored one.
201
+ * @memberof CyberiaObjectLayerModel
202
+ */
203
+ const hasValue = (value) => value !== null && value !== undefined && value !== '';
204
+
205
+ /**
206
+ * Deep-merges `incoming` over `existing`, keeping the last attribute that
207
+ * actually carries a value. Plain objects merge key by key; every other value
208
+ * (scalars, arrays, ObjectIds, Dates, Buffers) is replaced atomically.
209
+ *
210
+ * @param {*} existing - Currently persisted value.
211
+ * @param {*} incoming - Value received from the writer.
212
+ * @returns {*} The merged value.
213
+ * @memberof CyberiaObjectLayerModel
214
+ */
215
+ function mergeObjectLayerData(existing, incoming) {
216
+ if (isMergeableObject(incoming)) {
217
+ if (!isMergeableObject(existing)) return incoming;
218
+ const merged = { ...existing };
219
+ for (const key of Object.keys(incoming)) merged[key] = mergeObjectLayerData(existing[key], incoming[key]);
220
+ return merged;
221
+ }
222
+ return hasValue(incoming) ? incoming : existing;
223
+ }
224
+
225
+ /**
226
+ * Document fields an upsert may carry. `sha256` is excluded on purpose: it is
227
+ * always recomputed from the merged `data`, so it can never drift from the
228
+ * payload the writer hashed before merging.
229
+ * @memberof CyberiaObjectLayerModel
230
+ */
231
+ const UPSERTABLE_FIELDS = ['data', 'cid', 'objectLayerRenderFramesId', 'atlasSpriteSheetId'];
232
+
233
+ /**
234
+ * Collapses any pre-existing duplicates of a given `data.item.id` down to a
235
+ * single document, keeping the oldest one so `_id` references held elsewhere
236
+ * (instances, inventories, atlas metadata) stay valid.
237
+ *
238
+ * @param {import('mongoose').Model} Model - The bound ObjectLayer model.
239
+ * @param {string} itemId - The item id to collapse.
240
+ * @returns {Promise<{ survivor: Object|null, removedIds: Array }>} Survivor and removed document ids.
241
+ * @memberof CyberiaObjectLayerModel
242
+ */
243
+ async function collapseItemIdDuplicates(Model, itemId) {
244
+ const [survivor, ...duplicates] = await Model.find({ 'data.item.id': itemId }).sort({ createdAt: 1, _id: 1 });
245
+ if (!survivor || duplicates.length === 0) return { survivor: survivor || null, removedIds: [] };
246
+
247
+ const removedIds = duplicates.map((duplicate) => duplicate._id);
248
+ await Model.deleteMany({ _id: { $in: removedIds } });
249
+
250
+ // Orphaned render frames have no other cleanup path once their owner is gone.
251
+ const renderFramesIds = duplicates.map((duplicate) => duplicate.objectLayerRenderFramesId).filter(Boolean);
252
+ if (renderFramesIds.length > 0 && Model.db.models.ObjectLayerRenderFrames) {
253
+ await Model.db.models.ObjectLayerRenderFrames.deleteMany({ _id: { $in: renderFramesIds } });
254
+ }
255
+
256
+ return { survivor, removedIds };
257
+ }
258
+
259
+ /**
260
+ * Resolves the canonical document for an item id. While a legacy collection can
261
+ * still hold duplicates, an unsorted `findOne` may hand back a document that the
262
+ * next upsert collapses away, so readers and writers must agree on the same
263
+ * survivor: the oldest one.
264
+ *
265
+ * @param {string} itemId - The `data.item.id` to look up.
266
+ * @returns {import('mongoose').Query} Query resolving to the canonical document or `null`.
267
+ * @memberof CyberiaObjectLayerModel
268
+ */
269
+ ObjectLayerSchema.statics.findByItemId = function (itemId) {
270
+ return this.findOne({ 'data.item.id': itemId }).sort({ createdAt: 1, _id: 1 });
271
+ };
272
+
273
+ /**
274
+ * Dedupes the whole collection by `data.item.id`.
275
+ *
276
+ * @returns {Promise<Array>} Ids of the removed duplicate documents.
277
+ * @memberof CyberiaObjectLayerModel
278
+ */
279
+ ObjectLayerSchema.statics.dedupeByItemId = async function () {
280
+ const duplicated = await this.aggregate([
281
+ { $group: { _id: '$data.item.id', count: { $sum: 1 } } },
282
+ { $match: { count: { $gt: 1 } } },
283
+ ]);
284
+ const removedIds = [];
285
+ for (const { _id: itemId } of duplicated) {
286
+ if (itemId === null || itemId === undefined) continue;
287
+ const { removedIds: removed } = await collapseItemIdDuplicates(this, itemId);
288
+ removedIds.push(...removed);
289
+ }
290
+ return removedIds;
291
+ };
292
+
293
+ /**
294
+ * Idempotent migration that enforces the one-document-per-`data.item.id`
295
+ * invariant at the storage layer: dedupes first, then upgrades the legacy
296
+ * non-unique index to a unique one. Safe to rerun; a no-op once applied.
297
+ *
298
+ * @returns {Promise<{ removedIds: Array, indexUpgraded: boolean }>} Migration outcome.
299
+ * @memberof CyberiaObjectLayerModel
300
+ */
301
+ ObjectLayerSchema.statics.ensureUniqueItemIdIndex = async function () {
302
+ const removedIds = await this.dedupeByItemId();
303
+
304
+ const indexes = await this.collection.indexes().catch(() => []);
305
+ const current = indexes.find((index) => index.name === ITEM_ID_INDEX_NAME);
306
+ if (current?.unique) return { removedIds, indexUpgraded: false };
307
+
308
+ if (current) await this.collection.dropIndex(ITEM_ID_INDEX_NAME);
309
+ await this.collection.createIndex({ 'data.item.id': 1 }, { name: ITEM_ID_INDEX_NAME, unique: true });
310
+ return { removedIds, indexUpgraded: true };
311
+ };
312
+
313
+ /**
314
+ * The single write path for object layers keyed by item id.
315
+ *
316
+ * Guarantees that exactly one document exists per `data.item.id`: pre-existing
317
+ * duplicates are collapsed onto the oldest document, which is then updated in
318
+ * place. Attributes are merged with {@link mergeObjectLayerData}, so a writer
319
+ * that omits a value (or sends `null` / `''` because a stage was skipped) keeps
320
+ * whatever a previous writer stored instead of erasing it. `sha256` is always
321
+ * recomputed from the merged result.
322
+ *
323
+ * @param {Object} payload - Document payload; `data.item.id` is required.
324
+ * @param {Object} payload.data - Object layer data (item, stats, ledger, render).
325
+ * @param {Object} [options] - Upsert options.
326
+ * @param {Object} [options.setOnInsert=null] - Partial document applied only when the item is new,
327
+ * for fields a writer wants to seed but never refresh (mirrors Mongo's `$setOnInsert`).
328
+ * @returns {Promise<Object>} The single surviving ObjectLayer document.
329
+ * @memberof CyberiaObjectLayerModel
330
+ */
331
+ ObjectLayerSchema.statics.upsertByItemId = async function (payload, { setOnInsert = null } = {}) {
332
+ const itemId = payload?.data?.item?.id;
333
+ if (!itemId) throw new Error('ObjectLayer.upsertByItemId requires data.item.id');
334
+
335
+ const { survivor } = await collapseItemIdDuplicates(this, itemId);
336
+
337
+ if (!survivor) {
338
+ const inserted = setOnInsert ? mergeObjectLayerData(setOnInsert, payload) : payload;
339
+ return await this.create({ ...inserted, sha256: computeObjectLayerSha256(inserted.data) });
340
+ }
341
+
342
+ const existing = survivor.toObject({ virtuals: false, depopulate: true });
343
+ const update = {};
344
+ for (const field of UPSERTABLE_FIELDS) {
345
+ if (!(field in payload)) continue;
346
+ const merged = mergeObjectLayerData(existing[field], payload[field]);
347
+ if (merged !== undefined) update[field] = merged;
348
+ }
349
+ update.sha256 = computeObjectLayerSha256(update.data ?? existing.data);
350
+
351
+ return await this.findByIdAndUpdate(survivor._id, { $set: update }, { returnDocument: 'after' });
352
+ };
353
+
161
354
  // Create and export the model
162
355
  const ObjectLayerModel = model('ObjectLayer', ObjectLayerSchema);
163
356
  const ProviderSchema = ObjectLayerSchema;
@@ -197,4 +390,12 @@ class ObjectLayerDto {
197
390
  },
198
391
  };
199
392
  }
200
- export { ObjectLayerSchema, ObjectLayerModel, ProviderSchema, ObjectLayerDto };
393
+ export {
394
+ ObjectLayerSchema,
395
+ ObjectLayerModel,
396
+ ProviderSchema,
397
+ ObjectLayerDto,
398
+ computeObjectLayerSha256,
399
+ mergeObjectLayerData,
400
+ ITEM_ID_INDEX_NAME,
401
+ };
@@ -215,9 +215,6 @@ class ObjectLayerService {
215
215
  }
216
216
  }
217
217
 
218
- // Compute final SHA-256 with all CIDs
219
- bodyData.sha256 = ObjectLayerEngine.computeSha256(bodyData.data);
220
-
221
218
  // Pin data JSON to IPFS
222
219
  try {
223
220
  const itemId = bodyData.data.item.id;
@@ -232,17 +229,7 @@ class ObjectLayerService {
232
229
  }
233
230
 
234
231
  // Atomic create/replace – ObjectLayer is fully populated with all CIDs
235
- let newObjectLayer;
236
- const existingByItemId = await ObjectLayer.findOne({ 'data.item.id': bodyData.data.item.id });
237
- if (existingByItemId) {
238
- newObjectLayer = await ObjectLayer.findByIdAndUpdate(existingByItemId._id, bodyData, {
239
- returnDocument: 'after',
240
- }).populate('objectLayerRenderFramesId');
241
- } else {
242
- newObjectLayer = await (await new ObjectLayer(bodyData).save()).populate('objectLayerRenderFramesId');
243
- }
244
-
245
- return newObjectLayer;
232
+ return await (await ObjectLayer.upsertByItemId(bodyData)).populate('objectLayerRenderFramesId');
246
233
  };
247
234
 
248
235
  /**
@@ -5,9 +5,8 @@
5
5
  */
6
6
 
7
7
  import { fileURLToPath } from 'url';
8
- import { getNpmRootPath, getUnderpostRootPath } from '../server/conf.js';
8
+ import { getNpmRootPath } from '../server/environment.js';
9
9
  import { pbcopy, shellExec } from '../server/process.js';
10
- import dotenv from 'dotenv';
11
10
  import { loggerFactory, loggerMiddleware } from '../server/logger.js';
12
11
  import fs from 'fs-extra';
13
12
  import path from 'path';
@@ -8,7 +8,7 @@
8
8
  import { shellExec } from '../server/process.js';
9
9
  import fs from 'fs-extra';
10
10
  import { loggerFactory } from '../server/logger.js';
11
- import { getNpmRootPath } from '../server/conf.js';
11
+ import { getNpmRootPath } from '../server/environment.js';
12
12
  import Underpost from '../index.js';
13
13
 
14
14
  const logger = loggerFactory(import.meta);