@underpostnet/cyberia 3.2.80 → 3.2.90

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 (96) hide show
  1. package/.env.example +34 -15
  2. package/.github/workflows/cyberia-client.cd.yml +13 -1
  3. package/.github/workflows/cyberia-server.cd.yml +13 -1
  4. package/.github/workflows/docker-image.cyberia-client.ci.yml +4 -4
  5. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +4 -4
  6. package/.github/workflows/docker-image.cyberia-server.ci.yml +4 -4
  7. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +4 -4
  8. package/.github/workflows/docker-image.engine-cyberia.ci.yml +3 -3
  9. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +3 -3
  10. package/.github/workflows/engine-cyberia.cd.yml +14 -3
  11. package/CHANGELOG.md +182 -1
  12. package/CLI-HELP.md +37 -16
  13. package/Dockerfile +1 -1
  14. package/Dockerfile.dev +1 -1
  15. package/Dockerfile.test +1 -1
  16. package/bin/cyberia.js +203 -49
  17. package/bin/deploy.js +18 -16
  18. package/bin/index.js +203 -49
  19. package/compose.env +34 -15
  20. package/deployment.yaml +1 -210
  21. package/docker-compose.yml +73 -62
  22. package/hardhat/package-lock.json +134 -126
  23. package/hardhat/package.json +2 -2
  24. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  25. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  26. package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -210
  27. package/manifests/deployment/dd-cyberia-development/gateway.yaml +80 -0
  28. package/manifests/deployment/dd-cyberia-development/httproute.yaml +504 -0
  29. package/manifests/deployment/dd-cyberia-development/proxy.yaml +12 -12
  30. package/manifests/deployment/dd-cyberia-development/pv-pvc.yaml +0 -82
  31. package/manifests/deployment/dd-cyberia-development/traffic-service.yaml +121 -0
  32. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  33. package/manifests/deployment/playwright/deployment.yaml +1 -1
  34. package/manifests/mongodb/kustomization.yaml +4 -1
  35. package/manifests/mongodb/statefulset.yaml +4 -0
  36. package/manifests/mongodb/storage-class.yaml +9 -2
  37. package/nginx.conf +86 -16
  38. package/package.json +2 -2
  39. package/proxy.yaml +12 -12
  40. package/pv-pvc.yaml +0 -82
  41. package/scripts/nat-iptables.sh +10 -4
  42. package/scripts/test-monitor.sh +4 -3
  43. package/src/api/cyberia-action/cyberia-action.model.js +1 -0
  44. package/src/api/cyberia-instance/cyberia-fallback-world.js +41 -14
  45. package/src/api/cyberia-instance/cyberia-instance-map.service.js +8 -12
  46. package/src/api/cyberia-instance/cyberia-portal-connector.js +7 -5
  47. package/src/api/cyberia-instance/cyberia-random-source.js +80 -0
  48. package/src/api/cyberia-instance/cyberia-world-generator.js +4 -3
  49. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +73 -0
  50. package/src/cli/cluster.js +740 -55
  51. package/src/cli/db.js +2 -2
  52. package/src/cli/deploy.js +1679 -174
  53. package/src/cli/docker-compose.js +19 -178
  54. package/src/cli/image.js +15 -6
  55. package/src/cli/index.js +124 -35
  56. package/src/cli/ipfs.js +82 -11
  57. package/src/cli/monitor.js +1 -1
  58. package/src/cli/repository.js +1 -1
  59. package/src/cli/run.js +2161 -420
  60. package/src/cli/secrets.js +969 -0
  61. package/src/cli/ssh.js +8 -28
  62. package/src/client/components/cyberia/InstanceSelectionView.js +11 -8
  63. package/src/client/components/cyberia/SharedDefaultsCyberia.js +5 -0
  64. package/src/client/public/cyberia-docs/ACTION-SYSTEM.md +106 -39
  65. package/src/client/public/cyberia-docs/ARCHITECTURE.md +18 -0
  66. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +44 -7
  67. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  68. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  69. package/src/client-builder/client-build.js +94 -11
  70. package/src/client-builder/ssr.js +27 -73
  71. package/src/db/mongo/MongoBootstrap.js +295 -54
  72. package/src/db/mongo/MongooseDB.js +47 -32
  73. package/src/index.js +1 -1
  74. package/src/projects/cyberia/besu-genesis-generator.js +3 -2
  75. package/src/projects/cyberia/hot-reload-trigger.js +3 -3
  76. package/src/projects/cyberia/instance-data.js +42 -1
  77. package/src/runtime/cyberia-client/Dockerfile +1 -1
  78. package/src/runtime/cyberia-client/Dockerfile.dev +1 -1
  79. package/src/runtime/cyberia-server/Dockerfile +1 -1
  80. package/src/runtime/cyberia-server/Dockerfile.dev +1 -1
  81. package/src/runtime/engine-cyberia/Dockerfile +1 -1
  82. package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
  83. package/src/runtime/engine-cyberia/Dockerfile.test +1 -1
  84. package/src/runtime/engine-cyberia/compose.env +34 -15
  85. package/src/runtime/engine-cyberia/docker-compose.yml +73 -62
  86. package/src/runtime/engine-cyberia/nginx.conf +86 -16
  87. package/src/server/conf.js +1208 -70
  88. package/src/server/cri.js +70 -0
  89. package/src/server/underpost-gateway.js +1073 -0
  90. package/src/server/underpost-ingress.js +364 -0
  91. package/test/cluster-instances.test.js +435 -0
  92. package/test/deploy-node-placement.test.js +45 -0
  93. package/test/instance-traffic-plan.test.js +710 -0
  94. package/test/sops-secret-store.test.js +612 -0
  95. package/test/underpost-gateway.test.js +469 -0
  96. package/test/underpost-ingress.test.js +253 -0
