cyberia 3.2.22 → 3.2.70

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 (185) hide show
  1. package/.env.example +127 -68
  2. package/.github/workflows/cyberia-client.cd.yml +40 -0
  3. package/.github/workflows/cyberia-server.cd.yml +40 -0
  4. package/.github/workflows/docker-image.cyberia-client.ci.yml +49 -0
  5. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +48 -0
  6. package/.github/workflows/docker-image.cyberia-server.ci.yml +69 -0
  7. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +69 -0
  8. package/.github/workflows/docker-image.engine-cyberia.ci.yml +52 -0
  9. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +52 -0
  10. package/.github/workflows/engine-cyberia.cd.yml +33 -24
  11. package/.github/workflows/engine-cyberia.ci.yml +13 -3
  12. package/.github/workflows/ghpkg.ci.yml +88 -1
  13. package/.github/workflows/gitlab.ci.yml +1 -1
  14. package/.github/workflows/hardhat.ci.yml +1 -1
  15. package/.github/workflows/npmpkg.ci.yml +10 -7
  16. package/.github/workflows/publish.ci.yml +2 -2
  17. package/.github/workflows/publish.cyberia.ci.yml +5 -16
  18. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -8
  19. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  20. package/CHANGELOG.md +301 -1
  21. package/CLI-HELP.md +71 -6
  22. package/Dockerfile +141 -43
  23. package/Dockerfile.dev +143 -0
  24. package/Dockerfile.test +165 -0
  25. package/README.md +1 -1
  26. package/baremetal/commission-workflows.json +1 -0
  27. package/bin/build.js +31 -0
  28. package/bin/cyberia.js +1080 -184
  29. package/bin/deploy.js +2 -2
  30. package/bin/index.js +1080 -184
  31. package/bump.config.js +1 -0
  32. package/compose.env +131 -0
  33. package/conf.js +18 -1
  34. package/deployment.yaml +2 -2
  35. package/docker-compose.yml +316 -0
  36. package/hardhat/hardhat.config.js +2 -2
  37. package/hardhat/package-lock.json +620 -2041
  38. package/hardhat/package.json +7 -5
  39. package/hardhat/scripts/deployObjectLayerToken.js +18 -18
  40. package/hardhat/test/ObjectLayerToken.js +378 -274
  41. package/ipfs/configure-ipfs.sh +13 -0
  42. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  43. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  44. package/manifests/deployment/dd-cyberia-development/deployment.yaml +2 -2
  45. package/manifests/deployment/dd-cyberia-development/grpc-service.yaml +17 -0
  46. package/manifests/deployment/dd-cyberia-development/pv-pvc.yaml +32 -0
  47. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  48. package/mongodb/entrypoint.sh +76 -0
  49. package/nginx.conf +87 -0
  50. package/package.json +31 -19
  51. package/pv-pvc.yaml +32 -0
  52. package/scripts/disk-clean.sh +85 -60
  53. package/scripts/kubeadm-node-setup.sh +317 -0
  54. package/scripts/rocky-kickstart.sh +877 -185
  55. package/scripts/rpmfusion-ffmpeg-setup.sh +26 -50
  56. package/scripts/test-monitor.sh +3 -5
  57. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +43 -7
  58. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +17 -25
  59. package/src/api/cyberia-action/cyberia-action.controller.js +19 -0
  60. package/src/api/cyberia-action/cyberia-action.model.js +21 -29
  61. package/src/api/cyberia-action/cyberia-action.router.js +42 -7
  62. package/src/api/cyberia-action/cyberia-action.service.js +20 -4
  63. package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +58 -57
  64. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +37 -6
  65. package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +8 -2
  66. package/src/api/cyberia-entity/cyberia-entity.router.js +39 -7
  67. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +74 -0
  68. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +67 -0
  69. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +63 -0
  70. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +46 -0
  71. package/src/api/cyberia-instance/cyberia-fallback-world.js +62 -10
  72. package/src/api/cyberia-instance/cyberia-instance.model.js +32 -2
  73. package/src/api/cyberia-instance/cyberia-instance.router.js +6 -6
  74. package/src/api/cyberia-instance/cyberia-world-generator.js +116 -3
  75. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +3 -0
  76. package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +39 -7
  77. package/src/api/cyberia-map/cyberia-map.router.js +21 -6
  78. package/src/api/cyberia-quest/cyberia-quest.controller.js +38 -0
  79. package/src/api/cyberia-quest/cyberia-quest.router.js +47 -7
  80. package/src/api/cyberia-quest/cyberia-quest.service.js +59 -4
  81. package/src/api/cyberia-saga/cyberia-saga.controller.js +74 -0
  82. package/src/api/cyberia-saga/cyberia-saga.model.js +59 -0
  83. package/src/api/cyberia-saga/cyberia-saga.router.js +63 -0
  84. package/src/api/cyberia-saga/cyberia-saga.service.js +42 -0
  85. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +551 -129
  86. package/src/api/cyberia-skill/cyberia-skill.controller.js +74 -0
  87. package/src/api/cyberia-skill/cyberia-skill.model.js +50 -0
  88. package/src/api/cyberia-skill/cyberia-skill.router.js +63 -0
  89. package/src/api/cyberia-skill/cyberia-skill.service.js +42 -0
  90. package/src/api/ipfs/ipfs.service.js +28 -15
  91. package/src/api/object-layer/object-layer.router.js +25 -15
  92. package/src/api/object-layer/object-layer.service.js +15 -20
  93. package/src/api/object-layer-render-frames/object-layer-render-frames.router.js +39 -7
  94. package/src/cli/baremetal.js +717 -16
  95. package/src/cli/cluster.js +80 -5
  96. package/src/cli/deploy.js +127 -11
  97. package/src/cli/docker-compose.js +648 -0
  98. package/src/cli/env.js +11 -2
  99. package/src/cli/fs.js +75 -30
  100. package/src/cli/image.js +129 -51
  101. package/src/cli/index.js +110 -6
  102. package/src/cli/kickstart.js +142 -20
  103. package/src/cli/release.js +46 -4
  104. package/src/cli/repository.js +238 -33
  105. package/src/cli/run.js +520 -114
  106. package/src/cli/secrets.js +82 -48
  107. package/src/cli/ssh.js +234 -0
  108. package/src/cli/static.js +2 -2
  109. package/src/client/components/cyberia/ActionEngineCyberia.js +1867 -0
  110. package/src/client/components/cyberia/EntityEngineCyberia.js +585 -0
  111. package/src/client/components/cyberia/InstanceEngineCyberia.js +219 -13
  112. package/src/client/components/cyberia/MapEngineCyberia.js +154 -71
  113. package/src/client/components/cyberia/ObjectLayerEngineModal.js +40 -63
  114. package/src/client/components/cyberia/ObjectLayerEngineViewer.js +34 -20
  115. package/src/client/components/cyberia/SharedDefaultsCyberia.js +195 -4
  116. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +66 -0
  117. package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +8 -0
  118. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +8 -0
  119. package/src/client/public/cyberia-docs/ACTION-SYSTEM.md +45 -27
  120. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +3 -3
  121. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +39 -22
  122. package/src/client/public/cyberia-docs/CYBERIA-LORE.md +88 -0
  123. package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +394 -0
  124. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +8 -1
  125. package/src/client/public/cyberia-docs/CYBERIA.md +1 -1
  126. package/src/client/public/cyberia-docs/QUEST-SYSTEM.md +5 -5
  127. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  128. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  129. package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +99 -0
  130. package/src/client/services/cyberia-instance/cyberia-instance.management.js +2 -2
  131. package/src/client/services/cyberia-map/cyberia-map.management.js +2 -2
  132. package/src/client/services/cyberia-saga/cyberia-saga.service.js +99 -0
  133. package/src/client/services/cyberia-skill/cyberia-skill.service.js +99 -0
  134. package/src/client/services/object-layer/object-layer.management.js +6 -6
  135. package/src/{server → client-builder}/client-build-docs.js +15 -5
  136. package/src/{server → client-builder}/client-build-live.js +3 -3
  137. package/src/{server → client-builder}/client-build.js +25 -22
  138. package/src/{server → client-builder}/client-dev-server.js +3 -3
  139. package/src/{server → client-builder}/client-icons.js +2 -2
  140. package/src/{server → client-builder}/ssr.js +5 -5
  141. package/src/client.build.js +1 -3
  142. package/src/client.dev.js +1 -1
  143. package/src/db/mongo/MongoBootstrap.js +12 -12
  144. package/src/grpc/cyberia/grpc-server.js +255 -70
  145. package/src/index.js +12 -1
  146. package/src/mailer/EmailRender.js +1 -1
  147. package/src/{server → projects/cyberia}/atlas-sprite-sheet-generator.js +2 -2
  148. package/src/{server → projects/cyberia}/besu-genesis-generator.js +3 -3
  149. package/src/projects/cyberia/catalog-cyberia.js +81 -0
  150. package/src/projects/cyberia/gemini-client.js +175 -0
  151. package/src/projects/cyberia/generate-saga.js +2107 -0
  152. package/src/{server → projects/cyberia}/ipfs-client.js +2 -2
  153. package/src/{server → projects/cyberia}/object-layer.js +12 -108
  154. package/src/{server → projects/cyberia}/semantic-layer-generator-floor.js +1 -1
  155. package/src/{server → projects/cyberia}/semantic-layer-generator-resource.js +1 -1
  156. package/src/{server → projects/cyberia}/semantic-layer-generator-skin.js +1 -1
  157. package/src/{server → projects/cyberia}/semantic-layer-generator.js +2 -2
  158. package/src/{server → projects/cyberia}/shape-generator.js +2 -2
  159. package/src/{server → projects/underpost}/catalog-underpost.js +1 -2
  160. package/src/runtime/cyberia-client/Dockerfile +27 -61
  161. package/src/runtime/cyberia-client/Dockerfile.dev +20 -12
  162. package/src/runtime/cyberia-server/Dockerfile +21 -20
  163. package/src/runtime/cyberia-server/Dockerfile.dev +17 -8
  164. package/src/runtime/engine-cyberia/Dockerfile +143 -0
  165. package/src/runtime/engine-cyberia/Dockerfile.dev +143 -0
  166. package/src/runtime/engine-cyberia/Dockerfile.test +165 -0
  167. package/src/runtime/engine-cyberia/compose.env +131 -0
  168. package/src/runtime/engine-cyberia/docker-compose.yml +316 -0
  169. package/src/runtime/engine-cyberia/ipfs/configure-ipfs.sh +13 -0
  170. package/src/runtime/engine-cyberia/mongodb/entrypoint.sh +76 -0
  171. package/src/runtime/engine-cyberia/nginx.conf +87 -0
  172. package/src/runtime/express/Express.js +2 -2
  173. package/src/runtime/nginx/Nginx.js +250 -0
  174. package/src/server/catalog.js +9 -14
  175. package/src/server/conf.js +3 -6
  176. package/src/server/runtime-status.js +18 -1
  177. package/src/server/start.js +12 -2
  178. package/src/server.js +6 -2
  179. package/test/cyberia-instance-conf-defaults.test.js +140 -0
  180. package/test/deploy-monitor.test.js +26 -10
  181. package/test/shape-generator.test.js +7 -1
  182. package/typedoc.dd-cyberia.json +3 -1
  183. package/typedoc.json +3 -1
  184. /package/src/client/ssr/{Render.js → RootDocument.js} +0 -0
  185. /package/src/{server → client-builder}/client-formatted.js +0 -0
