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.
- package/.env.example +127 -68
- package/.github/workflows/cyberia-client.cd.yml +40 -0
- package/.github/workflows/cyberia-server.cd.yml +40 -0
- package/.github/workflows/docker-image.cyberia-client.ci.yml +49 -0
- package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +48 -0
- package/.github/workflows/docker-image.cyberia-server.ci.yml +69 -0
- package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +69 -0
- package/.github/workflows/docker-image.engine-cyberia.ci.yml +52 -0
- package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +52 -0
- package/.github/workflows/engine-cyberia.cd.yml +33 -24
- package/.github/workflows/engine-cyberia.ci.yml +13 -3
- package/.github/workflows/ghpkg.ci.yml +88 -1
- package/.github/workflows/gitlab.ci.yml +1 -1
- package/.github/workflows/hardhat.ci.yml +1 -1
- package/.github/workflows/npmpkg.ci.yml +10 -7
- package/.github/workflows/publish.ci.yml +2 -2
- package/.github/workflows/publish.cyberia.ci.yml +5 -16
- package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -8
- package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
- package/CHANGELOG.md +301 -1
- package/CLI-HELP.md +71 -6
- package/Dockerfile +141 -43
- package/Dockerfile.dev +143 -0
- package/Dockerfile.test +165 -0
- package/README.md +1 -1
- package/baremetal/commission-workflows.json +1 -0
- package/bin/build.js +31 -0
- package/bin/cyberia.js +1080 -184
- package/bin/deploy.js +2 -2
- package/bin/index.js +1080 -184
- package/bump.config.js +1 -0
- package/compose.env +131 -0
- package/conf.js +18 -1
- package/deployment.yaml +2 -2
- package/docker-compose.yml +316 -0
- package/hardhat/hardhat.config.js +2 -2
- package/hardhat/package-lock.json +620 -2041
- package/hardhat/package.json +7 -5
- package/hardhat/scripts/deployObjectLayerToken.js +18 -18
- package/hardhat/test/ObjectLayerToken.js +378 -274
- package/ipfs/configure-ipfs.sh +13 -0
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/deployment/dd-cyberia-development/deployment.yaml +2 -2
- package/manifests/deployment/dd-cyberia-development/grpc-service.yaml +17 -0
- package/manifests/deployment/dd-cyberia-development/pv-pvc.yaml +32 -0
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/mongodb/entrypoint.sh +76 -0
- package/nginx.conf +87 -0
- package/package.json +31 -19
- package/pv-pvc.yaml +32 -0
- package/scripts/disk-clean.sh +85 -60
- package/scripts/kubeadm-node-setup.sh +317 -0
- package/scripts/rocky-kickstart.sh +877 -185
- package/scripts/rpmfusion-ffmpeg-setup.sh +26 -50
- package/scripts/test-monitor.sh +3 -5
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +43 -7
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +17 -25
- package/src/api/cyberia-action/cyberia-action.controller.js +19 -0
- package/src/api/cyberia-action/cyberia-action.model.js +21 -29
- package/src/api/cyberia-action/cyberia-action.router.js +42 -7
- package/src/api/cyberia-action/cyberia-action.service.js +20 -4
- package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +58 -57
- package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +37 -6
- package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +8 -2
- package/src/api/cyberia-entity/cyberia-entity.router.js +39 -7
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +74 -0
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +67 -0
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +63 -0
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +46 -0
- package/src/api/cyberia-instance/cyberia-fallback-world.js +62 -10
- package/src/api/cyberia-instance/cyberia-instance.model.js +32 -2
- package/src/api/cyberia-instance/cyberia-instance.router.js +6 -6
- package/src/api/cyberia-instance/cyberia-world-generator.js +116 -3
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +3 -0
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +39 -7
- package/src/api/cyberia-map/cyberia-map.router.js +21 -6
- package/src/api/cyberia-quest/cyberia-quest.controller.js +38 -0
- package/src/api/cyberia-quest/cyberia-quest.router.js +47 -7
- package/src/api/cyberia-quest/cyberia-quest.service.js +59 -4
- package/src/api/cyberia-saga/cyberia-saga.controller.js +74 -0
- package/src/api/cyberia-saga/cyberia-saga.model.js +59 -0
- package/src/api/cyberia-saga/cyberia-saga.router.js +63 -0
- package/src/api/cyberia-saga/cyberia-saga.service.js +42 -0
- package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +551 -129
- package/src/api/cyberia-skill/cyberia-skill.controller.js +74 -0
- package/src/api/cyberia-skill/cyberia-skill.model.js +50 -0
- package/src/api/cyberia-skill/cyberia-skill.router.js +63 -0
- package/src/api/cyberia-skill/cyberia-skill.service.js +42 -0
- package/src/api/ipfs/ipfs.service.js +28 -15
- package/src/api/object-layer/object-layer.router.js +25 -15
- package/src/api/object-layer/object-layer.service.js +15 -20
- package/src/api/object-layer-render-frames/object-layer-render-frames.router.js +39 -7
- package/src/cli/baremetal.js +717 -16
- package/src/cli/cluster.js +80 -5
- package/src/cli/deploy.js +127 -11
- package/src/cli/docker-compose.js +648 -0
- package/src/cli/env.js +11 -2
- package/src/cli/fs.js +75 -30
- package/src/cli/image.js +129 -51
- package/src/cli/index.js +110 -6
- package/src/cli/kickstart.js +142 -20
- package/src/cli/release.js +46 -4
- package/src/cli/repository.js +238 -33
- package/src/cli/run.js +520 -114
- package/src/cli/secrets.js +82 -48
- package/src/cli/ssh.js +234 -0
- package/src/cli/static.js +2 -2
- package/src/client/components/cyberia/ActionEngineCyberia.js +1867 -0
- package/src/client/components/cyberia/EntityEngineCyberia.js +585 -0
- package/src/client/components/cyberia/InstanceEngineCyberia.js +219 -13
- package/src/client/components/cyberia/MapEngineCyberia.js +154 -71
- package/src/client/components/cyberia/ObjectLayerEngineModal.js +40 -63
- package/src/client/components/cyberia/ObjectLayerEngineViewer.js +34 -20
- package/src/client/components/cyberia/SharedDefaultsCyberia.js +195 -4
- package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +66 -0
- package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +8 -0
- package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +8 -0
- package/src/client/public/cyberia-docs/ACTION-SYSTEM.md +45 -27
- package/src/client/public/cyberia-docs/CYBERIA-CLI.md +3 -3
- package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +39 -22
- package/src/client/public/cyberia-docs/CYBERIA-LORE.md +88 -0
- package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +394 -0
- package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +8 -1
- package/src/client/public/cyberia-docs/CYBERIA.md +1 -1
- package/src/client/public/cyberia-docs/QUEST-SYSTEM.md +5 -5
- package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
- package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
- package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +99 -0
- package/src/client/services/cyberia-instance/cyberia-instance.management.js +2 -2
- package/src/client/services/cyberia-map/cyberia-map.management.js +2 -2
- package/src/client/services/cyberia-saga/cyberia-saga.service.js +99 -0
- package/src/client/services/cyberia-skill/cyberia-skill.service.js +99 -0
- package/src/client/services/object-layer/object-layer.management.js +6 -6
- package/src/{server → client-builder}/client-build-docs.js +15 -5
- package/src/{server → client-builder}/client-build-live.js +3 -3
- package/src/{server → client-builder}/client-build.js +25 -22
- package/src/{server → client-builder}/client-dev-server.js +3 -3
- package/src/{server → client-builder}/client-icons.js +2 -2
- package/src/{server → client-builder}/ssr.js +5 -5
- package/src/client.build.js +1 -3
- package/src/client.dev.js +1 -1
- package/src/db/mongo/MongoBootstrap.js +12 -12
- package/src/grpc/cyberia/grpc-server.js +255 -70
- package/src/index.js +12 -1
- package/src/mailer/EmailRender.js +1 -1
- package/src/{server → projects/cyberia}/atlas-sprite-sheet-generator.js +2 -2
- package/src/{server → projects/cyberia}/besu-genesis-generator.js +3 -3
- package/src/projects/cyberia/catalog-cyberia.js +81 -0
- package/src/projects/cyberia/gemini-client.js +175 -0
- package/src/projects/cyberia/generate-saga.js +2107 -0
- package/src/{server → projects/cyberia}/ipfs-client.js +2 -2
- package/src/{server → projects/cyberia}/object-layer.js +12 -108
- package/src/{server → projects/cyberia}/semantic-layer-generator-floor.js +1 -1
- package/src/{server → projects/cyberia}/semantic-layer-generator-resource.js +1 -1
- package/src/{server → projects/cyberia}/semantic-layer-generator-skin.js +1 -1
- package/src/{server → projects/cyberia}/semantic-layer-generator.js +2 -2
- package/src/{server → projects/cyberia}/shape-generator.js +2 -2
- package/src/{server → projects/underpost}/catalog-underpost.js +1 -2
- package/src/runtime/cyberia-client/Dockerfile +27 -61
- package/src/runtime/cyberia-client/Dockerfile.dev +20 -12
- package/src/runtime/cyberia-server/Dockerfile +21 -20
- package/src/runtime/cyberia-server/Dockerfile.dev +17 -8
- package/src/runtime/engine-cyberia/Dockerfile +143 -0
- package/src/runtime/engine-cyberia/Dockerfile.dev +143 -0
- package/src/runtime/engine-cyberia/Dockerfile.test +165 -0
- package/src/runtime/engine-cyberia/compose.env +131 -0
- package/src/runtime/engine-cyberia/docker-compose.yml +316 -0
- package/src/runtime/engine-cyberia/ipfs/configure-ipfs.sh +13 -0
- package/src/runtime/engine-cyberia/mongodb/entrypoint.sh +76 -0
- package/src/runtime/engine-cyberia/nginx.conf +87 -0
- package/src/runtime/express/Express.js +2 -2
- package/src/runtime/nginx/Nginx.js +250 -0
- package/src/server/catalog.js +9 -14
- package/src/server/conf.js +3 -6
- package/src/server/runtime-status.js +18 -1
- package/src/server/start.js +12 -2
- package/src/server.js +6 -2
- package/test/cyberia-instance-conf-defaults.test.js +140 -0
- package/test/deploy-monitor.test.js +26 -10
- package/test/shape-generator.test.js +7 -1
- package/typedoc.dd-cyberia.json +3 -1
- package/typedoc.json +3 -1
- /package/src/client/ssr/{Render.js → RootDocument.js} +0 -0
- /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
|
+
};
|