package/src/cli/ssh.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import { generateRandomPasswordSelection } from '../client/components/core/CommonJs.js';
8
8
  import { pbcopy, shellExec } from '../server/process.js';
9
9
  import { loggerFactory } from '../server/logger.js';
10
+ import { waitForPort } from '../server/conf.js';
10
11
  import fs from 'fs-extra';
11
12
  import Underpost from '../index.js';
12
13
 
@@ -606,8 +607,8 @@ EOF
606
607
  },
607
608
 
608
609
  /**
609
- * Waits until a TCP SSH port becomes reachable on a host.
610
- * @async
610
+ * Waits until a TCP SSH port becomes reachable on a host. Delegates to
611
+ * {@link ServerConfBuilder.waitForPort}, which owns the probe.
611
612
  * @function waitForSshPort
612
613
  * @memberof UnderpostSSH
613
614
  * @param {object} params
@@ -617,25 +618,14 @@ EOF
617
618
  * @param {number} [params.intervalMs=3000] - Poll interval.
618
619
  * @returns {Promise<boolean>} True once the port accepts connections, false on timeout.
619
620
  */
620
- waitForSshPort: async ({ host, port = 22, timeoutMs = 10 * 60 * 1000, intervalMs = 3000 }) => {
621
- const deadline = Date.now() + timeoutMs;
622
- while (Date.now() < deadline) {
623
- const probe = shellExec(
624
- `timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`,
625
- { silent: true, stdout: true, silentOnError: true, disableLog: true },
626
- );
627
- if (`${probe}`.trim() === 'open') return true;
628
- await new Promise((r) => setTimeout(r, intervalMs));
629
- }
630
- logger.warn(`SSH port ${host}:${port} not reachable within timeout`);
631
- return false;
632
- },
621
+ waitForSshPort: ({ host, port = 22, timeoutMs = 10 * 60 * 1000, intervalMs = 3000 }) =>
622
+ waitForPort({ host, port, open: true, timeoutMs, intervalMs }),
633
623
 
634
624
  /**
635
625
  * Waits until a host's SSH port stops accepting connections (e.g. while it
636
626
  * reboots). Used to detect a reboot edge before waiting for the port to come
637
627
  * back up, so callers don't latch onto the pre-reboot (ephemeral) sshd.
638
- * @async
628
+ * Delegates to {@link ServerConfBuilder.waitForPort}.
639
629
  * @function waitForSshPortClosed
640
630
  * @memberof UnderpostSSH
641
631
  * @param {object} params
@@ -645,18 +635,8 @@ EOF
645
635
  * @param {number} [params.intervalMs=3000] - Poll interval.
646
636
  * @returns {Promise<boolean>} True once the port is closed, false on timeout.
647
637
  */
648
- waitForSshPortClosed: async ({ host, port = 22, timeoutMs = 3 * 60 * 1000, intervalMs = 3000 }) => {
649
- const deadline = Date.now() + timeoutMs;
650
- while (Date.now() < deadline) {
651
- const probe = shellExec(
652
- `timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`,
653
- { silent: true, stdout: true, silentOnError: true, disableLog: true },
654
- );
655
- if (`${probe}`.trim() === 'closed') return true;
656
- await new Promise((r) => setTimeout(r, intervalMs));
657
- }
658
- return false;
659
- },
638
+ waitForSshPortClosed: ({ host, port = 22, timeoutMs = 3 * 60 * 1000, intervalMs = 3000 }) =>
639
+ waitForPort({ host, port, open: false, timeoutMs, intervalMs }),
660
640
 