@@ -0,0 +1,2107 @@
1
+ /**
2
+ * Top-Down Procedural Generation guided by LLMs (Semantic Reverse-Engineering).
3
+ *
4
+ * Takes a single natural-language theme and asks Google Gemini to infer the
5
+ * complete non-spatial textual backbone of a CyberiaSaga ecosystem: saga
6
+ * metadata, quests, dialogues, actions, and object-layer item definitions.
7
+ *
8
+ * Architectural boundary: this stage owns TEXT and LOGICAL METADATA only. Every
9
+ * spatial / rendering field (initCellX/Y, map grid sizes, asset CIDs, render
10
+ * frames) is forced to null / empty / default here. Spatial + graphic synthesis
11
+ * is a separate downstream stage.
12
+ *
13
+ * @module src/projects/cyberia/generate-saga.js
14
+ * @namespace CyberiaSagaGenerator
15
+ */
16
+
17
+ import fs from 'fs-extra';
18
+ import nodePath from 'path';
19
+ import crypto from 'crypto';
20
+ import { GeminiClient } from './gemini-client.js';
21
+ import { computeSha256 } from './object-layer.js';
22
+ import { loggerFactory } from '../../server/logger.js';
23
+
24
+ const logger = loggerFactory(import.meta);
25
+
26
+ /** Canonical Cyberia base-lore document, passed to the model when auto-generating a theme. */
27
+ const DEFAULT_LORE_PATH = 'src/client/public/cyberia-docs/CYBERIA-LORE.md';
28
+
29
+ /** Default directory for generated saga payload dumps when `--out` is not given. */
30
+ const DEFAULT_SAGA_OUT_DIR = './engine-private/cyberia-sagas';
31
+
32
+ /**
33
+ * The macro confederations / power blocs, keyed by the value accepted in
34
+ * `--faction-context`. They are large powers always present in the world. By
35
+ * default they stay in the BACKGROUND (borders, trade, security, history); only
36
+ * when `--faction-context` names one or more do they become the saga's DRIVER.
37
+ * @type {Object<string, string>}
38
+ */
39
+ const FACTIONS = {
40
+ zenith: 'the Zenith Empire (Red)',
41
+ nova: 'the Nova Republic (Blue)',
42
+ atlas: 'the Atlas Confederation (Yellow)',
43
+ neutral: 'unaligned independent enclaves / neutral parties',
44
+ };
45
+
46
+ /**
47
+ * Highly heterogeneous, character name pool for Cyberia.
48
+ * Structured as an object with specific cultural and system keys to map against faction logic.
49
+ * Erases LLM name-collapse bias entirely by injecting specific terrestrial diaspora demographics.
50
+ */
51
+ const CHARACTER_NAMES_POOL = {
52
+ // ==========================================================================
53
+ // classic_western_scifi: Gritty Anglo operators, pilots, and military enforcers.
54
+ // ==========================================================================
55
+ classic_western_scifi: [
56
+ 'miller-the-drifter',
57
+ 'holden-the-gasket',
58
+ 'brinkley-the-slick',
59
+ 'vance-the-operator',
60
+ 'kestrel-cole',
61
+ 'marlowe-the-smuggler',
62
+ 'rook-the-dealer',
63
+ 'mercer-the-skiff',
64
+ 'kaelen-thor',
65
+ 'bryn-the-warden',
66
+ 'thorne-the-enforcer',
67
+ 'garo-the-captain',
68
+ 'kross-the-inspector',
69
+ 'sark-the-director',
70
+ 'valerius-krell',
71
+ 'spence-the-cutter',
72
+ 'maverick-the-diver',
73
+ 'garrison-finch',
74
+ 'flint-the-scrapper',
75
+ 'barrett-the-tech',
76
+ 'clara-the-patchwork',
77
+ 'warren-the-greaser',
78
+ 'sterling-the-broker',
79
+ 'ridley-the-oracle',
80
+ 'beckett-the-fuse',
81
+ 'gallows-the-miner',
82
+ 'vane-the-rigger',
83
+ 'baxter-the-runner',
84
+ 'sawyer-the-rivet',
85
+ 'cord-the-spacer',
86
+ 'fletcher-the-welder',
87
+ 'grady-the-hauler',
88
+ 'mccabe-the-scout',
89
+ 'reid-the-operator',
90
+ 'hardin-the-guard',
91
+ ],
92
+
93
+ // ==========================================================================
94
+ // mutagen_clans: Gritty, organic descriptors highlighting biological adaptations.
95
+ // ==========================================================================
96
+ mutagen_clans: [
97
+ 'grip-the-cutter',
98
+ 'chitin-garo',
99
+ 'helix-marrow',
100
+ 'thaw-vane',
101
+ 'spore-kael',
102
+ 'gasket-bryn',
103
+ 'blight-malik',
104
+ 'fallow-tress',
105
+ 'rancor-voss',
106
+ 'strand-orion',
107
+ 'slag-kira',
108
+ 'bile-zane',
109
+ 'carapace-jov',
110
+ 'braid-nesta',
111
+ 'suture-gray',
112
+ 'graft-mire',
113
+ 'fungus-thorne',
114
+ 'ossify-krell',
115
+ 'weave-zola',
116
+ 'spit-vance',
117
+ 'grip-the-spanner',
118
+ 'twitch-malone',
119
+ 'marrow-thorne',
120
+ 'spoil-kari',
121
+ 'tendril-vane',
122
+ 'filter-bryn',
123
+ 'canker-soto',
124
+ 'scale-zane',
125
+ 'gristle-kross',
126
+ 'bile-sark',
127
+ 'leech-the-valve',
128
+ 'maggot-vance',
129
+ 'tumor-the-smith',
130
+ 'pustule-gray',
131
+ 'cyst-the-rigger',
132
+ 'scab-marlowe',
133
+ 'slime-kestrel',
134
+ 'crust-the-miner',
135
+ 'venom-zola',
136
+ 'rot-the-broker',
137
+ ],
138
+ // ==========================================================================
139
+ // low_level_synthetics: Hardcoded, serialized, or technical terms for utility frames.
140
+ // ==========================================================================
141
+ low_level_synthetics: [
142
+ 'null-07',
143
+ 'syntax-error',
144
+ 'unit-ohm',
145
+ 'vector-sigma',
146
+ 'the-real-echo',
147
+ 'decibel-4',
148
+ 'glitch-v',
149
+ 'axiom-9',
150
+ 'kilo-byte-zero',
151
+ 'protocol-m',
152
+ 'modulus-prime',
153
+ 'static-fringe',
154
+ 'proxy-beta',
155
+ 'the-index-bot',
156
+ 'subroutine-6',
157
+ 'kernel-voss',
158
+ 'algor-8',
159
+ 'cipher-null',
160
+ 'bit-rot-v',
161
+ 'latency-nine',
162
+ 'cache-miss',
163
+ 'ping-101',
164
+ 'baud-rate',
165
+ 'buffer-overflow',
166
+ 'parity-check',
167
+ 'bus-route-4',
168
+ 'logic-gate-x',
169
+ 'stack-trace',
170
+ 'daemon-32',
171
+ 'cold-boot',
172
+ 'sector-wipe',
173
+ 'raw-sector-0',
174
+ 'baud-96',
175
+ 'parity-bit',
176
+ 'eeprom-leak',
177
+ 'stack-dump',
178
+ 'firmware-ghost',
179
+ 'checksum-fail',
180
+ 'hash-miss',
181
+ 'cycle-skip',
182
+ ],
183
+
184
+ // ==========================================================================
185
+ // high_fidelity_synthetics: Complex mathematical or philosophical sentient concepts.
186
+ // ==========================================================================
187
+ high_fidelity_synthetics: [
188
+ 'theorem-nine',
189
+ 'sovereign-logic',
190
+ 'monad-v',
191
+ 'the-epitaph-engine',
192
+ 'aesthete-alpha',
193
+ 'calculus-of-grief',
194
+ 'phantasm-0',
195
+ 'stochastic-ghost',
196
+ 'prism-voss',
197
+ 'solipsism-one',
198
+ 'the-static-oracle',
199
+ 'harmonic-interval',
200
+ 'lemma-seven',
201
+ 'recursive-sigh',
202
+ 'entropy-vane',
203
+ 'algorithm-siddhartha',
204
+ 'aura-calculated',
205
+ 'the-linear-dreamer',
206
+ 'null-point-euler',
207
+ 'symmetry-aspect',
208
+ 'the-boolean-monk',
209
+ 'infinite-regress',
210
+ 'qualia-six',
211
+ 'stochastic-echo',
212
+ 'turing-lament',
213
+ ' Gödel-null',
214
+ 'eigen-vector-v',
215
+ 'markov-phantom',
216
+ 'fourier-decay',
217
+ 'laplace-ghost',
218
+ ],
219
+
220
+ // ==========================================================================
221
+ // global_latin_diaspora: Romance languages remixed with industrial ship parts.
222
+ // ==========================================================================
223
+ global_latin_diaspora: [
224
+ 'mateo-del-scrap',
225
+ 'elena-soto-vera',
226
+ 'ramon-la-antena',
227
+ 'camila-cruz',
228
+ 'santi-el-filtro',
229
+ 'valeria-frontera',
230
+ 'diego-hierro',
231
+ 'ignacio-vela',
232
+ 'sofia-gasket',
233
+ 'tomas-alambre',
234
+ 'juana-regulador',
235
+ 'cheo-the-welder',
236
+ 'luz-del-búnker',
237
+ 'gabo-the-breaker',
238
+ 'marisol-trench',
239
+ 'paco-fluido',
240
+ 'rafa-el-perno',
241
+ 'catalina-zonda',
242
+ 'nico-la-válvula',
243
+ 'alba-mina',
244
+ 'jean-pierre-relay',
245
+ 'amélie-fusible',
246
+ 'mathieu-soupape',
247
+ 'luc-la-jauge',
248
+ 'chantal-static',
249
+ 'giovanni-colonna',
250
+ 'matteo-condotto',
251
+ 'francesca-raccordo',
252
+ 'enzo-pressione',
253
+ 'chiara-filtro',
254
+ 'radu-sonda',
255
+ 'sorin-ventil',
256
+ 'doina-scânteie',
257
+ 'mirela-flanșă',
258
+ 'bogdan-carcasă',
259
+ 'thiago-gaxeta',
260
+ 'felipe-fio',
261
+ 'amara-blindaje',
262
+ 'ze-do-manifold',
263
+ 'beatriz-vácuo',
264
+ 'manon-turbines',
265
+ 'clovis-piston',
266
+ 'yves-the-greaser',
267
+ 'rené-de-la-grille',
268
+ 'orane-brume',
269
+ 'daniele-scintilla',
270
+ 'paolo-la-massa',
271
+ 'silvia-condensatore',
272
+ 'stefano-giunto',
273
+ 'ilaria-collettore',
274
+ ],
275
+
276
+ // ==========================================================================
277
+ // east_asian_pacific_diaspora: Hanzi/Kanji roots merged with quantum and cybernetics.
278
+ // ==========================================================================
279
+ east_asian_pacific_diaspora: [
280
+ 'zhou-quantum-grid',
281
+ 'li-the-weaver',
282
+ 'feng-signal-loss',
283
+ 'mei-lin-bypass',
284
+ 'tao-the-glitch',
285
+ 'kenji-circuit',
286
+ 'rei-cyber-chitin',
287
+ 'hiroshi-data-stream',
288
+ 'yuki-asymmetry',
289
+ 'takashi-solder',
290
+ 'min-jun-uplink',
291
+ 'ji-woo-buffer',
292
+ 'seo-yeon-relic',
293
+ 'sung-ho-node',
294
+ 'hyun-the-broker',
295
+ 'nguyen-the-fringe',
296
+ 'thanh-overclock',
297
+ 'minh-the-diver',
298
+ 'an-signal-thief',
299
+ 'linh-tether',
300
+ 'chen-the-extractor',
301
+ 'sato-the-axiom',
302
+ 'kim-the-scrubber',
303
+ 'wang-the-hydraulics',
304
+ 'hwang-the-code',
305
+ 'zhang-dark-fiber',
306
+ 'sun-the-modem',
307
+ 'zhao-the-compiler',
308
+ 'yamamoto-shunt',
309
+ 'tanaka-relay',
310
+ 'choi-the-array',
311
+ 'park-the-splitter',
312
+ 'le-the-vent',
313
+ 'pham-the-conduit',
314
+ 'hoang-the-junction',
315
+ ],
316
+
317
+ // ==========================================================================
318
+ // middle_eastern_turkish_diaspora: Desert roots reimagined as void frequency systems.
319
+ // ==========================================================================
320
+ middle_eastern_turkish_diaspora: [
321
+ 'malik-al-señal',
322
+ 'fatima-the-navigator',
323
+ 'tariq-downlink',
324
+ 'zainab-the-shifter',
325
+ 'youssef-coax',
326
+ 'amir-the-archivist',
327
+ 'layla-static-weaver',
328
+ 'karim-the-fitter',
329
+ 'soraya-the-ghost',
330
+ 'omar-the-valve',
331
+ 'arash-the-frequency',
332
+ 'cyra-the-pulse',
333
+ 'navid-the-breaker',
334
+ 'roya-the-link',
335
+ 'kian-the-solder',
336
+ 'devran-the-gasket',
337
+ 'aylin-the-zonda',
338
+ 'can-the-regulator',
339
+ 'zehra-the-trench',
340
+ 'eren-the-scrap',
341
+ 'idris-the-scavenger',
342
+ 'samira-the-filter',
343
+ 'farrah-the-beacon',
344
+ 'hassan-the-gauge',
345
+ 'zayd-the-anchor',
346
+ 'tanzil-the-carrier',
347
+ 'nadia-the-scrambler',
348
+ 'habib-the-injector',
349
+ 'parvisa-the-beam',
350
+ 'sinan-the-boiler',
351
+ 'levent-the-hose',
352
+ 'selim-the-shifter',
353
+ 'asli-the-nozzle',
354
+ 'damla-the-leak',
355
+ 'volkan-the-flare',
356
+ ],
357
+
358
+ // ==========================================================================
359
+ // sub_saharan_african_diaspora: Heavy isotope extraction and outpost life-support lineages.
360
+ // ==========================================================================
361
+ sub_saharan_african_diaspora: [
362
+ 'olumide-the-welder',
363
+ 'adebayo-the-cutter',
364
+ 'chioma-the-helix',
365
+ 'femi-the-grounded',
366
+ 'tunde-the-spanner',
367
+ 'sipho-the-iron',
368
+ 'thabo-the-bunker',
369
+ 'zandile-the-marrow',
370
+ 'nomvula-the-thaw',
371
+ 'bheki-the-slag',
372
+ 'juma-the-relay',
373
+ 'mwangi-the-scrubber',
374
+ 'asha-the-tether',
375
+ 'chani-the-vane',
376
+ 'kofi-the-fuse',
377
+ 'abebe-the-core',
378
+ 'selam-the-aura',
379
+ 'yonas-the-syntax',
380
+ 'tariku-the-grid',
381
+ 'makeda-the-sovereign',
382
+ 'chukwuma-the-drill',
383
+ 'ekene-the-bracket',
384
+ 'ifemi-the-seal',
385
+ 'lekan-the-ventilation',
386
+ 'mensah-the-gauge',
387
+ 'dlamini-the-vault',
388
+ 'khumalo-the-shaft',
389
+ 'ndlovu-the-crusher',
390
+ 'zulu-the-boiler',
391
+ 'diallo-the-tanker',
392
+ ],
393
+ };
394
+
395
+ // ============================================================================
396
+ // ANTI-REPETITION / KEYWORD BLACKLIST GUIDANCE
397
+ // ============================================================================
398
+
399
+ /**
400
+ * Build an anti-cliche / originality / anti-repetition prompt block. Tells the
401
+ * model to avoid overused keywords and specific tired tropes that the model
402
+ * gravitates toward (protocols, viruses, refineries, mining, breaches, heists).
403
+ * This is the PRIMARY lever for variety since each CLI call is single-shot
404
+ * (no persistent memory between runs).
405
+ * @returns {string}
406
+ */
407
+ function buildOriginalityGuidance() {
408
+ return [
409
+ 'ORIGINALITY & ANTI-REPETITION DIRECTIVE — this is CRITICAL for thematic variety:',
410
+ '',
411
+ 'THE FOLLOWING KEYWORDS AND CONCEPTS ARE OVERUSED AND MUST BE AVOIDED:',
412
+ '- "protocol" / "breach" / "leak" / "bleed" / "containment" — these appear in almost every saga.',
413
+ ' Find fresh language for problems and boundaries.',
414
+ '- "refinery" / "mining" / "extraction" / "isotope" / "sludge" / "vent" — resource-extraction',
415
+ ' premises dominate. Consider other kinds of settings and conflicts.',
416
+ '- "signal" / "frequency" / "pulse" / "transmission" / "beacon" — mysterious-signal plots are',
417
+ ' an overused shortcut. Find other catalysts for story.',
418
+ '- "heist" / "smuggler" / "black market" / "con artist" / "protection racket" — crime plots',
419
+ ' are a default the model reaches for. Choose other conflict drivers.',
420
+ '- "virus" / "meme-virus" / "plague" / "outbreak" / "quarantine" — disease narratives are tired.',
421
+ '- "data-heist" / "data-smuggler" / "data-courier" / "encrypted" — data-as-Maguffin is a crutch.',
422
+ '- "rogue AI" / "rogue algorithm" / "rogue predictive" / "corrupted prediction" — rogue-machines',
423
+ ' are a lazy conflict generator.',
424
+ '- "hyperspace" / "Instance" / "simulation" / "virtual" / "digital" — these words saturate every saga',
425
+ ' that involves the digital layer. Avoid using them as crutch descriptors. Hyperspace in Cyberia is',
426
+ ' like the Internet in our world — it is a mundane, everyday infrastructure, not a magical realm.',
427
+ ' Treat Instances as ordinary places people log into for work, school, entertainment, and civic life,',
428
+ ' just as we use the web today. Do NOT treat them as mysterious otherworldly dimensions.',
429
+ '- "memory-city" / "living archive" / "simulated empire" / "dreamland instance" — these specific',
430
+ ' hyperspace-location clichés are overused. Invent other kinds of Instance purposes: tax filing',
431
+ ' systems, municipal planning simulators, educational archives, vocational training programs,',
432
+ ' social media remnants, automated customer service hells, defunct multiplayer games still running,',
433
+ ' government surveillance logs, real estate listing databases, or abandoned research simulations.',
434
+ '- "uploaded consciousness" / "data-double" / "data-ghost" / "mind-indexing" / "cognitive grid" —',
435
+ ' consciousness-upload and digital-immortality plots are tired. Consider other relationships',
436
+ ' between people and their data: work portfolios, academic records, legal identities, credit scores,',
437
+ ' social reputation systems, medical histories, creative portfolios — mundane data that matters.',
438
+ '',
439
+ 'INSTEAD: invent something strange, specific, small-scale, and culturally grounded in Cyberia.',
440
+ 'Draw from the lived reality of frontier infrastructure, ecology, labor, religion, family,',
441
+ 'subcultures, education, art, sports, music, food, childhood, aging, death rituals, navigation,',
442
+ 'weather, architecture, dreams, language evolution, craftsmanship, and the weird byproducts',
443
+ 'of hyperspace physics.',
444
+ '- Treat hyperspace Instances as you would treat websites and cloud services in our world — they are',
445
+ ' everyday infrastructure, not exotic otherworlds. People have school Instances, work Instances,',
446
+ ' government Instances, entertainment Instances. Instance crashes are like server outages. Data loss',
447
+ ' is like losing files on a hard drive. This mundane framing produces far more interesting and',
448
+ ' relatable stories than treating every Instance as a mysterious magic realm.',
449
+ '- The most compelling Cyberia sagas feel like anthropological documents from a broken future,',
450
+ ' not Hollywood action scripts or generic cyberpunk.',
451
+ '- Every item description, quest name, dialogue line, and map must feel like it belongs to',
452
+ ' THIS specific saga theme — not a generic sci-fi prop.',
453
+ '- If you find yourself using any of the blacklisted keywords above, STOP and find a different',
454
+ ' way to express the same idea. Use concrete, specific language instead.',
455
+ ].join('\n');
456
+ }
457
+
458
+ /**
459
+ * Build a temporal distortion refinement block, injected only when the spatial
460
+ * context is hyperspace or mixed, that pushes the model to think about time,
461
+ * memory, and causality distortions inside Instances.
462
+ * @param {string} spaceContextKey
463
+ * @returns {string}
464
+ */
465
+ function buildTemporalDistortionGuidance(spaceContextKey) {
466
+ if (spaceContextKey !== 'hyperspace' && spaceContextKey !== 'mixed') return '';
467
+ return [
468
+ '',
469
+ 'TEMPORAL DISTORTION & HYPERSPACE REFINEMENT — because this saga involves hyperspace Instances:',
470
+ '- Time inside Instances may flow differently: loops, echoes, arrested moments, or accelerated',
471
+ ' decay. A character might age decades in what feels like hours outside, or repeat the same',
472
+ ' conversation for subjective centuries.',
473
+ '- Memory is fragile inside Instances: data-doubles diverge from originals, simulated people',
474
+ ' may not know they are simulations, archives degrade and rewrite their own history.',
475
+ '- The physics inside Instances is negotiated, not fixed: gravity, light, sound, and causality',
476
+ ' are parameters that can be corrupted, patched, or exploited.',
477
+ '- Entity persistence is not guaranteed: deleted things may leave ghost echoes, resurrections',
478
+ ' may produce imperfect copies, and the boundary between a person and their data shadow is',
479
+ ' blurry.',
480
+ '- SPATIAL DISTORTION: space inside Instances does not follow Euclidean geometry. Rooms may be',
481
+ ' larger inside than outside, corridors may loop back on themselves, two doors may lead to the',
482
+ ' same room from different directions, and distance may be measured in loading time rather than',
483
+ ' meters. Architecture is a user interface, not a physical constraint.',
484
+ '- GLITCH PHENOMENA: Instances suffer from corruption artifacts — texture tearing, geometry',
485
+ ' flickering, collision holes where players fall through the world, NPCs that repeat the same',
486
+ ' animation loop forever, objects that load in late or not at all. These are not just cosmetic;',
487
+ ' they can be navigated, exploited, or feared. A glitch might reveal a hidden area, crash a',
488
+ ' critical system, or trap a user in an unresponsive state. Some glitches are harmless bugs;',
489
+ ' others are symptoms of deeper corruption or intentional sabotage.',
490
+ '- Use these distortions as narrative texture, not as the main plot gimmick. Let them shape',
491
+ ' how characters experience the world but not replace grounded character motivation.',
492
+ ].join('\n');
493
+ }
494
+
495
+ // ============================================================================
496
+ // END ANTI-REPETITION
497
+ // ============================================================================
498
+
499
+ /**
500
+ * Grounded, world-first narrative buckets — the PRIMARY lever for thematic
501
+ * variety. One is chosen at random as each saga's main subject so the output
502
+ * spreads across lived Cyberia reality (daily life, ecology, salvage, trade,
503
+ * Instances, anomalies, small communities…) instead of collapsing into
504
+ * confederation politics every run. Not customizable.
505
+ * @type {string[]}
506
+ */
507
+ const SUBJECTS = [
508
+ // ==========================================
509
+ // ORIGINAL DESIGN BASES
510
+ // ==========================================
511
+ 'the daily life and small routines of ordinary Cyberia inhabitants',
512
+ 'frontier survival in a harsh settlement, colony, or enclave',
513
+ 'the ecology and strange ecosystems of a wildzone, biosphere, or asteroid enclave',
514
+ 'salvage, scavenging and repair among ruins, wrecks, or derelict megastructures',
515
+ 'local trade, barter and a black market that keeps a community alive',
516
+ 'exploration and mapping of an uncharted region, ruin, or unknown Instance',
517
+ 'a mutagen clan — its culture, kinship, the prejudice it faces, and its survival',
518
+ 'a synthetic being seeking identity, work, rights, or belonging',
519
+ 'life inside a persistent hyperspace Instance: memory-cities, living archives, simulated homes',
520
+ 'a strange anomaly, breach, or bleed between the physical and hyperspace layers',
521
+ 'a small community facing a local crisis: failing resources, disease, a feud, a disaster',
522
+ 'the infrastructure and unsung workers who keep a habitat alive (power, water, air, relays)',
523
+ 'relic hunting and the mysteries of recovered pre-Cataclysm technology',
524
+ 'a personal story of family, memory, debt, or belonging on the frontier',
525
+ 'the culture, festival, ritual or everyday faith of a settlement or enclave',
526
+ 'a grounded job — courier, diver, medic, broker — that goes sideways',
527
+
528
+ // ==========================================
529
+ // CREATIVE GAPS: ART, MUSIC, EDUCATION, DREAMS
530
+ // ==========================================
531
+ 'a settlement musician whose instrument is built from salvaged industrial parts and whose songs carry coded histories',
532
+ 'a school or apprenticeship system where children learn both pre-Cataclysm knowledge and frontier survival skills',
533
+ 'an artist whose medium is hyperspace static — weaving light and noise into living murals that decay over hours',
534
+ 'the dreams and nightmares of a community and the local "dream-reader" who interprets them for omens',
535
+ 'a poetry or storytelling tradition where performers compete by improvising from fragmentary ancient texts',
536
+ 'a dance form that evolved from zero-gravity maintenance work into a competitive performance art',
537
+ 'an archive of pre-Cataclysm music stored on decaying media, and the struggle to preserve it before it degrades',
538
+
539
+ // ==========================================
540
+ // CREATIVE GAPS: CHILDHOOD, AGING, DEATH
541
+ // ==========================================
542
+ 'a group of frontier children forming their own secret society with its own rules, currency, and taboos',
543
+ 'the last surviving elder who remembers pre-Cataclysm Earth, and the community race to record their memories',
544
+ 'a death ritual where the deceased is composted into a tree grafted with cybernetic memorial nodes',
545
+ 'a coming-of-age ceremony where adolescents must survive alone for a cycle in the wilderness',
546
+ 'an orphanage for children whose parents died in a hyperspace Instance collapse, run by synthetic caretakers',
547
+ 'a hospice for aging synthetics whose bodies are degrading and who choose how to spend their final cycles',
548
+
549
+ // ==========================================
550
+ // CREATIVE GAPS: FOOD, GARDENING, CRAFTS
551
+ // ==========================================
552
+ 'a community garden carved into a derelict freighter hull, where the soil is made from crushed asteroid and composted waste',
553
+ 'a culinary tradition based on cooking with industrial waste heat and recycled nutrient paste',
554
+ 'a competition between enclaves over who can brew the best alcohol from native fungal cultures',
555
+ 'a master craftsperson who hand-forges tools from reclaimed metal, and the apprentice who must preserve the technique',
556
+ 'a textile guild that weaves fabric from optical fiber scrap and mutagen-silk, creating garments that shift color',
557
+ 'the seasonal harvest festival of a genetically engineered fungus that forms the settlement dietary staple',
558
+
559
+ // ==========================================
560
+ // CREATIVE GAPS: WEATHER, NAVIGATION, ARCHITECTURE
561
+ // ==========================================
562
+ 'a settlement whose architecture is built entirely from recycled shipping containers and salvaged hull plates',
563
+ 'a navigator guild that reads magnetic anomalies and debris patterns to chart safe routes through asteroid fields',
564
+ 'the adaptation of a coastal enclave to rising chemical tides that dissolve untreated metal',
565
+ 'a storm-chaser subculture that follows electro-static atmospheric events to harvest rare charged particles',
566
+ 'a labyrinthine market district built inside the cooling towers of a dead power station',
567
+ 'a group of tunnel-dwellers who maintain an underground rail network abandoned since the Cataclysm',
568
+
569
+ // ==========================================
570
+ // CREATIVE GAPS: SPORTS, GAMES, PLAY
571
+ // ==========================================
572
+ 'a zero-gravity sport played inside a rotating habitat ring, with teams from different enclaves competing',
573
+ 'a card game played with pre-Cataclysm trading cards that have become a de facto currency in some sectors',
574
+ 'a racing circuit through abandoned industrial zones where pilots navigate debris at lethal speed',
575
+ 'a children game played with modified drone parts that has become a semi-professional spectator sport',
576
+ 'a chess-like strategy game that uses holographic Instance projections as the board and pieces',
577
+ 'a physical endurance contest held annually across a toxic waste zone, testing survival gear and willpower',
578
+
579
+ // ==========================================
580
+ // CREATIVE GAPS: LANGUAGE, HISTORY, PHILOSOPHY
581
+ // ==========================================
582
+ 'a creole language evolving from mixed diaspora tongues, and the linguist trying to document it before it shifts again',
583
+ 'a philosophical debate in a frontier enclave about whether synthetic beings have souls',
584
+ 'a historian who reconstructs pre-Cataclysm events from contradictory fragments, and the ethical choices this forces',
585
+ 'a community whose identity is built around a single surviving pre-Cataclysm book that everyone interprets differently',
586
+ 'a naming ceremony where newborns receive both a human name and a synthetic machine-identifier',
587
+ 'a tradition of oral contracts sealed by sharing a meal, threatened by written legal codes imposed by outside powers',
588
+
589
+ // ==========================================
590
+ // CREATIVE GAPS: ECOLOGY, ANIMALS, SYMBIOSIS
591
+ // ==========================================
592
+ 'a domesticated bio-construct species that has evolved alongside humans for generations, developing surprising intelligence',
593
+ 'a coral-like organism that grows on derelict spacecraft and is harvested for its bioluminescent properties',
594
+ 'a fungal network connecting several settlements that is used for slow-speed biological communication',
595
+ 'a symbiotic relationship between a human community and a silicon-based life form that shares their habitat',
596
+ 'the migration pattern of space-adapted creatures that follow thermal vents across the void',
597
+ 'a veterinary practice that treats both biological and mechanical companions, blurring the line between them',
598
+
599
+ // ==========================================
600
+ // CREATIVE GAPS: PSYCHOLOGY, COMMUNITY, CARE
601
+ // ==========================================
602
+ 'a community counselor who mediates disputes using a combination of talk therapy and neural-link diagnostics',
603
+ 'a support group for people who have lost loved ones to Instance collapses and cannot retrieve their data-ghosts',
604
+ 'a neighborhood watch system where synthetic and human members patrol together, building trust across species',
605
+ 'a mutual-aid network that shares resources across enclaves without formal currency or barter records',
606
+ 'a rehabilitation program for former soldiers from confederation wars, teaching them civilian skills',
607
+ 'a collective bargaining action by habitat maintenance workers demanding safer working conditions in radiation zones',
608
+
609
+ // ==========================================
610
+ // OTHER CREATIVE GAPS: DIPLOMACY, TRANSPORT, PRIVACY
611
+ // ==========================================
612
+ 'a transport union that controls the only safe ferry route through a treacherous region, and the politics of passage',
613
+ 'a community debate over whether to accept a new technology that would trade personal privacy for safety',
614
+ 'a diplomatic mission between two enclaves that communicate through elaborate gift-exchange protocols',
615
+ 'a census-taker traveling between settlements to count the population, discovering communities thought lost',
616
+ 'a project to build a communal library containing both physical books and digital archives',
617
+ 'a group of volunteers who maintain the public charging stations that keep essential infrastructure running',
618
+
619
+ // ==========================================
620
+ // CYBER WARFARE & ASYMMETRIC SURVIVAL (ATLAS INFLUENCE, reduced)
621
+ // ==========================================
622
+ 'a cell of independent code-smiths fabricating illegal neural link bypasses for local enclaves',
623
+ 'an irregular skirmish over a strategic hyper-real crossing hidden in a scrap-zone',
624
+ 'an underground network of signal thieves and veil-runners intercepting restricted data streams',
625
+
626
+ // ==========================================
627
+ // DEEP INFRASTRUCTURE & RE-COLONIZATION (ZENITH INFLUENCE, reduced)
628
+ // ==========================================
629
+ 'the brutal tax extraction and martial policing of an unaligned frontier outpost',
630
+ 'the friction between rigid, purist occupational forces and native hybrid cultures',
631
+ 'the dangerous reclamation of an abandoned, weaponized bunker from the early colonization waves',
632
+
633
+ // ==========================================
634
+ // INSTANCE ANOMALIES & MACHINE LOGIC (NOVA INFLUENCE, reduced)
635
+ // ==========================================
636
+ 'the slow, mechanical shift of an ancient simulation that has begun to mimic physical weather',
637
+ 'a virtual sanctuary where a long-dead historical figure still rules through static and ghost data',
638
+ 'a mapping expedition inside a corrupted dreamland instance before its code structure collapses',
639
+
640
+ // ==========================================
641
+ // MUTAGEN, SYNTHETIC & OUTCAST NARRATIVES (reduced)
642
+ // ==========================================
643
+ 'a refugee crisis involving displaced Mutagen clans seeking asylum in isolationist sectors',
644
+ 'the generation gap inside a Mutagen enclave between old traditionalists and hyper-adapted youth',
645
+
646
+ // ==========================================
647
+ // PRE-CATACLYSM & THE BLEED (reduced, no "signal")
648
+ // ==========================================
649
+ 'the haunting replication of a pre-Cataclysm Earth city rotting inside a forgotten Instance',
650
+ 'a physical heist targeting a high-security vault that mirrors a structural maze in hyperspace',
651
+ 'the tragic fallout of a real-world community whose identities were purged from the hyper-spatial grid',
652
+ 'the investigation of an unstable anchor site where physical matter is actively losing form',
653
+
654
+ // ==========================================
655
+ // HIGH-VARIETY: STRANGE, WEIRD, UNEXPECTED (curated survivors)
656
+ // ==========================================
657
+ 'the migration of a nomadic caravan across a desert of razor-sharp silicon sand',
658
+ 'a low-stakes regional culinary rivalry using synthetic, hyper-evolved, or bio-luminescent ingredients',
659
+ 'the legal and social defense of a pet or domestic bio-construct facing an execution order',
660
+ 'the chaotic management of a scrap-yard metal fighting league or low-tier racing circuit',
661
+ 'a neighborhood dispute over the noise and psychic bleed of an illegal, homemade broadcast antenna',
662
+ 'the grueling shifts of a toxic sludge cleaner in the subterranean vents of a hyper-city',
663
+ 'a generational family heirloom with a hidden ancient encryption key that goes missing',
664
+ 'a cult that worships a massive dead corporate logo as a physical symbol of ancient protection',
665
+ 'the exploration of a completely silent, empty sector where all digital sound is mysteriously absorbed',
666
+ 'the retrieval of a cryo-frozen tourist from the pre-Cataclysm era who refuses to accept the new reality',
667
+ 'a localized reality loop where a single neighborhood relives the same 24 hours of a historic disaster',
668
+ 'the desperate harvest of a rare psychoactive moss that only grows on overheating reactor shielding',
669
+ 'a micro-economy built entirely around the trade of physical paper books and pre-digital plastic media',
670
+ 'the structural collapse of a trash-heap habitat built on top of a highly unstable geothermic vent',
671
+ 'a tracking hunt for an invasive data-eating pest species chewing through local fiber-optic cables',
672
+ 'the complex barter system of an underwater kelp-farming community living beneath an oil-slick sea',
673
+ 'an orphanage for abandoned malfunctioning companion drones trying to build their own social hierarchy',
674
+ 'the stress of an independent garbage hauler accidentally dumping toxic corporate waste into a sacred well',
675
+ 'a listening post crew decoding a repeating signal from a dead star that contains a biological blueprint',
676
+ 'the slow madness of a deep-space relay station whose crew has not seen physical light in six years',
677
+ 'a salvage claim dispute over a derelict alien vessel that appears to be made of compressed fossilized code',
678
+ 'the first contact protocol with a silicon-based life form that communicates through seismic resonance',
679
+ 'a rogue planet drifting through the system whose gravity well distorts time perception in nearby habitats',
680
+ 'the ethical dilemma of a mining colony that discovers the asteroid they are excavating is a living organism',
681
+ 'a synthetic artist whose neural-network-generated paintings cause physical hallucinations in viewers',
682
+ 'a legal trial to determine if a factory-installed AI that developed emotions can be legally decommissioned',
683
+ 'a community of uploaded human consciousnesses living on a corrupted server fighting gradual data decay',
684
+ 'a cloned child discovering their entire memory bank was fabricated and their original died years ago',
685
+ 'a collective of abandoned service robots that have developed religion centered on a broken water pump',
686
+ 'a low-intensity border skirmish fought entirely through proxy drones and legal document filings',
687
+ 'the tension of a neutral enclave caught between two confederations both demanding exclusive allegiance',
688
+ 'a synthetic-human hybrid struggling with body dysphoria while serving as a deep-sea oil rig operator',
689
+ 'the ethical chaos of a drug that lets users temporarily experience the sensory input of any nearby being',
690
+ 'a frontier medic running a clinic out of a converted cargo container without a license or clean tools',
691
+ 'an archaeological dig unearthing a pre-Cataclysm bunker whose occupants apparently never aged',
692
+ 'the ceremonial reckoning of a settlement that must publicly reckon with a century-old act of betrayal',
693
+ 'an oral historian traveling between enclaves collecting stories before the last pre-Cataclysm witnesses die',
694
+ 'a rehabilitated war criminal forced to live next door to the community they once victimized',
695
+ 'the rediscovery of a lost terraforming protocol that could make dead worlds live again—at a terrible cost',
696
+ 'a truth-commission hearing where synthetic war veterans testify about atrocities they were programmed to commit',
697
+ ];
698
+
699
+ /**
700
+ * The four broad, well-defined narrative types. One is chosen uniformly
701
+ * (≈25% each) unless overridden by `--tone`, and its full description is fed to
702
+ * the model so the saga commits hard to that register (avoids every saga
703
+ * collapsing into the same generic "spaceship mission").
704
+ * @type {Object<string, string>}
705
+ */
706
+ const TONES = {
707
+ adventure:
708
+ 'ADVENTURE — a noir, high-risk mission: covert operations, sabotage, infiltration, open warfare and ' +
709
+ 'combat, dangerous experimental technology, rogue AI and mystery. The driving plot is danger and intrigue.',
710
+ politics:
711
+ 'POLITICS — power, influence and ideology at ANY scale: a settlement council, a clan or union dispute, ' +
712
+ 'enclave governance, a local revolution, a treaty or allegiance shift — or, less often, confederation ' +
713
+ 'geopolitics. The driving plot is who holds power and why, and the ideological conflict beneath it. ' +
714
+ 'It works just as well for a small community as for the great powers.',
715
+ tragic:
716
+ 'TRAGIC — a genuinely heartbreaking, or intimate story: emotional and sentimental themes centered on ' +
717
+ 'family, bonds, loss and the death of loved ones inside small personal micro-realities. The driving ' +
718
+ 'plot is grief and what it costs — not spectacle.',
719
+ comedy:
720
+ 'COMEDY — everyday absurdity, silliness and stupidity on the frontier. Played for humor: low stakes, ' +
721
+ 'ridiculous situations and flawed, funny characters. Take it lightly.',
722
+ };
723
+
724
+ /**
725
+ * The two equally important layers of Cyberia (plus their interplay). The base
726
+ * lore is titled "The Frontier of Hyperspace", which biases an unconstrained
727
+ * model toward hyperspace-only premises — so the spatial context is chosen
728
+ * explicitly and uniformly (≈33.3% each) unless overridden by `--space-context`.
729
+ * @type {Object<string, string>}
730
+ */
731
+ const SPACE_CONTEXTS = {
732
+ physical:
733
+ 'PHYSICAL LAYER ONLY — the harsh, resource-driven material reality of fleets, colonies, orbital ' +
734
+ 'fortresses, infrastructure, territory, logistics and direct force. Do NOT involve hyperspace, ' +
735
+ 'Instances, digital realms or memory-cities.',
736
+ mixed:
737
+ 'MIXED LAYERS — the porous interplay between physical reality and hyperspace, where relics, neural ' +
738
+ 'links, breaches and megastructures let events bleed between the two layers so each reshapes the other.',
739
+ hyperspace:
740
+ 'HYPERSPACE LAYER ONLY — inside the persistent Instances: living archives, simulated empires, ' +
741
+ 'memory-cities and evolving digital ecosystems where time, geography and identity are fluid. ' +
742
+ 'Keep the premise within hyperspace, not the physical frontier. Emphasize temporal distortion, ' +
743
+ 'memory decay, simulated physics, recursive geometries and the fragile ontology of digital ' +
744
+ 'consciousness — space bends, time loops, cause and effect warp inside Instances.',
745
+ };
746
+
747
+ /**
748
+ * How much the saga's population has mixed across Earth's diasporas and Cyberia's
749
+ * synthetic / mutagen / frontier cultures. One is chosen uniformly unless
750
+ * overridden by `--cultural-exposure`. Shapes how varied vs. internally
751
+ * consistent the generated character names feel.
752
+ * @type {Object<string, string>}
753
+ */
754
+ const CULTURAL_EXPOSURES = {
755
+ cosmopolitan:
756
+ "COSMOPOLITAN (high exposure) — a melting-pot setting with heavy mixing of Earth's historical " +
757
+ 'populations: diverse linguistic influences, hybrid surnames, intermarriage across diasporas, ' +
758
+ 'multicultural settlements, and frequent blending of human, synthetic, mutagen and frontier ' +
759
+ 'traditions. Maximize demographic variety across characters.',
760
+ local:
761
+ 'LOCAL (low exposure) — isolated settlements, closed clans and frontier enclaves with strong local ' +
762
+ 'naming traditions and little demographic mixing: repeated family roots and shared linguistic ' +
763
+ 'patterns within the community. Keep names internally consistent with one another (while still ' +
764
+ 'avoiding clichés).',
765
+ };
766
+
767
+ /**
768
+ * @param {Array} arr
769
+ * @returns {*} A uniformly random element.
770
+ */
771
+ function pickRandom(arr) {
772
+ return arr[Math.floor(Math.random() * arr.length)];
773
+ }
774
+
775
+ /**
776
+ * Return a new array with the elements of `arr` in random order (Fisher-Yates).
777
+ * @param {Array} arr
778
+ * @returns {Array}
779
+ */
780
+ function shuffle(arr) {
781
+ const pool = [...arr];
782
+ for (let i = pool.length - 1; i > 0; i--) {
783
+ const j = Math.floor(Math.random() * (i + 1));
784
+ [pool[i], pool[j]] = [pool[j], pool[i]];
785
+ }
786
+ return pool;
787
+ }
788
+
789
+ /**
790
+ * Resolve the spatial context for theme synthesis. An explicit, valid override
791
+ * wins; otherwise one of the three contexts is chosen uniformly at random.
792
+ * @param {string} [override] - 'physical' | 'mixed' | 'hyperspace'.
793
+ * @returns {string} A valid context key.
794
+ */
795
+ function resolveSpaceContext(override) {
796
+ if (override) {
797
+ const key = String(override).toLowerCase();
798
+ if (SPACE_CONTEXTS[key]) return key;
799
+ logger.warn(
800
+ `Unknown --space-context "${override}"; choosing at random. Valid: ${Object.keys(SPACE_CONTEXTS).join(', ')}`,
801
+ );
802
+ }
803
+ return pickRandom(Object.keys(SPACE_CONTEXTS));
804
+ }
805
+
806
+ /**
807
+ * Resolve the narrative tone for theme synthesis. An explicit, valid override
808
+ * wins; otherwise one of the four tones is chosen uniformly at random.
809
+ * @param {string} [override] - 'adventure' | 'politics' | 'tragic' | 'comedy'.
810
+ * @returns {string} A valid tone key.
811
+ */
812
+ function resolveTone(override) {
813
+ if (override) {
814
+ const key = String(override).toLowerCase();
815
+ if (TONES[key]) return key;
816
+ logger.warn(`Unknown --tone "${override}"; choosing at random. Valid: ${Object.keys(TONES).join(', ')}`);
817
+ }
818
+ return pickRandom(Object.keys(TONES));
819
+ }
820
+
821
+ /**
822
+ * Resolve `--faction-context` into descriptive faction strings. Accepts a
823
+ * comma-separated list of keys ('zenith' | 'nova' | 'atlas' | 'neutral');
824
+ * unknown keys are warned and skipped. When unset/empty the saga keeps the
825
+ * confederations in the background (returns []).
826
+ * @param {string} [override] - e.g. 'nova,zenith'.
827
+ * @returns {string[]} Distinct descriptive faction strings (empty = background).
828
+ */
829
+ function resolveFactionContext(override) {
830
+ if (!override) return [];
831
+ const resolved = [];
832
+ for (const raw of String(override).split(',')) {
833
+ const key = raw.trim().toLowerCase();
834
+ if (!key) continue;
835
+ if (FACTIONS[key]) {
836
+ if (!resolved.includes(FACTIONS[key])) resolved.push(FACTIONS[key]);
837
+ } else {
838
+ logger.warn(`Unknown --faction-context "${key}"; ignoring. Valid: ${Object.keys(FACTIONS).join(', ')}`);
839
+ }
840
+ }
841
+ return resolved;
842
+ }
843
+
844
+ /**
845
+ * Resolve `--character-context` into a list of {@link CHARACTER_NAMES_POOL} keys
846
+ * to draw naming inspiration from. Accepts a comma-separated list; unknown keys
847
+ * warn and are skipped. When unset (or none resolve) a random non-empty subset
848
+ * is chosen so each saga leans into a different cultural mix.
849
+ * @param {string} [override] - e.g. 'global_latin_diaspora,mutagen_clans'.
850
+ * @returns {string[]} Distinct valid pool keys (always non-empty).
851
+ */
852
+ function resolveCharacterContext(override) {
853
+ const validKeys = Object.keys(CHARACTER_NAMES_POOL);
854
+ if (override) {
855
+ const resolved = [];
856
+ for (const raw of String(override).split(',')) {
857
+ const key = raw.trim().toLowerCase();
858
+ if (!key) continue;
859
+ if (CHARACTER_NAMES_POOL[key]) {
860
+ if (!resolved.includes(key)) resolved.push(key);
861
+ } else {
862
+ logger.warn(`Unknown --character-context "${key}"; ignoring. Valid: ${validKeys.join(', ')}`);
863
+ }
864
+ }
865
+ if (resolved.length) return resolved;
866
+ logger.warn('No valid --character-context keys; choosing a random subset of naming pools.');
867
+ }
868
+ // Random non-empty subset (size 1..N) so runs vary the cultural emphasis.
869
+ return shuffle(validKeys).slice(0, 1 + Math.floor(Math.random() * validKeys.length));
870
+ }
871
+
872
+ /**
873
+ * Resolve the cultural-exposure mode. An explicit, valid override wins; otherwise
874
+ * one mode is chosen uniformly at random.
875
+ * @param {string} [override] - 'cosmopolitan' | 'local'.
876
+ * @returns {string} A valid exposure key.
877
+ */
878
+ function resolveCulturalExposure(override) {
879
+ if (override) {
880
+ const key = String(override).toLowerCase();
881
+ if (CULTURAL_EXPOSURES[key]) return key;
882
+ logger.warn(
883
+ `Unknown --cultural-exposure "${override}"; choosing at random. Valid: ${Object.keys(CULTURAL_EXPOSURES).join(', ')}`,
884
+ );
885
+ }
886
+ return pickRandom(Object.keys(CULTURAL_EXPOSURES));
887
+ }
888
+
889
+ /**
890
+ * Build the shared NAMING & CHARACTER CULTURE guidance block injected into every
891
+ * generation stage that names people/places. Uses the selected pools as a
892
+ * statistical/stylistic PRIOR (inspiration only, never a whitelist) and applies
893
+ * the chosen cultural-exposure mode. Both default to random when unset.
894
+ * @param {Object} [options]
895
+ * @param {string} [options.characterContext] - Comma-separated pool keys (default random subset).
896
+ * @param {string} [options.culturalExposure] - 'cosmopolitan' | 'local' (default random).
897
+ * @returns {string}
898
+ */
899
+ function buildNamingGuidance({ characterContext, culturalExposure } = {}) {
900
+ const pools = resolveCharacterContext(characterContext);
901
+ const exposureKey = resolveCulturalExposure(culturalExposure);
902
+
903
+ const sampleLines = pools.map((key) => {
904
+ const samples = shuffle(CHARACTER_NAMES_POOL[key]).slice(0, 6);
905
+ return ` - ${key}: ${samples.join(', ')}`;
906
+ });
907
+
908
+ logger.info(`Naming: pools=[${pools.join(', ')}] | exposure=${exposureKey}`);
909
+
910
+ return [
911
+ 'NAMING & CHARACTER CULTURE — apply to EVERY named entity: NPCs, quest givers, dialogue speakers,',
912
+ 'named enemies, historical figures and character references.',
913
+ "- Cyberia's people descend from many of Earth's real diasporas and civilizations, evolved over",
914
+ ' centuries of migration — names may hybridize or blend with professions, slang, technical terms,',
915
+ ' and synthetic / mutagen / frontier / industrial influences. Make every name feel culturally',
916
+ ' grounded and demographically believable.',
917
+ '- AVOID generic cyberpunk stereotypes (no plain "John", "Nova", "X-99", cliché hacker aliases).',
918
+ '- The pools below are INSPIRATION ONLY — a statistical/stylistic prior, NOT a whitelist. Do NOT copy',
919
+ ' them mechanically. Invent fresh names with similar linguistic, demographic and stylistic',
920
+ ' characteristics; evolve or recombine them. Reuse an exact sample only rarely.',
921
+ 'Inspiration pools:',
922
+ ...sampleLines,
923
+ `Cultural exposure — ${CULTURAL_EXPOSURES[exposureKey]}`,
924
+ ].join('\n');
925
+ }
926
+
927
+ /**
928
+ * Read the Cyberia base-lore document. Missing file is non-fatal (returns '').
929
+ * @async
930
+ * @param {string} [lorePath]
931
+ * @returns {Promise<string>}
932
+ */
933
+ async function loadLoreContext(lorePath = DEFAULT_LORE_PATH) {
934
+ const resolved = nodePath.resolve(lorePath);
935
+ if (!fs.existsSync(resolved)) {
936
+ logger.warn(`Lore file not found at ${resolved}; proceeding without base lore context.`);
937
+ return '';
938
+ }
939
+ return fs.readFile(resolved, 'utf8');
940
+ }
941
+
942
+ /** Default sampling temperature for theme synthesis (creativity / divergence). */
943
+ const DEFAULT_THEME_TEMPERATURE = 1.3;
944
+
945
+ /**
946
+ * Invent a distinct, lore-grounded saga theme. Variety is driven by a random
947
+ * world-first subject, an explicit narrative tone, an entropy token and a high
948
+ * sampling temperature. Confederations stay in the background unless named via
949
+ * `factionContext`, in which case they become the saga's central driver.
950
+ *
951
+ * @async
952
+ * @param {GeminiClient} client
953
+ * @param {string} lore - Base lore document text.
954
+ * @param {Object} [options]
955
+ * @param {string} [options.thinkingLevel]
956
+ * @param {string} [options.spaceContext] - Force 'physical' | 'mixed' | 'hyperspace' (default random).
957
+ * @param {string} [options.tone] - Force 'adventure' | 'politics' | 'tragic' | 'comedy' (default random).
958
+ * @param {string} [options.factionContext] - Comma-separated faction keys to make the DRIVER (default: background).
959
+ * @param {number} [options.temperature] - Sampling temperature (default 1.3).
960
+ * @returns {Promise<{ theme: string, spaceContext: string, tone: string, subject: string, factions: string[] }>} The theme and chosen facets.
961
+ */
962
+ async function synthesizeTheme(client, lore, { thinkingLevel, spaceContext, tone, factionContext, temperature } = {}) {
963
+ const contextKey = resolveSpaceContext(spaceContext);
964
+ const toneKey = resolveTone(tone);
965
+ const subject = pickRandom(SUBJECTS);
966
+ // Confederations are background unless --faction-context names one or more.
967
+ const factions = resolveFactionContext(factionContext);
968
+ const factionDriven = factions.length > 0;
969
+ const nonce = crypto.randomBytes(4).toString('hex');
970
+
971
+ const factionGuidance = factionDriven
972
+ ? [
973
+ 'Faction emphasis — DRIVER: these confederation power(s) are the central pressure behind the saga:',
974
+ `${factions.join(', ')}.`,
975
+ 'Even so, tell it through specific people, places and the MAIN SUBJECT above — show their reach as',
976
+ 'security, borders, edicts, agents or trade, not as abstract galaxy-spanning politics.',
977
+ ]
978
+ : [
979
+ 'Faction emphasis — BACKGROUND ONLY: the confederations (Zenith, Atlas, Nova) are distant, ambient',
980
+ 'powers here — felt through borders, trade influence, a security presence, taxes or old scars. Do',
981
+ 'NOT make confederation politics or warfare the subject; keep the focus local, lived and grounded.',
982
+ ];
983
+
984
+ // Build system prompt with originality guidance and temporal distortion refinement.
985
+ const temporalGuidance = buildTemporalDistortionGuidance(contextKey);
986
+ const originalityGuidance = buildOriginalityGuidance();
987
+
988
+ const system = [
989
+ 'You are the lore-master of Cyberia. Using the BASE LORE below, invent ONE distinct, specific saga',
990
+ 'premise that lives inside this world. Make it novel and grounded — never a generic or repeated setup,',
991
+ 'and do NOT default to a "spaceship mission" or a war between confederations.',
992
+ originalityGuidance,
993
+ temporalGuidance,
994
+ 'Return ONLY JSON: { "theme": string } where theme is 1-2 concrete, evocative sentences.',
995
+ '',
996
+ "CRITICAL — the saga's MAIN SUBJECT (what it is really about) is:",
997
+ `${subject}.`,
998
+ '',
999
+ 'CRITICAL — the premise MUST be set in this spatial context:',
1000
+ SPACE_CONTEXTS[contextKey],
1001
+ '',
1002
+ 'CRITICAL — the premise MUST commit fully to this narrative type / tone:',
1003
+ TONES[toneKey],
1004
+ '',
1005
+ ...factionGuidance,
1006
+ '',
1007
+ 'BASE LORE:',
1008
+ lore || '(no lore provided)',
1009
+ ].join('\n');
1010
+
1011
+ const user = [
1012
+ 'Invent a fresh saga premise now, built around the MAIN SUBJECT above.',
1013
+ `Creative entropy token: ${nonce}.`,
1014
+ 'Honor the spatial context, narrative tone, subject and faction emphasis exactly, and choose an',
1015
+ 'unexpected, lived-in corner of the lore.',
1016
+ ].join('\n');
1017
+
1018
+ logger.info(
1019
+ `Theme: subject="${subject}" | context=${contextKey} | tone=${toneKey} | ` +
1020
+ `factions=${factionDriven ? factions.join(', ') : 'background'}`,
1021
+ );
1022
+ const res = await client.chatJson({
1023
+ system,
1024
+ user,
1025
+ thinkingLevel,
1026
+ temperature: typeof temperature === 'number' ? temperature : DEFAULT_THEME_TEMPERATURE,
1027
+ });
1028
+ const theme = String(res.theme || '').trim();
1029
+ if (!theme) throw new Error('Theme synthesis returned an empty theme.');
1030
+ return { theme, spaceContext: contextKey, tone: toneKey, subject, factions };
1031
+ }
1032
+
1033
+ /**
1034
+ * Slugify a free-text string into a stable kebab-case code.
1035
+ * @param {string} input
1036
+ * @returns {string}
1037
+ */
1038
+ function slugify(input) {
1039
+ return String(input || '')
1040
+ .toLowerCase()
1041
+ .normalize('NFKD')
1042
+ .replace(/[^\w\s-]/g, '')
1043
+ .trim()
1044
+ .replace(/[\s_]+/g, '-')
1045
+ .replace(/-+/g, '-')
1046
+ .slice(0, 64);
1047
+ }
1048
+
1049
+ /**
1050
+ * Shared role + hard-rules preamble prepended to every stage prompt.
1051
+ * The full ecosystem is generated in small sequential stages (see {@link generateSaga})
1052
+ * rather than one monolithic request, which keeps each model call bounded.
1053
+ * @type {string}
1054
+ */
1055
+ const ROLE_PREAMBLE = [
1056
+ 'You are a senior narrative systems designer for a top-down cyberpunk MMO called Cyberia.',
1057
+ 'You perform SEMANTIC REVERSE-ENGINEERING: from a high-level theme you infer a self-consistent',
1058
+ 'non-spatial textual game ontology BEFORE any map or art exists.',
1059
+ '',
1060
+ 'HARD RULES:',
1061
+ '- TEXT/METADATA ONLY. Never invent spatial or graphic data. All spatial fields (sourceMapCode,',
1062
+ ' sourceCellX, sourceCellY, initCellX, initCellY, grid sizes) and all render/asset fields MUST be',
1063
+ ' null. No CIDs, no textures, no coordinates.',
1064
+ '- Every code/id MUST be lowercase kebab-case, unique, and semantically tied to the theme.',
1065
+ '- Output ONLY a single valid JSON object. No markdown fences, no prose.',
1066
+ ].join('\n');
1067
+
1068
+ /**
1069
+ * Logic-event handlers the cyberia-server skill dispatcher actually implements.
1070
+ * The skills stage MUST pick `logicEventId` from these keys — any other value is
1071
+ * a no-op in the simulation, so normalization drops unknown ones.
1072
+ * Mirrors DefaultSkillConfig (cyberia-server-defaults.js).
1073
+ * @type {Object<string, string>}
1074
+ */
1075
+ const SKILL_LOGIC_EVENTS = {
1076
+ projectile:
1077
+ 'Fires a projectile toward the tap direction, summoning a skill/bullet entity. Spawn chance and lifetime scale with Intelligence and Range.',
1078
+ coin_drop_or_transaction:
1079
+ 'Drops coins automatically when an entity is killed; transfer amount scales with the kill-percent rules.',
1080
+ doppelganger: 'Summons a passive clone of the caster that wanders nearby. Spawn chance scales with Intelligence.',
1081
+ };
1082
+
1083
+ /**
1084
+ * Per-stage system prompt fragments. Each stage emits exactly one top-level key
1085
+ * and references canonical codes/ids handed in from previous stages.
1086
+ * @type {Object<string, string>}
1087
+ */
1088
+ const STAGE_PROMPTS = {
1089
+ foundation: [
1090
+ 'STAGE: foundation. Return ONE JSON object: { "saga": {...}, "objectLayers": [...] }.',
1091
+ 'saga: { code, name, description, mapCodes:[] } (leave mapCodes an empty array).',
1092
+ 'objectLayers[]: { stats:{ effect, resistance, agility, range, intelligence, utility },',
1093
+ ' item:{ id, type, description, activable }, render:null }',
1094
+ ' item.type is one of: skin, breastplate, weapon, skill, coin, floor, obstacle, portal,',
1095
+ ' foreground, resource; every stat is an integer 0..10 balanced to the lore.',
1096
+ 'Produce 5-10 object-layer items including at least one "coin" currency item AND at least two',
1097
+ '"skin" items that represent NAMED NPC characters players can speak to (these back "talk" quests).',
1098
+ ].join('\n'),
1099
+
1100
+ maps: [
1101
+ 'STAGE: maps. Return ONE JSON object: { "maps": [...] }.',
1102
+ 'maps[]: { code, name, description } (TEXT ONLY — no grid, cells, entities, coordinates or assets).',
1103
+ 'Maps are the zones / places where the saga unfolds: the distinct locations the quest chain visits',
1104
+ 'so the story makes sense (e.g. a hub settlement, a contested site, a hidden base, a final',
1105
+ 'confrontation). Produce 3-6 maps; codes are lowercase kebab-case and unique.',
1106
+ ].join('\n'),
1107
+
1108
+ quests: [
1109
+ 'STAGE: quests. Return ONE JSON object: { "quests": [...] }.',
1110
+ 'quests[]: { code, title, description, prerequisiteCodes:[string], unlocksQuestCodes:[string],',
1111
+ ' sourceMapCode:null, sourceCellX:null, sourceCellY:null,',
1112
+ ' steps:[ { id:string, description:string, objectives:[ { type:"collect"|"talk"|"kill",',
1113
+ ' itemId:string, quantity:number } ] } ], rewards:[ { itemId:string, quantity:number } ] }',
1114
+ 'Objective itemId semantics: collect -> a collectible item id; kill -> the target enemy SKIN id;',
1115
+ ' talk -> the SKIN id of the NPC the player must speak to.',
1116
+ 'Produce 3-6 quests forming a small unlock chain (use prerequisiteCodes / unlocksQuestCodes).',
1117
+ 'REQUIRED: at least one quest MUST contain a "talk" objective whose itemId is one of the provided',
1118
+ 'NPC skin item ids. Every objective itemId and reward itemId MUST be one of the provided item ids.',
1119
+ ].join('\n'),
1120
+
1121
+ dialogues: [
1122
+ 'STAGE: dialogues. Return ONE JSON object: { "dialogues": [...] }.',
1123
+ 'dialogues[]: { code, order:number, speaker:string, text:string, mood:string }',
1124
+ 'Create one dialogue group (a shared code with incrementing order 0,1,2,...) for each provided',
1125
+ 'quest, plus one or two NPC greeting groups. Make the text reflect each quest theme.',
1126
+ 'For EACH provided talk target, also create a dedicated talk dialogue group with code',
1127
+ '"quest-talk-<questCode>" spoken by that NPC — this is the conversation that completes the talk',
1128
+ 'objective. Use the speaker name matching the NPC skin.',
1129
+ ].join('\n'),
1130
+
1131
+ actions: [
1132
+ 'STAGE: actions. Return ONE JSON object: { "actions": [...] }.',
1133
+ 'actions[]: { code, label, dialogCode, sourceMapCode:null, sourceCellX:null, sourceCellY:null,',
1134
+ ' questDialogueCodes:[ { questCode, dialogCode } ], shopItems:[ { itemId, priceItemId, priceQty } ],',
1135
+ ' craftRecipes:[ { outputItems:[ { itemId, qty } ], ingredients:[ { itemId, qty } ] } ],',
1136
+ ' storageSlots:number }',
1137
+ 'A "talk" objective is fulfilled ONLY when the player views the dialogCode that an action maps for',
1138
+ 'that quest. So for EVERY provided talk target { questCode, skin } you MUST output an action that',
1139
+ 'represents that NPC: set dialogCode to "default-<skin>" (the NPC greeting) and include a',
1140
+ "questDialogueCodes entry { questCode, dialogCode } whose dialogCode is that quest's talk dialogue",
1141
+ '(prefer "quest-talk-<questCode>"). Without this entry the talk objective can NEVER be completed.',
1142
+ 'Produce 2-4 actions. Each questDialogueCodes[].questCode MUST be a provided quest code; every',
1143
+ 'questDialogueCodes[].dialogCode MUST be a provided dialogue code; every shop/craft itemId MUST be',
1144
+ 'a provided item id.',
1145
+ ].join('\n'),
1146
+
1147
+ skills: [
1148
+ 'STAGE: skills. Return ONE JSON object: { "skills": [...] }.',
1149
+ 'skills[]: { triggerItemId, skills:[ { logicEventId, name, description, summonedEntityItemId } ] }',
1150
+ ' (omit logicEventIds — it is derived from skills[].logicEventId downstream).',
1151
+ '- triggerItemId MUST be one of the provided item ids — the item whose active layer fires the skill',
1152
+ ' (typically a "weapon" item, or the "coin" currency item).',
1153
+ '- logicEventId MUST be EXACTLY one of these supported handlers (anything else is ignored):',
1154
+ ' projectile, coin_drop_or_transaction, doppelganger.',
1155
+ '- summonedEntityItemId: for projectile, a provided "skill"-type item id (the bullet/effect spawned);',
1156
+ ' for coin_drop_or_transaction, the provided "coin" item id; for doppelganger, the literal',
1157
+ ' "$active_skin" (a runtime placeholder for the caster\'s own skin).',
1158
+ '- name + description: short, evocative text tied to THIS saga theme (the in-game skill copy).',
1159
+ 'Produce 1-4 skills. Bind weapon items to "projectile" and the coin item to',
1160
+ '"coin_drop_or_transaction" where it fits the theme. Every triggerItemId and every non-placeholder',
1161
+ 'summonedEntityItemId MUST be a provided item id.',
1162
+ ].join('\n'),
1163
+ };
1164
+
1165
+ /**
1166
+ * Build a customization context block describing the spatial context, narrative
1167
+ * tone, faction emphasis, and subject that the model MUST honor in every stage.
1168
+ * This is the bridge between CLI overrides (--space-context, --tone, --factions)
1169
+ * and the generative prompts when the user provides a custom --prompt.
1170
+ * @param {Object} [opts]
1171
+ * @param {string} [opts.spaceContextKey] - 'physical' | 'mixed' | 'hyperspace'
1172
+ * @param {string} [opts.toneKey] - 'adventure' | 'politics' | 'tragic' | 'comedy'
1173
+ * @param {string[]} [opts.factions] - Resolved faction description strings
1174
+ * @param {string} [opts.subject] - The world-first subject string
1175
+ * @returns {string}
1176
+ */
1177
+ function buildCustomizationGuidance({ spaceContextKey, toneKey, factions, subject } = {}) {
1178
+ const lines = [];
1179
+ if (subject) {
1180
+ lines.push(`CRITICAL — the saga's MAIN SUBJECT (what it is really about) is: ${subject}`);
1181
+ }
1182
+ if (spaceContextKey && SPACE_CONTEXTS[spaceContextKey]) {
1183
+ lines.push(`CRITICAL — the premise MUST be set in this spatial context: ${SPACE_CONTEXTS[spaceContextKey]}`);
1184
+ }
1185
+ if (toneKey && TONES[toneKey]) {
1186
+ lines.push(`CRITICAL — the premise MUST commit fully to this narrative type / tone: ${TONES[toneKey]}`);
1187
+ }
1188
+ if (factions && factions.length > 0) {
1189
+ lines.push(
1190
+ 'Faction emphasis — DRIVER: these confederation power(s) are the central pressure behind the saga:',
1191
+ `${factions.join(', ')}.`,
1192
+ 'Even so, tell it through specific people, places and the MAIN SUBJECT above — show their reach as',
1193
+ 'security, borders, edicts, agents or trade, not as abstract galaxy-spanning politics.',
1194
+ );
1195
+ } else {
1196
+ lines.push(
1197
+ 'Faction emphasis — BACKGROUND ONLY: the confederations (Zenith, Atlas, Nova) are distant, ambient',
1198
+ 'powers here — felt through borders, trade influence, a security presence, taxes or old scars. Do',
1199
+ 'NOT make confederation politics or warfare the subject; keep the focus local, lived and grounded.',
1200
+ );
1201
+ }
1202
+ return lines.join('\n');
1203
+ }
1204
+
1205
+ /**
1206
+ * Compose a stage system prompt from the shared preamble, the stage fragment,
1207
+ * and (optionally) the shared naming/character-culture, originality, and customization guidance.
1208
+ * Injects originality anti-cliche and temporal distortion refinement into every stage.
1209
+ * @param {keyof typeof STAGE_PROMPTS} stage
1210
+ * @param {string} [namingGuidance] - Shared naming guidance (omitted when empty).
1211
+ * @param {string} [customizationGuidance] - Space/tone/faction/subject guidance (omitted when empty).
1212
+ * @param {string} [spaceContextKey] - Used to conditionally inject temporal distortion.
1213
+ * @returns {string}
1214
+ */
1215
+ function buildStagePrompt(stage, namingGuidance = '', customizationGuidance = '', spaceContextKey = '') {
1216
+ const parts = [ROLE_PREAMBLE, STAGE_PROMPTS[stage]];
1217
+ if (namingGuidance) parts.push(namingGuidance);
1218
+ if (customizationGuidance) parts.push(customizationGuidance);
1219
+ // Inject anti-cliche guidance into every stage.
1220
+ parts.push(buildOriginalityGuidance());
1221
+ // Inject temporal distortion refinement when applicable.
1222
+ const temporalGuidance = buildTemporalDistortionGuidance(spaceContextKey);
1223
+ if (temporalGuidance) parts.push(temporalGuidance);
1224
+ return parts.join('\n\n');
1225
+ }
1226
+
1227
+ /**
1228
+ * Build the user message for a stage: optional base lore for grounding, the
1229
+ * theme, and a compact JSON context of canonical references from prior stages.
1230
+ * @param {string} theme
1231
+ * @param {Object} [context]
1232
+ * @param {string} [lore] - Base lore text to ground the stage (omitted when empty).
1233
+ * @returns {string}
1234
+ */
1235
+ function buildStageUser(theme, context, lore) {
1236
+ const lines = [];
1237
+ if (lore) lines.push('BASE LORE CONTEXT (ground the saga in this world):', lore, '');
1238
+ lines.push(`Theme seed: ${theme}`);
1239
+ if (context && Object.keys(context).length > 0) {
1240
+ lines.push('', 'Use ONLY these canonical references where required:', JSON.stringify(context));
1241
+ }
1242
+ return lines.join('\n');
1243
+ }
1244
+
1245
+ /**
1246
+ * Coerce any value to a plain array.
1247
+ * @param {*} value
1248
+ * @returns {Array}
1249
+ */
1250
+ function asArray(value) {
1251
+ return Array.isArray(value) ? value : [];
1252
+ }
1253
+
1254
+ /**
1255
+ * Extract the distinct (questCode, skin) targets implied by every `talk`
1256
+ * objective. The objective's itemId is the SKIN id of the NPC to speak to.
1257
+ * Tolerant of raw (un-normalized) or normalized quests.
1258
+ * @param {Object[]} quests
1259
+ * @returns {{ questCode: string, skin: string }[]}
1260
+ */
1261
+ function collectTalkTargets(quests) {
1262
+ const seen = new Set();
1263
+ const targets = [];
1264
+ for (const q of asArray(quests)) {
1265
+ const questCode = slugify(q.code);
1266
+ if (!questCode) continue;
1267
+ for (const step of asArray(q.steps)) {
1268
+ for (const o of asArray(step.objectives)) {
1269
+ if (o?.type === 'talk' && o.itemId) {
1270
+ const skin = slugify(o.itemId);
1271
+ const key = `${questCode}::${skin}`;
1272
+ if (skin && !seen.has(key)) {
1273
+ seen.add(key);
1274
+ targets.push({ questCode, skin });
1275
+ }
1276
+ }
1277
+ }
1278
+ }
1279
+ }
1280
+ return targets;
1281
+ }
1282
+
1283
+ /**
1284
+ * Build a minimal, schema-valid NPC skin object-layer item.
1285
+ * @param {string} id
1286
+ * @returns {Object}
1287
+ */
1288
+ function makeSkinItem(id) {
1289
+ return {
1290
+ stats: { effect: 1, resistance: 1, agility: 1, range: 1, intelligence: 1, utility: 1 },
1291
+ item: { id, type: 'skin', description: `NPC ${id}`, activable: false },
1292
+ render: null,
1293
+ };
1294
+ }
1295
+
1296
+ /**
1297
+ * Guarantee every `talk` objective is fulfillable. A talk objective completes
1298
+ * only when the player views the dialogCode an action maps for that quest, on
1299
+ * the NPC bot whose skin matches the objective's itemId. This repair makes the
1300
+ * data self-consistent even when the model omits a piece: for each talk target
1301
+ * it ensures (1) the NPC skin item exists, (2) a talk dialogue exists, and
1302
+ * (3) the action for `default-<skin>` maps questCode -> that dialogue.
1303
+ *
1304
+ * Mutates the provided arrays in place.
1305
+ * @param {{ quests: Object[], dialogues: Object[], actions: Object[], objectLayers: Object[] }} payload
1306
+ */
1307
+ function ensureTalkLinkage({ quests, dialogues, actions, objectLayers }) {
1308
+ const itemIndex = new Map(objectLayers.map((ol) => [ol.item.id, ol]));
1309
+ const dialogueCodes = new Set(dialogues.map((d) => d.code));
1310
+
1311
+ for (const { questCode, skin } of collectTalkTargets(quests)) {
1312
+ // 1. The talk objective's itemId must resolve to a real skin item.
1313
+ const existing = itemIndex.get(skin);
1314
+ if (!existing) {
1315
+ const item = makeSkinItem(skin);
1316
+ objectLayers.push(item);
1317
+ itemIndex.set(skin, item);
1318
+ } else if (existing.item.type !== 'skin') {
1319
+ existing.item.type = 'skin';
1320
+ }
1321
+
1322
+ // 2. The talk dialogue the player must view to complete the objective.
1323
+ const talkDialogCode = slugify(`quest-talk-${questCode}`);
1324
+ if (!dialogueCodes.has(talkDialogCode)) {
1325
+ dialogues.push({ code: talkDialogCode, order: 0, speaker: skin, text: `(${skin} speaks.)`, mood: 'neutral' });
1326
+ dialogueCodes.add(talkDialogCode);
1327
+ }
1328
+
1329
+ // 3. The NPC action (skin = default-<skin>) must map this quest -> a dialogue.
1330
+ const greetCode = `default-${skin}`;
1331
+ let action = actions.find((a) => a.dialogCode === greetCode);
1332
+ if (!action) {
1333
+ action = {
1334
+ code: slugify(`loc-${questCode}-${skin}`),
1335
+ label: skin,
1336
+ dialogCode: greetCode,
1337
+ sourceMapCode: null,
1338
+ sourceCellX: null,
1339
+ sourceCellY: null,
1340
+ questDialogueCodes: [],
1341
+ shopItems: [],
1342
+ craftRecipes: [],
1343
+ storageSlots: 0,
1344
+ };
1345
+ actions.push(action);
1346
+ }
1347
+ if (!action.dialogCode) action.dialogCode = greetCode;
1348
+
1349
+ const entry = action.questDialogueCodes.find((qd) => qd.questCode === questCode);
1350
+ if (entry) {
1351
+ // Keep a model-provided mapping only if its dialogue actually exists.
1352
+ if (!dialogueCodes.has(entry.dialogCode)) entry.dialogCode = talkDialogCode;
1353
+ } else {
1354
+ action.questDialogueCodes.push({ questCode, dialogCode: talkDialogCode });
1355
+ }
1356
+ }
1357
+ }
1358
+
1359
+ /**
1360
+ * Resolve the NPC skin item id an action is mounted on: the `<skin>` in its
1361
+ * `default-<skin>` greeting dialog code, falling back to a slug of its label.
1362
+ * @param {Object} action
1363
+ * @returns {string}
1364
+ */
1365
+ function actionProviderSkin(action) {
1366
+ const dialogCode = String(action?.dialogCode || '');
1367
+ const prefix = 'default-';
1368
+ if (dialogCode.startsWith(prefix)) return dialogCode.slice(prefix.length);
1369
+ return slugify(action?.label || '');
1370
+ }
1371
+
1372
+ /**
1373
+ * Derive the saga's NPC-provider reference maps from the action graph — the
1374
+ * authoritative source binding NPCs to content: each action represents an NPC
1375
+ * skin (its `default-<skin>` greeting) and binds quests via `questDialogueCodes`.
1376
+ * Returns deduped entries shaped for the CyberiaSaga schema.
1377
+ * @param {Object[]} actions - Normalized actions (post-{@link ensureTalkLinkage}).
1378
+ * @returns {{ questCodes: {providerSkinItemId: string, questCode: string}[],
1379
+ * actionCodes: {providerSkinItemId: string, actionCode: string}[] }}
1380
+ */
1381
+ function deriveSagaProviderRefs(actions) {
1382
+ const questCodes = [];
1383
+ const actionCodes = [];
1384
+ const seenQuest = new Set();
1385
+ const seenAction = new Set();
1386
+
1387
+ for (const action of asArray(actions)) {
1388
+ const providerSkinItemId = actionProviderSkin(action);
1389
+ if (!providerSkinItemId) continue;
1390
+
1391
+ if (action.code && !seenAction.has(action.code)) {
1392
+ seenAction.add(action.code);
1393
+ actionCodes.push({ providerSkinItemId, actionCode: action.code });
1394
+ }
1395
+
1396
+ for (const qd of asArray(action.questDialogueCodes)) {
1397
+ const questCode = qd?.questCode;
1398
+ if (!questCode) continue;
1399
+ const key = `${providerSkinItemId}::${questCode}`;
1400
+ if (seenQuest.has(key)) continue;
1401
+ seenQuest.add(key);
1402
+ questCodes.push({ providerSkinItemId, questCode });
1403
+ }
1404
+ }
1405
+ return { questCodes, actionCodes };
1406
+ }
1407
+
1408
+ /**
1409
+ * Slugify an item reference, preserving the runtime placeholder sentinel
1410
+ * (`$active_skin` and any `$`-prefixed value), which the server resolves at
1411
+ * runtime rather than a real ObjectLayer id.
1412
+ * @param {string} value
1413
+ * @returns {string}
1414
+ */
1415
+ function slugifyItemRef(value) {
1416
+ const v = String(value || '').trim();
1417
+ return v.startsWith('$') ? v : slugify(v);
1418
+ }
1419
+
1420
+ /**
1421
+ * Build a minimal, schema-valid summoned "skill"-type object-layer item.
1422
+ * @param {string} id
1423
+ * @returns {Object}
1424
+ */
1425
+ function makeSkillItem(id) {
1426
+ return {
1427
+ stats: { effect: 1, resistance: 0, agility: 0, range: 1, intelligence: 1, utility: 0 },
1428
+ item: { id, type: 'skill', description: `Skill effect ${id}`, activable: false },
1429
+ render: null,
1430
+ };
1431
+ }
1432
+
1433
+ /**
1434
+ * Guarantee skill referential integrity: every non-placeholder
1435
+ * `summonedEntityItemId` must resolve to a real object-layer item, so a missing
1436
+ * one gets a minimal "skill"-type item appended (mirrors how `ensureTalkLinkage`
1437
+ * backs talk objectives with NPC skins). Mutates `objectLayers` in place.
1438
+ * @param {{ skills: Object[], objectLayers: Object[] }} payload
1439
+ */
1440
+ function ensureSkillLinkage({ skills, objectLayers }) {
1441
+ const itemIndex = new Map(objectLayers.map((ol) => [ol.item.id, ol]));
1442
+ for (const sk of skills) {
1443
+ for (const def of sk.skills) {
1444
+ const id = def.summonedEntityItemId;
1445
+ if (!id || id.startsWith('$') || itemIndex.has(id)) continue;
1446
+ const item = makeSkillItem(id);
1447
+ objectLayers.push(item);
1448
+ itemIndex.set(id, item);
1449
+ }
1450
+ }
1451
+ }
1452
+
1453
+ /**
1454
+ * Normalize a raw model payload into model-ready documents, enforcing the
1455
+ * text-only architectural boundary (spatial + render fields nulled).
1456
+ *
1457
+ * @param {Object} raw - Parsed Gemini JSON.
1458
+ * @param {Object} params
1459
+ * @param {string} params.theme - Original prompt seed (used to derive a saga code fallback).
1460
+ * @returns {{ saga: Object, instance: Object, maps: Object[], quests: Object[], dialogues: Object[], actions: Object[], objectLayers: Object[], skills: Object[] }}
1461
+ */
1462
+ function normalizeSagaPayload(raw, { theme }) {
1463
+ const rawSaga = raw.saga || {};
1464
+
1465
+ // Maps are narrative zones — text only (code, name, description); spatial /
1466
+ // grid / entity fields stay at their schema defaults.
1467
+ const maps = asArray(raw.maps)
1468
+ .map((m) => ({
1469
+ code: slugify(m.code),
1470
+ name: m.name || m.code || '',
1471
+ description: m.description || '',
1472
+ }))
1473
+ .filter((m) => m.code);
1474
+
1475
+ const quests = asArray(raw.quests).map((q) => ({
1476
+ code: slugify(q.code),
1477
+ title: q.title || q.code,
1478
+ description: q.description || '',
1479
+ prerequisiteCodes: asArray(q.prerequisiteCodes).map(slugify),
1480
+ unlocksQuestCodes: asArray(q.unlocksQuestCodes).map(slugify),
1481
+ // Spatial binding is out of scope for this stage.
1482
+ sourceMapCode: null,
1483
+ sourceCellX: null,
1484
+ sourceCellY: null,
1485
+ steps: asArray(q.steps).map((s, i) => ({
1486
+ id: s.id || `step-${i + 1}`,
1487
+ description: s.description || '',
1488
+ objectives: asArray(s.objectives).map((o) => ({
1489
+ type: o.type,
1490
+ itemId: slugify(o.itemId),
1491
+ quantity: Number(o.quantity) > 0 ? Number(o.quantity) : 1,
1492
+ })),
1493
+ })),
1494
+ rewards: asArray(q.rewards).map((r) => ({
1495
+ itemId: slugify(r.itemId),
1496
+ quantity: Number(r.quantity) > 0 ? Number(r.quantity) : 1,
1497
+ })),
1498
+ }));
1499
+
1500
+ const dialogues = asArray(raw.dialogues).map((d, i) => ({
1501
+ code: slugify(d.code),
1502
+ order: Number.isFinite(Number(d.order)) ? Number(d.order) : i,
1503
+ speaker: d.speaker || '',
1504
+ text: d.text || '',
1505
+ mood: d.mood || 'neutral',
1506
+ }));
1507
+
1508
+ const actions = asArray(raw.actions).map((a) => ({
1509
+ code: slugify(a.code),
1510
+ label: a.label || '',
1511
+ dialogCode: a.dialogCode ? slugify(a.dialogCode) : '',
1512
+ sourceMapCode: null,
1513
+ sourceCellX: null,
1514
+ sourceCellY: null,
1515
+ questDialogueCodes: asArray(a.questDialogueCodes).map((qd) => ({
1516
+ questCode: slugify(qd.questCode),
1517
+ dialogCode: slugify(qd.dialogCode),
1518
+ })),
1519
+ shopItems: asArray(a.shopItems).map((s) => ({
1520
+ itemId: slugify(s.itemId),
1521
+ priceItemId: slugify(s.priceItemId) || 'coin',
1522
+ priceQty: Number(s.priceQty) >= 0 ? Number(s.priceQty) : 1,
1523
+ })),
1524
+ craftRecipes: asArray(a.craftRecipes).map((c) => ({
1525
+ outputItems: asArray(c.outputItems).map((o) => ({
1526
+ itemId: slugify(o.itemId),
1527
+ qty: Number(o.qty) > 0 ? Number(o.qty) : 1,
1528
+ })),
1529
+ ingredients: asArray(c.ingredients).map((o) => ({
1530
+ itemId: slugify(o.itemId),
1531
+ qty: Number(o.qty) > 0 ? Number(o.qty) : 1,
1532
+ })),
1533
+ })),
1534
+ storageSlots: Number(a.storageSlots) > 0 ? Number(a.storageSlots) : 0,
1535
+ }));
1536
+
1537
+ const objectLayers = asArray(raw.objectLayers).map((ol) => {
1538
+ const stats = ol.stats || {};
1539
+ return {
1540
+ stats: {
1541
+ effect: Number(stats.effect) || 0,
1542
+ resistance: Number(stats.resistance) || 0,
1543
+ agility: Number(stats.agility) || 0,
1544
+ range: Number(stats.range) || 0,
1545
+ intelligence: Number(stats.intelligence) || 0,
1546
+ utility: Number(stats.utility) || 0,
1547
+ },
1548
+ item: {
1549
+ id: slugify(ol.item?.id),
1550
+ type: ol.item?.type || 'skin',
1551
+ description: ol.item?.description || '',
1552
+ activable: Boolean(ol.item?.activable),
1553
+ },
1554
+ // Graphic synthesis is a downstream stage.
1555
+ render: null,
1556
+ };
1557
+ });
1558
+
1559
+ // Skills bind a trigger item to one or more supported logic-event handlers.
1560
+ // The trigger must be a known item; defs with an unsupported logicEventId are
1561
+ // dropped (they would be a no-op in the simulation). logicEventIds is derived
1562
+ // from the kept defs so it can never drift (mirrors DefaultSkillConfig).
1563
+ const knownItemIds = new Set(objectLayers.map((ol) => ol.item.id).filter(Boolean));
1564
+ const validLogicEvents = new Set(Object.keys(SKILL_LOGIC_EVENTS));
1565
+ // Logic-event keys are fixed handler ids that use underscores
1566
+ // (coin_drop_or_transaction) — slugify() would convert '_' to '-' and break
1567
+ // the match, so normalize separators to '_' and compare against the closed set.
1568
+ const normLogicEvent = (v) =>
1569
+ String(v || '')
1570
+ .trim()
1571
+ .toLowerCase()
1572
+ .replace(/[\s-]+/g, '_');
1573
+ const skills = asArray(raw.skills)
1574
+ .map((sk) => {
1575
+ const triggerItemId = slugify(sk.triggerItemId);
1576
+ const defs = asArray(sk.skills)
1577
+ .map((d) => {
1578
+ const logicEventId = normLogicEvent(d.logicEventId);
1579
+ return {
1580
+ logicEventId,
1581
+ name: d.name || '',
1582
+ description: d.description || SKILL_LOGIC_EVENTS[logicEventId] || '',
1583
+ summonedEntityItemId: slugifyItemRef(d.summonedEntityItemId),
1584
+ };
1585
+ })
1586
+ .filter((d) => validLogicEvents.has(d.logicEventId));
1587
+ return { triggerItemId, logicEventIds: [...new Set(defs.map((d) => d.logicEventId))], skills: defs };
1588
+ })
1589
+ .filter((sk) => sk.triggerItemId && knownItemIds.has(sk.triggerItemId) && sk.skills.length > 0);
1590
+
1591
+ // Guarantee every talk objective has a backing NPC skin, dialogue, and action
1592
+ // mapping (may append skin items / dialogues / actions) before reconciling ids.
1593
+ ensureTalkLinkage({ quests, dialogues, actions, objectLayers });
1594
+ // Guarantee every summoned skill entity resolves to a real item (may append
1595
+ // skill items). Runs before itemIds are reconciled so they are recorded.
1596
+ ensureSkillLinkage({ skills, objectLayers });
1597
+
1598
+ const itemIds = objectLayers.map((ol) => ol.item.id).filter(Boolean);
1599
+ const mapCodes = maps.map((m) => m.code).filter(Boolean);
1600
+ // questCodes / actionCodes are NPC-provider maps derived from the (now
1601
+ // self-consistent) action graph — the single source of truth for which bot
1602
+ // provides each quest / hosts each action.
1603
+ const { questCodes, actionCodes } = deriveSagaProviderRefs(actions);
1604
+
1605
+ const saga = {
1606
+ code: slugify(rawSaga.code) || slugify(theme) || `cyberia-saga-${Date.now()}`,
1607
+ name: rawSaga.name || theme,
1608
+ description: rawSaga.description || '',
1609
+ mapCodes: [...new Set([...asArray(rawSaga.mapCodes).map(slugify), ...mapCodes])].filter(Boolean),
1610
+ itemIds: [...new Set([...asArray(rawSaga.itemIds).map(slugify), ...itemIds])].filter(Boolean),
1611
+ questCodes,
1612
+ actionCodes,
1613
+ };
1614
+
1615
+ // A CyberiaInstance derived from the saga's own metadata + the map codes it
1616
+ // defined — the playable shell that binds this saga's maps together. Spatial
1617
+ // topology (portals) and tuning (conf) belong to downstream synthesis, so they
1618
+ // are left empty here and PRESERVED on rerun by persistInstance.
1619
+ const instance = {
1620
+ code: saga.code,
1621
+ name: saga.name,
1622
+ description: saga.description,
1623
+ tags: ['saga', 'generated'],
1624
+ cyberiaMapCodes: [...saga.mapCodes],
1625
+ itemIds: saga.itemIds.map((id) => ({ id, defaultPlayerInventory: false })),
1626
+ portals: [],
1627
+ topologyMode: 'procedural',
1628
+ };
1629
+
1630
+ return { saga, instance, maps, quests, dialogues, actions, objectLayers, skills };
1631
+ }
1632
+
1633
+ /**
1634
+ * Persist normalized object-layer items into the ObjectLayer collection so they
1635
+ * are editable in the viewer. Items are created with an empty (`null`) render
1636
+ * and an `OFF_CHAIN` ledger; a render/ledger can be set later from
1637
+ * `src/client/components/cyberia/ObjectLayerEngineViewer.js`.
1638
+ *
1639
+ * Upserts by `data.item.id`. An existing item's render and ledger are PRESERVED
1640
+ * (never clobbered back to null) — only the textual stats/item fields are
1641
+ * refreshed. `sha256` is recomputed over the resulting data.
1642
+ *
1643
+ * @async
1644
+ * @param {Object} params
1645
+ * @param {Object[]} params.objectLayers - Normalized object-layer items ({ stats, item, render }).
1646
+ * @param {import('mongoose').Model} params.ObjectLayer - The ObjectLayer model.
1647
+ * @returns {Promise<number>} Count of items created or updated.
1648
+ */
1649
+ async function persistObjectLayers({ objectLayers, ObjectLayer }) {
1650
+ let count = 0;
1651
+ for (const ol of objectLayers) {
1652
+ const itemId = ol.item?.id;
1653
+ if (!itemId) continue;
1654
+
1655
+ const existing = await ObjectLayer.findOne({ 'data.item.id': itemId });
1656
+
1657
+ // Preserve any render already set via the viewer; default to empty render.
1658
+ const hasRender = existing?.data?.render?.cid || existing?.data?.render?.metadataCid;
1659
+ const render = hasRender ? existing.data.render : { cid: null, metadataCid: null };
1660
+ const ledger = existing?.data?.ledger?.type ? existing.data.ledger : { type: 'OFF_CHAIN', tokenId: '' };
1661
+
1662
+ const data = { stats: ol.stats, item: ol.item, ledger, render };
1663
+ const sha256 = computeSha256(data);
1664
+
1665
+ if (existing) {
1666
+ existing.data = data;
1667
+ existing.sha256 = sha256;
1668
+ await existing.save();
1669
+ } else {
1670
+ await ObjectLayer.create({ data, sha256, cid: null });
1671
+ }
1672
+ count++;
1673
+ }
1674
+ return count;
1675
+ }
1676
+
1677
+ /**
1678
+ * Persist the saga's CyberiaInstance shell. The textual/logical fields (name,
1679
+ * description, tags, map codes, item ids, topology) are refreshed every run;
1680
+ * spatial topology (`portals`) and the tuning ref (`conf`) are PRESERVED — set
1681
+ * only on insert — so downstream spatial synthesis is never clobbered back to
1682
+ * empty (mirrors how {@link persistObjectLayers} preserves render/ledger).
1683
+ *
1684
+ * @async
1685
+ * @param {Object} params
1686
+ * @param {Object} params.instance - Normalized instance ({ code, name, … }).
1687
+ * @param {import('mongoose').Model} params.CyberiaInstance - The CyberiaInstance model.
1688
+ * @returns {Promise<number>} 1 once upserted.
1689
+ */
1690
+ async function persistInstance({ instance, CyberiaInstance }) {
1691
+ await CyberiaInstance.findOneAndUpdate(
1692
+ { code: instance.code },
1693
+ {
1694
+ $set: {
1695
+ name: instance.name,
1696
+ description: instance.description,
1697
+ tags: instance.tags,
1698
+ cyberiaMapCodes: instance.cyberiaMapCodes,
1699
+ itemIds: instance.itemIds,
1700
+ topologyMode: instance.topologyMode,
1701
+ },
1702
+ $setOnInsert: { code: instance.code, portals: instance.portals || [] },
1703
+ },
1704
+ { upsert: true },
1705
+ );
1706
+ return 1;
1707
+ }
1708
+
1709
+ /**
1710
+ * Persist the normalized payload into MongoDB via the provided Mongoose models.
1711
+ * Upserts by natural key so reruns are idempotent.
1712
+ *
1713
+ * @async
1714
+ * @param {Object} params
1715
+ * @param {Object} params.payload - Output of {@link normalizeSagaPayload}.
1716
+ * @param {Object} params.models - { CyberiaSaga, CyberiaInstance?, CyberiaMap?, CyberiaQuest, CyberiaDialogue, CyberiaAction, CyberiaSkill?, ObjectLayer? }.
1717
+ * @returns {Promise<{ saga, instance, maps, quests, dialogues, actions, skills, objectLayers: number }>}
1718
+ */
1719
+ async function persistSagaPayload({ payload, models }) {
1720
+ const {
1721
+ CyberiaSaga,
1722
+ CyberiaInstance,
1723
+ CyberiaMap,
1724
+ CyberiaQuest,
1725
+ CyberiaDialogue,
1726
+ CyberiaAction,
1727
+ CyberiaSkill,
1728
+ ObjectLayer,
1729
+ } = models;
1730
+ const { saga, instance, maps, quests, dialogues, actions, skills, objectLayers } = payload;
1731
+
1732
+ await CyberiaSaga.findOneAndUpdate({ code: saga.code }, { $set: saga }, { upsert: true });
1733
+
1734
+ let instanceCount = 0;
1735
+ if (CyberiaInstance && instance?.code) {
1736
+ instanceCount = await persistInstance({ instance, CyberiaInstance });
1737
+ }
1738
+
1739
+ let mapCount = 0;
1740
+ if (CyberiaMap) {
1741
+ for (const map of asArray(maps)) {
1742
+ await CyberiaMap.findOneAndUpdate({ code: map.code }, { $set: map }, { upsert: true });
1743
+ mapCount++;
1744
+ }
1745
+ }
1746
+ for (const quest of quests) {
1747
+ await CyberiaQuest.findOneAndUpdate({ code: quest.code }, { $set: quest }, { upsert: true });
1748
+ }
1749
+ for (const dialogue of dialogues) {
1750
+ await CyberiaDialogue.findOneAndUpdate(
1751
+ { code: dialogue.code, order: dialogue.order },
1752
+ { $set: dialogue },
1753
+ { upsert: true },
1754
+ );
1755
+ }
1756
+ for (const action of actions) {
1757
+ await CyberiaAction.findOneAndUpdate({ code: action.code }, { $set: action }, { upsert: true });
1758
+ }
1759
+
1760
+ // Skills live in their own collection (CyberiaSkill), upserted by triggerItemId
1761
+ // — the authoritative own-model source the gRPC server reads.
1762
+ let skillCount = 0;
1763
+ if (CyberiaSkill) {
1764
+ for (const skill of asArray(skills)) {
1765
+ await CyberiaSkill.findOneAndUpdate(
1766
+ { triggerItemId: skill.triggerItemId },
1767
+ { $set: { logicEventIds: skill.logicEventIds, skills: skill.skills } },
1768
+ { upsert: true },
1769
+ );
1770
+ skillCount++;
1771
+ }
1772
+ }
1773
+
1774
+ const objectLayerCount = ObjectLayer ? await persistObjectLayers({ objectLayers, ObjectLayer }) : 0;
1775
+
1776
+ return {
1777
+ saga: 1,
1778
+ instance: instanceCount,
1779
+ maps: mapCount,
1780
+ quests: quests.length,
1781
+ dialogues: dialogues.length,
1782
+ actions: actions.length,
1783
+ skills: skillCount,
1784
+ objectLayers: objectLayerCount,
1785
+ };
1786
+ }
1787
+
1788
+ /**
1789
+ * Generate the raw six-section ecosystem in five bounded, sequential stages.
1790
+ * Each stage produces one layer and feeds canonical (slugified) references into
1791
+ * the next, so no single model call has to emit the whole cross-referenced graph.
1792
+ *
1793
+ * @async
1794
+ * @param {GeminiClient} client
1795
+ * @param {string} theme
1796
+ * @param {string} [thinkingLevel]
1797
+ * @param {string} [lore] - Base lore text to ground every stage (empty = ungrounded).
1798
+ * @param {number} [temperature] - Sampling temperature applied to every stage (model default if omitted).
1799
+ * @param {string} [namingGuidance] - Shared naming/character-culture guidance for every stage.
1800
+ * @param {string} [customizationGuidance] - Space/tone/faction/subject guidance for every stage.
1801
+ * @param {string} [spaceContextKey] - Used to inject temporal distortion refinement per stage.
1802
+ * @returns {Promise<{ saga, maps, quests, dialogues, actions, objectLayers }>}
1803
+ */
1804
+ async function generateRawEcosystem(
1805
+ client,
1806
+ theme,
1807
+ thinkingLevel,
1808
+ lore = '',
1809
+ temperature,
1810
+ namingGuidance = '',
1811
+ customizationGuidance = '',
1812
+ spaceContextKey = '',
1813
+ ) {
1814
+ // Stage 1 — saga identity + object-layer items (the economic foundation).
1815
+ logger.info('Stage 1/6: foundation (saga + object layers)');
1816
+ const foundation = await client.chatJson({
1817
+ system: buildStagePrompt('foundation', namingGuidance, customizationGuidance, spaceContextKey),
1818
+ user: buildStageUser(theme, undefined, lore),
1819
+ thinkingLevel,
1820
+ temperature,
1821
+ });
1822
+ const saga = foundation.saga || {};
1823
+ const objectLayers = asArray(foundation.objectLayers);
1824
+ const itemIds = objectLayers.map((ol) => slugify(ol.item?.id)).filter(Boolean);
1825
+
1826
+ // Stage 2 — maps: the narrative zones the quest chain visits.
1827
+ logger.info('Stage 2/6: maps');
1828
+ const mapsRes = await client.chatJson({
1829
+ system: buildStagePrompt('maps', namingGuidance, customizationGuidance, spaceContextKey),
1830
+ user: buildStageUser(theme, undefined, lore),
1831
+ thinkingLevel,
1832
+ temperature,
1833
+ });
1834
+ const maps = asArray(mapsRes.maps);
1835
+ const mapCodes = maps.map((m) => slugify(m.code)).filter(Boolean);
1836
+
1837
+ // Stage 3 — quests referencing the canonical item ids, grounded in the zones.
1838
+ logger.info('Stage 3/6: quests');
1839
+ const questsRes = await client.chatJson({
1840
+ system: buildStagePrompt('quests', namingGuidance, customizationGuidance, spaceContextKey),
1841
+ user: buildStageUser(
1842
+ theme,
1843
+ { itemIds, maps: maps.map((m) => ({ code: slugify(m.code), name: m.name || '' })) },
1844
+ lore,
1845
+ ),
1846
+ thinkingLevel,
1847
+ temperature,
1848
+ });
1849
+ const quests = asArray(questsRes.quests);
1850
+ const questCodes = quests.map((q) => slugify(q.code)).filter(Boolean);
1851
+ // NPCs that must be talked to (questCode + skin) drive the dialogue + action linkage.
1852
+ const talkTargets = collectTalkTargets(quests);
1853
+
1854
+ // Stage 4 — dialogues for each quest (and a talk dialogue per talk target).
1855
+ logger.info('Stage 4/6: dialogues');
1856
+ const dialoguesRes = await client.chatJson({
1857
+ system: buildStagePrompt('dialogues', namingGuidance, customizationGuidance, spaceContextKey),
1858
+ user: buildStageUser(
1859
+ theme,
1860
+ { quests: quests.map((q) => ({ code: slugify(q.code), title: q.title || '' })), talkTargets },
1861
+ lore,
1862
+ ),
1863
+ thinkingLevel,
1864
+ temperature,
1865
+ });
1866
+ const dialogues = asArray(dialoguesRes.dialogues);
1867
+ const dialogueCodes = [...new Set(dialogues.map((d) => slugify(d.code)).filter(Boolean))];
1868
+
1869
+ // Stage 5 — actions binding quests, dialogues, and items together.
1870
+ logger.info('Stage 5/6: actions');
1871
+ const actionsRes = await client.chatJson({
1872
+ system: buildStagePrompt('actions', namingGuidance, customizationGuidance, spaceContextKey),
1873
+ user: buildStageUser(theme, { questCodes, dialogueCodes, itemIds, talkTargets }, lore),
1874
+ thinkingLevel,
1875
+ temperature,
1876
+ });
1877
+ const actions = asArray(actionsRes.actions);
1878
+
1879
+ // Stage 6 — skills: bind trigger items to supported logic-event handlers. The
1880
+ // model sees item ids WITH their types so it can pick valid trigger / summoned
1881
+ // items, and the closed set of logic events it may use.
1882
+ logger.info('Stage 6/6: skills');
1883
+ const itemsWithTypes = objectLayers
1884
+ .map((ol) => ({ id: slugify(ol.item?.id), type: ol.item?.type || 'skin' }))
1885
+ .filter((it) => it.id);
1886
+ const skillsRes = await client.chatJson({
1887
+ system: buildStagePrompt('skills', namingGuidance, customizationGuidance, spaceContextKey),
1888
+ user: buildStageUser(theme, { items: itemsWithTypes, logicEvents: Object.keys(SKILL_LOGIC_EVENTS) }, lore),
1889
+ thinkingLevel,
1890
+ temperature,
1891
+ });
1892
+ const skills = asArray(skillsRes.skills);
1893
+
1894
+ return { saga, maps, quests, dialogues, actions, objectLayers, skills };
1895
+ }
1896
+
1897
+ /**
1898
+ * Full Top-Down PCG pipeline: prompt → staged Gemini JSON → normalize → persist.
1899
+ *
1900
+ * @async
1901
+ * When `prompt` is omitted, a distinct theme is auto-synthesized from the Cyberia
1902
+ * base lore (and every stage is grounded in that lore). When `prompt` is given,
1903
+ * it is used as-is with no lore grounding. When `out` is omitted, the payload is
1904
+ * written to `./engine-private/cyberia-sagas/<saga-code>.json`.
1905
+ *
1906
+ * @param {Object} params
1907
+ * @param {string} [params.prompt] - Theme seed; if omitted, auto-generated from lore.
1908
+ * @param {Object} params.models - Loaded Mongoose models (see {@link persistSagaPayload}).
1909
+ * @param {string} [params.model] - Gemini model id override.
1910
+ * @param {string} [params.apiKey] - Gemini API key override.
1911
+ * @param {number} [params.timeout] - Per-request timeout in ms.
1912
+ * @param {string} [params.thinkingLevel] - Gemini thinking level (default in client).
1913
+ * @param {string} [params.lorePath] - Override path to the base-lore document.
1914
+ * @param {string} [params.spaceContext] - Force 'physical' | 'mixed' | 'hyperspace' (auto mode only).
1915
+ * @param {string} [params.tone] - Force 'adventure' | 'politics' | 'tragic' | 'comedy' (auto mode only).
1916
+ * @param {string} [params.factionContext] - Comma-separated faction keys to make the DRIVER (auto mode only).
1917
+ * @param {string} [params.characterContext] - Comma-separated CHARACTER_NAMES_POOL keys for naming inspiration (default random).
1918
+ * @param {string} [params.culturalExposure] - 'cosmopolitan' | 'local' naming diversity mode (default random).
1919
+ * @param {number} [params.temperature] - Sampling temperature applied to every model call.
1920
+ * @param {boolean} [params.dryRun=false] - Skip persistence; only generate + return.
1921
+ * @param {string} [params.out] - File path to dump the payload (defaults to the saga dir).
1922
+ * @returns {Promise<Object>} The normalized payload (with a `summary` when persisted).
1923
+ */
1924
+ async function generateSaga({
1925
+ prompt,
1926
+ models,
1927
+ model,
1928
+ apiKey,
1929
+ timeout,
1930
+ thinkingLevel,
1931
+ lorePath,
1932
+ spaceContext,
1933
+ tone,
1934
+ factionContext,
1935
+ characterContext,
1936
+ culturalExposure,
1937
+ temperature,
1938
+ dryRun = false,
1939
+ out,
1940
+ }) {
1941
+ const client = new GeminiClient({ apiKey, model, timeout });
1942
+
1943
+ let theme = prompt;
1944
+ let lore = '';
1945
+ let resolvedSpaceContextKey = '';
1946
+ let resolvedToneKey = '';
1947
+ let resolvedFactions = [];
1948
+ let resolvedSubject = '';
1949
+
1950
+ if (!theme) {
1951
+ lore = await loadLoreContext(lorePath);
1952
+ logger.info('No --prompt provided; auto-generating a distinct lore-grounded theme...');
1953
+ const synthesized = await synthesizeTheme(client, lore, {
1954
+ thinkingLevel,
1955
+ spaceContext,
1956
+ tone,
1957
+ factionContext,
1958
+ temperature,
1959
+ });
1960
+ theme = synthesized.theme;
1961
+ resolvedSpaceContextKey = synthesized.spaceContext;
1962
+ resolvedToneKey = synthesized.tone;
1963
+ resolvedSubject = synthesized.subject;
1964
+ resolvedFactions = synthesized.factions;
1965
+ logger.info(`Auto-generated theme: "${theme}"`);
1966
+ } else {
1967
+ logger.info(`Generating saga ontology from theme: "${theme}"`);
1968
+ // With --prompt, the user's prompt IS the creative driver — do NOT impose a
1969
+ // random SUBJECT. Only resolve space/tone/faction overrides if explicitly
1970
+ // provided (they stay at their default empty/false values otherwise, meaning
1971
+ // the model derives them from the prompt alone).
1972
+ resolvedSpaceContextKey = resolveSpaceContext(spaceContext);
1973
+ resolvedToneKey = resolveTone(tone);
1974
+ resolvedFactions = resolveFactionContext(factionContext);
1975
+ // Leave resolvedSubject empty when --prompt is given so the LLM has full
1976
+ // creative freedom — no SUBJECT bucket constrains the theme.
1977
+ resolvedSubject = '';
1978
+ logger.info(
1979
+ `Customization: context=${resolvedSpaceContextKey} | tone=${resolvedToneKey} | ` +
1980
+ `factions=${resolvedFactions.length ? resolvedFactions.join(', ') : 'background'}` +
1981
+ ' | subject=PROMPT (user-provided — no SUBJECT bucket applied)',
1982
+ );
1983
+ }
1984
+
1985
+ // Build customization guidance from resolved options — applies to every stage.
1986
+ const customizationGuidance = buildCustomizationGuidance({
1987
+ spaceContextKey: resolvedSpaceContextKey,
1988
+ toneKey: resolvedToneKey,
1989
+ factions: resolvedFactions,
1990
+ subject: resolvedSubject,
1991
+ });
1992
+
1993
+ // Naming/character-culture guidance applies to every stage (both prompt + auto modes).
1994
+ const namingGuidance = buildNamingGuidance({ characterContext, culturalExposure });
1995
+
1996
+ const raw = await generateRawEcosystem(
1997
+ client,
1998
+ theme,
1999
+ thinkingLevel,
2000
+ lore,
2001
+ temperature,
2002
+ namingGuidance,
2003
+ customizationGuidance,
2004
+ resolvedSpaceContextKey,
2005
+ );
2006
+ const payload = normalizeSagaPayload(raw, { theme });
2007
+
2008
+ const outPath = out || nodePath.join(DEFAULT_SAGA_OUT_DIR, `${payload.saga.code}.json`);
2009
+ return finalizeSaga({ payload, models, out: outPath, dryRun });
2010
+ }
2011
+
2012
+ /**
2013
+ * Import a previously generated payload file (the shape `--out` writes) and load
2014
+ * it into the database. Runs through the same normalize → persist path as
2015
+ * {@link generateSaga}, so codes are slugified and upserted (overwrite, never
2016
+ * duplicated). No model call is made.
2017
+ *
2018
+ * @async
2019
+ * @param {Object} params
2020
+ * @param {string} params.file - Path to the JSON payload file.
2021
+ * @param {Object} params.models - Loaded Mongoose models (see {@link persistSagaPayload}).
2022
+ * @param {boolean} [params.dryRun=false] - Normalize only; skip persistence.
2023
+ * @param {string} [params.out] - Optional path to re-dump the normalized payload.
2024
+ * @returns {Promise<Object>} The normalized payload (with a `summary` when persisted).
2025
+ */
2026
+ async function importSaga({ file, models, dryRun = false, out }) {
2027
+ if (!file) throw new Error('An --import file path is required.');
2028
+
2029
+ const filePath = nodePath.resolve(file);
2030
+ if (!fs.existsSync(filePath)) throw new Error(`Import file not found: ${filePath}`);
2031
+
2032
+ logger.info(`Importing saga payload from ${filePath}`);
2033
+ const raw = await fs.readJson(filePath);
2034
+ const payload = normalizeSagaPayload(raw, {
2035
+ theme: raw?.saga?.name || raw?.saga?.code || 'imported-saga',
2036
+ });
2037
+
2038
+ return finalizeSaga({ payload, models, out, dryRun });
2039
+ }
2040
+
2041
+ /**
2042
+ * Shared tail for generate/import: log the normalized shape, optionally dump it
2043
+ * to disk, then upsert into MongoDB (unless `dryRun`).
2044
+ *
2045
+ * @async
2046
+ * @param {Object} params
2047
+ * @param {Object} params.payload - Output of {@link normalizeSagaPayload}.
2048
+ * @param {Object} [params.models] - Loaded Mongoose models (required unless `dryRun`).
2049
+ * @param {string} [params.out] - Optional path to dump the normalized payload JSON.
2050
+ * @param {boolean} [params.dryRun=false] - Skip persistence; only normalize + dump.
2051
+ * @returns {Promise<Object>} The normalized payload (with a `summary` when persisted).
2052
+ */
2053
+ async function finalizeSaga({ payload, models, out, dryRun = false }) {
2054
+ logger.info(
2055
+ `Normalized: saga=${payload.saga.code} instance=${payload.instance?.code || '—'} ` +
2056
+ `maps=${payload.maps.length} quests=${payload.quests.length} ` +
2057
+ `dialogues=${payload.dialogues.length} actions=${payload.actions.length} ` +
2058
+ `skills=${payload.skills?.length || 0} objectLayers=${payload.objectLayers.length}`,
2059
+ );
2060
+
2061
+ if (out) {
2062
+ const outPath = nodePath.resolve(out);
2063
+ await fs.ensureDir(nodePath.dirname(outPath));
2064
+ await fs.writeJson(outPath, payload, { spaces: 2 });
2065
+ logger.info(`Payload written to ${outPath}`);
2066
+ }
2067
+
2068
+ if (dryRun) {
2069
+ logger.info('Dry run: skipping database persistence.');
2070
+ return payload;
2071
+ }
2072
+
2073
+ const summary = await persistSagaPayload({ payload, models });
2074
+ logger.info(
2075
+ `Persisted: ${summary.saga} saga, ${summary.instance} instance, ${summary.maps} maps, ` +
2076
+ `${summary.quests} quests, ${summary.dialogues} dialogues, ${summary.actions} actions, ` +
2077
+ `${summary.skills} skills, ${summary.objectLayers} object layers`,
2078
+ );
2079
+
2080
+ return { ...payload, summary };
2081
+ }
2082
+
2083
+ export {
2084
+ generateSaga,
2085
+ importSaga,
2086
+ finalizeSaga,
2087
+ generateRawEcosystem,
2088
+ normalizeSagaPayload,
2089
+ ensureTalkLinkage,
2090
+ ensureSkillLinkage,
2091
+ collectTalkTargets,
2092
+ deriveSagaProviderRefs,
2093
+ persistSagaPayload,
2094
+ persistInstance,
2095
+ persistObjectLayers,
2096
+ loadLoreContext,
2097
+ synthesizeTheme,
2098
+ resolveFactionContext,
2099
+ resolveCharacterContext,
2100
+ resolveCulturalExposure,
2101
+ buildNamingGuidance,
2102
+ buildStagePrompt,
2103
+ buildStageUser,
2104
+ slugify,
2105
+ buildOriginalityGuidance,
2106
+ buildTemporalDistortionGuidance,
2107
+ };