661
641
  /**
662
642
  * Orchestrates a non-interactive, key-only SSH session against a freshly
@@ -26,6 +26,7 @@ import { htmls, s, htmlStrSanitize } from '../core/VanillaJs.js';
26
26
  import { getProxyPath } from '../core/Router.js';
27
27
  import { getApiBaseUrl } from '../../services/core/core.service.js';
28
28
  import { CyberiaInstanceService } from '../../services/cyberia-instance/cyberia-instance.service.js';
29
+ import { DEFAULT_INSTANCE_CODE } from './SharedDefaultsCyberia.js';
29
30
 
30
31
  // Client deployment that actually hosts the playable worlds. Matches the portal
31
32
  // landing CTA (MainBodyCyberiaPortal); override per-instance via `onPlay`.
@@ -33,9 +34,8 @@ const DEFAULT_CLIENT_BASE_URL = 'https://client.cyberiaonline.com';
33
34
 
34
35
  // Authoritative simulation host and the default instance's code, mirroring the
35
36
  // mmo-server multiInstance block in conf.instances.json. The default variant is
36
- // served at the root path; other variants at `/<CODE>`.
37
+ // served at `/`; other variants use their literal `/<CODE>` path.
37
38
  const DEFAULT_SERVER_BASE_URL = 'https://server.cyberiaonline.com';
38
- const DEFAULT_INSTANCE_CODE = 'amethyst-strata-expansion';
39
39
 
40
40
  const placeholderThumbnail = () => `${getProxyPath()}assets/ui-icons/world-default-forest-city.png`;
41
41
 
@@ -95,17 +95,17 @@ const normalizeInstance = (doc, { clientBaseUrl }) => {
95
95
  players: Number.isFinite(players) ? players : null,
96
96
  capacity: Number.isFinite(capacity) && capacity > 0 ? capacity : null,
97
97
  tags: Array.isArray(doc.tags) ? doc.tags.filter(Boolean) : [],
98
- playUrl: `${clientBaseUrl}${code ? `/${encodeURIComponent(code)}` : ''}`,
98
+ playUrl: `${clientBaseUrl}${code && code !== DEFAULT_INSTANCE_CODE ? `/${encodeURIComponent(code)}` : ''}`,
99
99
  playable: statusMeta(status).playable,
100
100
  };
101
101
  };
102
102
 
103
103
  // ── Live status via the cyberia-server metrics API ───────────────────────────
104
- // Each variant is served at server.cyberiaonline.com/<path> '/' for the
105
- // default instance, '/<CODE>' for the rest (the multiInstance variants in
106
- // conf.instances.json). The proxy strips the prefix, so each world's own
107
- // /api/v1/metrics reports its health + capacity. Cross-origin, so the metrics
108
- // CORS allow-list (CYBERIA_CORS_ALLOWED_ORIGINS) must include this portal.
104
+ // The default is served at `/`; other variants use the compact `/<CODE>` paths
105
+ // from conf.instances.json. The proxy strips non-root prefixes, so each world's
106
+ // own /api/v1/metrics reports its health + capacity.
107
+ // Cross-origin, so the metrics CORS allow-list (CYBERIA_CORS_ALLOWED_ORIGINS)
108
+ // must include this portal.
109
109
 
110
110
  const metricsUrlFor = (code, { serverBaseUrl, defaultInstanceCode }) => {
111
111
  const path = code && code !== defaultInstanceCode ? `/${encodeURIComponent(code)}` : '';
@@ -316,6 +316,9 @@ class InstanceSelectionView {
316
316
  });
317
317
  return;
318
318
  }
319
+ const defaultSuffix = `/${encodeURIComponent(DEFAULT_INSTANCE_CODE)}`;
320
+ if (instance.playUrl?.endsWith(defaultSuffix))
321
+ instance.playUrl = instance.playUrl.slice(0, -defaultSuffix.length);
319
322
  if (typeof onPlay === 'function') return onPlay(instance);
320
323
  if (instance.playUrl) location.href = instance.playUrl;
321
324
  };
@@ -50,6 +50,11 @@
50
50
  // Shared content vocabulary
51
51
  // ─────────────────────────────────────────────────────────────────────────────
52
52
 
53
+ /**
54
+ * Default instance code for the Cyberia engine
55
+ */
56
+ export const DEFAULT_INSTANCE_CODE = 'amethyst-strata-expansion';
57
+
53
58
  /**
54
59
  * Canonical set of ObjectLayer item type names. Used as the
55
60
  * `data.item.type` discriminator and as the asset directory name on disk.
@@ -8,7 +8,7 @@
8
8
 
9
9
  The Action System defines how NPC entities interact with players. An **Action** is a spatial, typed payload attached to a map entity that the player activates by tapping the NPC. Actions drive dialogue, shops, crafting, storage, and quest grant events.
10
10
 
11
- > **Implementation status — Alpha (talk / quest-talk):** The CyberiaAction and CyberiaDialogue MongoDB schemas and Engine REST API (`src/api/cyberia-action`, `src/api/cyberia-dialogue`) are defined. The `talk` and `quest-talk` paths are wired end-to-end: the Go server binds actions to entities at instance init, validates dialogue completion, grants quests, and advances `talk` objectives (see **Dialogue Interaction Protocol** below). Shop / craft / storage transaction processing remains planned for a later Alpha increment. The `freeze_start`/`freeze_end` WS messages for modal protection are implemented; dialogue freeze now rides on the `dlg_*` frames.
11
+ > **Implementation status — Alpha (talk / quest-talk / shop):** The CyberiaAction and CyberiaDialogue MongoDB schemas and Engine REST API (`src/api/cyberia-action`, `src/api/cyberia-dialogue`) are defined. The `talk` and `quest-talk` paths are wired end-to-end: the Go server binds actions to entities at instance init, validates dialogue completion, grants quests, and advances `talk` objectives (see **Dialogue Interaction Protocol** below). The `shop` path is wired end-to-end as well (see **Shop Transaction Flow**). Craft / storage transaction processing remains planned for a later Alpha increment. The `freeze_start`/`freeze_end` WS messages for modal protection are implemented; dialogue freeze now rides on the `dlg_*` frames.
12
12
 
13
13
  ---
14
14
 
@@ -18,9 +18,9 @@ The Action System defines how NPC entities interact with players. An **Action**
18
18
 
19
19
  ```
20
20
  CyberiaAction {
21
- code: String // stable unique slug
22
- type: String // see Action Types below
23
- label: String // display label on interaction button
21
+ code: String // stable unique, location-scoped slug
22
+ label: String // NPC overhead nameplate (there is no `type` field —
23
+ // see Action Capabilities below)
24
24
 
25
25
  // Spatial origin — NPC entity cell providing this action
26
26
  sourceMapCode: String
@@ -50,7 +50,7 @@ CyberiaAction {
50
50
  ingredients: [{ itemId: String, qty: Number }]
51
51
  }]
52
52
 
53
- storageSlots: Number // storage capacity (type='storage' only)
53
+ storageSlots: Number // vault capacity in slots; 0 disables the capability
54
54
  }
55
55
  ```
56
56
 
@@ -70,15 +70,19 @@ A single `code` groups many ordered dialogue lines. The C client fetches all lin
70
70
 
71
71
  ---
72
72
 
73
- ## Action Types
73
+ ## Action Capabilities
74
74
 
75
- | Type | Description | Active Payload |
76
- | ------------ | -------------------------------------------------------------- | ---------------------------------------------------- |
77
- | `quest-talk` | Awards the quest bound to its cell (via Take Quest), shows dialogue | quests bound by cell, `dialogCode`, `questDialogueCodes` |
78
- | `talk` | NPC dialogue only — satisfies `talk` quest objectives | `dialogCode`, `questDialogueCodes` |
79
- | `shop` | Item shop — player buys items with in-game currency | `shopItems[]` |
80
- | `craft` | Crafting station consume ingredients to produce output items | `craftRecipes[]` |
81
- | `storage` | Personal item storage vault | `storageSlots` |
75
+ An action has **no type**. Its capabilities are whatever payloads are populated,
76
+ resolved per player at interaction time — one action can be a shop and a
77
+ quest-talk giver at once.
78
+
79
+ | Capability | Active when | Payload |
80
+ | ------------ | --------------------------------------------------------------- | ---------------------------------------------------- |
81
+ | `quest-talk` | CyberiaQuests are bound to this action's cell | quests bound by cell, `dialogCode`, `questDialogueCodes` |
82
+ | `talk` | always — satisfies `talk` quest objectives | `dialogCode`, `questDialogueCodes` |
83
+ | `shop` | `shopItems[]` is non-empty — player buys items with a currency | `shopItems[]` |
84
+ | `craft` | `craftRecipes[]` is non-empty — player assembles outputs | `craftRecipes[]` |
85
+ | `storage` | `storageSlots > 0` — player banks items in a personal vault | `storageSlots` |
82
86
 
83
87
  ---
84
88
 
@@ -109,26 +113,58 @@ graph LR
109
113
 
110
114
  ## Shop Transaction Flow
111
115
 
116
+ An action carrying a non-empty `shopItems[]` is a **vendor** — there is no type
117
+ flag. The catalog reaches the two runtimes on their own transports: the Go
118
+ server receives it with the world over gRPC (`CyberiaActionMessage.shop_items`),
119
+ the C client fetches it by action code over REST and renders the **Shop** tab.
120
+ Only the server prices a purchase.
121
+
122
+ A live vendor also lights the **action-provider** capability bit
123
+ (`InteractionFlagAction`), so it carries the same overhead attention icon,
124
+ orbiting particles, and coloured interaction-column border as a pending
125
+ action-talk — the player can see there is something to do before tapping.
126
+
112
127
  ```mermaid
113
128
  sequenceDiagram
114
- participant P as Player
129
+ participant P as Player (C client)
115
130
  participant G as Go Server
116
131
  participant E as Engine (Node.js)
117
132
 
118
- P->>G: Tap shop NPC
119
- G->>E: GET /api/cyberia-action?sourceMapCode=...&sourceCellX=...
120
- E-->>G: CyberiaAction { type: 'shop', shopItems: [...] }
121
- G-->>P: init_data shop payload (item list + prices)
122
- P-->>G: FrozenInteractionState (modal open)
123
-
124
- P->>G: Buy request { itemId, quantity }
125
- G->>E: GET player coin balance
126
- Note over G: balance >= price * quantity?
127
- G->>E: Deduct coins + grant item to inventory
128
- G-->>P: FCT: CoinLoss + ItemGain events
129
- G-->>P: ThawPlayer (modal close allowed)
133
+ E-->>G: getFullInstance CyberiaAction { shopItems: [...] } (world build)
134
+ G-->>P: AOI bot block → actionCode + action-provider capability bit
135
+ P->>E: GET /api/cyberia-action/code/:code (interaction modal opens)
136
+ E-->>P: CyberiaAction { label, dialogCode, questDialogueCodes, shopItems }
137
+ Note over P: Shop tab leads the strip and opens active.<br/>Two columns of cards: item slot, name,<br/>price icon + qty, Buy (wallet icon)
138
+
139
+ P->>P: Buy quantity picker (◀ / ▶ #N, 1..10, capped by what the player<br/>can pay) with a running total, then Cancel or Buy
140
+ P->>G: shop_buy { entityId, itemId, quantity }
141
+ Note over G: vendor bound to entity? row on sale?<br/>entity inside the player's AOI?<br/>held(priceItemId) >= priceQty × quantity?
142
+ G->>G: FreezePlayer("interact") no kill mid-trade
143
+ G->>G: removePlayerItem(priceItemId, priceQty × quantity)
144
+ G->>G: addPlayerItem(itemId, quantity) + collect-objective reconcile
145
+ G-->>P: shop_ack { entityId, itemId, quantity, ok, reason }
146
+ G-->>P: AOI self-player block → authoritative inventory
147
+ Note over P: the card holds until the grant lands, then the<br/>currency's "-N" pop + expend spray play, and the<br/>item flies from the picker's slot into its<br/>inventory slot ("+N" pop)
130
148
  ```
131
149
 
150
+ The picker deliberately waits for the grant before animating: a first copy has
151
+ no inventory slot until the server delivers it, so launching the flight on the
152
+ button press would aim at the bar's fallback centre instead of the item's own
153
+ slot. Waiting also fixes the ordering — the spend is seen leaving before the
154
+ goods arrive.
155
+
156
+ Binary uplink opcode: `shop_buy` `0x1C` — `[u8 kind][str entityId][str itemId][u8 quantity]`.
157
+ The quantity is clamped server-side to `[1, shopBuyMaxQty]` (10); a client that
158
+ sends 0 means one unit. A purchase is all-or-nothing: an unaffordable total is
159
+ rejected rather than partially filled.
160
+
161
+ Rejection reasons on `shop_ack`: `no_vendor`, `not_for_sale`, `out_of_range`,
162
+ `insufficient_funds`.
163
+
164
+ `shop_ack` is notify-only, and only a rejection is surfaced (as a toast). The
165
+ inventory itself always arrives through the AOI self-player block, so a dropped
166
+ ack costs the player nothing.
167
+
132
168
  ---
133
169
 
134
170
  ## Craft Transaction Flow
@@ -159,11 +195,18 @@ sequenceDiagram
159
195
  ## Dialogue Interaction Protocol (talk / quest-talk)
160
196
 
161
197
  Tapping an interaction bubble opens the Raylib-native **`modal_interact`** modal
162
- (top half of the screen). It has a tab strip — **stack** (active item slots),
163
- **stats** (six-stat stack totals), and **action** (mission interface, shown only
164
- for action-provider entities, ESI 8) over a fixed bottom bar of right-aligned
198
+ (top half of the screen). It has a tab strip — **shop** (vendor catalog, shown
199
+ only when its action carries `shopItems`), **quest** (mission interface, shown
200
+ only when the entity provides quest codes), **stack** (active item slots), and
201
+ **stats** (six-stat stack totals) — over a fixed bottom bar of right-aligned
165
202
  integration buttons (**Chat**, **Integration**) that open the JS overlay. The
166
- action tab's **Talk / Take mission** opens `modal_dialogue` (bottom half).
203
+ paired `modal_dialogue` (bottom half) carries the talk flow.
204
+
205
+ Capability tabs lead the strip, and the leading one opens active — Shop for a
206
+ vendor, else Quest. Because the catalog resolves through an async REST fetch
207
+ after the modal is already open, the active tab keeps tracking the leading
208
+ capability until the player picks a tab themselves. Switching tabs plays a
209
+ pop-in transition, during which content taps are ignored.
167
210
 
168
211
  The client is identical for `talk` and `quest-talk`; the **server** branches after
169
212
  `dlg_complete`. The client never declares the action type, quest code, or quest
@@ -186,6 +229,25 @@ Binary uplink opcodes: `dlg_start` `0x17`, `dlg_complete` `0x18`, `dlg_cancel`
186
229
  client upserts into its local `quest_store` (Quest Journal); it never gates
187
230
  simulation state.
188
231
 
232
+ ### Provider freeze
233
+
234
+ A dialogue is one step of a provider session, not the whole of it: the interact
235
+ modal stays open afterwards with its shop and quest tabs live. So when the
236
+ talked-to entity has a bound `CyberiaAction`, `dlg_complete` / `dlg_cancel`
237
+ re-bridge the freeze to `"interact"` instead of thawing, and `shop_buy` asserts
238
+ the same freeze before it mutates anything. The player therefore cannot be
239
+ killed anywhere inside a provider session, whether or not the client
240
+ re-asserted the freeze itself.
241
+
242
+ The client half holds up its end for as long as a modal is open. `modal_interact`,
243
+ `inventory_modal` and `modal_instance_map` each own a freeze reason
244
+ (`"interact"`, `"inventory"`, `"instance-map"`) and call
245
+ `local_player_keep_freeze()` every frame they stay open — without that renewal
246
+ the 30-second freeze watchdog auto-sends `freeze_end`, and a player browsing a
247
+ shop longer than that would silently become killable. A modal closing over
248
+ another one that still owns a freeze re-bridges to it rather than ending the
249
+ freeze, so there is never a thawed frame between them.
250
+
189
251
  ### Server `dlg_complete` handling
190
252
 
191
253
  1. Validate `player.activeDialogueEntityID == msg.entityId`; drop otherwise.
@@ -292,16 +354,21 @@ The C client fetches the full `code` group sorted by `order`, then renders lines
292
354
 
293
355
  ```json
294
356
  {
295
- "code": "wason-npc",
296
- "type": "quest-talk",
297
- "label": "Talk",
298
- "sourceMapCode": "cyberia-village",
299
- "sourceCellX": 12,
300
- "sourceCellY": 8,
301
- "dialogCode": "default-wason",
302
- "questDialogueCodes": ["default-wason"],
303
- "shopItems": [],
357
+ "code": "loc-fallback-map-0-18-16",
358
+ "label": "Punk",
359
+ "sourceMapCode": "fallback-map-0",
360
+ "sourceCellX": 18,
361
+ "sourceCellY": 16,
362
+ "dialogCode": "default-punk",
363
+ "questDialogueCodes": [],
364
+ "shopItems": [{ "itemId": "tim-knife", "priceItemId": "coin", "priceQty": 10 }],
304
365
  "craftRecipes": [],
305
366
  "storageSlots": 0
306
367
  }
307
368
  ```
369
+
370
+ This is the vendor shipped in the canonical defaults
371
+ (`DefaultCyberiaActions`): the `punk`-skinned NPC on `fallback-map-0` at
372
+ (18, 16) sells `tim-knife` for 10 coins. `bin/cyberia run-workflow
373
+ seed-actions-quests` upserts it; the procedural fallback world serves it
374
+ unpersisted.
@@ -133,6 +133,24 @@ Underpost Platform deploy orchestration ensures the backend layer is ready befor
133
133
 
134
134
  ---
135
135
 
136
+ ## Edge tier
137
+
138
+ Every `cyberiaonline.com` hostname is served through one Envoy Gateway data plane over HTTP/1.1, HTTP/2 and HTTP/3 (QUIC), with TLS terminated per hostname by SNI. In development the certificates are self-signed and locally trusted, and the hostnames are mapped in `/etc/hosts`, so a browser reaches the real routing stack rather than a dev proxy.
139
+
140
+ Status pages never reach the engine at all, and the engine knows nothing about them. All three runtimes are agnostic: they return a standard HTTP status code or become unreachable. `underpost-gateway` — one shared Nginx workload in the gateway tier — proxies the site paths and intercepts those statuses, serving the declared document with the original URI and the original status code.
141
+
142
+ | Condition | Declared by | Answered from |
143
+ | --------------------------- | --------------------------------------- | -------------------------------------------------------- |
144
+ | `404` on any unmatched path | a `CyberiaPortal` view with path `/404` | `www.cyberiaonline.com/root/status-pages/404/index.html` |
145
+ | `502` / `503` / `504` | the view flagged `maintenanceDefault` | `www.cyberiaonline.com/root/maintenance/index.html` |
146
+ | `/offline`, `/maintenance` | `offlineDefault` / `maintenanceDefault` | the matching context directory |
147
+
148
+ A request to an unknown path reaches the portal, which answers a bare 404; the gateway swaps in the page. The address bar keeps the path the player typed, and the response is a true 404 — no redirect, no client-side script, and nothing for `engine-cyberia`, `cyberia-server` or `cyberia-client` to implement. API sub-paths bypass the interception, so a JSON 404 stays JSON.
149
+
150
+ Per-instance hosts follow the same layout under their own sub-path — `client.cyberiaonline.com/FOREST/status-pages/404/index.html` — from the `customStatusPages` entries in `conf.instances.json`. Both configuration files live in `engine-private/`, which is a private repository: expect the layout to be referenced without assuming the files are present locally.
151
+
152
+ ---
153
+
136
154
  ## Tick model
137
155
 
138
156
  The tick is the universal coordinate of the simulation. Every server→client snapshot and every client→server input command carries a tick value.
@@ -176,14 +176,14 @@ cyberia chain unpause [--network besu-k8s]
176
176
 
177
177
  Named scripts from the `scripts/` directory for seeding and build maintenance.
178
178
 
179
- | Subcommand | Description |
180
- | ---------------------------- | ----------------------------------------------------------------------------------- |
179
+ | Subcommand | Description |
180
+ | ---------------------------- | -------------------------------------------------------------------------------------- |
181
181
  | `import-default-items` | Import default object layers, skills, dialogues, actions/quests, client-hints to Mongo |
182
- | `seed-skills` | Upsert `DefaultSkillConfig` into the `cyberia-skill` collection (full records) |
183
- | `seed-dialogues` | Upsert `DefaultCyberiaDialogues` into the `cyberia-dialogue` collection |
184
- | `generate-semantic-examples` | Generate one procedural example per registered semantic prefix |
185
- | `build-manifest` | Build K8s Deployment + Service manifests for mmo-client / mmo-server |
186
- | `build-server-dashboard` | Build the static cyberia-server metrics/status dashboard (`--dev`, `--output-path`) |
182
+ | `seed-skills` | Upsert `DefaultSkillConfig` into the `cyberia-skill` collection (full records) |
183
+ | `seed-dialogues` | Upsert `DefaultCyberiaDialogues` into the `cyberia-dialogue` collection |
184
+ | `generate-semantic-examples` | Generate one procedural example per registered semantic prefix |
185
+ | `build-manifest` | Build K8s Deployment + Service manifests for mmo-client / mmo-server |
186
+ | `build-server-dashboard` | Build the static cyberia-server metrics/status dashboard (`--dev`, `--output-path`) |
187
187
 
188
188
  ```bash
189
189
  cyberia run-workflow import-default-items --env-path ./engine-private/conf/dd-cyberia/.env.development
@@ -195,6 +195,43 @@ cyberia run-workflow build-server-dashboard
195
195
 
196
196
  ---
197
197
 
198
+ ## Bringing up the full stack locally
199
+
200
+ ```bash
201
+ node bin run cluster 'express,dd-cyberia' --dev
202
+ ```
203
+
204
+ One command, no extra flags. It resets and rebuilds the node, deploys MongoDB / IPFS / Valkey, imports each database from its git backup, installs the Gateway API control plane, and deploys `dd-cyberia` behind it.
205
+
206
+ What `--dev` implies, rather than requiring you to pass it:
207
+
208
+ - **Gateway API + Envoy Gateway**, with **HTTP/3 (QUIC) on by default** beside HTTP/2 and HTTP/1.1.
209
+ - **Self-signed, locally trusted TLS** for every hostname in `conf.server.json`, plus the matching `/etc/hosts` entries — so a local Chromium reaches `https://www.cyberiaonline.com` through the real data plane.
210
+ - **The gateway static tier seeded** with the portal's `/404`, `/offline` and `/maintenance` documents before the routes are applied, then refreshed from the running container once it is Ready. See [Architecture → Edge tier](./ARCHITECTURE.md).
211
+
212
+ The run ends with a gateway status report: listener and route conditions, the workloads behind them, and an HTTPS probe of every route hostname.
213
+
214
+ ### With the MMO services
215
+
216
+ The optional third path segment brings up custom instances from `engine-private/conf/dd-cyberia/conf.instances.json` in the same run:
217
+
218
+ ```bash
219
+ node bin run cluster 'express,dd-cyberia,mmo-server' --dev
220
+ node bin run cluster 'express,dd-cyberia,mmo-server+mmo-client' --dev
221
+ ```
222
+
223
+ Each id runs only where `dd-cyberia` declares it, and only once the portal workload has rolled out — `cyberia-server` dials the engine's gRPC ClusterIP for its world configuration at boot, so the content authority has to be serving first. `mmo-server` names the whole variant family (`amethyst-strata-expansion`, `FOREST`, `TEST`); `mmo-server-forest` names one variant.
224
+
225
+ `server.cyberiaonline.com` and `client.cyberiaonline.com` are issued the same self-signed certificates as the portal hosts and written into the same `/etc/hosts` pass, so the three services are reachable over TLS from a local browser without further setup. In production the same segment issues cert-manager certificates instead.
226
+
227
+ To place the static documents again without redeploying — after rebuilding the portal client, for instance:
228
+
229
+ ```bash
230
+ node bin deploy dd-cyberia development --sync-static --gateway-api --kubeadm
231
+ ```
232
+
233
+ ---
234
+
198
235
  ## Operational rules
199
236
 
200
237
  - Preserve public CLI entrypoints and command names unless a change is intentionally breaking.
@@ -1,6 +1,6 @@
1
1
  # Cyberia Online — Development Roadmap
2
2
 
3
- **Current version:** 3.2.80 | **Target milestone:** Open Alpha
3
+ **Current version:** 3.2.90 | **Target milestone:** Open Alpha
4
4
 
5
5
  ---
6
6
 
@@ -18,7 +18,7 @@ _Stackable Rendering Layers as a Unified Tokenized Reality_
18
18
 
19
19
  ---
20
20
 
21
- **Version:** 3.2.80 | **Status:** Draft | **Authors:** Underpost Engineering
21
+ **Version:** 3.2.90 | **Status:** Draft | **Authors:** Underpost Engineering
22
22
 
23
23
  ---
24
24
 
@@ -23,12 +23,81 @@ import { shellExec } from '../server/process.js';
23
23
  import { SitemapStream, streamToPromise } from 'sitemap';
24
24
  import { Readable } from 'stream';
25
25
  import { buildIcons } from './client-icons.js';
26
+ import { statusPageBuildSegment } from '../server/underpost-gateway.js';
26
27
  import Underpost from '../index.js';
27
28
  import { buildDocs } from './client-build-docs.js';
28
29
  import { ssrFactory } from './ssr.js';
29
30
 
30
31
  // Static Site Generation (SSG)
31
32
 
33
+ const STATUS_PAGE_VIEW_PATH = /^\/([1-5]\d{2})$/;
34
+
35
+ // Views a route intercepts before the workload sees the request. They are
36
+ // declared by the flag that already marks them as the app's default for that
37
+ // condition, so a new one becomes edge-served by adding its flag here rather
38
+ // than by naming its path in a second place.
39
+ const INTERCEPT_VIEW_FLAGS = ['maintenanceDefault', 'offlineDefault'];
40
+
41
+ /**
42
+ * Resolves the SSR views that render an HTTP status page into the static
43
+ * artifacts they build to. A view is a status page when its route path is a
44
+ * bare status code (`/404`, `/500`, `/503`), which the build writes to
45
+ * `<path>/index.html` inside the served bundle.
46
+ *
47
+ * Single source of truth for PWA status-page routing: this build writes the
48
+ * artifact, and `deploy --build-manifest` points its HTTPRoute rules at the
49
+ * same resolved URL — neither side hardcodes a status code.
50
+ * @function statusPageRoutesFactory
51
+ * @param {Array<object>} [views] - SSR view entries from `conf.ssr.json`.
52
+ * @param {string} [proxyPath] - The client's proxy sub-path (`/`, `/peer`, ...).
53
+ * @returns {Array<{status: string, routePath: string, indexUrl: string, title: string, client: string}>}
54
+ * One entry per status view, in declaration order.
55
+ * @memberof clientBuild
56
+ */
57
+ const statusPageRoutesFactory = ({ views = [], proxyPath = '/' } = {}) => {
58
+ const prefix = !proxyPath || proxyPath === '/' ? '' : proxyPath.replace(/\/$/, '');
59
+ return (Array.isArray(views) ? views : [])
60
+ .map((view) => ({ view, status: STATUS_PAGE_VIEW_PATH.exec(view?.path || '')?.[1] }))
61
+ .filter(({ status }) => status !== undefined)
62
+ .map(({ view, status }) => ({
63
+ status,
64
+ routePath: `${prefix}${view.path}`,
65
+ indexUrl: `${prefix}${view.path}/index.html`,
66
+ title: view.title,
67
+ client: view.client,
68
+ }));
69
+ };
70
+
71
+ /**
72
+ * Resolves the SSR views a gateway route intercepts and serves statically —
73
+ * the maintenance and offline documents. They carry no request-time logic, so
74
+ * the workload never needs to see them; the same build that writes the artifact
75
+ * hands `deploy --build-manifest` the URL its HTTPRoute rule targets.
76
+ * @function staticContextRoutesFactory
77
+ * @param {Array<object>} [views] - SSR view entries from `conf.ssr.json`.
78
+ * @param {string} [proxyPath] - The client's proxy sub-path (`/`, `/peer`, ...).
79
+ * @returns {Array<{context: string, routePath: string, indexUrl: string, title: string, client: string}>}
80
+ * One entry per intercepted view, in declaration order.
81
+ * @memberof clientBuild
82
+ */
83
+ const staticContextRoutesFactory = ({ views = [], proxyPath = '/' } = {}) => {
84
+ const prefix = !proxyPath || proxyPath === '/' ? '' : proxyPath.replace(/\/$/, '');
85
+ return (Array.isArray(views) ? views : [])
86
+ .filter(
87
+ (view) =>
88
+ view?.path &&
89
+ !STATUS_PAGE_VIEW_PATH.test(view.path) &&
90
+ INTERCEPT_VIEW_FLAGS.some((flag) => view[flag] === true),
91
+ )
92
+ .map((view) => ({
93
+ context: view.path.replace(/^\/+|\/+$/g, ''),
94
+ routePath: `${prefix}${view.path}`,
95
+ indexUrl: `${prefix}${view.path}/index.html`,
96
+ title: view.title,
97
+ client: view.client,
98
+ }));
99
+ };
100
+
32
101
  /**
33
102
  * Recursively copies files from source to destination, but only files that don't exist in destination.
34
103
  * @function copyNonExistingFiles
@@ -742,14 +811,12 @@ const buildClient = async (
742
811
  }
743
812
 
744
813
  if (views) {
745
- if (
746
- !(
747
- enableLiveRebuild &&
748
- !options.liveClientBuildPaths.find(
749
- (p) => p.srcBuildPath.startsWith(`./src/client/ssr`) || p.srcBuildPath.slice(-9) === '.index.js',
750
- )
814
+ if (!(
815
+ enableLiveRebuild &&
816
+ !options.liveClientBuildPaths.find(
817
+ (p) => p.srcBuildPath.startsWith(`./src/client/ssr`) || p.srcBuildPath.slice(-9) === '.index.js',
751
818
  )
752
- )
819
+ ))
753
820
  for (const view of views) {
754
821
  const buildPath = `${
755
822
  rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath
@@ -977,6 +1044,8 @@ Sitemap: ${sitemapBaseUrl}/sitemap.xml`,
977
1044
  // when the network is unreachable.
978
1045
  const ssrClientConf = confSSR[getCapVariableName(client)] || {};
979
1046
  const ssrViews = Array.isArray(ssrClientConf.views) ? ssrClientConf.views : [];
1047
+ const statusPageRoutes = statusPageRoutesFactory({ views: ssrViews, proxyPath: path });
1048
+ if (statusPageRoutes.length > 0) logger.info('ssr status page routes', statusPageRoutes);
980
1049
  const PRE_CACHED_RESOURCES = [];
981
1050
  let offlineFallbackUrl = null;
982
1051
  let maintenanceFallbackUrl = null;
@@ -999,9 +1068,16 @@ Sitemap: ${sitemapBaseUrl}/sitemap.xml`,
999
1068
  renderApi: { JSONweb },
1000
1069
  });
1001
1070
 
1002
- const buildPath = `${
1003
- rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath
1004
- }${view.path === '/' ? view.path : `${view.path}/`}`;
1071
+ // A status view is built under `status-pages/<status>/`, not on its own
1072
+ // `/<status>` route: the gateway serves it by intercepting the
1073
+ // runtime's error, and a document on that route would give the runtime
1074
+ // a page of its own to serve or redirect to for the same condition.
1075
+ const statusCode = statusPageRoutes.find((route) => route.routePath === `${proxyPrefix}${view.path}`)?.status;
1076
+ const clientRoot =
1077
+ rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath;
1078
+ const buildPath = statusCode
1079
+ ? `${clientRoot}/${dir.dirname(statusPageBuildSegment(statusCode))}/`
1080
+ : `${clientRoot}${view.path === '/' ? view.path : `${view.path}/`}`;
1005
1081
 
1006
1082
  const indexUrl = buildIndexUrl(view.path);
1007
1083
  if (view.offlineDefault) {
@@ -1089,4 +1165,11 @@ ${swTransformedJs}`,
1089
1165
  }
1090
1166
  };
1091
1167
 
1092
- export { buildClient, copyNonExistingFiles, unzipClientBuild, mergeClientBuildZip };
1168
+ export {
1169
+ buildClient,
1170
+ copyNonExistingFiles,
1171
+ unzipClientBuild,
1172
+ mergeClientBuildZip,
1173
+ staticContextRoutesFactory,
1174
+ statusPageRoutesFactory,
1175
+ };