@underpostnet/cyberia 3.3.73 → 3.3.76
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 +17 -5
- package/.github/workflows/coverall.cyberia.ci.yml +5 -0
- package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
- package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
- package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
- package/.github/workflows/publish.cyberia.ci.yml +44 -0
- package/AGENTS.md +61 -12
- package/CHANGELOG.md +126 -41
- package/CLI-HELP.md +19 -12
- package/Dockerfile +1 -1
- package/Dockerfile.dev +1 -1
- package/Dockerfile.test +2 -2
- package/README.md +28 -28
- package/bin/build.js +37 -17
- package/bin/cyberia.js +1247 -1373
- package/bin/index.js +1247 -1373
- package/compose.env +17 -5
- package/conf.js +7 -3
- package/deploy/cyberia-client/deploy.sh +10 -11
- package/deploy/cyberia-client/package.sh +0 -1
- package/deploy/cyberia-client/state.sh +3 -2
- package/deploy/cyberia-server/deploy.sh +10 -11
- package/deploy/cyberia-server/package.sh +0 -1
- package/deploy/cyberia-server/state.sh +3 -2
- package/deploy/dd-cyberia/deploy.sh +5 -5
- package/deploy/dd-cyberia/init.sh +15 -14
- package/deploy/dd-cyberia/package.sh +0 -1
- package/deploy/dd-cyberia/state.sh +2 -2
- package/deploy/dd-cyberia/sync-deploy.sh +139 -75
- package/deploy/lib/config.sh +29 -0
- package/deploy/lib/github-actions-logging.sh +92 -76
- package/deploy/lib/host.sh +27 -18
- package/deploy/lib/state.sh +4 -1
- package/deploy/pwa-microservices-template/deploy.sh +10 -8
- package/deploy/release/deploy.sh +0 -2
- package/deployment.yaml +1 -1
- package/docker-compose.yml +23 -17
- package/hardhat/package-lock.json +2 -2
- package/hardhat/package.json +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
- package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/mongo-express/deployment.yaml +46 -27
- package/manifests/deployment/mongo-express/kustomization.yaml +9 -0
- package/manifests/deployment/mongo-express/mongo-express-nodeport.yaml +18 -0
- package/manifests/deployment/mongo-express/service.yaml +16 -0
- package/manifests/deployment/mongo-express-no-auth/disable-mongodb-auth.yaml +16 -0
- package/manifests/deployment/mongo-express-no-auth/kustomization.yaml +16 -0
- package/package.json +19 -17
- package/scripts/test-monitor.sh +3 -3
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +14 -9
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +36 -5
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +5 -0
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +88 -184
- package/src/api/cyberia-action/cyberia-action.router.js +2 -2
- package/src/api/cyberia-action/cyberia-action.service.js +3 -4
- package/src/api/cyberia-audio/cyberia-audio.controller.js +6 -0
- package/src/api/cyberia-audio/cyberia-audio.model.js +61 -0
- package/src/api/cyberia-audio/cyberia-audio.router.js +21 -0
- package/src/api/cyberia-audio/cyberia-audio.service.js +220 -0
- package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -3
- package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +10 -19
- package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +12 -34
- package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +15 -32
- package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +1 -1
- package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
- package/src/api/cyberia-entity/cyberia-entity.model.js +2 -0
- package/src/api/cyberia-entity/cyberia-entity.service.js +1 -1
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +8 -2
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +31 -20
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +16 -1
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +466 -3
- package/src/api/cyberia-instance/cyberia-fallback-capture.js +87 -51
- package/src/api/cyberia-instance/cyberia-fallback-world.js +21 -40
- package/src/api/cyberia-instance/cyberia-instance-boot.service.js +5 -5
- package/src/api/cyberia-instance/cyberia-instance-items.js +98 -0
- package/src/api/cyberia-instance/cyberia-instance-map.service.js +34 -141
- package/src/api/cyberia-instance/cyberia-instance.controller.js +1 -4
- package/src/api/cyberia-instance/cyberia-instance.model.js +0 -14
- package/src/api/cyberia-instance/cyberia-instance.router.js +3 -16
- package/src/api/cyberia-instance/cyberia-instance.service.js +3 -43
- package/src/api/cyberia-instance/cyberia-world-generator.js +8 -13
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +49 -75
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +74 -1
- package/src/api/cyberia-map/cyberia-map.service.js +6 -2
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.controller.js +31 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.model.js +56 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.router.js +33 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +189 -0
- package/src/api/cyberia-quest/cyberia-quest.router.js +3 -3
- package/src/api/cyberia-quest/cyberia-quest.service.js +3 -3
- package/src/api/cyberia-quest-progress/cyberia-quest-progress.model.js +1 -1
- package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +403 -188
- package/src/api/cyberia-skill/cyberia-skill.model.js +6 -6
- package/src/api/file/file.ref.js +153 -0
- package/src/api/file/file.ref.json +11 -2
- package/src/api/object-layer/object-layer.model.js +47 -12
- package/src/api/object-layer/object-layer.router.js +10 -3
- package/src/api/object-layer/object-layer.service.js +113 -120
- package/src/cli/app.js +12 -1
- package/src/cli/client.js +2 -2
- package/src/cli/cluster.js +7 -0
- package/src/cli/deploy.js +4 -4
- package/src/cli/docker-compose.js +1 -1
- package/src/cli/fs-selection.js +115 -0
- package/src/cli/fs.js +158 -342
- package/src/cli/index.js +22 -10
- package/src/cli/repository.js +28 -0
- package/src/cli/run.js +71 -197
- package/src/cli/test.js +4 -0
- package/src/cli/wireguard.js +32 -31
- package/src/client/components/core/Docs.js +77 -100
- package/src/client/components/core/Modal.js +56 -1
- package/src/client/components/cyberia/ActionEngineCyberia.js +12 -36
- package/src/client/components/cyberia/EntityEngineCyberia.js +492 -121
- package/src/client/components/cyberia/InstanceEngineCyberia.js +129 -160
- package/src/client/components/cyberia/InstanceSelectionView.js +7 -1
- package/src/client/components/cyberia/MapEngineCyberia.js +175 -69
- package/src/client/components/cyberia/ObjectLayerEngine.js +1011 -97
- package/src/client/components/cyberia/ObjectLayerEngineModal.js +81 -155
- package/src/client/components/cyberia/ObjectLayerEngineViewer.js +113 -98
- package/src/client/components/cyberia/SharedDefaultsCyberia.js +215 -56
- package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +0 -34
- package/src/client/components/cyberia-portal/CssCyberiaPortal.js +1 -1
- package/src/client/components/cyberia-portal/MainBodyCyberiaPortal.js +712 -76
- package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +0 -4
- package/src/client/public/cyberia-docs/ARCHITECTURE.md +15 -15
- package/src/client/public/cyberia-docs/CYBERIA-CLI.md +280 -24
- package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +51 -29
- package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +52 -53
- package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +16 -11
- package/src/client/public/cyberia-docs/CYBERIA.md +28 -28
- package/src/client/public/cyberia-docs/ENTITY-PROFILE.md +11 -5
- package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
- package/src/client/public/cyberia-docs/STATS-PROGRESSION.md +217 -0
- package/src/client/public/cyberia-docs/WHITE-PAPER.md +20 -12
- package/src/client/services/cyberia-audio/cyberia-audio.service.js +99 -0
- package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +38 -0
- package/src/client/services/cyberia-instance/cyberia-instance.management.js +5 -4
- package/src/client/services/cyberia-instance/cyberia-instance.service.js +0 -40
- package/src/client/services/cyberia-map/cyberia-map.management.js +5 -4
- package/src/client/services/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +138 -0
- package/src/client/services/object-layer/object-layer.management.js +6 -10
- package/src/client/services/object-layer/object-layer.service.js +3 -1
- package/src/client-builder/client-build-docs.js +52 -52
- package/src/client-builder/client-build.js +67 -44
- package/src/client-builder/client-bundle.js +347 -0
- package/src/db/mongo/MongoExpress.js +187 -0
- package/src/grpc/cyberia/grpc-server.js +5 -7
- package/src/index.js +1 -1
- package/src/projects/cyberia/atlas-sprite-sheet-generator.js +143 -146
- package/src/projects/cyberia/atlas-sprite-sheet-store.js +391 -0
- package/src/projects/cyberia/besu-genesis-generator.js +5 -14
- package/src/projects/cyberia/boot-contract-fixtures.js +32 -0
- package/src/projects/cyberia/catalog-cyberia.js +3 -2
- package/src/projects/cyberia/gemini-client.js +3 -3
- package/src/projects/cyberia/generate-saga.js +4 -11
- package/src/projects/cyberia/hot-reload-trigger.js +17 -28
- package/src/projects/cyberia/instance-backup.js +210 -0
- package/src/projects/cyberia/instance-data.js +177 -257
- package/src/projects/cyberia/map-preview-generator.js +32 -68
- package/src/projects/cyberia/object-layer.js +152 -319
- package/src/projects/cyberia/seed-audio.js +139 -0
- package/src/projects/cyberia/semantic-layer-generator-skin.js +2 -8
- package/src/projects/cyberia/semantic-layer-generator.js +2 -18
- package/src/projects/cyberia/stat-balance.js +81 -0
- package/src/projects/cyberia/stat-commands.js +23 -0
- package/src/projects/cyberia/stat-contract-generator.js +101 -0
- package/src/runtime/cyberia-client/Dockerfile +8 -4
- package/src/runtime/cyberia-client/Dockerfile.dev +5 -2
- package/src/runtime/cyberia-server/Dockerfile +9 -2
- package/src/runtime/cyberia-server/Dockerfile.dev +8 -1
- package/src/runtime/engine-cyberia/Dockerfile +1 -1
- package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
- package/src/runtime/engine-cyberia/Dockerfile.test +2 -2
- package/src/runtime/engine-cyberia/compose.env +17 -5
- package/src/runtime/engine-cyberia/docker-compose.yml +23 -17
- package/src/server/build/coverage.js +113 -42
- package/src/server/build/package.js +55 -12
- package/src/server/build/testing.js +59 -9
- package/src/server/network/middlewares.js +8 -2
- package/src/server/ops/logger.js +70 -35
- package/src/server/runtime/conf.js +4 -2
- package/src/server/storage/data-query.js +16 -0
- package/src/server/storage/downloader.js +62 -14
- package/src/server/storage/zip.js +153 -0
- package/test/integration/app/cyberia/atlas-sprite-sheet-store.test.js +402 -0
- package/test/integration/app/cyberia/atlas-sprite-sheet.test.js +86 -0
- package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +34 -0
- package/test/integration/app/cyberia/cyberia-entity-type-default.test.js +842 -0
- package/test/integration/app/cyberia/cyberia-instance-conf-coerce.test.js +53 -0
- package/test/integration/app/cyberia/cyberia-instance-conf-defaults.test.js +16 -22
- package/test/integration/app/cyberia/cyberia-instance-items.test.js +90 -0
- package/test/integration/app/cyberia/cyberia-load.test.js +6 -7
- package/test/integration/app/cyberia/cyberia-map-audio-conf.test.js +256 -0
- package/test/integration/app/cyberia/cyberia-stats.test.js +150 -0
- package/test/integration/app/cyberia/fallback-world-capture.test.js +87 -2
- package/test/integration/app/cyberia/object-layer-item-selection.test.js +56 -0
- package/test/integration/app/cyberia/object-layer-natural-key.test.js +32 -0
- package/test/integration/app/cyberia/seed-audio.test.js +275 -0
- package/test/integration/infra/2-network/wireguard-cli.test.js +33 -9
- package/test/integration/infra/2-network/wireguard-edge.test.js +58 -18
- package/test/integration/infra/3-cluster/mongo-express-deploy.test.js +156 -0
- package/test/integration/infra/4-ingress/api-cross-origin.test.js +79 -0
- package/test/unit/client-build-docs.test.js +97 -70
- package/test/unit/client-bundle.test.js +313 -0
- package/test/unit/conf-resolution.test.js +17 -0
- package/test/unit/coverage-artifact.test.js +120 -29
- package/test/unit/cyberia/instance-backup.test.js +183 -0
- package/test/unit/cyberia/instance-object-layer-items.test.js +46 -0
- package/test/unit/cyberia/publish-workflow.test.js +22 -0
- package/test/unit/cyberia/stat-balance.test.js +69 -0
- package/test/unit/deploy-log-table.test.js +76 -5
- package/test/unit/downloader-integrity.test.js +98 -0
- package/test/unit/file-reference-registry.test.js +141 -0
- package/test/unit/fs-storage-paths.test.js +596 -0
- package/test/unit/logger-redaction.test.js +29 -0
- package/test/unit/package.test.js +70 -16
- package/test/unit/prepare-host.test.js +84 -1
- package/test/unit/test-tiers.test.js +27 -0
- package/test/unit/zip-archive.test.js +142 -0
- package/vitest.config.js +11 -3
- package/src/api/cyberia-instance/cyberia-fallback-default-items.js +0 -63
- package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +0 -368
package/bin/index.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import dotenv from 'dotenv';
|
|
13
|
-
import {
|
|
13
|
+
import { registerStatCommands } from '../src/projects/cyberia/stat-commands.js';
|
|
14
|
+
import { Command, InvalidArgumentError } from 'commander';
|
|
14
15
|
import fs from 'fs-extra';
|
|
15
16
|
import stringify from 'fast-json-stable-stringify';
|
|
16
17
|
import { shellExec } from '../src/server/runtime/process.js';
|
|
@@ -18,9 +19,23 @@ import { cli } from '../src/server/build/execution.js';
|
|
|
18
19
|
import { loggerFactory } from '../src/server/ops/logger.js';
|
|
19
20
|
import { generateBesuManifests, deployBesu, removeBesu } from '../src/projects/cyberia/besu-genesis-generator.js';
|
|
20
21
|
import { DataBaseProviderService } from '../src/db/DataBaseProvider.js';
|
|
22
|
+
import { CyberiaAudioService } from '../src/api/cyberia-audio/cyberia-audio.service.js';
|
|
23
|
+
import { CyberiaEntityTypeDefaultService } from '../src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js';
|
|
24
|
+
import { CyberiaInstanceConfService } from '../src/api/cyberia-instance-conf/cyberia-instance-conf.service.js';
|
|
25
|
+
import {
|
|
26
|
+
collectInstanceItemIds,
|
|
27
|
+
collectSummonedItemIds,
|
|
28
|
+
selectInstanceSkills,
|
|
29
|
+
} from '../src/api/cyberia-instance/cyberia-instance-items.js';
|
|
30
|
+
import { prepareFallbackAudio, seedFallbackAudio, seedInstanceAudio } from '../src/projects/cyberia/seed-audio.js';
|
|
31
|
+
import {
|
|
32
|
+
CyberiaMapAudioConfService,
|
|
33
|
+
parseEventAudioBinding,
|
|
34
|
+
} from '../src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js';
|
|
21
35
|
import {
|
|
22
36
|
deployEnvFilePath,
|
|
23
37
|
etcHostFactory,
|
|
38
|
+
instanceProjectPathFactory,
|
|
24
39
|
loadConfServerJson,
|
|
25
40
|
normalizeInstanceTopology,
|
|
26
41
|
} from '../src/server/runtime/conf.js';
|
|
@@ -30,8 +45,12 @@ import {
|
|
|
30
45
|
pngDirectoryIteratorByObjectLayerType,
|
|
31
46
|
buildImgFromTile,
|
|
32
47
|
} from '../src/projects/cyberia/object-layer.js';
|
|
48
|
+
import { fetchInstanceObjectLayerItemIds, getInstanceModels } from '../src/projects/cyberia/instance-data.js';
|
|
49
|
+
import { atlasBackupFileKey, restoreObjectLayerBackup } from '../src/projects/cyberia/instance-backup.js';
|
|
33
50
|
import { getKeyframeDirectionsByCode } from '../src/client/components/cyberia/SharedDefaultsCyberia.js';
|
|
34
|
-
import {
|
|
51
|
+
import { DEFAULT_ATLAS_UPSCALE_FACTOR } from '../src/projects/cyberia/atlas-sprite-sheet-generator.js';
|
|
52
|
+
import { ATLAS_FILE_FIELDS, AtlasSpriteSheetStore } from '../src/projects/cyberia/atlas-sprite-sheet-store.js';
|
|
53
|
+
import { fileRefFields } from '../src/api/file/file.ref.js';
|
|
35
54
|
import {
|
|
36
55
|
generateMultiFrame,
|
|
37
56
|
lookupSemantic,
|
|
@@ -51,7 +70,6 @@ import {
|
|
|
51
70
|
DefaultCyberiaActions,
|
|
52
71
|
DefaultCyberiaQuests,
|
|
53
72
|
ENTITY_TYPE_DEFAULTS,
|
|
54
|
-
fillInstanceConfDefaults,
|
|
55
73
|
} from '../src/api/cyberia-server-defaults/cyberia-server-defaults.js';
|
|
56
74
|
import cyberiaCatalog from '../src/projects/cyberia/catalog-cyberia.js';
|
|
57
75
|
|
|
@@ -60,6 +78,7 @@ import {
|
|
|
60
78
|
ITEM_TYPES as itemTypes,
|
|
61
79
|
DefaultCyberiaItems,
|
|
62
80
|
} from '../src/client/components/cyberia/SharedDefaultsCyberia.js';
|
|
81
|
+
import { balanceStats, resolveStatBounds, statPolicyActive } from '../src/projects/cyberia/stat-balance.js';
|
|
63
82
|
import { loadDeployCatalog } from '../src/server/build/catalog.js';
|
|
64
83
|
import {
|
|
65
84
|
DEPLOY_MANIFEST_INDENT,
|
|
@@ -105,9 +124,193 @@ async function connectDbForChain({ envPath, mongoHost }) {
|
|
|
105
124
|
return { ObjectLayer, host, path };
|
|
106
125
|
}
|
|
107
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Rewrites a conf backup's `entityDefaults` into the reference shape the schema now stores.
|
|
129
|
+
*
|
|
130
|
+
* Backups written before the collection became the single owner embedded whole documents in the
|
|
131
|
+
* conf. Those files are the only place that shape still exists, so it is converted here, at the
|
|
132
|
+
* boundary where they enter — nothing downstream understands anything but an id.
|
|
133
|
+
*
|
|
134
|
+
* Each embedded entry is matched against the entity-type-default documents travelling in the same
|
|
135
|
+
* backup, by entity type and live-item set. That lookup is safe precisely because it cannot see
|
|
136
|
+
* the database: it can only ever resolve to a document this instance exported. An entry with no
|
|
137
|
+
* counterpart is written to the collection so the reference it gets resolves to something.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} confData - Parsed cyberia-instance-conf.json.
|
|
140
|
+
* @param {string} backupDir - Backup root, holding cyberia-entity-type-defaults/.
|
|
141
|
+
* @param {import('mongoose').Model} CyberiaEntityTypeDefault
|
|
142
|
+
* @returns {Promise<object>} The conf, with `entityDefaults` as ids.
|
|
143
|
+
*/
|
|
144
|
+
const adoptEntityTypeDefaultRefs = async (confData, backupDir, CyberiaEntityTypeDefault) => {
|
|
145
|
+
const entries = confData.entityDefaults || [];
|
|
146
|
+
const embedded = entries.filter((entry) => entry && 'object' === typeof entry && entry.entityType);
|
|
147
|
+
if (0 === embedded.length) return confData;
|
|
148
|
+
|
|
149
|
+
const dir = `${backupDir}/cyberia-entity-type-defaults`;
|
|
150
|
+
const exported = fs.existsSync(dir)
|
|
151
|
+
? fs
|
|
152
|
+
.readdirSync(dir)
|
|
153
|
+
.filter((file) => file.endsWith('.json'))
|
|
154
|
+
.map((file) => fs.readJsonSync(`${dir}/${file}`))
|
|
155
|
+
: [];
|
|
156
|
+
const liveKey = (doc) => `${doc.entityType}::${[...(doc.liveItemIds || [])].sort().join(',')}`;
|
|
157
|
+
const byKey = new Map(exported.map((doc) => [liveKey(doc), doc]));
|
|
158
|
+
|
|
159
|
+
const ids = [];
|
|
160
|
+
let created = 0;
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
if (!entry || 'object' !== typeof entry || !entry.entityType) {
|
|
163
|
+
if (entry) ids.push(entry);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const match = byKey.get(liveKey(entry));
|
|
167
|
+
if (match?._id) {
|
|
168
|
+
ids.push(match._id);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const doc = await CyberiaEntityTypeDefault.create({
|
|
172
|
+
entityType: entry.entityType,
|
|
173
|
+
liveItemIds: entry.liveItemIds || [],
|
|
174
|
+
deadItemIds: entry.deadItemIds || [],
|
|
175
|
+
dropItemIds: entry.dropItemIds || [],
|
|
176
|
+
inventoryItemsIds: entry.inventoryItemsIds || [],
|
|
177
|
+
overrideItemsIdsState: entry.overrideItemsIdsState || [],
|
|
178
|
+
behavior: entry.behavior || '',
|
|
179
|
+
});
|
|
180
|
+
ids.push(doc._id);
|
|
181
|
+
created++;
|
|
182
|
+
}
|
|
183
|
+
confData.entityDefaults = ids;
|
|
184
|
+
logger.info('Migrated embedded conf entityDefaults to collection references', {
|
|
185
|
+
instanceCode: confData.instanceCode,
|
|
186
|
+
references: ids.length,
|
|
187
|
+
created,
|
|
188
|
+
});
|
|
189
|
+
return confData;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** Default source of recorded `<name>.wav` + `<name>.json` pairs for `cyberia audio --import`. */
|
|
193
|
+
const DEFAULT_AUDIO_RECORDS_PATH = './cyberia-audio/records';
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Commander parser for the repeatable `<logic-event-id>:<audio-code>` flag.
|
|
197
|
+
*
|
|
198
|
+
* @function eventAudioBindingFactory
|
|
199
|
+
* @param {string} flag - Flag name, used in the usage error.
|
|
200
|
+
* @returns {(value: string, previous: Array<{event: string, code: string}>) => Array<{event: string, code: string}>} Accumulating parser.
|
|
201
|
+
* @memberof CyberiaCLI
|
|
202
|
+
*/
|
|
203
|
+
const eventAudioBindingFactory =
|
|
204
|
+
(flag) =>
|
|
205
|
+
(value, previous = []) => {
|
|
206
|
+
try {
|
|
207
|
+
return previous.concat([parseEventAudioBinding(value)]);
|
|
208
|
+
} catch {
|
|
209
|
+
throw new InvalidArgumentError(`${flag} expects <logic-event-id>:<audio-code>`);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
108
213
|
/** @type {Function} */
|
|
109
214
|
const logger = loggerFactory(import.meta);
|
|
110
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Reads the comma-separated item-id argument of the `ol` command.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} [itemId] - The raw command argument.
|
|
220
|
+
* @returns {string[]} Trimmed, non-empty item ids.
|
|
221
|
+
*/
|
|
222
|
+
const parseItemIds = (itemId) =>
|
|
223
|
+
itemId
|
|
224
|
+
? itemId
|
|
225
|
+
.split(',')
|
|
226
|
+
.map((id) => id.trim())
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
: [];
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Applies the `ol` stat policy (`--normalize-stats`, `--random-stats`,
|
|
232
|
+
* `--min-stat`, `--max-stat`) to one object layer before it is written.
|
|
233
|
+
* Marks the path on a Mongoose document so the save carries it; a plain
|
|
234
|
+
* payload needs no mark. A policy that changes nothing leaves the stats alone.
|
|
235
|
+
*
|
|
236
|
+
* @param {{ data: { item: { id: string, type: string }, stats: Object }, markModified?: Function }} objectLayer
|
|
237
|
+
* @param {import('../src/projects/cyberia/stat-balance.js').StatPolicy} policy
|
|
238
|
+
* @returns {boolean} Whether the stats were rewritten.
|
|
239
|
+
*/
|
|
240
|
+
const applyStatPolicy = (objectLayer, policy) => {
|
|
241
|
+
if (!statPolicyActive(policy)) return false;
|
|
242
|
+
const { stats, item } = objectLayer.data;
|
|
243
|
+
objectLayer.data.stats = balanceStats({
|
|
244
|
+
stats: typeof stats?.toObject === 'function' ? stats.toObject() : stats,
|
|
245
|
+
itemType: item.type,
|
|
246
|
+
policy,
|
|
247
|
+
});
|
|
248
|
+
if (typeof objectLayer.markModified === 'function') objectLayer.markModified('data.stats');
|
|
249
|
+
logger.info(
|
|
250
|
+
`Stats for '${objectLayer.data.item.id}' (${objectLayer.data.item.type}): ${JSON.stringify(objectLayer.data.stats)}`,
|
|
251
|
+
);
|
|
252
|
+
return true;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Finds the asset type directory that holds one item id.
|
|
257
|
+
*
|
|
258
|
+
* @param {string} itemId - Object layer item id.
|
|
259
|
+
* @returns {{ type: string, folder: string }|null} The type and folder, or null when absent.
|
|
260
|
+
*/
|
|
261
|
+
const findAssetFolder = (itemId) => {
|
|
262
|
+
for (const type of Object.keys(itemTypes)) {
|
|
263
|
+
const folder = `./src/client/public/cyberia/assets/${type}/${itemId}`;
|
|
264
|
+
if (fs.existsSync(folder) && fs.statSync(folder).isDirectory()) return { type, folder };
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Resolves the stored item ids one `ol` action works on.
|
|
271
|
+
*
|
|
272
|
+
* The scope is the item-id argument, one instance, or the whole ObjectLayer
|
|
273
|
+
* collection. Only ids the collection holds survive, so an action can never
|
|
274
|
+
* create an object layer. Exits when the scope resolves to nothing.
|
|
275
|
+
*
|
|
276
|
+
* @param {Object} params
|
|
277
|
+
* @param {import('mongoose').Model} params.ObjectLayer - Mongoose ObjectLayer model.
|
|
278
|
+
* @param {string} [params.itemId] - The comma-separated item-id argument.
|
|
279
|
+
* @param {string} [params.instance] - Instance code from `--instance`.
|
|
280
|
+
* @param {string} params.host - Deploy host.
|
|
281
|
+
* @param {string} params.path - Deploy path.
|
|
282
|
+
* @param {string} params.action - The flag being served, for the log lines.
|
|
283
|
+
* @returns {Promise<string[]>} Item ids to work on.
|
|
284
|
+
*/
|
|
285
|
+
const selectScopedItemIds = async ({ ObjectLayer, itemId, instance, host, path, action }) => {
|
|
286
|
+
let storedItemIds;
|
|
287
|
+
if (instance) {
|
|
288
|
+
try {
|
|
289
|
+
storedItemIds = await fetchInstanceObjectLayerItemIds(getInstanceModels({ host, path }), instance);
|
|
290
|
+
} catch (instanceError) {
|
|
291
|
+
logger.error(instanceError.message);
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
logger.info(`Instance '${instance}' runs on ${storedItemIds.length} stored object layer(s)`);
|
|
295
|
+
} else {
|
|
296
|
+
const storedDocs = await ObjectLayer.find({}, { 'data.item.id': 1 }).lean();
|
|
297
|
+
storedItemIds = storedDocs.map((doc) => doc?.data?.item?.id);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const { itemIds, missingItemIds } = ObjectLayerEngine.selectStoredItemIds({
|
|
301
|
+
storedItemIds,
|
|
302
|
+
requestedItemIds: parseItemIds(itemId),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const scope = instance ? `instance '${instance}'` : 'the ObjectLayer collection';
|
|
306
|
+
if (missingItemIds.length > 0) logger.warn(`Not in ${scope}, skipped: ${missingItemIds.join(', ')}`);
|
|
307
|
+
if (itemIds.length === 0) {
|
|
308
|
+
logger.error(`No object layer of ${scope} matches the requested item-id(s) for ${action}`);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
return itemIds;
|
|
312
|
+
};
|
|
313
|
+
|
|
111
314
|
const CYBERIA_DOCKER_HOST_ALIASES = ['cyberia-client', 'cyberia-server', 'engine-cyberia'];
|
|
112
315
|
|
|
113
316
|
const installCyberiaDockerHostAliases = () => {
|
|
@@ -126,6 +329,7 @@ const installCyberiaDockerHostAliases = () => {
|
|
|
126
329
|
|
|
127
330
|
try {
|
|
128
331
|
const program = new Command();
|
|
332
|
+
registerStatCommands(program);
|
|
129
333
|
|
|
130
334
|
/** @type {string} */
|
|
131
335
|
const version = Underpost.version;
|
|
@@ -142,14 +346,52 @@ try {
|
|
|
142
346
|
.command('ol [item-id]')
|
|
143
347
|
.option(
|
|
144
348
|
'--to-atlas-sprite-sheet [dim]',
|
|
145
|
-
'
|
|
349
|
+
'Rebuild both atlas renders of stored object layers, optionally capped to a dimension (default: auto-calculated based on frame count)',
|
|
146
350
|
)
|
|
147
351
|
.option('--show-atlas-sprite-sheet', 'Show consolidated atlas sprite sheet PNG for given item-id')
|
|
148
352
|
.option(
|
|
149
353
|
'--import',
|
|
150
|
-
'Import specific item-id(s) passed as comma-separated command argument (e.g. ol hatchet,sword --import)',
|
|
354
|
+
'Import specific item-id(s) passed as comma-separated command argument (e.g. ol hatchet,sword --instance FOREST --import); with --from-directory, from the asset directory instead',
|
|
355
|
+
)
|
|
356
|
+
.option(
|
|
357
|
+
'--from-directory',
|
|
358
|
+
'Source --import and --import-types from src/client/public/cyberia/assets/<type>/<item-id>/<direction>/<frame>.png',
|
|
359
|
+
)
|
|
360
|
+
.option(
|
|
361
|
+
'--minify',
|
|
362
|
+
'Refresh the minified atlas render the client downloads, for stored object layers (e.g. ol hatchet --minify, or ol --minify for all)',
|
|
363
|
+
)
|
|
364
|
+
.option(
|
|
365
|
+
'--instance <instance-code>',
|
|
366
|
+
'Limit --minify and --to-atlas-sprite-sheet to the object layers one instance runs on, or make --import restore item(s) from that instance backup under engine-private (e.g. ol hatchet --instance FOREST --import)',
|
|
367
|
+
)
|
|
368
|
+
.option(
|
|
369
|
+
'--normalize-stats',
|
|
370
|
+
'Clamp every stat of each object layer the action writes into the semantic bounds of its item type',
|
|
371
|
+
)
|
|
372
|
+
.option(
|
|
373
|
+
'--random-stats',
|
|
374
|
+
'Regenerate every stat of each object layer the action writes, with random signed modifiers',
|
|
375
|
+
)
|
|
376
|
+
.option(
|
|
377
|
+
'--min-stat <value>',
|
|
378
|
+
'Lowest value --random-stats or --normalize-stats may leave (default: -100)',
|
|
379
|
+
parseInt,
|
|
380
|
+
)
|
|
381
|
+
.option(
|
|
382
|
+
'--max-stat <value>',
|
|
383
|
+
'Highest value --random-stats or --normalize-stats may leave (default: 100)',
|
|
384
|
+
parseInt,
|
|
385
|
+
)
|
|
386
|
+
.option(
|
|
387
|
+
'--upscale <px-factor>',
|
|
388
|
+
`Pixels per cell of the human-resolution atlas render; on its own it rebuilds that render (default: ${DEFAULT_ATLAS_UPSCALE_FACTOR})`,
|
|
389
|
+
parseInt,
|
|
390
|
+
)
|
|
391
|
+
.option(
|
|
392
|
+
'--import-types [object-layer-type]',
|
|
393
|
+
'Batch import by object layer type from the asset directory, needs --from-directory (e.g. skin,floors or all)',
|
|
151
394
|
)
|
|
152
|
-
.option('--import-types [object-layer-type]', 'Batch import by object layer type e.g. skin,floors or all')
|
|
153
395
|
.option('--show-frame [direction-frame]', 'View object layer frame for given item-id e.g. 08_0 (default: 08_0)')
|
|
154
396
|
.option('--generate', 'Generate procedural object layers from semantic item-id (e.g. floor-desert)')
|
|
155
397
|
.option('--count <count>', 'Shape element count multiplier for --generate (default: 3)', parseFloat)
|
|
@@ -159,7 +401,6 @@ try {
|
|
|
159
401
|
.option('--density <density>', 'Density factor 0..1 for --generate (default: 0.5)', parseFloat)
|
|
160
402
|
.option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
|
|
161
403
|
.option('--mongo-host <mongo-host>', 'Mongo host override')
|
|
162
|
-
.option('--storage-file-path <storage-file-path>', 'Storage file path override')
|
|
163
404
|
.option('--drop', 'Drop existing data before importing')
|
|
164
405
|
.option('--client-public', 'When used with --drop, also remove static asset folders for dropped items')
|
|
165
406
|
.option('--git-clean', 'When used with --drop, run underpost clean on the cyberia asset directory')
|
|
@@ -172,11 +413,18 @@ try {
|
|
|
172
413
|
* @param {string|undefined} itemId - Optional item ID argument.
|
|
173
414
|
* @param {Object} options - Command options parsed by Commander.
|
|
174
415
|
* @param {boolean} options.import - Import specific item-id(s) from the command argument (comma-separated).
|
|
416
|
+
* @param {boolean} options.fromDirectory - Source --import and --import-types from the asset directory.
|
|
417
|
+
* @param {boolean} options.minify - Refresh the minified atlas render of stored item(s).
|
|
418
|
+
* @param {string} options.instance - Instance code whose object layers --minify reprocesses.
|
|
419
|
+
* @param {boolean} options.normalizeStats - Clamp the stats of every object layer the action writes to its type's bounds.
|
|
420
|
+
* @param {boolean} options.randomStats - Regenerate the stats of every object layer the action writes.
|
|
421
|
+
* @param {number} [options.minStat] - Lowest value --random-stats may draw.
|
|
422
|
+
* @param {number} [options.maxStat] - Highest value --random-stats may draw.
|
|
423
|
+
* @param {number} options.upscale - Pixels per cell of the human-resolution atlas render.
|
|
175
424
|
* @param {boolean|string} options.importTypes - Object layer types to batch import (e.g., 'all', 'skin,floor') or `false`.
|
|
176
425
|
* @param {boolean|string} options.showFrame - Direction-frame string (e.g., '08_0') or `true` for default.
|
|
177
426
|
* @param {string} options.envPath - Path to the `.env` file.
|
|
178
427
|
* @param {string} options.mongoHost - MongoDB host override.
|
|
179
|
-
* @param {string} options.storageFilePath - Path to a storage filter JSON file.
|
|
180
428
|
* @param {boolean|string} options.toAtlasSpriteSheet - Atlas dimension or `true` for auto-calc.
|
|
181
429
|
* @param {boolean} options.showAtlasSpriteSheet - Whether to display the atlas sprite sheet.
|
|
182
430
|
* @param {boolean} options.drop - Whether to drop existing data before importing.
|
|
@@ -196,11 +444,16 @@ try {
|
|
|
196
444
|
itemId,
|
|
197
445
|
options = {
|
|
198
446
|
import: false,
|
|
447
|
+
fromDirectory: false,
|
|
448
|
+
minify: false,
|
|
449
|
+
instance: '',
|
|
450
|
+
upscale: DEFAULT_ATLAS_UPSCALE_FACTOR,
|
|
199
451
|
importTypes: false,
|
|
452
|
+
normalizeStats: false,
|
|
453
|
+
randomStats: false,
|
|
200
454
|
showFrame: '',
|
|
201
455
|
envPath: '',
|
|
202
456
|
mongoHost: '',
|
|
203
|
-
storageFilePath: '',
|
|
204
457
|
toAtlasSpriteSheet: '',
|
|
205
458
|
showAtlasSpriteSheet: false,
|
|
206
459
|
drop: false,
|
|
@@ -215,6 +468,12 @@ try {
|
|
|
215
468
|
density: 0.5,
|
|
216
469
|
},
|
|
217
470
|
) => {
|
|
471
|
+
const upscaleFactor = options.upscale ?? DEFAULT_ATLAS_UPSCALE_FACTOR;
|
|
472
|
+
if (!Number.isInteger(upscaleFactor) || upscaleFactor < 1) {
|
|
473
|
+
logger.error('--upscale takes a whole pixel factor of 1 or more');
|
|
474
|
+
process.exit(1);
|
|
475
|
+
}
|
|
476
|
+
|
|
218
477
|
if (!options.envPath) options.envPath = `./.env`;
|
|
219
478
|
if (fs.existsSync(options.envPath)) dotenv.config({ path: options.envPath, override: true });
|
|
220
479
|
|
|
@@ -250,8 +509,21 @@ try {
|
|
|
250
509
|
path,
|
|
251
510
|
});
|
|
252
511
|
|
|
512
|
+
// --instance reads the world the runtime reads, so its content collections load too.
|
|
513
|
+
const instanceApis = options.instance
|
|
514
|
+
? [
|
|
515
|
+
'cyberia-instance',
|
|
516
|
+
'cyberia-instance-conf',
|
|
517
|
+
'cyberia-map',
|
|
518
|
+
'cyberia-quest',
|
|
519
|
+
'cyberia-action',
|
|
520
|
+
'cyberia-skill',
|
|
521
|
+
'cyberia-entity-type-default',
|
|
522
|
+
]
|
|
523
|
+
: [];
|
|
524
|
+
|
|
253
525
|
await DataBaseProviderService.load({
|
|
254
|
-
apis: ['object-layer', 'object-layer-render-frames', 'atlas-sprite-sheet', 'file', 'ipfs'],
|
|
526
|
+
apis: ['object-layer', 'object-layer-render-frames', 'atlas-sprite-sheet', 'file', 'ipfs', ...instanceApis],
|
|
255
527
|
host,
|
|
256
528
|
path,
|
|
257
529
|
db,
|
|
@@ -268,12 +540,47 @@ try {
|
|
|
268
540
|
/** @type {import('mongoose').Model} */
|
|
269
541
|
const Ipfs = DataBaseProviderService.getModel('ipfs', { host, path });
|
|
270
542
|
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
543
|
+
// A model handle binds to one connection, and the health monitor replaces that
|
|
544
|
+
// connection when it drops. A batch that runs for minutes therefore resolves its
|
|
545
|
+
// model per item instead of holding the handle it started with.
|
|
546
|
+
const liveObjectLayer = () => DataBaseProviderService.getModel('object-layer', { host, path });
|
|
547
|
+
|
|
548
|
+
const rebuildAtlases = ObjectLayerEngine.selectAtlasRebuild(options);
|
|
549
|
+
|
|
550
|
+
/* Bounds fail here, before any write, rather than on the first item. */
|
|
551
|
+
const statPolicy = {
|
|
552
|
+
normalize: !!options.normalizeStats,
|
|
553
|
+
random: !!options.randomStats,
|
|
554
|
+
min: options.minStat,
|
|
555
|
+
max: options.maxStat,
|
|
556
|
+
};
|
|
557
|
+
if (statPolicyActive(statPolicy)) {
|
|
558
|
+
try {
|
|
559
|
+
resolveStatBounds('', statPolicy);
|
|
560
|
+
} catch (boundsError) {
|
|
561
|
+
logger.error(`--min-stat/--max-stat: ${boundsError.message}`);
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
} else if (statPolicy.min !== undefined || statPolicy.max !== undefined) {
|
|
565
|
+
logger.warn('--min-stat and --max-stat only bound --random-stats and --normalize-stats, ignored');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (options.instance && !options.minify && !rebuildAtlases && !options.import) {
|
|
569
|
+
logger.warn('--instance only narrows --minify and --to-atlas-sprite-sheet, or sources --import, ignored');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Idempotent repair, run only before a flow that writes: collapse
|
|
573
|
+
// duplicates and make the data.item.id index unique, so every later
|
|
574
|
+
// write lands on one document. A read-only subcommand stays free of
|
|
575
|
+
// side effects; findByItemId resolves the same canonical document.
|
|
576
|
+
if (
|
|
577
|
+
options.import ||
|
|
578
|
+
options.minify ||
|
|
579
|
+
options.importTypes ||
|
|
580
|
+
options.drop ||
|
|
581
|
+
options.generate ||
|
|
582
|
+
rebuildAtlases
|
|
583
|
+
) {
|
|
277
584
|
const { removedIds, indexUpgraded } = await ObjectLayer.ensureUniqueItemIdIndex();
|
|
278
585
|
if (removedIds.length > 0) logger.warn(`Removed ${removedIds.length} duplicate ObjectLayer document(s)`);
|
|
279
586
|
if (indexUpgraded) logger.info('Upgraded data.item.id index to unique');
|
|
@@ -295,9 +602,9 @@ try {
|
|
|
295
602
|
logger.info('Dropping ALL object layer data');
|
|
296
603
|
}
|
|
297
604
|
|
|
298
|
-
// Build query filter: targeted or all
|
|
605
|
+
// Build query filter: targeted or all. The atlas side of the drop is selected by
|
|
606
|
+
// the store, from the item keys and the links collected below.
|
|
299
607
|
const olFilter = isTargetedDrop ? { 'data.item.id': { $in: dropItemIds } } : {};
|
|
300
|
-
const atlasFilter = isTargetedDrop ? { 'metadata.itemKey': { $in: dropItemIds } } : {};
|
|
301
608
|
|
|
302
609
|
// Collect data before deletion
|
|
303
610
|
const olDocs = await ObjectLayer.find(olFilter, {
|
|
@@ -308,7 +615,6 @@ try {
|
|
|
308
615
|
objectLayerRenderFramesId: 1,
|
|
309
616
|
atlasSpriteSheetId: 1,
|
|
310
617
|
}).lean();
|
|
311
|
-
const atlasDocs = await AtlasSpriteSheet.find(atlasFilter, { fileId: 1, cid: 1 }).lean();
|
|
312
618
|
|
|
313
619
|
const cidsToUnpin = new Set();
|
|
314
620
|
const itemIdsToClean = new Set();
|
|
@@ -324,34 +630,36 @@ try {
|
|
|
324
630
|
if (doc.atlasSpriteSheetId) atlasIds.push(doc.atlasSpriteSheetId);
|
|
325
631
|
}
|
|
326
632
|
|
|
327
|
-
const atlasFileIds = atlasDocs.map((a) => a.fileId).filter(Boolean);
|
|
328
|
-
for (const atlas of atlasDocs) {
|
|
329
|
-
if (atlas.cid) cidsToUnpin.add(atlas.cid);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
633
|
const olCount = olDocs.length;
|
|
333
|
-
const atlasCount = atlasDocs.length;
|
|
334
634
|
|
|
335
635
|
// Delete targeted documents
|
|
336
636
|
if (isTargetedDrop) {
|
|
337
637
|
const olIds = olDocs.map((d) => d._id);
|
|
338
638
|
if (olIds.length > 0) await ObjectLayer.deleteMany({ _id: { $in: olIds } });
|
|
339
639
|
if (renderFrameIds.length > 0) await ObjectLayerRenderFrames.deleteMany({ _id: { $in: renderFrameIds } });
|
|
340
|
-
if (atlasIds.length > 0) await AtlasSpriteSheet.deleteMany({ _id: { $in: atlasIds } });
|
|
341
640
|
} else {
|
|
342
641
|
await ObjectLayer.deleteMany();
|
|
343
642
|
await ObjectLayerRenderFrames.deleteMany();
|
|
344
|
-
await AtlasSpriteSheet.deleteMany();
|
|
345
643
|
}
|
|
346
644
|
|
|
347
645
|
const rfCount = renderFrameIds.length;
|
|
348
646
|
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
647
|
+
// The atlas owns two renders and the store is what knows that: a drop naming
|
|
648
|
+
// `fileId` alone left every minified render unreachable in the File collection.
|
|
649
|
+
// Both selectors are passed, so an atlas linked by the object layer and one
|
|
650
|
+
// matching the item key are the same drop.
|
|
651
|
+
const purged = await AtlasSpriteSheetStore.purge({
|
|
652
|
+
itemKeys: isTargetedDrop ? dropItemIds : [],
|
|
653
|
+
atlasIds: isTargetedDrop ? atlasIds : [],
|
|
654
|
+
all: !isTargetedDrop,
|
|
655
|
+
options: { host, path },
|
|
656
|
+
});
|
|
657
|
+
for (const cid of purged.cids) cidsToUnpin.add(cid);
|
|
658
|
+
const atlasCount = purged.atlases;
|
|
659
|
+
// Renders an earlier drop left behind are unreachable by definition, so this
|
|
660
|
+
// drop takes them too instead of letting them accumulate.
|
|
661
|
+
const fileCount =
|
|
662
|
+
purged.files + (await AtlasSpriteSheetStore.pruneOrphanRenders({ options: { host, path } }));
|
|
355
663
|
|
|
356
664
|
// Delete IPFS pin registry records for all collected CIDs
|
|
357
665
|
if (cidsToUnpin.size > 0) {
|
|
@@ -404,374 +712,166 @@ try {
|
|
|
404
712
|
}
|
|
405
713
|
}
|
|
406
714
|
|
|
407
|
-
|
|
408
|
-
|
|
715
|
+
// ── Handle --minify (stored item-id(s)) ──────────────────────────
|
|
716
|
+
// Refreshes only the minified atlas render, the one the client runtime
|
|
717
|
+
// downloads. It reads its item ids from the collection, so it never
|
|
718
|
+
// creates an object layer. With --random-stats every stored document
|
|
719
|
+
// in scope is rewritten, whether or not its render can be refreshed.
|
|
720
|
+
if (options.minify) {
|
|
721
|
+
const selectedItemIds = await selectScopedItemIds({
|
|
722
|
+
ObjectLayer,
|
|
723
|
+
itemId,
|
|
724
|
+
instance: options.instance,
|
|
725
|
+
host,
|
|
726
|
+
path,
|
|
727
|
+
action: '--minify',
|
|
728
|
+
});
|
|
409
729
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
730
|
+
logger.info(`Minify refresh for ${selectedItemIds.length} stored item(s)`);
|
|
731
|
+
const tally = { updated: 0, unchanged: 0, missing: 0, stale: 0, failed: [] };
|
|
732
|
+
|
|
733
|
+
// Isolated per item, for the same reason the atlas rebuild is.
|
|
734
|
+
for (const currentItemId of selectedItemIds) {
|
|
735
|
+
try {
|
|
736
|
+
const objectLayer = await liveObjectLayer()
|
|
737
|
+
.findByItemId(currentItemId)
|
|
738
|
+
.populate('objectLayerRenderFramesId');
|
|
739
|
+
if (objectLayer && applyStatPolicy(objectLayer, statPolicy)) {
|
|
740
|
+
await objectLayer.save();
|
|
741
|
+
await ObjectLayerEngine.computeAndSaveFinalSha256({ objectLayer, options: { host, path } });
|
|
742
|
+
}
|
|
743
|
+
if (!objectLayer?.objectLayerRenderFramesId) {
|
|
744
|
+
logger.warn(`No render frames stored for '${currentItemId}', render skipped`);
|
|
745
|
+
tally.missing++;
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const { status } = await AtlasSpriteSheetStore.syncMinifyRender({
|
|
750
|
+
itemKey: currentItemId,
|
|
751
|
+
objectLayerRenderFrames: objectLayer.objectLayerRenderFramesId,
|
|
752
|
+
options: { host, path },
|
|
753
|
+
});
|
|
754
|
+
tally[status]++;
|
|
755
|
+
|
|
756
|
+
if (status === 'missing') logger.warn(`No atlas stored for '${currentItemId}'; generate it first`);
|
|
757
|
+
else if (status === 'stale')
|
|
758
|
+
logger.warn(`Render frames of '${currentItemId}' moved the atlas layout; regenerate the atlas`);
|
|
759
|
+
else logger.info(`Minified render ${status} for '${currentItemId}'`);
|
|
760
|
+
|
|
761
|
+
const still = await AtlasSpriteSheetStore.syncIdlePreview({
|
|
762
|
+
itemKey: currentItemId,
|
|
763
|
+
options: { host, path },
|
|
764
|
+
});
|
|
765
|
+
if (still.status !== 'missing') logger.info(`Idle preview ${still.status} for '${currentItemId}'`);
|
|
766
|
+
} catch (minifyError) {
|
|
767
|
+
logger.error(`Minify failed for '${currentItemId}': ${minifyError.message}`);
|
|
768
|
+
tally.failed.push(currentItemId);
|
|
769
|
+
}
|
|
415
770
|
}
|
|
416
771
|
|
|
417
|
-
|
|
418
|
-
.
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
772
|
+
logger.info(
|
|
773
|
+
`Minify done: ${tally.updated} updated, ${tally.unchanged} unchanged, ` +
|
|
774
|
+
`${tally.stale} stale, ${tally.missing} without an atlas, ${tally.failed.length} failed`,
|
|
775
|
+
);
|
|
776
|
+
if (tally.failed.length > 0) {
|
|
777
|
+
logger.warn(`Rerun for the failed item(s): ${tally.failed.join(',')}`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
422
780
|
|
|
781
|
+
// ── Handle --import --instance: restore item(s) from the instance backup ──
|
|
782
|
+
// The backup under engine-private is the authority for the item id: every document it
|
|
783
|
+
// holds for the item replaces the database's, rather than regenerating from the asset
|
|
784
|
+
// directory. The item never touches the stat policy — the backup already states its stats.
|
|
785
|
+
if (options.import && options.instance) {
|
|
786
|
+
const itemIds = parseItemIds(itemId);
|
|
787
|
+
if (itemIds.length === 0) {
|
|
788
|
+
logger.error(
|
|
789
|
+
'item-id is required for --import --instance (e.g. ol hatchet,sword --instance FOREST --import)',
|
|
790
|
+
);
|
|
791
|
+
process.exit(1);
|
|
792
|
+
}
|
|
793
|
+
const backupDir = `./engine-private/cyberia-instances/${options.instance}`;
|
|
794
|
+
if (!fs.existsSync(backupDir)) {
|
|
795
|
+
logger.error(`No instance backup at ${backupDir}`);
|
|
796
|
+
process.exit(1);
|
|
797
|
+
}
|
|
798
|
+
logger.info(`Restoring ${itemIds.length} item(s) from instance backup '${options.instance}'`);
|
|
799
|
+
let restored = 0;
|
|
423
800
|
for (const currentItemId of itemIds) {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
801
|
+
try {
|
|
802
|
+
const summary = await restoreObjectLayerBackup({
|
|
803
|
+
backupDir,
|
|
804
|
+
itemId: currentItemId,
|
|
805
|
+
options: { host, path },
|
|
806
|
+
});
|
|
807
|
+
logger.info(`Restored '${currentItemId}' from backup`, summary);
|
|
808
|
+
restored++;
|
|
809
|
+
} catch (restoreError) {
|
|
810
|
+
logger.error(`Restore failed for '${currentItemId}': ${restoreError.message}`);
|
|
434
811
|
}
|
|
812
|
+
}
|
|
813
|
+
logger.info(`Instance restore done: ${restored}/${itemIds.length} item(s)`);
|
|
814
|
+
}
|
|
435
815
|
|
|
436
|
-
|
|
816
|
+
if (options.import && !options.instance === !options.fromDirectory) {
|
|
817
|
+
logger.error(
|
|
818
|
+
'--import takes exactly one source: --instance <code> for a backup, or --from-directory for the asset tree',
|
|
819
|
+
);
|
|
820
|
+
process.exit(1);
|
|
821
|
+
}
|
|
822
|
+
if (options.importTypes && !options.fromDirectory) {
|
|
823
|
+
logger.error('--import-types reads the asset tree and needs --from-directory');
|
|
824
|
+
process.exit(1);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ── Handle --import --from-directory (specific item-id(s)) ────────
|
|
828
|
+
if (options.import && options.fromDirectory) {
|
|
829
|
+
const itemIds = parseItemIds(itemId);
|
|
830
|
+
if (itemIds.length === 0) {
|
|
831
|
+
logger.error(
|
|
832
|
+
'item-id is required for --import --from-directory (comma-separated item IDs, e.g. ol hatchet,sword --from-directory --import)',
|
|
833
|
+
);
|
|
834
|
+
process.exit(1);
|
|
835
|
+
}
|
|
836
|
+
logger.info(`Importing specific item(s) from the asset directory: ${itemIds.join(', ')}`);
|
|
837
|
+
|
|
838
|
+
for (const currentItemId of itemIds) {
|
|
839
|
+
const found = findAssetFolder(currentItemId);
|
|
840
|
+
if (!found) {
|
|
437
841
|
logger.error(
|
|
438
842
|
`Item-id '${currentItemId}' not found in any asset type directory (${Object.keys(itemTypes).join(', ')})`,
|
|
439
843
|
);
|
|
440
844
|
continue;
|
|
441
845
|
}
|
|
442
|
-
|
|
443
|
-
logger.info(`Found item '${currentItemId}' in type '${foundType}' at ${foundFolder}`);
|
|
846
|
+
logger.info(`Found item '${currentItemId}' in type '${found.type}' at ${found.folder}`);
|
|
444
847
|
|
|
445
848
|
const { objectLayerRenderFramesData, objectLayerData } =
|
|
446
849
|
await ObjectLayerEngine.buildObjectLayerDataFromDirectory({
|
|
447
|
-
folder:
|
|
448
|
-
objectLayerType:
|
|
850
|
+
folder: found.folder,
|
|
851
|
+
objectLayerType: found.type,
|
|
449
852
|
objectLayerId: currentItemId,
|
|
450
853
|
});
|
|
854
|
+
applyStatPolicy(objectLayerData, statPolicy);
|
|
451
855
|
|
|
452
856
|
// Write processed frames back to disk so WebP matches atlas
|
|
453
|
-
const srcBasePath = './src/client/public/cyberia/';
|
|
454
|
-
const publicBasePath = `./public/${host}${path}`;
|
|
455
857
|
await ObjectLayerEngine.writeStaticFrameAssets({
|
|
456
|
-
basePaths: [
|
|
457
|
-
itemType:
|
|
858
|
+
basePaths: ['./src/client/public/cyberia/', `./public/${host}${path}`],
|
|
859
|
+
itemType: found.type,
|
|
458
860
|
itemId: currentItemId,
|
|
459
861
|
objectLayerRenderFramesData,
|
|
460
862
|
objectLayerData,
|
|
461
|
-
cellPixelDim:
|
|
863
|
+
cellPixelDim: upscaleFactor,
|
|
462
864
|
});
|
|
463
865
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
// 1. Prepare staging data entirely in memory (no DB writes yet)
|
|
473
|
-
const stagingData = JSON.parse(JSON.stringify(objectLayerData.data));
|
|
474
|
-
if (!stagingData.render) stagingData.render = {};
|
|
475
|
-
stagingData.render.cid = '';
|
|
476
|
-
stagingData.render.metadataCid = '';
|
|
477
|
-
|
|
478
|
-
// 2. Generate atlas, pin to IPFS, compute SHA-256 — all in memory
|
|
479
|
-
let cutoverReady = false;
|
|
480
|
-
let stagingFileDoc = null;
|
|
481
|
-
let stagingAtlasDoc = null;
|
|
482
|
-
let stagingCid = '';
|
|
483
|
-
try {
|
|
484
|
-
const itemKey = currentItemId;
|
|
485
|
-
|
|
486
|
-
// Generate atlas from in-memory render frames data (plain object, no DB doc needed)
|
|
487
|
-
const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
|
|
488
|
-
objectLayerRenderFramesData,
|
|
489
|
-
itemKey,
|
|
490
|
-
20,
|
|
491
|
-
);
|
|
492
|
-
|
|
493
|
-
stagingFileDoc = await new File({
|
|
494
|
-
name: `${itemKey}-atlas.png`,
|
|
495
|
-
data: buffer,
|
|
496
|
-
size: buffer.length,
|
|
497
|
-
mimetype: 'image/png',
|
|
498
|
-
md5: crypto.createHash('md5').update(buffer).digest('hex'),
|
|
499
|
-
}).save();
|
|
500
|
-
|
|
501
|
-
let importItemCid = '';
|
|
502
|
-
let importItemMetadataCid = '';
|
|
503
|
-
try {
|
|
504
|
-
const ipfsResult = await IpfsClient.addBufferToIpfs(
|
|
505
|
-
buffer,
|
|
506
|
-
`${itemKey}_atlas_sprite_sheet.png`,
|
|
507
|
-
`/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
|
|
508
|
-
);
|
|
509
|
-
if (ipfsResult) {
|
|
510
|
-
importItemCid = ipfsResult.cid;
|
|
511
|
-
logger.info(`[staging] Atlas pinned to IPFS – CID: ${importItemCid}`);
|
|
512
|
-
try {
|
|
513
|
-
await createPinRecord({
|
|
514
|
-
cid: importItemCid,
|
|
515
|
-
resourceType: 'atlas-sprite-sheet',
|
|
516
|
-
mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
|
|
517
|
-
options: { host, path },
|
|
518
|
-
});
|
|
519
|
-
} catch (prErr) {
|
|
520
|
-
logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
} catch (ipfsError) {
|
|
524
|
-
logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
try {
|
|
528
|
-
const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
|
|
529
|
-
metadata,
|
|
530
|
-
`${itemKey}_atlas_sprite_sheet_metadata.json`,
|
|
531
|
-
`/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
|
|
532
|
-
);
|
|
533
|
-
if (metadataIpfsResult) {
|
|
534
|
-
importItemMetadataCid = metadataIpfsResult.cid;
|
|
535
|
-
logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importItemMetadataCid}`);
|
|
536
|
-
try {
|
|
537
|
-
await createPinRecord({
|
|
538
|
-
cid: importItemMetadataCid,
|
|
539
|
-
resourceType: 'atlas-metadata',
|
|
540
|
-
mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
|
|
541
|
-
options: { host, path },
|
|
542
|
-
});
|
|
543
|
-
} catch (prErr) {
|
|
544
|
-
logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
} catch (ipfsError) {
|
|
548
|
-
logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
// Persist atlas doc (or update existing one for this itemKey)
|
|
552
|
-
stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
|
|
553
|
-
if (stagingAtlasDoc) {
|
|
554
|
-
if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
|
|
555
|
-
stagingAtlasDoc.fileId = stagingFileDoc._id;
|
|
556
|
-
stagingAtlasDoc.cid = importItemCid;
|
|
557
|
-
stagingAtlasDoc.metadata = metadata;
|
|
558
|
-
await stagingAtlasDoc.save();
|
|
559
|
-
} else {
|
|
560
|
-
stagingAtlasDoc = await new AtlasSpriteSheet({
|
|
561
|
-
fileId: stagingFileDoc._id,
|
|
562
|
-
cid: importItemCid,
|
|
563
|
-
metadata,
|
|
564
|
-
}).save();
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Finalize staging data in memory with render CIDs
|
|
568
|
-
stagingData.render.cid = importItemCid;
|
|
569
|
-
stagingData.render.metadataCid = importItemMetadataCid;
|
|
570
|
-
|
|
571
|
-
// Pin data JSON to IPFS
|
|
572
|
-
try {
|
|
573
|
-
const ipfsDataResult = await IpfsClient.addJsonToIpfs(
|
|
574
|
-
stagingData,
|
|
575
|
-
`${itemKey}_data.json`,
|
|
576
|
-
`/object-layer/${itemKey}/${itemKey}_data.json`,
|
|
577
|
-
);
|
|
578
|
-
if (ipfsDataResult) {
|
|
579
|
-
stagingCid = ipfsDataResult.cid;
|
|
580
|
-
logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
|
|
581
|
-
try {
|
|
582
|
-
await createPinRecord({
|
|
583
|
-
cid: stagingCid,
|
|
584
|
-
resourceType: 'object-layer-data',
|
|
585
|
-
mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
|
|
586
|
-
options: { host, path },
|
|
587
|
-
});
|
|
588
|
-
} catch (prErr) {
|
|
589
|
-
logger.warn('[staging] Failed to create data pin record:', prErr.message);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
} catch (ipfsError) {
|
|
593
|
-
logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
cutoverReady = true;
|
|
597
|
-
logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for cut-over`);
|
|
598
|
-
} catch (atlasError) {
|
|
599
|
-
logger.error(`[staging] Failed for ${currentItemId}, live document untouched:`, atlasError);
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
// 3. Atomic cut-over: create new RenderFrames, swap live ObjectLayer in a single update
|
|
603
|
-
if (cutoverReady) {
|
|
604
|
-
const oldRenderFramesId = existingOL.objectLayerRenderFramesId;
|
|
605
|
-
|
|
606
|
-
// Create the new RenderFrames doc (only now touches DB)
|
|
607
|
-
const newRenderFrames = await ObjectLayerRenderFrames.create(objectLayerRenderFramesData);
|
|
608
|
-
|
|
609
|
-
// Single atomic upsert on the natural key; empty staged CIDs keep their stored value
|
|
610
|
-
objectLayer = await ObjectLayer.upsertByItemId({
|
|
611
|
-
data: stagingData,
|
|
612
|
-
cid: stagingCid,
|
|
613
|
-
objectLayerRenderFramesId: newRenderFrames._id,
|
|
614
|
-
atlasSpriteSheetId: stagingAtlasDoc._id,
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
// Clean up old render frames
|
|
618
|
-
if (oldRenderFramesId) {
|
|
619
|
-
await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
logger.info(`[cut-over] Live document ${objectLayer._id} updated atomically`);
|
|
623
|
-
} else {
|
|
624
|
-
// Rollback: only File/AtlasSpriteSheet were written, clean those up
|
|
625
|
-
if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
|
|
626
|
-
logger.warn(`[cut-over] Staging rolled back for ${currentItemId}, live document preserved`);
|
|
627
|
-
objectLayer = await ObjectLayer.findByItemId(currentItemId);
|
|
628
|
-
}
|
|
629
|
-
} else {
|
|
630
|
-
// ── New item: stage everything before creating (same cut-over pattern) ──
|
|
631
|
-
logger.info(`ObjectLayer '${currentItemId}' is new, staging creation...`);
|
|
632
|
-
|
|
633
|
-
const itemKey = currentItemId;
|
|
634
|
-
const stagingData = JSON.parse(JSON.stringify(objectLayerData.data));
|
|
635
|
-
if (!stagingData.render) stagingData.render = {};
|
|
636
|
-
stagingData.render.cid = '';
|
|
637
|
-
stagingData.render.metadataCid = '';
|
|
638
|
-
|
|
639
|
-
let cutoverReady = false;
|
|
640
|
-
let stagingFileDoc = null;
|
|
641
|
-
let stagingAtlasDoc = null;
|
|
642
|
-
let stagingCid = '';
|
|
643
|
-
try {
|
|
644
|
-
const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
|
|
645
|
-
objectLayerRenderFramesData,
|
|
646
|
-
itemKey,
|
|
647
|
-
20,
|
|
648
|
-
);
|
|
649
|
-
|
|
650
|
-
stagingFileDoc = await new File({
|
|
651
|
-
name: `${itemKey}-atlas.png`,
|
|
652
|
-
data: buffer,
|
|
653
|
-
size: buffer.length,
|
|
654
|
-
mimetype: 'image/png',
|
|
655
|
-
md5: crypto.createHash('md5').update(buffer).digest('hex'),
|
|
656
|
-
}).save();
|
|
657
|
-
|
|
658
|
-
let importItemCid = '';
|
|
659
|
-
let importItemMetadataCid = '';
|
|
660
|
-
try {
|
|
661
|
-
const ipfsResult = await IpfsClient.addBufferToIpfs(
|
|
662
|
-
buffer,
|
|
663
|
-
`${itemKey}_atlas_sprite_sheet.png`,
|
|
664
|
-
`/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
|
|
665
|
-
);
|
|
666
|
-
if (ipfsResult) {
|
|
667
|
-
importItemCid = ipfsResult.cid;
|
|
668
|
-
logger.info(`[staging] Atlas pinned to IPFS – CID: ${importItemCid}`);
|
|
669
|
-
try {
|
|
670
|
-
await createPinRecord({
|
|
671
|
-
cid: importItemCid,
|
|
672
|
-
resourceType: 'atlas-sprite-sheet',
|
|
673
|
-
mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
|
|
674
|
-
options: { host, path },
|
|
675
|
-
});
|
|
676
|
-
} catch (prErr) {
|
|
677
|
-
logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
} catch (ipfsError) {
|
|
681
|
-
logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
try {
|
|
685
|
-
const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
|
|
686
|
-
metadata,
|
|
687
|
-
`${itemKey}_atlas_sprite_sheet_metadata.json`,
|
|
688
|
-
`/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
|
|
689
|
-
);
|
|
690
|
-
if (metadataIpfsResult) {
|
|
691
|
-
importItemMetadataCid = metadataIpfsResult.cid;
|
|
692
|
-
logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importItemMetadataCid}`);
|
|
693
|
-
try {
|
|
694
|
-
await createPinRecord({
|
|
695
|
-
cid: importItemMetadataCid,
|
|
696
|
-
resourceType: 'atlas-metadata',
|
|
697
|
-
mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
|
|
698
|
-
options: { host, path },
|
|
699
|
-
});
|
|
700
|
-
} catch (prErr) {
|
|
701
|
-
logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
} catch (ipfsError) {
|
|
705
|
-
logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
|
|
709
|
-
if (stagingAtlasDoc) {
|
|
710
|
-
if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
|
|
711
|
-
stagingAtlasDoc.fileId = stagingFileDoc._id;
|
|
712
|
-
stagingAtlasDoc.cid = importItemCid;
|
|
713
|
-
stagingAtlasDoc.metadata = metadata;
|
|
714
|
-
await stagingAtlasDoc.save();
|
|
715
|
-
} else {
|
|
716
|
-
stagingAtlasDoc = await new AtlasSpriteSheet({
|
|
717
|
-
fileId: stagingFileDoc._id,
|
|
718
|
-
cid: importItemCid,
|
|
719
|
-
metadata,
|
|
720
|
-
}).save();
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
stagingData.render.cid = importItemCid;
|
|
724
|
-
stagingData.render.metadataCid = importItemMetadataCid;
|
|
725
|
-
|
|
726
|
-
try {
|
|
727
|
-
const ipfsDataResult = await IpfsClient.addJsonToIpfs(
|
|
728
|
-
stagingData,
|
|
729
|
-
`${itemKey}_data.json`,
|
|
730
|
-
`/object-layer/${itemKey}/${itemKey}_data.json`,
|
|
731
|
-
);
|
|
732
|
-
if (ipfsDataResult) {
|
|
733
|
-
stagingCid = ipfsDataResult.cid;
|
|
734
|
-
logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
|
|
735
|
-
try {
|
|
736
|
-
await createPinRecord({
|
|
737
|
-
cid: stagingCid,
|
|
738
|
-
resourceType: 'object-layer-data',
|
|
739
|
-
mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
|
|
740
|
-
options: { host, path },
|
|
741
|
-
});
|
|
742
|
-
} catch (prErr) {
|
|
743
|
-
logger.warn('[staging] Failed to create data pin record:', prErr.message);
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
} catch (ipfsError) {
|
|
747
|
-
logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
cutoverReady = true;
|
|
751
|
-
logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for creation`);
|
|
752
|
-
} catch (atlasError) {
|
|
753
|
-
logger.error(`[staging] Failed for ${currentItemId}, no document created:`, atlasError);
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
if (cutoverReady) {
|
|
757
|
-
const newRenderFrames = await ObjectLayerRenderFrames.create(objectLayerRenderFramesData);
|
|
758
|
-
objectLayer = await ObjectLayer.upsertByItemId({
|
|
759
|
-
data: stagingData,
|
|
760
|
-
cid: stagingCid,
|
|
761
|
-
objectLayerRenderFramesId: newRenderFrames._id,
|
|
762
|
-
atlasSpriteSheetId: stagingAtlasDoc._id,
|
|
763
|
-
});
|
|
764
|
-
logger.info(`[cut-over] New ObjectLayer ${objectLayer._id} created with all CIDs populated`);
|
|
765
|
-
} else {
|
|
766
|
-
if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
|
|
767
|
-
logger.warn(`[cut-over] Staging failed for ${currentItemId}, no ObjectLayer created`);
|
|
768
|
-
continue;
|
|
769
|
-
}
|
|
770
|
-
}
|
|
866
|
+
const { objectLayer } = await ObjectLayerEngine.persistObjectLayerDocuments({
|
|
867
|
+
ObjectLayer,
|
|
868
|
+
ObjectLayerRenderFrames,
|
|
869
|
+
objectLayerRenderFramesData,
|
|
870
|
+
objectLayerData,
|
|
871
|
+
persistOptions: { upscaleFactor, options: { host, path } },
|
|
872
|
+
});
|
|
771
873
|
|
|
772
|
-
|
|
773
|
-
const finalObjectLayer = await ObjectLayer.findById(objectLayer._id).populate('objectLayerRenderFramesId');
|
|
774
|
-
console.log(finalObjectLayer.toObject());
|
|
874
|
+
console.log((await ObjectLayer.findById(objectLayer._id).populate('objectLayerRenderFramesId')).toObject());
|
|
775
875
|
}
|
|
776
876
|
}
|
|
777
877
|
|
|
@@ -806,12 +906,6 @@ try {
|
|
|
806
906
|
await pngDirectoryIteratorByObjectLayerType(
|
|
807
907
|
argItemType,
|
|
808
908
|
async ({ path: framePath, objectLayerType, objectLayerId, direction, frame }) => {
|
|
809
|
-
if (
|
|
810
|
-
storage &&
|
|
811
|
-
!storage[`src/client/public/cyberia/assets/${objectLayerType}/${objectLayerId}/08/0.png`]
|
|
812
|
-
)
|
|
813
|
-
return;
|
|
814
|
-
|
|
815
909
|
// Skip items that already exist in the database (bulk import only)
|
|
816
910
|
if (isImportAll && existingItemIds.has(objectLayerId)) return;
|
|
817
911
|
|
|
@@ -826,6 +920,7 @@ try {
|
|
|
826
920
|
objectLayerType,
|
|
827
921
|
objectLayerId,
|
|
828
922
|
});
|
|
923
|
+
applyStatPolicy(objectLayerData, statPolicy);
|
|
829
924
|
|
|
830
925
|
// Write processed frames back to disk so WebP matches atlas
|
|
831
926
|
const srcBasePath = './src/client/public/cyberia/';
|
|
@@ -836,14 +931,10 @@ try {
|
|
|
836
931
|
itemId: objectLayerId,
|
|
837
932
|
objectLayerRenderFramesData,
|
|
838
933
|
objectLayerData,
|
|
839
|
-
cellPixelDim:
|
|
934
|
+
cellPixelDim: upscaleFactor,
|
|
840
935
|
});
|
|
841
936
|
|
|
842
|
-
objectLayers[objectLayerId] = {
|
|
843
|
-
...objectLayerData,
|
|
844
|
-
objectLayerRenderFramesData,
|
|
845
|
-
_processed: true,
|
|
846
|
-
};
|
|
937
|
+
objectLayers[objectLayerId] = { ...objectLayerData, objectLayerRenderFramesData };
|
|
847
938
|
}
|
|
848
939
|
},
|
|
849
940
|
);
|
|
@@ -852,369 +943,17 @@ try {
|
|
|
852
943
|
for (const objectLayerId of Object.keys(objectLayers)) {
|
|
853
944
|
const entry = objectLayers[objectLayerId];
|
|
854
945
|
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
const
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
if (existingOL) {
|
|
865
|
-
// ── Cut-over consistency: stage everything in memory before touching the live document ──
|
|
866
|
-
logger.info(`ObjectLayer '${objectLayerId}' already exists (${existingOL._id}), staging update...`);
|
|
867
|
-
|
|
868
|
-
// 1. Prepare staging data entirely in memory (no DB writes yet)
|
|
869
|
-
const stagingData = JSON.parse(JSON.stringify(entry.data));
|
|
870
|
-
if (!stagingData.render) stagingData.render = {};
|
|
871
|
-
stagingData.render.cid = '';
|
|
872
|
-
stagingData.render.metadataCid = '';
|
|
873
|
-
|
|
874
|
-
// 2. Generate atlas, pin to IPFS, compute SHA-256 — all in memory
|
|
875
|
-
let cutoverReady = false;
|
|
876
|
-
let stagingFileDoc = null;
|
|
877
|
-
let stagingAtlasDoc = null;
|
|
878
|
-
let stagingCid = '';
|
|
879
|
-
try {
|
|
880
|
-
const itemKey = objectLayerId;
|
|
881
|
-
|
|
882
|
-
// Generate atlas from in-memory render frames data (plain object, no DB doc needed)
|
|
883
|
-
const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
|
|
884
|
-
entry.objectLayerRenderFramesData,
|
|
885
|
-
itemKey,
|
|
886
|
-
20,
|
|
887
|
-
);
|
|
888
|
-
|
|
889
|
-
stagingFileDoc = await new File({
|
|
890
|
-
name: `${itemKey}-atlas.png`,
|
|
891
|
-
data: buffer,
|
|
892
|
-
size: buffer.length,
|
|
893
|
-
mimetype: 'image/png',
|
|
894
|
-
md5: crypto.createHash('md5').update(buffer).digest('hex'),
|
|
895
|
-
}).save();
|
|
896
|
-
|
|
897
|
-
let importAtlasCid = '';
|
|
898
|
-
let importAtlasMetadataCid = '';
|
|
899
|
-
try {
|
|
900
|
-
const ipfsResult = await IpfsClient.addBufferToIpfs(
|
|
901
|
-
buffer,
|
|
902
|
-
`${itemKey}_atlas_sprite_sheet.png`,
|
|
903
|
-
`/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
|
|
904
|
-
);
|
|
905
|
-
if (ipfsResult) {
|
|
906
|
-
importAtlasCid = ipfsResult.cid;
|
|
907
|
-
logger.info(`[staging] Atlas pinned to IPFS – CID: ${importAtlasCid}`);
|
|
908
|
-
try {
|
|
909
|
-
await createPinRecord({
|
|
910
|
-
cid: importAtlasCid,
|
|
911
|
-
resourceType: 'atlas-sprite-sheet',
|
|
912
|
-
mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
|
|
913
|
-
options: { host, path },
|
|
914
|
-
});
|
|
915
|
-
} catch (prErr) {
|
|
916
|
-
logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
} catch (ipfsError) {
|
|
920
|
-
logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
try {
|
|
924
|
-
const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
|
|
925
|
-
metadata,
|
|
926
|
-
`${itemKey}_atlas_sprite_sheet_metadata.json`,
|
|
927
|
-
`/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
|
|
928
|
-
);
|
|
929
|
-
if (metadataIpfsResult) {
|
|
930
|
-
importAtlasMetadataCid = metadataIpfsResult.cid;
|
|
931
|
-
logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importAtlasMetadataCid}`);
|
|
932
|
-
try {
|
|
933
|
-
await createPinRecord({
|
|
934
|
-
cid: importAtlasMetadataCid,
|
|
935
|
-
resourceType: 'atlas-metadata',
|
|
936
|
-
mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
|
|
937
|
-
options: { host, path },
|
|
938
|
-
});
|
|
939
|
-
} catch (prErr) {
|
|
940
|
-
logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
} catch (ipfsError) {
|
|
944
|
-
logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
|
|
948
|
-
if (stagingAtlasDoc) {
|
|
949
|
-
if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
|
|
950
|
-
stagingAtlasDoc.fileId = stagingFileDoc._id;
|
|
951
|
-
stagingAtlasDoc.cid = importAtlasCid;
|
|
952
|
-
stagingAtlasDoc.metadata = metadata;
|
|
953
|
-
await stagingAtlasDoc.save();
|
|
954
|
-
} else {
|
|
955
|
-
stagingAtlasDoc = await new AtlasSpriteSheet({
|
|
956
|
-
fileId: stagingFileDoc._id,
|
|
957
|
-
cid: importAtlasCid,
|
|
958
|
-
metadata,
|
|
959
|
-
}).save();
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
// Finalize staging data in memory with render CIDs
|
|
963
|
-
stagingData.render.cid = importAtlasCid;
|
|
964
|
-
stagingData.render.metadataCid = importAtlasMetadataCid;
|
|
965
|
-
|
|
966
|
-
// Pin data JSON to IPFS
|
|
967
|
-
try {
|
|
968
|
-
const ipfsDataResult = await IpfsClient.addJsonToIpfs(
|
|
969
|
-
stagingData,
|
|
970
|
-
`${itemKey}_data.json`,
|
|
971
|
-
`/object-layer/${itemKey}/${itemKey}_data.json`,
|
|
972
|
-
);
|
|
973
|
-
if (ipfsDataResult) {
|
|
974
|
-
stagingCid = ipfsDataResult.cid;
|
|
975
|
-
logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
|
|
976
|
-
try {
|
|
977
|
-
await createPinRecord({
|
|
978
|
-
cid: stagingCid,
|
|
979
|
-
resourceType: 'object-layer-data',
|
|
980
|
-
mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
|
|
981
|
-
options: { host, path },
|
|
982
|
-
});
|
|
983
|
-
} catch (prErr) {
|
|
984
|
-
logger.warn('[staging] Failed to create data pin record:', prErr.message);
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
} catch (ipfsError) {
|
|
988
|
-
logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
cutoverReady = true;
|
|
992
|
-
logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for cut-over`);
|
|
993
|
-
} catch (atlasError) {
|
|
994
|
-
logger.error(`[staging] Failed for ${objectLayerId}, live document untouched:`, atlasError);
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
// 3. Atomic cut-over: create new RenderFrames, swap live ObjectLayer in a single update
|
|
998
|
-
if (cutoverReady) {
|
|
999
|
-
const oldRenderFramesId = existingOL.objectLayerRenderFramesId;
|
|
1000
|
-
const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
|
|
1001
|
-
|
|
1002
|
-
objectLayer = await ObjectLayer.upsertByItemId({
|
|
1003
|
-
data: stagingData,
|
|
1004
|
-
cid: stagingCid,
|
|
1005
|
-
objectLayerRenderFramesId: newRenderFrames._id,
|
|
1006
|
-
atlasSpriteSheetId: stagingAtlasDoc._id,
|
|
1007
|
-
});
|
|
1008
|
-
|
|
1009
|
-
if (oldRenderFramesId) {
|
|
1010
|
-
await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
|
|
1011
|
-
}
|
|
1012
|
-
logger.info(`[cut-over] Live document ${objectLayer._id} updated atomically`);
|
|
1013
|
-
} else {
|
|
1014
|
-
if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
|
|
1015
|
-
logger.warn(`[cut-over] Staging rolled back for ${objectLayerId}, live document preserved`);
|
|
1016
|
-
objectLayer = await ObjectLayer.findByItemId(objectLayerId);
|
|
1017
|
-
}
|
|
1018
|
-
} else {
|
|
1019
|
-
// ── New item: stage everything before creating (same cut-over pattern) ──
|
|
1020
|
-
logger.info(`ObjectLayer '${objectLayerId}' is new, staging creation...`);
|
|
1021
|
-
|
|
1022
|
-
const itemKey = objectLayerId;
|
|
1023
|
-
const stagingData = JSON.parse(JSON.stringify(entry.data));
|
|
1024
|
-
if (!stagingData.render) stagingData.render = {};
|
|
1025
|
-
stagingData.render.cid = '';
|
|
1026
|
-
stagingData.render.metadataCid = '';
|
|
1027
|
-
|
|
1028
|
-
let cutoverReady = false;
|
|
1029
|
-
let stagingFileDoc = null;
|
|
1030
|
-
let stagingAtlasDoc = null;
|
|
1031
|
-
let stagingCid = '';
|
|
1032
|
-
try {
|
|
1033
|
-
const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
|
|
1034
|
-
entry.objectLayerRenderFramesData,
|
|
1035
|
-
itemKey,
|
|
1036
|
-
20,
|
|
1037
|
-
);
|
|
1038
|
-
|
|
1039
|
-
stagingFileDoc = await new File({
|
|
1040
|
-
name: `${itemKey}-atlas.png`,
|
|
1041
|
-
data: buffer,
|
|
1042
|
-
size: buffer.length,
|
|
1043
|
-
mimetype: 'image/png',
|
|
1044
|
-
md5: crypto.createHash('md5').update(buffer).digest('hex'),
|
|
1045
|
-
}).save();
|
|
1046
|
-
|
|
1047
|
-
let importAtlasCid = '';
|
|
1048
|
-
let importAtlasMetadataCid = '';
|
|
1049
|
-
try {
|
|
1050
|
-
const ipfsResult = await IpfsClient.addBufferToIpfs(
|
|
1051
|
-
buffer,
|
|
1052
|
-
`${itemKey}_atlas_sprite_sheet.png`,
|
|
1053
|
-
`/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
|
|
1054
|
-
);
|
|
1055
|
-
if (ipfsResult) {
|
|
1056
|
-
importAtlasCid = ipfsResult.cid;
|
|
1057
|
-
logger.info(`[staging] Atlas pinned to IPFS – CID: ${importAtlasCid}`);
|
|
1058
|
-
try {
|
|
1059
|
-
await createPinRecord({
|
|
1060
|
-
cid: importAtlasCid,
|
|
1061
|
-
resourceType: 'atlas-sprite-sheet',
|
|
1062
|
-
mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
|
|
1063
|
-
options: { host, path },
|
|
1064
|
-
});
|
|
1065
|
-
} catch (prErr) {
|
|
1066
|
-
logger.warn('[staging] Failed to create atlas pin record:', prErr.message);
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
} catch (ipfsError) {
|
|
1070
|
-
logger.warn('[staging] Failed to add atlas to IPFS:', ipfsError.message);
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
try {
|
|
1074
|
-
const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
|
|
1075
|
-
metadata,
|
|
1076
|
-
`${itemKey}_atlas_sprite_sheet_metadata.json`,
|
|
1077
|
-
`/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
|
|
1078
|
-
);
|
|
1079
|
-
if (metadataIpfsResult) {
|
|
1080
|
-
importAtlasMetadataCid = metadataIpfsResult.cid;
|
|
1081
|
-
logger.info(`[staging] Atlas metadata pinned to IPFS – CID: ${importAtlasMetadataCid}`);
|
|
1082
|
-
try {
|
|
1083
|
-
await createPinRecord({
|
|
1084
|
-
cid: importAtlasMetadataCid,
|
|
1085
|
-
resourceType: 'atlas-metadata',
|
|
1086
|
-
mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
|
|
1087
|
-
options: { host, path },
|
|
1088
|
-
});
|
|
1089
|
-
} catch (prErr) {
|
|
1090
|
-
logger.warn('[staging] Failed to create atlas-metadata pin record:', prErr.message);
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
} catch (ipfsError) {
|
|
1094
|
-
logger.warn('[staging] Failed to add atlas metadata to IPFS:', ipfsError.message);
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
stagingAtlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
|
|
1098
|
-
if (stagingAtlasDoc) {
|
|
1099
|
-
if (stagingAtlasDoc.fileId) await File.findByIdAndDelete(stagingAtlasDoc.fileId);
|
|
1100
|
-
stagingAtlasDoc.fileId = stagingFileDoc._id;
|
|
1101
|
-
stagingAtlasDoc.cid = importAtlasCid;
|
|
1102
|
-
stagingAtlasDoc.metadata = metadata;
|
|
1103
|
-
await stagingAtlasDoc.save();
|
|
1104
|
-
} else {
|
|
1105
|
-
stagingAtlasDoc = await new AtlasSpriteSheet({
|
|
1106
|
-
fileId: stagingFileDoc._id,
|
|
1107
|
-
cid: importAtlasCid,
|
|
1108
|
-
metadata,
|
|
1109
|
-
}).save();
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
stagingData.render.cid = importAtlasCid;
|
|
1113
|
-
stagingData.render.metadataCid = importAtlasMetadataCid;
|
|
1114
|
-
|
|
1115
|
-
try {
|
|
1116
|
-
const ipfsDataResult = await IpfsClient.addJsonToIpfs(
|
|
1117
|
-
stagingData,
|
|
1118
|
-
`${itemKey}_data.json`,
|
|
1119
|
-
`/object-layer/${itemKey}/${itemKey}_data.json`,
|
|
1120
|
-
);
|
|
1121
|
-
if (ipfsDataResult) {
|
|
1122
|
-
stagingCid = ipfsDataResult.cid;
|
|
1123
|
-
logger.info(`[staging] Data JSON pinned to IPFS – CID: ${stagingCid}`);
|
|
1124
|
-
try {
|
|
1125
|
-
await createPinRecord({
|
|
1126
|
-
cid: stagingCid,
|
|
1127
|
-
resourceType: 'object-layer-data',
|
|
1128
|
-
mfsPath: `/object-layer/${itemKey}/${itemKey}_data.json`,
|
|
1129
|
-
options: { host, path },
|
|
1130
|
-
});
|
|
1131
|
-
} catch (prErr) {
|
|
1132
|
-
logger.warn('[staging] Failed to create data pin record:', prErr.message);
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
} catch (ipfsError) {
|
|
1136
|
-
logger.warn('[staging] Failed to pin data JSON to IPFS:', ipfsError.message);
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
cutoverReady = true;
|
|
1140
|
-
logger.info(`[staging] Item '${itemKey}' fully staged in memory, ready for creation`);
|
|
1141
|
-
} catch (atlasError) {
|
|
1142
|
-
logger.error(`[staging] Failed for ${objectLayerId}, no document created:`, atlasError);
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
if (cutoverReady) {
|
|
1146
|
-
const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
|
|
1147
|
-
objectLayer = await ObjectLayer.upsertByItemId({
|
|
1148
|
-
data: stagingData,
|
|
1149
|
-
cid: stagingCid,
|
|
1150
|
-
objectLayerRenderFramesId: newRenderFrames._id,
|
|
1151
|
-
atlasSpriteSheetId: stagingAtlasDoc._id,
|
|
1152
|
-
});
|
|
1153
|
-
logger.info(`[cut-over] New ObjectLayer ${objectLayer._id} created with all CIDs populated`);
|
|
1154
|
-
} else {
|
|
1155
|
-
if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
|
|
1156
|
-
logger.warn(`[cut-over] Staging failed for ${objectLayerId}, no ObjectLayer created`);
|
|
1157
|
-
continue;
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
// Reload final state to include CID and render updates
|
|
1162
|
-
const finalObjectLayer = await ObjectLayer.findById((objectLayer._id || objectLayer).toString()).populate(
|
|
1163
|
-
'objectLayerRenderFramesId',
|
|
1164
|
-
);
|
|
1165
|
-
console.log(finalObjectLayer.toObject());
|
|
1166
|
-
} else {
|
|
1167
|
-
// --import all: skip items that already exist in the database
|
|
1168
|
-
if (existingItemIds.has(objectLayerId)) continue;
|
|
1169
|
-
|
|
1170
|
-
// --import all: create documents without atlas generation
|
|
1171
|
-
const existingOL = await ObjectLayer.findByItemId(objectLayerId);
|
|
1172
|
-
let objectLayer;
|
|
1173
|
-
|
|
1174
|
-
if (existingOL) {
|
|
1175
|
-
logger.info(
|
|
1176
|
-
`ObjectLayer '${objectLayerId}' already exists (${existingOL._id}), staging update (atlas skipped)...`,
|
|
1177
|
-
);
|
|
1178
|
-
|
|
1179
|
-
// ── In-memory staging (no atlas) ──────────────────────
|
|
1180
|
-
const stagingData = JSON.parse(JSON.stringify(entry.data));
|
|
1181
|
-
if (!stagingData.render) stagingData.render = {};
|
|
1182
|
-
stagingData.render.cid = '';
|
|
1183
|
-
stagingData.render.metadataCid = '';
|
|
1184
|
-
|
|
1185
|
-
// Atomic cut-over: create new RenderFrames, swap live doc, delete old
|
|
1186
|
-
const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
|
|
1187
|
-
const oldRenderFramesId = existingOL.objectLayerRenderFramesId;
|
|
1188
|
-
|
|
1189
|
-
objectLayer = await ObjectLayer.upsertByItemId({
|
|
1190
|
-
data: stagingData,
|
|
1191
|
-
objectLayerRenderFramesId: newRenderFrames._id,
|
|
1192
|
-
});
|
|
1193
|
-
|
|
1194
|
-
if (oldRenderFramesId) {
|
|
1195
|
-
await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
logger.info(`[cut-over] Live document ${objectLayer._id} updated atomically (atlas skipped)`);
|
|
1199
|
-
} else {
|
|
1200
|
-
// New item (no atlas for bulk import)
|
|
1201
|
-
const stagingData = JSON.parse(JSON.stringify(entry.data));
|
|
1202
|
-
if (!stagingData.render) stagingData.render = {};
|
|
1203
|
-
stagingData.render.cid = '';
|
|
1204
|
-
stagingData.render.metadataCid = '';
|
|
1205
|
-
|
|
1206
|
-
const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
|
|
1207
|
-
objectLayer = await ObjectLayer.upsertByItemId({
|
|
1208
|
-
data: stagingData,
|
|
1209
|
-
objectLayerRenderFramesId: newRenderFrames._id,
|
|
1210
|
-
});
|
|
1211
|
-
}
|
|
946
|
+
// A bulk import of every type skips atlas generation; `--to-atlas-sprite-sheet`
|
|
947
|
+
// or a targeted `--import` builds the atlas for an item that needs one.
|
|
948
|
+
const { objectLayer } = await ObjectLayerEngine.persistObjectLayerDocuments({
|
|
949
|
+
ObjectLayer,
|
|
950
|
+
ObjectLayerRenderFrames,
|
|
951
|
+
objectLayerRenderFramesData: entry.objectLayerRenderFramesData,
|
|
952
|
+
objectLayerData: { data: entry.data },
|
|
953
|
+
persistOptions: { generateAtlas: !isImportAll, upscaleFactor, options: { host, path } },
|
|
954
|
+
});
|
|
1212
955
|
|
|
1213
|
-
|
|
1214
|
-
`ObjectLayer ${existingOL ? 'updated' : 'created'} (atlas skipped for bulk import): ${objectLayerId}`,
|
|
1215
|
-
);
|
|
1216
|
-
console.log(objectLayer.toObject ? objectLayer.toObject() : objectLayer);
|
|
1217
|
-
}
|
|
956
|
+
console.log(objectLayer.toObject());
|
|
1218
957
|
}
|
|
1219
958
|
}
|
|
1220
959
|
|
|
@@ -1234,20 +973,16 @@ try {
|
|
|
1234
973
|
|
|
1235
974
|
logger.info(`Showing frame for item: ${itemId}, direction: ${direction}, frame: ${frameIndexNum}`);
|
|
1236
975
|
|
|
1237
|
-
// Find ObjectLayer by item-id
|
|
1238
976
|
const objectLayer = await ObjectLayer.findByItemId(itemId).populate('objectLayerRenderFramesId');
|
|
1239
|
-
|
|
1240
977
|
if (!objectLayer) {
|
|
1241
978
|
logger.error(`ObjectLayer not found for item-id: ${itemId}`);
|
|
1242
979
|
process.exit(1);
|
|
1243
980
|
}
|
|
1244
|
-
|
|
1245
981
|
if (!objectLayer.objectLayerRenderFramesId) {
|
|
1246
982
|
logger.error(`ObjectLayerRenderFrames not found for item: ${itemId}`);
|
|
1247
983
|
process.exit(1);
|
|
1248
984
|
}
|
|
1249
985
|
|
|
1250
|
-
// Get the keyframe direction name from the numerical direction code
|
|
1251
986
|
const objectLayerFrameDirections = getKeyframeDirectionsByCode(direction);
|
|
1252
987
|
if (objectLayerFrameDirections.length === 0) {
|
|
1253
988
|
logger.error(`Invalid direction code: ${direction}. Valid codes: 08, 18, 02, 12, 04, 14, 06, 16`);
|
|
@@ -1271,15 +1006,14 @@ try {
|
|
|
1271
1006
|
process.exit(1);
|
|
1272
1007
|
}
|
|
1273
1008
|
|
|
1274
|
-
const
|
|
1275
|
-
const outputPath = `./${itemKey}_${showFrameInput}.png`;
|
|
1009
|
+
const outputPath = `./${objectLayer.data.item.id}_${showFrameInput}.png`;
|
|
1276
1010
|
|
|
1277
1011
|
await buildImgFromTile({
|
|
1278
1012
|
tile: {
|
|
1279
1013
|
map_color: objectLayer.objectLayerRenderFramesId.colors,
|
|
1280
1014
|
frame_matrix: frames[frameIndexNum],
|
|
1281
1015
|
},
|
|
1282
|
-
cellPixelDim:
|
|
1016
|
+
cellPixelDim: upscaleFactor,
|
|
1283
1017
|
opacityFilter: (x, y, color) => 255,
|
|
1284
1018
|
imagePath: outputPath,
|
|
1285
1019
|
});
|
|
@@ -1289,158 +1023,90 @@ try {
|
|
|
1289
1023
|
}
|
|
1290
1024
|
|
|
1291
1025
|
// ── Handle --to-atlas-sprite-sheet ───────────────────────────────
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1026
|
+
// Rebuilds both atlas renders. The scope is the same selection --minify
|
|
1027
|
+
// uses: the given item-id(s), one instance, or the whole collection.
|
|
1028
|
+
// A bare --upscale asks for the same rebuild, since the factor only
|
|
1029
|
+
// takes effect on the human-resolution render.
|
|
1030
|
+
if (rebuildAtlases) {
|
|
1295
1031
|
/** @type {number|null} */
|
|
1296
|
-
const maxAtlasDim =
|
|
1032
|
+
const maxAtlasDim =
|
|
1033
|
+
options.toAtlasSpriteSheet === true || options.toAtlasSpriteSheet === undefined
|
|
1034
|
+
? null
|
|
1035
|
+
: parseInt(options.toAtlasSpriteSheet) || null;
|
|
1297
1036
|
|
|
1298
|
-
if (
|
|
1299
|
-
logger.error('item-id is required for --to-atlas-sprite-sheet');
|
|
1300
|
-
process.exit(1);
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
if (maxAtlasDim) {
|
|
1304
|
-
/** @type {string} */
|
|
1037
|
+
if (maxAtlasDim !== null) {
|
|
1305
1038
|
const sizeRecommendation =
|
|
1306
1039
|
maxAtlasDim < 2048
|
|
1307
1040
|
? ' (Warning: May be too small for all frames)'
|
|
1308
1041
|
: maxAtlasDim > 4096
|
|
1309
|
-
? ' (Large size: ensure GPU compatibility)'
|
|
1310
|
-
: ' (Recommended size)';
|
|
1311
|
-
|
|
1312
|
-
logger.info(
|
|
1313
|
-
`Generating atlas sprite sheet for item: ${itemId} with max dimension: ${maxAtlasDim}x${maxAtlasDim}${sizeRecommendation}`,
|
|
1314
|
-
);
|
|
1315
|
-
} else {
|
|
1316
|
-
logger.info(
|
|
1317
|
-
`Generating atlas sprite sheet for item: ${itemId} with auto-calculated dimensions (based on frame count)`,
|
|
1318
|
-
);
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
// Find ObjectLayer by item-id
|
|
1322
|
-
const objectLayer = await ObjectLayer.findByItemId(itemId).populate('objectLayerRenderFramesId');
|
|
1323
|
-
|
|
1324
|
-
if (!objectLayer) {
|
|
1325
|
-
logger.error(`ObjectLayer not found for item-id: ${itemId}`);
|
|
1326
|
-
process.exit(1);
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
const itemKey = objectLayer.data.item.id;
|
|
1330
|
-
logger.info(`Found ObjectLayer: ${itemKey} (${objectLayer._id})`);
|
|
1331
|
-
|
|
1332
|
-
// Generate atlas sprite sheet
|
|
1333
|
-
const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
|
|
1334
|
-
objectLayer.objectLayerRenderFramesId,
|
|
1335
|
-
itemKey,
|
|
1336
|
-
20, // cellPixelDim
|
|
1337
|
-
maxAtlasDim,
|
|
1338
|
-
);
|
|
1339
|
-
|
|
1340
|
-
/** @type {number} */
|
|
1341
|
-
const frameCount = Object.values(metadata.frames).reduce((sum, frames) => sum + frames.length, 0);
|
|
1342
|
-
logger.info(
|
|
1343
|
-
`Atlas generated: ${metadata.atlasWidth}x${metadata.atlasHeight} pixels (${frameCount} frames packed)`,
|
|
1344
|
-
);
|
|
1345
|
-
|
|
1346
|
-
// Save to File collection
|
|
1347
|
-
const fileDoc = await new File({
|
|
1348
|
-
name: `${itemKey}-atlas.png`,
|
|
1349
|
-
data: buffer,
|
|
1350
|
-
size: buffer.length,
|
|
1351
|
-
mimetype: 'image/png',
|
|
1352
|
-
md5: crypto.createHash('md5').update(buffer).digest('hex'),
|
|
1353
|
-
}).save();
|
|
1354
|
-
|
|
1355
|
-
logger.info(`File saved with ID: ${fileDoc._id}`);
|
|
1356
|
-
|
|
1357
|
-
// Pin atlas PNG to IPFS
|
|
1358
|
-
let toAtlasCid = '';
|
|
1359
|
-
let toAtlasMetadataCid = '';
|
|
1360
|
-
try {
|
|
1361
|
-
const ipfsResult = await IpfsClient.addBufferToIpfs(
|
|
1362
|
-
buffer,
|
|
1363
|
-
`${itemKey}_atlas_sprite_sheet.png`,
|
|
1364
|
-
`/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
|
|
1365
|
-
);
|
|
1366
|
-
if (ipfsResult) {
|
|
1367
|
-
toAtlasCid = ipfsResult.cid;
|
|
1368
|
-
logger.info(`Atlas sprite sheet pinned to IPFS – CID: ${toAtlasCid}`);
|
|
1369
|
-
try {
|
|
1370
|
-
await createPinRecord({
|
|
1371
|
-
cid: toAtlasCid,
|
|
1372
|
-
resourceType: 'atlas-sprite-sheet',
|
|
1373
|
-
mfsPath: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
|
|
1374
|
-
});
|
|
1375
|
-
} catch (e) {
|
|
1376
|
-
logger.warn('Failed to create pin record for atlas sprite sheet:', e.message);
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
} catch (ipfsError) {
|
|
1380
|
-
logger.warn('Failed to add atlas sprite sheet to IPFS:', ipfsError.message);
|
|
1042
|
+
? ' (Large size: ensure GPU compatibility)'
|
|
1043
|
+
: ' (Recommended size)';
|
|
1044
|
+
logger.info(`Max atlas dimension: ${maxAtlasDim}x${maxAtlasDim}${sizeRecommendation}`);
|
|
1381
1045
|
}
|
|
1382
1046
|
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1047
|
+
const selectedItemIds = await selectScopedItemIds({
|
|
1048
|
+
ObjectLayer,
|
|
1049
|
+
itemId,
|
|
1050
|
+
instance: options.instance,
|
|
1051
|
+
host,
|
|
1052
|
+
path,
|
|
1053
|
+
action: '--to-atlas-sprite-sheet',
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
logger.info(`Atlas rebuild for ${selectedItemIds.length} stored item(s) at ${upscaleFactor}px per cell`);
|
|
1057
|
+
const tally = { rebuilt: 0, skipped: 0, failed: [] };
|
|
1058
|
+
|
|
1059
|
+
// One item at a time, isolated: a long batch runs through IPFS and can
|
|
1060
|
+
// meet a dropped database connection, and a rerun of a failed item is a
|
|
1061
|
+
// no-op for every item that already succeeded.
|
|
1062
|
+
for (const currentItemId of selectedItemIds) {
|
|
1063
|
+
try {
|
|
1064
|
+
const objectLayer = await liveObjectLayer()
|
|
1065
|
+
.findByItemId(currentItemId)
|
|
1066
|
+
.populate('objectLayerRenderFramesId');
|
|
1067
|
+
if (!objectLayer?.objectLayerRenderFramesId) {
|
|
1068
|
+
logger.warn(`No render frames stored for '${currentItemId}', skipped`);
|
|
1069
|
+
tally.skipped++;
|
|
1070
|
+
continue;
|
|
1401
1071
|
}
|
|
1072
|
+
|
|
1073
|
+
const { atlasDoc, metadata, atlasCid, atlasMetadataCid } = await AtlasSpriteSheetStore.persist({
|
|
1074
|
+
itemKey: currentItemId,
|
|
1075
|
+
objectLayerRenderFrames: objectLayer.objectLayerRenderFramesId,
|
|
1076
|
+
upscaleFactor,
|
|
1077
|
+
maxAtlasDim,
|
|
1078
|
+
options: { host, path },
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
const frameCount = Object.values(metadata.frames).reduce((sum, frames) => sum + frames.length, 0);
|
|
1082
|
+
logger.info(
|
|
1083
|
+
`Atlas for '${currentItemId}': ${metadata.atlasWidth}x${metadata.atlasHeight} cells, ` +
|
|
1084
|
+
`${frameCount} frames packed`,
|
|
1085
|
+
);
|
|
1086
|
+
|
|
1087
|
+
objectLayer.atlasSpriteSheetId = atlasDoc._id;
|
|
1088
|
+
if (!objectLayer.data.render) objectLayer.data.render = {};
|
|
1089
|
+
objectLayer.data.render.cid = atlasCid;
|
|
1090
|
+
objectLayer.data.render.metadataCid = atlasMetadataCid;
|
|
1091
|
+
objectLayer.markModified('data.render');
|
|
1092
|
+
applyStatPolicy(objectLayer, statPolicy);
|
|
1093
|
+
await objectLayer.save();
|
|
1094
|
+
|
|
1095
|
+
await ObjectLayerEngine.computeAndSaveFinalSha256({ objectLayer, options: { host, path } });
|
|
1096
|
+
tally.rebuilt++;
|
|
1097
|
+
} catch (rebuildError) {
|
|
1098
|
+
logger.error(`Atlas rebuild failed for '${currentItemId}': ${rebuildError.message}`);
|
|
1099
|
+
tally.failed.push(currentItemId);
|
|
1402
1100
|
}
|
|
1403
|
-
} catch (ipfsError) {
|
|
1404
|
-
logger.warn('Failed to add atlas metadata to IPFS:', ipfsError.message);
|
|
1405
1101
|
}
|
|
1406
1102
|
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
atlasDoc.fileId = fileDoc._id;
|
|
1414
|
-
atlasDoc.cid = toAtlasCid;
|
|
1415
|
-
atlasDoc.metadata = metadata;
|
|
1416
|
-
await atlasDoc.save();
|
|
1417
|
-
logger.info(`Updated existing AtlasSpriteSheet document: ${atlasDoc._id}`);
|
|
1418
|
-
} else {
|
|
1419
|
-
// Create new
|
|
1420
|
-
atlasDoc = await new AtlasSpriteSheet({
|
|
1421
|
-
fileId: fileDoc._id,
|
|
1422
|
-
cid: toAtlasCid,
|
|
1423
|
-
metadata,
|
|
1424
|
-
}).save();
|
|
1425
|
-
logger.info(`Created new AtlasSpriteSheet document: ${atlasDoc._id}`);
|
|
1103
|
+
logger.info(
|
|
1104
|
+
`Atlas rebuild done: ${tally.rebuilt} rebuilt, ${tally.skipped} without render frames, ` +
|
|
1105
|
+
`${tally.failed.length} failed`,
|
|
1106
|
+
);
|
|
1107
|
+
if (tally.failed.length > 0) {
|
|
1108
|
+
logger.warn(`Rerun for the failed item(s): ${tally.failed.join(',')}`);
|
|
1426
1109
|
}
|
|
1427
|
-
|
|
1428
|
-
// Update ObjectLayer with reference to atlas sprite sheet and render CIDs
|
|
1429
|
-
objectLayer.atlasSpriteSheetId = atlasDoc._id;
|
|
1430
|
-
if (!objectLayer.data.render) objectLayer.data.render = {};
|
|
1431
|
-
objectLayer.data.render.cid = toAtlasCid;
|
|
1432
|
-
objectLayer.data.render.metadataCid = toAtlasMetadataCid;
|
|
1433
|
-
objectLayer.markModified('data.render');
|
|
1434
|
-
await objectLayer.save();
|
|
1435
|
-
|
|
1436
|
-
// Compute final SHA-256 and pin object layer data JSON to IPFS
|
|
1437
|
-
await ObjectLayerEngine.computeAndSaveFinalSha256({
|
|
1438
|
-
objectLayer,
|
|
1439
|
-
ipfsClient: IpfsClient,
|
|
1440
|
-
createPinRecord,
|
|
1441
|
-
});
|
|
1442
|
-
|
|
1443
|
-
logger.info(`Atlas sprite sheet completed for item: ${itemKey}`);
|
|
1444
1110
|
}
|
|
1445
1111
|
|
|
1446
1112
|
// ── Handle --show-atlas-sprite-sheet ─────────────────────────────
|
|
@@ -1541,6 +1207,7 @@ try {
|
|
|
1541
1207
|
|
|
1542
1208
|
// Overwrite the item id in the generated data with the unique variant
|
|
1543
1209
|
multiFrameResult.objectLayerData.data.item.id = uniqueItemId;
|
|
1210
|
+
applyStatPolicy(multiFrameResult.objectLayerData, statPolicy);
|
|
1544
1211
|
|
|
1545
1212
|
logger.info(
|
|
1546
1213
|
`Generated ${multiFrameResult.frameCount} frame(s) with ${multiFrameResult.objectLayerRenderFramesData.colors.length} unique colors`,
|
|
@@ -1555,7 +1222,7 @@ try {
|
|
|
1555
1222
|
itemId: uniqueItemId,
|
|
1556
1223
|
objectLayerRenderFramesData: multiFrameResult.objectLayerRenderFramesData,
|
|
1557
1224
|
objectLayerData: multiFrameResult.objectLayerData,
|
|
1558
|
-
cellPixelDim:
|
|
1225
|
+
cellPixelDim: upscaleFactor,
|
|
1559
1226
|
});
|
|
1560
1227
|
|
|
1561
1228
|
logger.info(`Wrote ${writtenFiles.length} asset file(s):`);
|
|
@@ -1563,148 +1230,34 @@ try {
|
|
|
1563
1230
|
logger.info(` → ${f}`);
|
|
1564
1231
|
}
|
|
1565
1232
|
|
|
1566
|
-
// 3. Persist
|
|
1567
|
-
const { objectLayer } = await ObjectLayerEngine.
|
|
1233
|
+
// 3. Persist ObjectLayer, render frames and the atlas, with every CID staged first
|
|
1234
|
+
const { objectLayer } = await ObjectLayerEngine.persistObjectLayerDocuments({
|
|
1568
1235
|
ObjectLayer,
|
|
1569
1236
|
ObjectLayerRenderFrames,
|
|
1570
1237
|
objectLayerRenderFramesData: multiFrameResult.objectLayerRenderFramesData,
|
|
1571
1238
|
objectLayerData: multiFrameResult.objectLayerData,
|
|
1572
|
-
|
|
1573
|
-
generateAtlas: false,
|
|
1574
|
-
},
|
|
1239
|
+
persistOptions: { upscaleFactor, options: { host, path } },
|
|
1575
1240
|
});
|
|
1576
1241
|
|
|
1577
1242
|
logger.info(`ObjectLayer persisted to MongoDB: ${objectLayer._id} (item: ${objectLayer.data.item.id})`);
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
const
|
|
1584
|
-
|
|
1585
|
-
)
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
const fileDoc = await new File({
|
|
1595
|
-
name: `${atlasItemKey}-atlas.png`,
|
|
1596
|
-
data: buffer,
|
|
1597
|
-
size: buffer.length,
|
|
1598
|
-
mimetype: 'image/png',
|
|
1599
|
-
md5: crypto.createHash('md5').update(buffer).digest('hex'),
|
|
1600
|
-
}).save();
|
|
1601
|
-
|
|
1602
|
-
// Pin atlas PNG to IPFS + copy into MFS
|
|
1603
|
-
let atlasMetadataCid = '';
|
|
1604
|
-
try {
|
|
1605
|
-
const ipfsResult = await IpfsClient.addBufferToIpfs(
|
|
1606
|
-
buffer,
|
|
1607
|
-
`${atlasItemKey}_atlas_sprite_sheet.png`,
|
|
1608
|
-
`/object-layer/${atlasItemKey}/${atlasItemKey}_atlas_sprite_sheet.png`,
|
|
1609
|
-
);
|
|
1610
|
-
if (ipfsResult) {
|
|
1611
|
-
atlasCid = ipfsResult.cid;
|
|
1612
|
-
logger.info(`Atlas sprite sheet pinned to IPFS – CID: ${atlasCid}`);
|
|
1613
|
-
try {
|
|
1614
|
-
await createPinRecord({
|
|
1615
|
-
cid: atlasCid,
|
|
1616
|
-
resourceType: 'atlas-sprite-sheet',
|
|
1617
|
-
mfsPath: `/object-layer/${atlasItemKey}/${atlasItemKey}_atlas_sprite_sheet.png`,
|
|
1618
|
-
options: { host, path },
|
|
1619
|
-
});
|
|
1620
|
-
} catch (e) {
|
|
1621
|
-
logger.warn('Failed to create pin record for atlas sprite sheet:', e.message);
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
} catch (ipfsError) {
|
|
1625
|
-
logger.warn('Failed to add atlas sprite sheet to IPFS:', ipfsError.message);
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
// Pin atlas metadata JSON to IPFS (fast-json-stable-stringify)
|
|
1629
|
-
try {
|
|
1630
|
-
const metadataIpfsResult = await IpfsClient.addJsonToIpfs(
|
|
1631
|
-
metadata,
|
|
1632
|
-
`${atlasItemKey}_atlas_sprite_sheet_metadata.json`,
|
|
1633
|
-
`/object-layer/${atlasItemKey}/${atlasItemKey}_atlas_sprite_sheet_metadata.json`,
|
|
1634
|
-
);
|
|
1635
|
-
if (metadataIpfsResult) {
|
|
1636
|
-
atlasMetadataCid = metadataIpfsResult.cid;
|
|
1637
|
-
logger.info(`Atlas metadata pinned to IPFS – CID: ${atlasMetadataCid}`);
|
|
1638
|
-
try {
|
|
1639
|
-
await createPinRecord({
|
|
1640
|
-
cid: atlasMetadataCid,
|
|
1641
|
-
resourceType: 'atlas-metadata',
|
|
1642
|
-
mfsPath: `/object-layer/${atlasItemKey}/${atlasItemKey}_atlas_sprite_sheet_metadata.json`,
|
|
1643
|
-
options: { host, path },
|
|
1644
|
-
});
|
|
1645
|
-
} catch (e) {
|
|
1646
|
-
logger.warn('Failed to create pin record for atlas metadata:', e.message);
|
|
1647
|
-
}
|
|
1243
|
+
logger.info(`Final SHA-256: ${objectLayer.sha256}`);
|
|
1244
|
+
if (objectLayer.cid) logger.info(`ObjectLayer data pinned to IPFS – CID: ${objectLayer.cid}`);
|
|
1245
|
+
|
|
1246
|
+
// 4. Mirror the human-resolution atlas PNG into both static asset directories
|
|
1247
|
+
if (objectLayer.atlasSpriteSheetId) {
|
|
1248
|
+
const atlasDoc = await AtlasSpriteSheet.findById(objectLayer.atlasSpriteSheetId);
|
|
1249
|
+
const atlasFile = atlasDoc ? await File.findById(atlasDoc.fileId) : null;
|
|
1250
|
+
if (atlasFile?.data) {
|
|
1251
|
+
for (const bp of [srcBasePath, publicBasePath]) {
|
|
1252
|
+
const atlasOutputDir = nodePath.join(bp, 'assets', descriptor.itemType, uniqueItemId);
|
|
1253
|
+
await fs.ensureDir(atlasOutputDir);
|
|
1254
|
+
const atlasOutputPath = nodePath.join(atlasOutputDir, `${uniqueItemId}-atlas.png`);
|
|
1255
|
+
await fs.writeFile(atlasOutputPath, atlasFile.data);
|
|
1256
|
+
logger.info(
|
|
1257
|
+
`Atlas sprite sheet written: ${atlasDoc.metadata.atlasWidth}x${atlasDoc.metadata.atlasHeight} cells → ${atlasOutputPath}`,
|
|
1258
|
+
);
|
|
1648
1259
|
}
|
|
1649
|
-
} catch (ipfsError) {
|
|
1650
|
-
logger.warn('Failed to add atlas metadata to IPFS:', ipfsError.message);
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
// Upsert AtlasSpriteSheet document (with CID)
|
|
1654
|
-
let atlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': atlasItemKey });
|
|
1655
|
-
if (atlasDoc) {
|
|
1656
|
-
if (atlasDoc.fileId) await File.findByIdAndDelete(atlasDoc.fileId);
|
|
1657
|
-
atlasDoc.fileId = fileDoc._id;
|
|
1658
|
-
atlasDoc.cid = atlasCid;
|
|
1659
|
-
atlasDoc.metadata = metadata;
|
|
1660
|
-
await atlasDoc.save();
|
|
1661
|
-
logger.info(`Updated existing AtlasSpriteSheet document: ${atlasDoc._id}`);
|
|
1662
|
-
} else {
|
|
1663
|
-
atlasDoc = await new AtlasSpriteSheet({
|
|
1664
|
-
fileId: fileDoc._id,
|
|
1665
|
-
cid: atlasCid,
|
|
1666
|
-
metadata,
|
|
1667
|
-
}).save();
|
|
1668
|
-
logger.info(`Created new AtlasSpriteSheet document: ${atlasDoc._id}`);
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
// Link atlas to ObjectLayer and set data.render.cid + data.render.metadataCid
|
|
1672
|
-
populatedObjectLayer.atlasSpriteSheetId = atlasDoc._id;
|
|
1673
|
-
if (!populatedObjectLayer.data.render) populatedObjectLayer.data.render = {};
|
|
1674
|
-
populatedObjectLayer.data.render.cid = atlasCid;
|
|
1675
|
-
populatedObjectLayer.data.render.metadataCid = atlasMetadataCid;
|
|
1676
|
-
populatedObjectLayer.markModified('data.render');
|
|
1677
|
-
await populatedObjectLayer.save();
|
|
1678
|
-
|
|
1679
|
-
// Also write atlas PNG to both static asset directories
|
|
1680
|
-
for (const bp of [srcBasePath, publicBasePath]) {
|
|
1681
|
-
const atlasOutputDir = nodePath.join(bp, 'assets', descriptor.itemType, uniqueItemId);
|
|
1682
|
-
await fs.ensureDir(atlasOutputDir);
|
|
1683
|
-
const atlasOutputPath = nodePath.join(atlasOutputDir, `${atlasItemKey}-atlas.png`);
|
|
1684
|
-
await fs.writeFile(atlasOutputPath, buffer);
|
|
1685
|
-
logger.info(
|
|
1686
|
-
`Atlas sprite sheet generated: ${metadata.atlasWidth}x${metadata.atlasHeight} → ${atlasOutputPath}`,
|
|
1687
|
-
);
|
|
1688
|
-
}
|
|
1689
|
-
} catch (atlasError) {
|
|
1690
|
-
logger.error(`Failed to generate atlas for ${uniqueItemId}:`, atlasError);
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
// 5. Compute final SHA-256, pin OL data JSON to IPFS, create pin records
|
|
1694
|
-
try {
|
|
1695
|
-
const finalObjectLayer = await ObjectLayer.findById(objectLayer._id).populate('objectLayerRenderFramesId');
|
|
1696
|
-
const finalized = await ObjectLayerEngine.computeAndSaveFinalSha256({
|
|
1697
|
-
objectLayer: finalObjectLayer,
|
|
1698
|
-
ipfsClient: IpfsClient,
|
|
1699
|
-
createPinRecord,
|
|
1700
|
-
options: { host, path },
|
|
1701
|
-
});
|
|
1702
|
-
logger.info(`Final SHA-256: ${finalized.sha256}`);
|
|
1703
|
-
if (finalized.cid) {
|
|
1704
|
-
logger.info(`ObjectLayer data pinned to IPFS – CID: ${finalized.cid}`);
|
|
1705
1260
|
}
|
|
1706
|
-
} catch (finalizeError) {
|
|
1707
|
-
logger.error('Failed to finalize SHA-256 / IPFS:', finalizeError);
|
|
1708
1261
|
}
|
|
1709
1262
|
|
|
1710
1263
|
logger.info(`✓ Generation complete for "${uniqueItemId}" (seed: ${genSeed}, frames: ${genFrameCount})`);
|
|
@@ -1748,6 +1301,10 @@ try {
|
|
|
1748
1301
|
.option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
|
|
1749
1302
|
.option('--mongo-host <mongo-host>', 'Mongo host override')
|
|
1750
1303
|
.option('--dev', 'Force development environment')
|
|
1304
|
+
.option(
|
|
1305
|
+
'--sync-entities',
|
|
1306
|
+
'Point the instance conf at every entity-type default its maps place and every skill their items trigger, dropping what the world no longer carries',
|
|
1307
|
+
)
|
|
1751
1308
|
.option('--publish-build', 'Build instance backup directory with all related maps, entities and object layers')
|
|
1752
1309
|
.option('--publish-remove', 'Remove published instance from underpostnet/cyberia-instances repository')
|
|
1753
1310
|
.option('--publish', 'Publish instance in underpostnet/cyberia-instances repository')
|
|
@@ -1818,14 +1375,9 @@ try {
|
|
|
1818
1375
|
// and git refuses to touch a tree owned by someone else until it is declared safe.
|
|
1819
1376
|
Underpost.repo.declareSafeDirectory('/home/dd/cyberia-instances');
|
|
1820
1377
|
if (options.publishBuild) {
|
|
1821
|
-
if (!fs.existsSync('/home/dd/cyberia-instances'))
|
|
1378
|
+
if (!fs.existsSync('/home/dd/cyberia-instances'))
|
|
1822
1379
|
shellExec(`cd /home/dd && ${cli()} clone underpostnet/cyberia-instances`);
|
|
1823
|
-
}
|
|
1824
|
-
shellExec(`${cli()} run clean /home/dd/cyberia-instances`);
|
|
1825
|
-
shellExec(`cd /home/dd/cyberia-instances && ${cli()} pull . underpostnet/cyberia-instances`, {
|
|
1826
|
-
silentOnError: true,
|
|
1827
|
-
});
|
|
1828
|
-
}
|
|
1380
|
+
else shellExec(`cd /home/dd/cyberia-instances && ${cli()} cmt --switch-repo underpostnet/cyberia-instances`);
|
|
1829
1381
|
|
|
1830
1382
|
fs.mkdirpSync(`/home/dd/cyberia-instances/conf/dd-cyberia`);
|
|
1831
1383
|
fs.copyFileSync(
|
|
@@ -1902,11 +1454,21 @@ try {
|
|
|
1902
1454
|
`./engine-private/conf/dd-cyberia/instances/mmo-server/build/development/.`,
|
|
1903
1455
|
`/home/dd/cyberia-instances/deployments/cyberia-server/.`,
|
|
1904
1456
|
);
|
|
1457
|
+
const folders = ['ui-icons', 'cursor', 'fonts', 'icons', 'splash', 'templates'];
|
|
1458
|
+
for (const folder of folders)
|
|
1459
|
+
fs.copySync(
|
|
1460
|
+
`./src/client/public/cyberia/assets/${folder}`,
|
|
1461
|
+
`/home/dd/cyberia-instances/public/cyberia/assets/${folder}`,
|
|
1462
|
+
);
|
|
1905
1463
|
// fs.removeSync(`/home/dd/cyberia-instances/public/cyberia`);
|
|
1906
1464
|
// fs.mkdirpSync(`/home/dd/cyberia-instances/public/cyberia`);
|
|
1907
1465
|
fs.mkdirpSync(`/home/dd/cyberia-instances/instances`);
|
|
1908
1466
|
fs.mkdirpSync(`/home/dd/cyberia-instances/sagas`);
|
|
1909
1467
|
for (const _instanceCode of instanceCode.split(',')) {
|
|
1468
|
+
if (fs.existsSync(`/home/dd/cyberia-instances/instances/${_instanceCode}`))
|
|
1469
|
+
shellExec(`rm -rf /home/dd/cyberia-instances/instances/${_instanceCode}`);
|
|
1470
|
+
if (fs.existsSync(`/home/dd/cyberia-instances/sagas/${_instanceCode}.json`))
|
|
1471
|
+
shellExec(`rm -rf /home/dd/cyberia-instances/sagas/${_instanceCode}.json`);
|
|
1910
1472
|
fs.copySync(
|
|
1911
1473
|
`./engine-private/cyberia-instances/${_instanceCode}`,
|
|
1912
1474
|
`/home/dd/cyberia-instances/instances/${_instanceCode}`,
|
|
@@ -1923,6 +1485,19 @@ try {
|
|
|
1923
1485
|
'/home/dd/cyberia-instances/conf/dd-cyberia/conf.instances.json',
|
|
1924
1486
|
);
|
|
1925
1487
|
|
|
1488
|
+
if (!fs.existsSync('/home/dd/cyberia-instances/manifests'))
|
|
1489
|
+
fs.mkdirSync('/home/dd/cyberia-instances/manifests');
|
|
1490
|
+
fs.copySync('./cyberia-server/manifests', '/home/dd/cyberia-instances/manifests', { overwrite: true });
|
|
1491
|
+
fs.copySync('./cyberia-client/manifests', '/home/dd/cyberia-instances/manifests', { overwrite: true });
|
|
1492
|
+
if (!fs.existsSync('/home/dd/cyberia-instances/manifests/deployments/dd-cyberia-development'))
|
|
1493
|
+
fs.mkdirSync('/home/dd/cyberia-instances/manifests/deployments/dd-cyberia-development', {
|
|
1494
|
+
recursive: true,
|
|
1495
|
+
});
|
|
1496
|
+
fs.copySync(
|
|
1497
|
+
'./manifests/deployment/dd-cyberia-development',
|
|
1498
|
+
'/home/dd/cyberia-instances/manifests/deployments/dd-cyberia-development',
|
|
1499
|
+
{ overwrite: true },
|
|
1500
|
+
);
|
|
1926
1501
|
const fromN = parseInt(options.fromNCommit) > 0 ? parseInt(options.fromNCommit) : 1;
|
|
1927
1502
|
const publishMessage =
|
|
1928
1503
|
shellExec(`node bin cmt --changelog-msg --from-n-commit ${fromN} --changelog-no-hash`, {
|
|
@@ -1987,6 +1562,8 @@ try {
|
|
|
1987
1562
|
'cyberia-skill',
|
|
1988
1563
|
'cyberia-entity-type-default',
|
|
1989
1564
|
'cyberia-saga',
|
|
1565
|
+
'cyberia-audio',
|
|
1566
|
+
'cyberia-map-audio-conf',
|
|
1990
1567
|
'object-layer',
|
|
1991
1568
|
'object-layer-render-frames',
|
|
1992
1569
|
'atlas-sprite-sheet',
|
|
@@ -2007,6 +1584,8 @@ try {
|
|
|
2007
1584
|
const CyberiaSkill = DataBaseProviderService.getModel('cyberia-skill', { host, path });
|
|
2008
1585
|
const CyberiaEntityTypeDefault = DataBaseProviderService.getModel('cyberia-entity-type-default', { host, path });
|
|
2009
1586
|
const CyberiaSaga = DataBaseProviderService.getModel('cyberia-saga', { host, path });
|
|
1587
|
+
const CyberiaAudio = DataBaseProviderService.getModel('cyberia-audio', { host, path });
|
|
1588
|
+
const CyberiaMapAudioConf = DataBaseProviderService.getModel('cyberia-map-audio-conf', { host, path });
|
|
2010
1589
|
const ObjectLayer = DataBaseProviderService.getModel('object-layer', { host, path });
|
|
2011
1590
|
const ObjectLayerRenderFrames = DataBaseProviderService.getModel('object-layer-render-frames', { host, path });
|
|
2012
1591
|
const AtlasSpriteSheet = DataBaseProviderService.getModel('atlas-sprite-sheet', { host, path });
|
|
@@ -2027,8 +1606,8 @@ try {
|
|
|
2027
1606
|
atlasMetadata: `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet_metadata.json`,
|
|
2028
1607
|
});
|
|
2029
1608
|
|
|
2030
|
-
//
|
|
2031
|
-
//
|
|
1609
|
+
// A canonical pin carries one `mfsPath`. `mfsPaths` (plural) is read too,
|
|
1610
|
+
// because a backup that consolidated shared CIDs writes that field.
|
|
2032
1611
|
const collectMfsPaths = (doc = {}) => {
|
|
2033
1612
|
const paths = new Set();
|
|
2034
1613
|
if (doc.mfsPath) paths.add(doc.mfsPath);
|
|
@@ -2084,10 +1663,9 @@ try {
|
|
|
2084
1663
|
...(entry.mfsPath ? { mfsPath: entry.mfsPath } : {}),
|
|
2085
1664
|
}));
|
|
2086
1665
|
|
|
2087
|
-
// Bring the
|
|
2088
|
-
// duplicate mfsPath rows
|
|
2089
|
-
//
|
|
2090
|
-
// partial unique index on mfsPath is built. Idempotent and safe to re-run.
|
|
1666
|
+
// Bring the Ipfs collection in line with the mfsPath-unique model: collapse
|
|
1667
|
+
// duplicate mfsPath rows, keeping the most recent, then sync indexes so the
|
|
1668
|
+
// partial unique index on mfsPath is built. Safe to re-run.
|
|
2091
1669
|
const reconcileIpfsRegistryIndexes = async () => {
|
|
2092
1670
|
let removedDuplicates = 0;
|
|
2093
1671
|
const duplicateGroups = await Ipfs.aggregate([
|
|
@@ -2141,13 +1719,10 @@ try {
|
|
|
2141
1719
|
|
|
2142
1720
|
// ── CAPTURE CURRENT FALLBACK WORLD ──────────────────────────────
|
|
2143
1721
|
//
|
|
2144
|
-
// The procedural fallback world only
|
|
2145
|
-
//
|
|
2146
|
-
//
|
|
2147
|
-
//
|
|
2148
|
-
// together with the content collections the fallback path serves from
|
|
2149
|
-
// code defaults rather than the DB — so the export below can back it up
|
|
2150
|
-
// and `--import` can restore it as an ordinary persisted instance.
|
|
1722
|
+
// The procedural fallback world lives only in engine memory. A capture
|
|
1723
|
+
// writes it to MongoDB under [instance-code], with the content
|
|
1724
|
+
// collections the fallback path serves from code defaults, so the export
|
|
1725
|
+
// below can back it up and `--import` can restore it.
|
|
2151
1726
|
if (options.exportCurrentFallbackworld) {
|
|
2152
1727
|
const { generateFallbackWorld } = await import('../src/api/cyberia-instance/cyberia-fallback-world.js');
|
|
2153
1728
|
const { captureFallbackWorld } = await import('../src/api/cyberia-instance/cyberia-fallback-capture.js');
|
|
@@ -2188,6 +1763,8 @@ try {
|
|
|
2188
1763
|
CyberiaSkill,
|
|
2189
1764
|
CyberiaEntityTypeDefault,
|
|
2190
1765
|
CyberiaDialogue,
|
|
1766
|
+
CyberiaMapAudioConf,
|
|
1767
|
+
CyberiaAudio,
|
|
2191
1768
|
ObjectLayer,
|
|
2192
1769
|
},
|
|
2193
1770
|
world,
|
|
@@ -2202,7 +1779,7 @@ try {
|
|
|
2202
1779
|
logger.error(
|
|
2203
1780
|
`Capture aborted: ${capture.missingObjectLayerItemIds.length} referenced item id(s) have no ObjectLayer in MongoDB:`,
|
|
2204
1781
|
capture.missingObjectLayerItemIds.join(', '),
|
|
2205
|
-
`— run \`node bin/cyberia ol ${capture.missingObjectLayerItemIds.join('
|
|
1782
|
+
`— run \`node bin/cyberia ol ${capture.missingObjectLayerItemIds.join(',')} --from-directory --import\` (or ` +
|
|
2206
1783
|
'`node bin/cyberia run-workflow import-default-items`) first.',
|
|
2207
1784
|
);
|
|
2208
1785
|
await DataBaseProviderService.getProvider({ host, path }, 'mongoose').close();
|
|
@@ -2214,6 +1791,7 @@ try {
|
|
|
2214
1791
|
mapCodes: capture.plan.instance.cyberiaMapCodes,
|
|
2215
1792
|
actions: capture.plan.actions.length,
|
|
2216
1793
|
quests: capture.plan.quests.length,
|
|
1794
|
+
audioConfs: capture.audio.audioConfs,
|
|
2217
1795
|
objectLayerItemIds: capture.plan.itemIds.length,
|
|
2218
1796
|
});
|
|
2219
1797
|
|
|
@@ -2222,6 +1800,28 @@ try {
|
|
|
2222
1800
|
if (options.export === undefined) options.export = true;
|
|
2223
1801
|
}
|
|
2224
1802
|
|
|
1803
|
+
// ── SYNC ENTITY-TYPE DEFAULTS ───────────────────────────────────
|
|
1804
|
+
if (options.syncEntities) {
|
|
1805
|
+
const result = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode }, { host, path });
|
|
1806
|
+
logger.info(`sync-entities ${instanceCode}`, {
|
|
1807
|
+
maps: result.mapCodes.length,
|
|
1808
|
+
references: result.entityDefaults.length,
|
|
1809
|
+
...(result.linked.length > 0 ? { linked: result.linked } : {}),
|
|
1810
|
+
...(result.dropped.length > 0 ? { dropped: result.dropped } : {}),
|
|
1811
|
+
...(result.skipped.length > 0 ? { claimedByAnotherInstance: result.skipped } : {}),
|
|
1812
|
+
...(result.skills.length > 0 ? { skills: result.skills } : {}),
|
|
1813
|
+
...(result.entitiesUpdated.length > 0
|
|
1814
|
+
? { entitiesUpdated: result.entitiesUpdated.map(({ mapCode, entities }) => `${mapCode}:${entities}`) }
|
|
1815
|
+
: {}),
|
|
1816
|
+
...(result.conflicts.length > 0 ? { notLinkedSameBuildAlreadyReferenced: result.conflicts } : {}),
|
|
1817
|
+
...(result.duplicates.length > 0 ? { duplicateReferencesDeleteOne: result.duplicates } : {}),
|
|
1818
|
+
});
|
|
1819
|
+
if (options.export === undefined && !options.import && !options.drop) {
|
|
1820
|
+
await DataBaseProviderService.getProvider({ host, path }, 'mongoose').close();
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
|
|
2225
1825
|
// ── EXPORT ──────────────────────────────────────────────────────
|
|
2226
1826
|
if (options.export !== undefined) {
|
|
2227
1827
|
const instance = await CyberiaInstance.findOne({ code: instanceCode }).lean();
|
|
@@ -2237,6 +1837,25 @@ try {
|
|
|
2237
1837
|
: `./engine-private/cyberia-instances/${instanceCode}`;
|
|
2238
1838
|
|
|
2239
1839
|
fs.ensureDirSync(backupDir);
|
|
1840
|
+
// The export is a projection of what this instance references, never an archive of
|
|
1841
|
+
// everything it once did: a document that drops out of the world has to leave the
|
|
1842
|
+
// directory with it, or the next import brings it back from the dead. Emptied up front,
|
|
1843
|
+
// so a collection that exports nothing this run ends up empty rather than stale. The
|
|
1844
|
+
// asset directories — files, ipfs, render-frames, atlas sheets — are content-addressed
|
|
1845
|
+
// and expensive to refetch, so they keep what they have.
|
|
1846
|
+
for (const collection of [
|
|
1847
|
+
'maps',
|
|
1848
|
+
'cyberia-map-audio-confs',
|
|
1849
|
+
'cyberia-audio',
|
|
1850
|
+
'cyberia-quests',
|
|
1851
|
+
'cyberia-actions',
|
|
1852
|
+
'cyberia-skills',
|
|
1853
|
+
'cyberia-entity-type-defaults',
|
|
1854
|
+
'cyberia-dialogues',
|
|
1855
|
+
'object-layers',
|
|
1856
|
+
]) {
|
|
1857
|
+
fs.emptyDirSync(`${backupDir}/${collection}`);
|
|
1858
|
+
}
|
|
2240
1859
|
logger.info('Exporting instance', { code: instanceCode, backupDir });
|
|
2241
1860
|
|
|
2242
1861
|
// Helper: export a File document to the files/ directory
|
|
@@ -2266,6 +1885,11 @@ try {
|
|
|
2266
1885
|
// 1b. Export linked CyberiaInstanceConf (skillRules, equipmentRules, entityDefaults, etc.)
|
|
2267
1886
|
// If no conf doc exists yet (instance created before auto-upsert logic), create one using
|
|
2268
1887
|
// schema defaults — identical to the behaviour in CyberiaInstanceService.post().
|
|
1888
|
+
//
|
|
1889
|
+
// Compact first: a reference whose entity-type default is gone cannot be exported, and
|
|
1890
|
+
// writing it into the backup would carry the dangling id into every world restored from
|
|
1891
|
+
// it. Dropping it here repairs the live conf and the backup in one step.
|
|
1892
|
+
await CyberiaEntityTypeDefaultService.compactInstanceRefs({ host, path }, { instanceCode });
|
|
2269
1893
|
let instanceConf =
|
|
2270
1894
|
(await CyberiaInstanceConf.findOne({ instanceCode }).lean()) ||
|
|
2271
1895
|
(instance.conf ? await CyberiaInstanceConf.findById(instance.conf).lean() : null);
|
|
@@ -2283,10 +1907,10 @@ try {
|
|
|
2283
1907
|
instanceConf = created?.toObject ? created.toObject() : created;
|
|
2284
1908
|
}
|
|
2285
1909
|
if (instanceConf) {
|
|
2286
|
-
// `.lean()` skips
|
|
2287
|
-
//
|
|
2288
|
-
//
|
|
2289
|
-
instanceConf =
|
|
1910
|
+
// `.lean()` skips the schema defaults and a document written before a bound tightened
|
|
1911
|
+
// may carry a value the schema now rejects; the backup is made whole and valid here so
|
|
1912
|
+
// it imports back as-is.
|
|
1913
|
+
({ conf: instanceConf } = await CyberiaInstanceConfService.coerceToSchema(instanceConf, CyberiaInstanceConf));
|
|
2290
1914
|
fs.writeJsonSync(`${backupDir}/cyberia-instance-conf.json`, instanceConf, { spaces: 2 });
|
|
2291
1915
|
logger.info('Exported CyberiaInstanceConf', { instanceCode });
|
|
2292
1916
|
} else {
|
|
@@ -2323,6 +1947,50 @@ try {
|
|
|
2323
1947
|
}
|
|
2324
1948
|
logger.info(`Exported ${maps.length} CyberiaMap document(s)`, { codes: maps.map((m) => m.code) });
|
|
2325
1949
|
|
|
1950
|
+
// 3a. Export the audio configuration of those maps, and the assets it binds.
|
|
1951
|
+
// A CyberiaMapAudioConf belongs to one map, so it travels with the instance. A
|
|
1952
|
+
// CyberiaAudio is global and shared by every map that binds its code, so it travels
|
|
1953
|
+
// as a copy: the import upserts it by code and leaves other instances' bindings alone.
|
|
1954
|
+
// The WAV rides along as an ordinary File document, keeping its _id so `fileId` still
|
|
1955
|
+
// resolves after a restore.
|
|
1956
|
+
const audioConfs = await CyberiaMapAudioConf.find({ mapCode: { $in: [...mapCodes] } }).lean();
|
|
1957
|
+
if (audioConfs.length > 0) {
|
|
1958
|
+
fs.ensureDirSync(`${backupDir}/cyberia-map-audio-confs`);
|
|
1959
|
+
const audioCodes = new Set();
|
|
1960
|
+
for (const conf of audioConfs) {
|
|
1961
|
+
fs.writeJsonSync(`${backupDir}/cyberia-map-audio-confs/${encodeURIComponent(conf.mapCode)}.json`, conf, {
|
|
1962
|
+
spaces: 2,
|
|
1963
|
+
});
|
|
1964
|
+
if (conf.defaultMusic) audioCodes.add(conf.defaultMusic);
|
|
1965
|
+
for (const event of conf.events || []) if (event.audioCode) audioCodes.add(event.audioCode);
|
|
1966
|
+
}
|
|
1967
|
+
logger.info(`Exported ${audioConfs.length} CyberiaMapAudioConf document(s)`, {
|
|
1968
|
+
mapCodes: audioConfs.map((c) => c.mapCode),
|
|
1969
|
+
});
|
|
1970
|
+
|
|
1971
|
+
const audioAssets = await CyberiaAudio.find({ code: { $in: [...audioCodes] } }).lean();
|
|
1972
|
+
if (audioAssets.length > 0) {
|
|
1973
|
+
fs.ensureDirSync(`${backupDir}/cyberia-audio`);
|
|
1974
|
+
for (const asset of audioAssets) {
|
|
1975
|
+
fs.writeJsonSync(`${backupDir}/cyberia-audio/${encodeURIComponent(asset.code)}.json`, asset, {
|
|
1976
|
+
spaces: 2,
|
|
1977
|
+
});
|
|
1978
|
+
if (asset.fileId) await exportFileDoc(asset.fileId, `audio-${asset.code}`);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
const missingAudioCodes = [...audioCodes].filter((code) => !audioAssets.some((asset) => asset.code === code));
|
|
1982
|
+
logger.info(`Exported ${audioAssets.length} CyberiaAudio document(s)`, {
|
|
1983
|
+
codes: audioAssets.map((a) => a.code),
|
|
1984
|
+
});
|
|
1985
|
+
if (missingAudioCodes.length > 0) {
|
|
1986
|
+
logger.warn(
|
|
1987
|
+
'Audio bindings reference codes with no imported CyberiaAudio document — ' +
|
|
1988
|
+
'run `node bin/cyberia audio --import` before restoring this backup',
|
|
1989
|
+
{ codes: missingAudioCodes },
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
2326
1994
|
// 3b. Export quests + actions bound to THIS instance's maps (sourceMapCode
|
|
2327
1995
|
// in the instance's map codes) — only the content tied to this instance.
|
|
2328
1996
|
// Dialogue codes the actions reference are collected so they travel too.
|
|
@@ -2353,134 +2021,51 @@ try {
|
|
|
2353
2021
|
logger.info(`Exported ${actions.length} CyberiaAction document(s)`, { codes: actions.map((a) => a.code) });
|
|
2354
2022
|
}
|
|
2355
2023
|
|
|
2356
|
-
// 4.
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
}
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
if (id) objectLayerItemIds.add(id);
|
|
2370
|
-
}
|
|
2371
|
-
|
|
2372
|
-
const contentItemIds = new Set(objectLayerItemIds);
|
|
2373
|
-
|
|
2374
|
-
// 4c. Add all itemIds referenced by CyberiaInstanceConf (entityDefaults + skillConfig).
|
|
2375
|
-
// This ensures liveItemIds, deadItemIds, dropItemIds, defaultObjectLayers and
|
|
2376
|
-
// skill trigger items are included even if no map entity currently uses them.
|
|
2377
|
-
if (instanceConf) {
|
|
2378
|
-
for (const ed of instanceConf.entityDefaults || []) {
|
|
2379
|
-
for (const id of ed.liveItemIds || []) if (id) objectLayerItemIds.add(id);
|
|
2380
|
-
for (const id of ed.deadItemIds || []) if (id) objectLayerItemIds.add(id);
|
|
2381
|
-
for (const id of ed.dropItemIds || []) if (id) objectLayerItemIds.add(id);
|
|
2382
|
-
for (const slot of ed.defaultObjectLayers || []) {
|
|
2383
|
-
if (slot.itemId) objectLayerItemIds.add(slot.itemId);
|
|
2384
|
-
}
|
|
2385
|
-
}
|
|
2386
|
-
for (const sc of instanceConf.skillConfig || []) {
|
|
2387
|
-
if (sc.triggerItemId) objectLayerItemIds.add(sc.triggerItemId);
|
|
2388
|
-
for (const skill of sc.skills || []) {
|
|
2389
|
-
if (skill.summonedEntityItemId && !skill.summonedEntityItemId.startsWith('$')) {
|
|
2390
|
-
objectLayerItemIds.add(skill.summonedEntityItemId);
|
|
2391
|
-
}
|
|
2392
|
-
}
|
|
2393
|
-
}
|
|
2394
|
-
}
|
|
2395
|
-
|
|
2396
|
-
// 4c-bis. Add the item ids the instance's vendor / assembler / quest
|
|
2397
|
-
// catalogs name. The interact modal draws an icon for every shop
|
|
2398
|
-
// item, recipe ingredient/output and quest reward, so their atlases
|
|
2399
|
-
// must travel with the backup even when no map entity wears them.
|
|
2400
|
-
// Kept out of contentItemIds: catalogs must not widen the
|
|
2401
|
-
// entity-type-default match surface below.
|
|
2402
|
-
for (const action of actions) {
|
|
2403
|
-
for (const shopItem of action.shopItems || []) {
|
|
2404
|
-
if (shopItem.itemId) objectLayerItemIds.add(shopItem.itemId);
|
|
2405
|
-
if (shopItem.priceItemId) objectLayerItemIds.add(shopItem.priceItemId);
|
|
2406
|
-
}
|
|
2407
|
-
for (const recipe of action.craftRecipes || []) {
|
|
2408
|
-
for (const ingredient of recipe.ingredients || []) {
|
|
2409
|
-
if (ingredient.itemId) objectLayerItemIds.add(ingredient.itemId);
|
|
2410
|
-
}
|
|
2411
|
-
for (const output of recipe.outputItems || []) {
|
|
2412
|
-
if (output.itemId) objectLayerItemIds.add(output.itemId);
|
|
2413
|
-
}
|
|
2414
|
-
}
|
|
2415
|
-
}
|
|
2416
|
-
for (const quest of quests) {
|
|
2417
|
-
for (const step of quest.steps || []) {
|
|
2418
|
-
for (const objective of step.objectives || []) {
|
|
2419
|
-
if (objective.itemId) objectLayerItemIds.add(objective.itemId);
|
|
2420
|
-
}
|
|
2421
|
-
}
|
|
2422
|
-
for (const reward of quest.rewards || []) {
|
|
2423
|
-
if (reward.itemId) objectLayerItemIds.add(reward.itemId);
|
|
2024
|
+
// 4. Export the entity-type defaults this instance's conf references, by _id.
|
|
2025
|
+
// Membership is a reference, not a resemblance: matching on item ids used to pull in
|
|
2026
|
+
// every document that happened to share a skin, so two instances built on the same
|
|
2027
|
+
// art exported each other's wiring and overwrote it on the way back in.
|
|
2028
|
+
const referencedIds = (instanceConf?.entityDefaults || []).map((id) => String(id?._id ?? id));
|
|
2029
|
+
// Every reference resolves: the conf was compacted before it was read.
|
|
2030
|
+
const entityDefaults = referencedIds.length
|
|
2031
|
+
? await CyberiaEntityTypeDefault.find({ _id: { $in: referencedIds } }).lean()
|
|
2032
|
+
: [];
|
|
2033
|
+
if (entityDefaults.length > 0) {
|
|
2034
|
+
fs.ensureDirSync(`${backupDir}/cyberia-entity-type-defaults`);
|
|
2035
|
+
for (const ed of entityDefaults) {
|
|
2036
|
+
fs.writeJsonSync(`${backupDir}/cyberia-entity-type-defaults/${ed._id}.json`, ed, { spaces: 2 });
|
|
2424
2037
|
}
|
|
2038
|
+
logger.info(`Exported ${entityDefaults.length} CyberiaEntityTypeDefault document(s)`, {
|
|
2039
|
+
entityTypes: entityDefaults.map((ed) => ed.entityType),
|
|
2040
|
+
});
|
|
2425
2041
|
}
|
|
2426
2042
|
|
|
2427
|
-
//
|
|
2428
|
-
//
|
|
2429
|
-
//
|
|
2430
|
-
//
|
|
2431
|
-
|
|
2432
|
-
|
|
2043
|
+
// 4b. Everything this instance names: what its maps place, what its entity-type defaults
|
|
2044
|
+
// wire, and what its vendor / assembler / quest catalogs trade. Every one of these
|
|
2045
|
+
// draws an icon somewhere, so the atlases travel with the backup even when no map
|
|
2046
|
+
// entity wears them. One rule, shared with the boot payload and the editor's sync.
|
|
2047
|
+
const objectLayerItemIds = collectInstanceItemIds({ maps, entityDefaults, actions, quests });
|
|
2048
|
+
|
|
2049
|
+
// 4c. Export the skills this instance runs: the collection owns the definitions, and one
|
|
2050
|
+
// belongs here when its trigger item is an id the instance names — which is how a
|
|
2051
|
+
// trigger only a quest objective or a vendor's shelf mentions still travels. Their
|
|
2052
|
+
// summoned entities join the OL set, so this runs before the dialogue + OL queries.
|
|
2053
|
+
{
|
|
2054
|
+
const skills = selectInstanceSkills(await CyberiaSkill.find({}).lean(), objectLayerItemIds);
|
|
2433
2055
|
if (skills.length > 0) {
|
|
2434
2056
|
fs.ensureDirSync(`${backupDir}/cyberia-skills`);
|
|
2435
2057
|
for (const skill of skills) {
|
|
2436
2058
|
fs.writeJsonSync(`${backupDir}/cyberia-skills/${encodeURIComponent(skill.triggerItemId)}.json`, skill, {
|
|
2437
2059
|
spaces: 2,
|
|
2438
2060
|
});
|
|
2439
|
-
for (const def of skill.skills || []) {
|
|
2440
|
-
if (def.summonedEntityItemId && !def.summonedEntityItemId.startsWith('$')) {
|
|
2441
|
-
objectLayerItemIds.add(def.summonedEntityItemId);
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
2444
2061
|
}
|
|
2062
|
+
for (const summoned of collectSummonedItemIds(skills)) objectLayerItemIds.add(summoned);
|
|
2445
2063
|
logger.info(`Exported ${skills.length} CyberiaSkill document(s)`, {
|
|
2446
2064
|
triggerItemIds: skills.map((sk) => sk.triggerItemId),
|
|
2447
2065
|
});
|
|
2448
2066
|
}
|
|
2449
2067
|
}
|
|
2450
2068
|
|
|
2451
|
-
// 4d-bis. Export entity-type defaults whose item ids belong to this
|
|
2452
|
-
// instance's real content (own model: CyberiaEntityTypeDefault). A
|
|
2453
|
-
// default is related when any of its live/dead/drop ids or default
|
|
2454
|
-
// object-layer ids appears in contentItemIds — map/instance content
|
|
2455
|
-
// only, NOT the canonical conf defaults every instance shares (which
|
|
2456
|
-
// would spuriously drag in the global seed entity-type-defaults).
|
|
2457
|
-
// Matched ids are folded back into objectLayerItemIds so the related
|
|
2458
|
-
// atlases + dialogues export too.
|
|
2459
|
-
if (contentItemIds.size > 0) {
|
|
2460
|
-
const idsForMatch = [...contentItemIds];
|
|
2461
|
-
const entityDefaults = await CyberiaEntityTypeDefault.find({
|
|
2462
|
-
$or: [
|
|
2463
|
-
{ liveItemIds: { $in: idsForMatch } },
|
|
2464
|
-
{ deadItemIds: { $in: idsForMatch } },
|
|
2465
|
-
{ dropItemIds: { $in: idsForMatch } },
|
|
2466
|
-
{ 'defaultObjectLayers.itemId': { $in: idsForMatch } },
|
|
2467
|
-
],
|
|
2468
|
-
}).lean();
|
|
2469
|
-
if (entityDefaults.length > 0) {
|
|
2470
|
-
fs.ensureDirSync(`${backupDir}/cyberia-entity-type-defaults`);
|
|
2471
|
-
for (const ed of entityDefaults) {
|
|
2472
|
-
fs.writeJsonSync(`${backupDir}/cyberia-entity-type-defaults/${ed._id}.json`, ed, { spaces: 2 });
|
|
2473
|
-
for (const id of ed.liveItemIds || []) if (id) objectLayerItemIds.add(id);
|
|
2474
|
-
for (const id of ed.deadItemIds || []) if (id) objectLayerItemIds.add(id);
|
|
2475
|
-
for (const id of ed.dropItemIds || []) if (id) objectLayerItemIds.add(id);
|
|
2476
|
-
for (const slot of ed.defaultObjectLayers || []) if (slot.itemId) objectLayerItemIds.add(slot.itemId);
|
|
2477
|
-
}
|
|
2478
|
-
logger.info(`Exported ${entityDefaults.length} CyberiaEntityTypeDefault document(s)`, {
|
|
2479
|
-
entityTypes: entityDefaults.map((ed) => ed.entityType),
|
|
2480
|
-
});
|
|
2481
|
-
}
|
|
2482
|
-
}
|
|
2483
|
-
|
|
2484
2069
|
// 4e. Export sagas related to this instance. A saga is considered related
|
|
2485
2070
|
// when its code matches the instance code (direct namespace match), or
|
|
2486
2071
|
// when its mapCodes or itemIds overlap with the instance's data.
|
|
@@ -2488,22 +2073,7 @@ try {
|
|
|
2488
2073
|
// conf-default, and skill-summoned item IDs — giving the broadest possible
|
|
2489
2074
|
// match surface for saga discovery.
|
|
2490
2075
|
const sagaCodeMatch = instanceCode ? await CyberiaSaga.find({ code: instanceCode }).lean() : [];
|
|
2491
|
-
|
|
2492
|
-
const sagaMapOverlap = true
|
|
2493
|
-
? []
|
|
2494
|
-
: mapCodes.size > 0
|
|
2495
|
-
? await CyberiaSaga.find({ mapCodes: { $in: [...mapCodes] } }).lean()
|
|
2496
|
-
: [];
|
|
2497
|
-
const sagaItemOverlap = true
|
|
2498
|
-
? []
|
|
2499
|
-
: objectLayerItemIds.size > 0
|
|
2500
|
-
? await CyberiaSaga.find({ itemIds: { $in: [...objectLayerItemIds] } }).lean()
|
|
2501
|
-
: [];
|
|
2502
|
-
const allSagas = [
|
|
2503
|
-
...new Map(
|
|
2504
|
-
[...sagaCodeMatch, ...sagaMapOverlap, ...sagaItemOverlap].map((s) => [s._id.toString(), s]),
|
|
2505
|
-
).values(),
|
|
2506
|
-
];
|
|
2076
|
+
const allSagas = [...new Map(sagaCodeMatch.map((s) => [s._id.toString(), s])).values()];
|
|
2507
2077
|
if (allSagas.length > 0) {
|
|
2508
2078
|
fs.ensureDirSync(`${backupDir}/cyberia-sagas`);
|
|
2509
2079
|
for (const saga of allSagas) {
|
|
@@ -2615,8 +2185,9 @@ try {
|
|
|
2615
2185
|
if (atlas) {
|
|
2616
2186
|
const atlasExport = newInstance(atlas);
|
|
2617
2187
|
objectLayerExport.atlasSpriteSheetId = atlas._id;
|
|
2618
|
-
|
|
2619
|
-
|
|
2188
|
+
// Every render travels with the atlas, so a restore leaves no reference dangling.
|
|
2189
|
+
for (const field of ATLAS_FILE_FIELDS) {
|
|
2190
|
+
if (atlas[field]) await exportFileDoc(atlas[field], atlasBackupFileKey(field, itemKey));
|
|
2620
2191
|
}
|
|
2621
2192
|
|
|
2622
2193
|
const atlasFile = atlas.fileId ? await File.findById(atlas.fileId).lean() : null;
|
|
@@ -2812,15 +2383,15 @@ try {
|
|
|
2812
2383
|
|
|
2813
2384
|
logger.info('Importing instance', { code: instanceCode, backupDir });
|
|
2814
2385
|
|
|
2815
|
-
//
|
|
2816
|
-
//
|
|
2386
|
+
// A restore writes object layers, so collapse duplicates and make the
|
|
2387
|
+
// data.item.id index unique first.
|
|
2817
2388
|
const { removedIds, indexUpgraded } = await ObjectLayer.ensureUniqueItemIdIndex();
|
|
2818
2389
|
if (removedIds.length > 0) logger.warn(`Removed ${removedIds.length} duplicate ObjectLayer document(s)`);
|
|
2819
2390
|
if (indexUpgraded) logger.info('Upgraded data.item.id index to unique');
|
|
2820
2391
|
|
|
2821
|
-
// Item ids
|
|
2822
|
-
//
|
|
2823
|
-
//
|
|
2392
|
+
// Item ids of this instance, from the imported object layers and the
|
|
2393
|
+
// instance doc. They backfill skills from DefaultSkillConfig when the
|
|
2394
|
+
// backup carries none.
|
|
2824
2395
|
const importedItemIds = new Set();
|
|
2825
2396
|
|
|
2826
2397
|
// 0. Drop existing documents if --drop is set
|
|
@@ -2841,30 +2412,17 @@ try {
|
|
|
2841
2412
|
// Query other instances/maps for shared thumbnail exclusion
|
|
2842
2413
|
const otherInstances = await CyberiaInstance.find({ code: { $ne: instanceCode } }, { thumbnail: 1 }).lean();
|
|
2843
2414
|
|
|
2844
|
-
// Add
|
|
2845
|
-
for (const entry of existingInstance.itemIds || []) {
|
|
2846
|
-
const id = typeof entry === 'string' ? entry : entry?.id;
|
|
2847
|
-
if (id) dropOlItemIds.add(id);
|
|
2848
|
-
}
|
|
2849
|
-
|
|
2850
|
-
// Add conf entityDefaults and skillConfig itemIds (liveItemIds, deadItemIds, dropItemIds, defaultObjectLayers)
|
|
2415
|
+
// Add the item ids the conf's referenced entity-type defaults name.
|
|
2851
2416
|
const existingConf =
|
|
2852
2417
|
(await CyberiaInstanceConf.findOne({ instanceCode }).lean()) ||
|
|
2853
2418
|
(existingInstance.conf ? await CyberiaInstanceConf.findById(existingInstance.conf).lean() : null);
|
|
2854
2419
|
if (existingConf) {
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
}
|
|
2861
|
-
for (const sc of existingConf.skillConfig || []) {
|
|
2862
|
-
if (sc.triggerItemId) dropOlItemIds.add(sc.triggerItemId);
|
|
2863
|
-
for (const skill of sc.skills || []) {
|
|
2864
|
-
if (skill.summonedEntityItemId && !skill.summonedEntityItemId.startsWith('$'))
|
|
2865
|
-
dropOlItemIds.add(skill.summonedEntityItemId);
|
|
2866
|
-
}
|
|
2867
|
-
}
|
|
2420
|
+
// The conf names its entity-type defaults by _id; read the documents to reach their items.
|
|
2421
|
+
const referencedIds = (existingConf.entityDefaults || []).map((id) => String(id?._id ?? id));
|
|
2422
|
+
const referenced = referencedIds.length
|
|
2423
|
+
? await CyberiaEntityTypeDefault.find({ _id: { $in: referencedIds } }).lean()
|
|
2424
|
+
: [];
|
|
2425
|
+
for (const itemId of collectInstanceItemIds({ entityDefaults: referenced })) dropOlItemIds.add(itemId);
|
|
2868
2426
|
}
|
|
2869
2427
|
|
|
2870
2428
|
const otherMaps = await CyberiaMap.find(
|
|
@@ -2888,6 +2446,12 @@ try {
|
|
|
2888
2446
|
const mapResult = await CyberiaMap.deleteMany({ code: { $in: [...dropMapCodes] } });
|
|
2889
2447
|
logger.info(`Dropped ${mapResult.deletedCount} CyberiaMap document(s)`);
|
|
2890
2448
|
|
|
2449
|
+
// A map's audio configuration belongs to that map. The assets it bound do not:
|
|
2450
|
+
// they are global and shared, so they stay.
|
|
2451
|
+
const audioConfResult = await CyberiaMapAudioConf.deleteMany({ mapCode: { $in: [...dropMapCodes] } });
|
|
2452
|
+
if (audioConfResult.deletedCount > 0)
|
|
2453
|
+
logger.info(`Dropped ${audioConfResult.deletedCount} CyberiaMapAudioConf document(s)`);
|
|
2454
|
+
|
|
2891
2455
|
// Quests + actions are bound to maps by sourceMapCode, so they drop
|
|
2892
2456
|
// with this instance's maps — only the content tied to this instance.
|
|
2893
2457
|
const questResult = await CyberiaQuest.deleteMany({ sourceMapCode: { $in: [...dropMapCodes] } });
|
|
@@ -2920,6 +2484,15 @@ try {
|
|
|
2920
2484
|
}
|
|
2921
2485
|
}
|
|
2922
2486
|
|
|
2487
|
+
// A conf stores no skills: the ones this instance ran are the ones its own items trigger.
|
|
2488
|
+
// What those summon is drawn by this instance alone, so it joins the drop surface and is
|
|
2489
|
+
// then protected by the same shared-with-another-map check as everything else.
|
|
2490
|
+
for (const itemId of collectSummonedItemIds(
|
|
2491
|
+
selectInstanceSkills(await CyberiaSkill.find({}).lean(), dropOlItemIds),
|
|
2492
|
+
)) {
|
|
2493
|
+
dropOlItemIds.add(itemId);
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2923
2496
|
// Exclude OL item IDs referenced by maps outside this instance
|
|
2924
2497
|
const sharedOlItemIds = new Set();
|
|
2925
2498
|
for (const m of otherMaps) {
|
|
@@ -2979,19 +2552,17 @@ try {
|
|
|
2979
2552
|
if (doc.atlasSpriteSheetId) atlasIds.push(doc.atlasSpriteSheetId);
|
|
2980
2553
|
}
|
|
2981
2554
|
|
|
2982
|
-
// Delete AtlasSpriteSheet +
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
const
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
}
|
|
2993
|
-
const atlasResult = await AtlasSpriteSheet.deleteMany({ _id: { $in: atlasIds } });
|
|
2994
|
-
logger.info(`Dropped ${atlasResult.deletedCount} AtlasSpriteSheet document(s)`);
|
|
2555
|
+
// Delete AtlasSpriteSheet + every File render it owns, through the store that
|
|
2556
|
+
// knows how many renders that is.
|
|
2557
|
+
if (atlasIds.length > 0 || itemKeysToClean.size > 0) {
|
|
2558
|
+
const purged = await AtlasSpriteSheetStore.purge({
|
|
2559
|
+
itemKeys: [...itemKeysToClean],
|
|
2560
|
+
atlasIds,
|
|
2561
|
+
options: { host, path },
|
|
2562
|
+
});
|
|
2563
|
+
for (const cid of purged.cids) cidsToUnpin.add(cid);
|
|
2564
|
+
if (purged.files > 0) logger.info(`Dropped ${purged.files} File document(s) (atlas)`);
|
|
2565
|
+
if (purged.atlases > 0) logger.info(`Dropped ${purged.atlases} AtlasSpriteSheet document(s)`);
|
|
2995
2566
|
}
|
|
2996
2567
|
|
|
2997
2568
|
// Delete RenderFrames
|
|
@@ -3048,13 +2619,16 @@ try {
|
|
|
3048
2619
|
const confImportPath = `${backupDir}/cyberia-instance-conf.json`;
|
|
3049
2620
|
let importedConf = null;
|
|
3050
2621
|
if (fs.existsSync(confImportPath)) {
|
|
3051
|
-
//
|
|
3052
|
-
//
|
|
3053
|
-
const confData =
|
|
2622
|
+
// Made whole and valid before the live conf is touched, so a backup the schema
|
|
2623
|
+
// rejects resets to defaults rather than leaving the instance with no conf.
|
|
2624
|
+
const { conf: confData } = await CyberiaInstanceConfService.coerceToSchema(
|
|
2625
|
+
await adoptEntityTypeDefaultRefs(fs.readJsonSync(confImportPath), backupDir, CyberiaEntityTypeDefault),
|
|
2626
|
+
CyberiaInstanceConf,
|
|
2627
|
+
);
|
|
3054
2628
|
if (confData._id) await CyberiaInstanceConf.deleteOne({ _id: confData._id });
|
|
3055
2629
|
await CyberiaInstanceConf.deleteOne({ instanceCode: confData.instanceCode });
|
|
3056
|
-
//
|
|
3057
|
-
//
|
|
2630
|
+
// Bump updatedAt so the world version changes and the server
|
|
2631
|
+
// re-applies the config without a restart.
|
|
3058
2632
|
confData.updatedAt = new Date();
|
|
3059
2633
|
importedConf = await CyberiaInstanceConf.create(confData);
|
|
3060
2634
|
logger.info('Imported CyberiaInstanceConf', { instanceCode: confData.instanceCode });
|
|
@@ -3062,11 +2636,10 @@ try {
|
|
|
3062
2636
|
logger.warn(`CyberiaInstanceConf backup not found: ${confImportPath}`);
|
|
3063
2637
|
}
|
|
3064
2638
|
|
|
3065
|
-
//
|
|
3066
|
-
//
|
|
3067
|
-
//
|
|
3068
|
-
//
|
|
3069
|
-
// server's version hash changes and ReloadWorld re-applies the config.
|
|
2639
|
+
// --conf must not recreate the CyberiaInstance: that would overwrite
|
|
2640
|
+
// cyberiaMapCodes, portals and itemIds from a possibly stale backup.
|
|
2641
|
+
// Update the conf ref and bump updatedAt, so the world version changes
|
|
2642
|
+
// and the server re-applies the config.
|
|
3070
2643
|
if (importedConf) {
|
|
3071
2644
|
const result = await CyberiaInstance.updateOne(
|
|
3072
2645
|
{ code: instanceCode },
|
|
@@ -3130,6 +2703,7 @@ try {
|
|
|
3130
2703
|
|
|
3131
2704
|
// 3. Import AtlasSpriteSheet
|
|
3132
2705
|
const atlasDir = `${backupDir}/atlas-sprite-sheets`;
|
|
2706
|
+
const atlasesWithoutMinify = new Set();
|
|
3133
2707
|
if (fs.existsSync(atlasDir)) {
|
|
3134
2708
|
const atlasFiles = fs.readdirSync(atlasDir).filter((f) => f.endsWith('.json'));
|
|
3135
2709
|
let atlasCount = 0;
|
|
@@ -3143,6 +2717,22 @@ try {
|
|
|
3143
2717
|
atlasCount++;
|
|
3144
2718
|
}
|
|
3145
2719
|
logger.info(`Imported ${atlasCount} AtlasSpriteSheet document(s)`);
|
|
2720
|
+
// A backup from before the still existed restores without one; cut each from its render.
|
|
2721
|
+
// An atlas restored without its minified render is rebuilt whole once its object
|
|
2722
|
+
// layer is in place, below.
|
|
2723
|
+
let stillCount = 0;
|
|
2724
|
+
for (const f of atlasFiles) {
|
|
2725
|
+
const atlasData = fs.readJsonSync(`${atlasDir}/${f}`);
|
|
2726
|
+
const itemKey = atlasData.metadata?.itemKey;
|
|
2727
|
+
if (!itemKey) continue;
|
|
2728
|
+
if (!atlasData.minifyFileId) atlasesWithoutMinify.add(itemKey);
|
|
2729
|
+
const { status } = await AtlasSpriteSheetStore.syncIdlePreview({ itemKey, options: { host, path } });
|
|
2730
|
+
if (status === 'updated') stillCount++;
|
|
2731
|
+
}
|
|
2732
|
+
if (stillCount) logger.info(`Filled ${stillCount} idle preview still(s)`);
|
|
2733
|
+
// The replaced atlases took their renders out of reach; the imported ones are
|
|
2734
|
+
// already stored, so what no atlas points at now is exactly the leftover.
|
|
2735
|
+
await AtlasSpriteSheetStore.pruneOrphanRenders({ options: { host, path } });
|
|
3146
2736
|
}
|
|
3147
2737
|
|
|
3148
2738
|
// 4. Import object layers
|
|
@@ -3172,6 +2762,36 @@ try {
|
|
|
3172
2762
|
logger.info(`Imported ${olCount} ObjectLayer document(s)`);
|
|
3173
2763
|
}
|
|
3174
2764
|
|
|
2765
|
+
// 4a. An atlas the backup restored without its minified render cannot be served: the
|
|
2766
|
+
// client runtime pairs the blob with the metadata, so a render cut from the frames
|
|
2767
|
+
// alone would be refused wherever the stored layout moved. Regenerate the whole
|
|
2768
|
+
// atlas from the frames the backup carries, and relink the object layer to it the
|
|
2769
|
+
// way `ol --to-atlas-sprite-sheet` does.
|
|
2770
|
+
for (const itemKey of atlasesWithoutMinify) {
|
|
2771
|
+
try {
|
|
2772
|
+
const objectLayer = await ObjectLayer.findByItemId(itemKey).populate('objectLayerRenderFramesId');
|
|
2773
|
+
if (!objectLayer?.objectLayerRenderFramesId) {
|
|
2774
|
+
logger.warn(`Backup atlas '${itemKey}' has no minified render and no render frames to rebuild it from`);
|
|
2775
|
+
continue;
|
|
2776
|
+
}
|
|
2777
|
+
const { atlasDoc, atlasCid, atlasMetadataCid } = await AtlasSpriteSheetStore.persist({
|
|
2778
|
+
itemKey,
|
|
2779
|
+
objectLayerRenderFrames: objectLayer.objectLayerRenderFramesId,
|
|
2780
|
+
options: { host, path },
|
|
2781
|
+
});
|
|
2782
|
+
objectLayer.atlasSpriteSheetId = atlasDoc._id;
|
|
2783
|
+
if (!objectLayer.data.render) objectLayer.data.render = {};
|
|
2784
|
+
objectLayer.data.render.cid = atlasCid;
|
|
2785
|
+
objectLayer.data.render.metadataCid = atlasMetadataCid;
|
|
2786
|
+
objectLayer.markModified('data.render');
|
|
2787
|
+
await objectLayer.save();
|
|
2788
|
+
await ObjectLayerEngine.computeAndSaveFinalSha256({ objectLayer, options: { host, path } });
|
|
2789
|
+
logger.info(`Rebuilt the atlas of '${itemKey}': the backup carried no minified render`);
|
|
2790
|
+
} catch (rebuildError) {
|
|
2791
|
+
logger.error(`Atlas rebuild failed for '${itemKey}': ${rebuildError.message}`);
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
|
|
3175
2795
|
// 4b. Regenerate static frame PNGs from imported render-frames + object-layer documents.
|
|
3176
2796
|
// Mirrors the writeStaticFrameAssets call in `ol --import` so src/client/public/cyberia
|
|
3177
2797
|
// and the public/<host><path> deployment dir are populated even when the cyberia
|
|
@@ -3210,7 +2830,7 @@ try {
|
|
|
3210
2830
|
itemId,
|
|
3211
2831
|
objectLayerRenderFramesData,
|
|
3212
2832
|
objectLayerData: olData,
|
|
3213
|
-
cellPixelDim:
|
|
2833
|
+
cellPixelDim: DEFAULT_ATLAS_UPSCALE_FACTOR,
|
|
3214
2834
|
});
|
|
3215
2835
|
staticWriteCount += written.length;
|
|
3216
2836
|
} catch (err) {
|
|
@@ -3235,12 +2855,69 @@ try {
|
|
|
3235
2855
|
logger.info(`Imported ${mapCount} CyberiaMap document(s)`);
|
|
3236
2856
|
}
|
|
3237
2857
|
|
|
2858
|
+
// 5a. Import audio assets, then the map bindings that reference them by code. Assets are
|
|
2859
|
+
// upserted by code (they are global and may already be present from another import);
|
|
2860
|
+
// their File documents were restored above with their original _id, so `fileId` still
|
|
2861
|
+
// resolves. A binding whose asset is absent is kept: the code is the reference, and
|
|
2862
|
+
// importing the asset later makes it play.
|
|
2863
|
+
const audioAssetsDir = `${backupDir}/cyberia-audio`;
|
|
2864
|
+
if (fs.existsSync(audioAssetsDir)) {
|
|
2865
|
+
const assetFiles = fs.readdirSync(audioAssetsDir).filter((f) => f.endsWith('.json'));
|
|
2866
|
+
let assetCount = 0;
|
|
2867
|
+
let replacedFiles = 0;
|
|
2868
|
+
for (const file of assetFiles) {
|
|
2869
|
+
const assetData = fs.readJsonSync(`${audioAssetsDir}/${file}`);
|
|
2870
|
+
if (!assetData.code) {
|
|
2871
|
+
logger.warn(`Skipping CyberiaAudio backup without code: ${file}`);
|
|
2872
|
+
continue;
|
|
2873
|
+
}
|
|
2874
|
+
// The asset this restore supersedes may hold different bytes under a different
|
|
2875
|
+
// fileId. Replacing the document without dropping that blob leaves it referenced by
|
|
2876
|
+
// nothing — an orphan `db clean-fs` would later have to sweep.
|
|
2877
|
+
const superseded = await CyberiaAudio.find({
|
|
2878
|
+
$or: [{ code: assetData.code }, ...(assetData._id ? [{ _id: assetData._id }] : [])],
|
|
2879
|
+
}).lean();
|
|
2880
|
+
await CyberiaAudio.deleteOne({ code: assetData.code });
|
|
2881
|
+
if (assetData._id) await CyberiaAudio.deleteOne({ _id: assetData._id });
|
|
2882
|
+
await CyberiaAudio.create(assetData);
|
|
2883
|
+
for (const old of superseded) {
|
|
2884
|
+
if (!old.fileId || String(old.fileId) === String(assetData.fileId)) continue;
|
|
2885
|
+
if (await File.findByIdAndDelete(old.fileId)) replacedFiles++;
|
|
2886
|
+
}
|
|
2887
|
+
assetCount++;
|
|
2888
|
+
}
|
|
2889
|
+
logger.info(`Imported ${assetCount} CyberiaAudio document(s)`, {
|
|
2890
|
+
...(replacedFiles > 0 ? { replacedFiles } : {}),
|
|
2891
|
+
});
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
const audioConfsDir = `${backupDir}/cyberia-map-audio-confs`;
|
|
2895
|
+
if (fs.existsSync(audioConfsDir)) {
|
|
2896
|
+
const confFiles = fs.readdirSync(audioConfsDir).filter((f) => f.endsWith('.json'));
|
|
2897
|
+
let audioConfCount = 0;
|
|
2898
|
+
for (const file of confFiles) {
|
|
2899
|
+
const confData = fs.readJsonSync(`${audioConfsDir}/${file}`);
|
|
2900
|
+
if (!confData.mapCode) {
|
|
2901
|
+
logger.warn(`Skipping CyberiaMapAudioConf backup without mapCode: ${file}`);
|
|
2902
|
+
continue;
|
|
2903
|
+
}
|
|
2904
|
+
await CyberiaMapAudioConf.deleteOne({ mapCode: confData.mapCode });
|
|
2905
|
+
if (confData._id) await CyberiaMapAudioConf.deleteOne({ _id: confData._id });
|
|
2906
|
+
await CyberiaMapAudioConf.create(confData);
|
|
2907
|
+
audioConfCount++;
|
|
2908
|
+
}
|
|
2909
|
+
logger.info(`Imported ${audioConfCount} CyberiaMapAudioConf document(s)`);
|
|
2910
|
+
}
|
|
2911
|
+
|
|
3238
2912
|
// 6. Import CyberiaInstanceConf (skillRules, equipmentRules, entityDefaults, etc.)
|
|
3239
2913
|
const confImportPath = `${backupDir}/cyberia-instance-conf.json`;
|
|
3240
2914
|
if (fs.existsSync(confImportPath)) {
|
|
3241
|
-
//
|
|
3242
|
-
//
|
|
3243
|
-
const confData =
|
|
2915
|
+
// Made whole and valid before the live conf is touched, so a backup the schema
|
|
2916
|
+
// rejects resets to defaults rather than leaving the instance with no conf.
|
|
2917
|
+
const { conf: confData } = await CyberiaInstanceConfService.coerceToSchema(
|
|
2918
|
+
await adoptEntityTypeDefaultRefs(fs.readJsonSync(confImportPath), backupDir, CyberiaEntityTypeDefault),
|
|
2919
|
+
CyberiaInstanceConf,
|
|
2920
|
+
);
|
|
3244
2921
|
if (confData._id) await CyberiaInstanceConf.deleteOne({ _id: confData._id });
|
|
3245
2922
|
await CyberiaInstanceConf.deleteOne({ instanceCode: confData.instanceCode });
|
|
3246
2923
|
await CyberiaInstanceConf.create(confData);
|
|
@@ -3253,13 +2930,6 @@ try {
|
|
|
3253
2930
|
const instancePath = `${backupDir}/cyberia-instance.json`;
|
|
3254
2931
|
if (fs.existsSync(instancePath)) {
|
|
3255
2932
|
const instanceData = fs.readJsonSync(instancePath);
|
|
3256
|
-
// Heal legacy shapes against the current model. itemIds migrated from a
|
|
3257
|
-
// flat string[] to [{ id, defaultPlayerInventory }] — a raw old backup
|
|
3258
|
-
// would fail Mongoose embedded-cast validation, so normalize it here.
|
|
3259
|
-
instanceData.itemIds = (instanceData.itemIds || [])
|
|
3260
|
-
.map((entry) => (typeof entry === 'string' ? { id: entry, defaultPlayerInventory: false } : entry))
|
|
3261
|
-
.filter((entry) => entry && entry.id);
|
|
3262
|
-
for (const entry of instanceData.itemIds) importedItemIds.add(entry.id);
|
|
3263
2933
|
await CyberiaInstance.deleteOne({ code: instanceCode });
|
|
3264
2934
|
await CyberiaInstance.deleteOne({ _id: instanceData._id });
|
|
3265
2935
|
await CyberiaInstance.create(instanceData);
|
|
@@ -3354,29 +3024,32 @@ try {
|
|
|
3354
3024
|
logger.info(`Imported ${skillCount} CyberiaSkill document(s)`);
|
|
3355
3025
|
}
|
|
3356
3026
|
|
|
3357
|
-
// 8d-bis. Import CyberiaEntityTypeDefault documents (
|
|
3358
|
-
//
|
|
3027
|
+
// 8d-bis. Import CyberiaEntityTypeDefault documents by _id (preserveUUID), which is how
|
|
3028
|
+
// the conf's references keep resolving after a restore. Only the documents this
|
|
3029
|
+
// backup carries are touched: overwriting by an (entityType, liveItemIds) "natural
|
|
3030
|
+
// key" clobbered another instance's document whenever two worlds shared a skin.
|
|
3359
3031
|
const entityDefaultsDir = `${backupDir}/cyberia-entity-type-defaults`;
|
|
3360
3032
|
if (fs.existsSync(entityDefaultsDir)) {
|
|
3361
3033
|
const entityDefaultFiles = fs.readdirSync(entityDefaultsDir).filter((f) => f.endsWith('.json'));
|
|
3362
3034
|
let entityDefaultCount = 0;
|
|
3363
3035
|
for (const file of entityDefaultFiles) {
|
|
3364
3036
|
const edData = fs.readJsonSync(`${entityDefaultsDir}/${file}`);
|
|
3365
|
-
if (!edData.entityType) {
|
|
3366
|
-
logger.warn(`Skipping CyberiaEntityTypeDefault backup without entityType: ${file}`);
|
|
3037
|
+
if (!edData.entityType || !edData._id) {
|
|
3038
|
+
logger.warn(`Skipping CyberiaEntityTypeDefault backup without entityType or _id: ${file}`);
|
|
3367
3039
|
continue;
|
|
3368
3040
|
}
|
|
3369
|
-
await CyberiaEntityTypeDefault.
|
|
3370
|
-
entityType: edData.entityType,
|
|
3371
|
-
liveItemIds: edData.liveItemIds || [],
|
|
3372
|
-
});
|
|
3373
|
-
if (edData._id) await CyberiaEntityTypeDefault.deleteOne({ _id: edData._id });
|
|
3041
|
+
await CyberiaEntityTypeDefault.deleteOne({ _id: edData._id });
|
|
3374
3042
|
await CyberiaEntityTypeDefault.create(edData);
|
|
3375
3043
|
entityDefaultCount++;
|
|
3376
3044
|
}
|
|
3377
3045
|
logger.info(`Imported ${entityDefaultCount} CyberiaEntityTypeDefault document(s)`);
|
|
3378
3046
|
}
|
|
3379
3047
|
|
|
3048
|
+
// A conf can reference a default this backup does not carry — an older backup, or one
|
|
3049
|
+
// exported before the reference existed. Restoring that reference would recreate the
|
|
3050
|
+
// orphan the export just removed, so the restored conf is compacted too.
|
|
3051
|
+
await CyberiaEntityTypeDefaultService.compactInstanceRefs({ host, path }, { instanceCode });
|
|
3052
|
+
|
|
3380
3053
|
// 8e. Backfill missing skills from the canonical DefaultSkillConfig. Old
|
|
3381
3054
|
// backups predate the CyberiaSkill model and ship no skills/ dir, so
|
|
3382
3055
|
// any instance item that has a canonical skill (e.g. atlas_pistol_mk2,
|
|
@@ -3693,30 +3366,17 @@ try {
|
|
|
3693
3366
|
// Query other instances for shared thumbnail exclusion
|
|
3694
3367
|
const otherInstances = await CyberiaInstance.find({ code: { $ne: instanceCode } }, { thumbnail: 1 }).lean();
|
|
3695
3368
|
|
|
3696
|
-
// Add
|
|
3697
|
-
for (const entry of existingInstance.itemIds || []) {
|
|
3698
|
-
const id = typeof entry === 'string' ? entry : entry?.id;
|
|
3699
|
-
if (id) dropOlItemIds.add(id);
|
|
3700
|
-
}
|
|
3701
|
-
|
|
3702
|
-
// Add conf entityDefaults and skillConfig itemIds (liveItemIds, deadItemIds, dropItemIds, defaultObjectLayers)
|
|
3369
|
+
// Add the item ids the conf's referenced entity-type defaults name.
|
|
3703
3370
|
const existingConf =
|
|
3704
3371
|
(await CyberiaInstanceConf.findOne({ instanceCode }).lean()) ||
|
|
3705
3372
|
(existingInstance.conf ? await CyberiaInstanceConf.findById(existingInstance.conf).lean() : null);
|
|
3706
3373
|
if (existingConf) {
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
}
|
|
3713
|
-
for (const sc of existingConf.skillConfig || []) {
|
|
3714
|
-
if (sc.triggerItemId) dropOlItemIds.add(sc.triggerItemId);
|
|
3715
|
-
for (const skill of sc.skills || []) {
|
|
3716
|
-
if (skill.summonedEntityItemId && !skill.summonedEntityItemId.startsWith('$'))
|
|
3717
|
-
dropOlItemIds.add(skill.summonedEntityItemId);
|
|
3718
|
-
}
|
|
3719
|
-
}
|
|
3374
|
+
// The conf names its entity-type defaults by _id; read the documents to reach their items.
|
|
3375
|
+
const referencedIds = (existingConf.entityDefaults || []).map((id) => String(id?._id ?? id));
|
|
3376
|
+
const referenced = referencedIds.length
|
|
3377
|
+
? await CyberiaEntityTypeDefault.find({ _id: { $in: referencedIds } }).lean()
|
|
3378
|
+
: [];
|
|
3379
|
+
for (const itemId of collectInstanceItemIds({ entityDefaults: referenced })) dropOlItemIds.add(itemId);
|
|
3720
3380
|
}
|
|
3721
3381
|
|
|
3722
3382
|
const otherMaps = await CyberiaMap.find(
|
|
@@ -3737,6 +3397,20 @@ try {
|
|
|
3737
3397
|
}
|
|
3738
3398
|
const mapResult = await CyberiaMap.deleteMany({ code: { $in: [...dropMapCodes] } });
|
|
3739
3399
|
logger.info(`Dropped ${mapResult.deletedCount} CyberiaMap document(s)`);
|
|
3400
|
+
|
|
3401
|
+
// A map's audio configuration belongs to that map; the shared assets it bound do not.
|
|
3402
|
+
const audioConfResult = await CyberiaMapAudioConf.deleteMany({ mapCode: { $in: [...dropMapCodes] } });
|
|
3403
|
+
if (audioConfResult.deletedCount > 0)
|
|
3404
|
+
logger.info(`Dropped ${audioConfResult.deletedCount} CyberiaMapAudioConf document(s)`);
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
// A conf stores no skills: the ones this instance ran are the ones its own items trigger.
|
|
3408
|
+
// What those summon is drawn by this instance alone, so it joins the drop surface and is
|
|
3409
|
+
// then protected by the same shared-with-another-map check as everything else.
|
|
3410
|
+
for (const itemId of collectSummonedItemIds(
|
|
3411
|
+
selectInstanceSkills(await CyberiaSkill.find({}).lean(), dropOlItemIds),
|
|
3412
|
+
)) {
|
|
3413
|
+
dropOlItemIds.add(itemId);
|
|
3740
3414
|
}
|
|
3741
3415
|
|
|
3742
3416
|
// Exclude OL item IDs referenced by maps outside this instance
|
|
@@ -3793,18 +3467,15 @@ try {
|
|
|
3793
3467
|
if (doc.atlasSpriteSheetId) atlasIds.push(doc.atlasSpriteSheetId);
|
|
3794
3468
|
}
|
|
3795
3469
|
|
|
3796
|
-
if (atlasIds.length > 0) {
|
|
3797
|
-
const
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
}
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
}
|
|
3806
|
-
const atlasResult = await AtlasSpriteSheet.deleteMany({ _id: { $in: atlasIds } });
|
|
3807
|
-
logger.info(`Dropped ${atlasResult.deletedCount} AtlasSpriteSheet document(s)`);
|
|
3470
|
+
if (atlasIds.length > 0 || itemKeysToClean.size > 0) {
|
|
3471
|
+
const purged = await AtlasSpriteSheetStore.purge({
|
|
3472
|
+
itemKeys: [...itemKeysToClean],
|
|
3473
|
+
atlasIds,
|
|
3474
|
+
options: { host, path },
|
|
3475
|
+
});
|
|
3476
|
+
for (const cid of purged.cids) cidsToUnpin.add(cid);
|
|
3477
|
+
if (purged.files > 0) logger.info(`Dropped ${purged.files} File document(s) (atlas)`);
|
|
3478
|
+
if (purged.atlases > 0) logger.info(`Dropped ${purged.atlases} AtlasSpriteSheet document(s)`);
|
|
3808
3479
|
}
|
|
3809
3480
|
|
|
3810
3481
|
if (renderFrameIds.length > 0) {
|
|
@@ -3956,6 +3627,130 @@ try {
|
|
|
3956
3627
|
}
|
|
3957
3628
|
});
|
|
3958
3629
|
|
|
3630
|
+
const runAudioCommand = async (audioCode, options = {}) => {
|
|
3631
|
+
const assignments = {
|
|
3632
|
+
...(options.setDefaultMusic === undefined ? {} : { defaultMusic: options.setDefaultMusic }),
|
|
3633
|
+
events: options.setEvent ?? [],
|
|
3634
|
+
};
|
|
3635
|
+
const configuring = assignments.defaultMusic !== undefined || assignments.events.length > 0;
|
|
3636
|
+
|
|
3637
|
+
if (configuring && !options.map) {
|
|
3638
|
+
logger.error('--map <map-code> is required to apply --set-default-music/--set-event');
|
|
3639
|
+
process.exit(1);
|
|
3640
|
+
}
|
|
3641
|
+
if (!options.import && !options.map) {
|
|
3642
|
+
logger.error('Nothing to do: pass --import, or --map <map-code> to read or configure a map');
|
|
3643
|
+
process.exit(1);
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
if (options.envPath && !fs.existsSync(options.envPath)) {
|
|
3647
|
+
logger.error(`Env file not found: ${options.envPath}`);
|
|
3648
|
+
process.exit(1);
|
|
3649
|
+
}
|
|
3650
|
+
const envPath =
|
|
3651
|
+
options.envPath || `./engine-private/conf/dd-cyberia/.env.${options.dev ? 'development' : 'production'}`;
|
|
3652
|
+
if (fs.existsSync(envPath)) dotenv.config({ path: envPath, override: true });
|
|
3653
|
+
|
|
3654
|
+
const deployId = process.env.DEFAULT_DEPLOY_ID;
|
|
3655
|
+
const host = process.env.DEFAULT_DEPLOY_HOST;
|
|
3656
|
+
const path = process.env.DEFAULT_DEPLOY_PATH;
|
|
3657
|
+
const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
|
|
3658
|
+
if (!fs.existsSync(confServerPath)) {
|
|
3659
|
+
logger.error(`Server config not found: ${confServerPath}. Ensure DEFAULT_DEPLOY_ID is set.`);
|
|
3660
|
+
process.exit(1);
|
|
3661
|
+
}
|
|
3662
|
+
const confServer = loadConfServerJson(confServerPath, { resolve: true });
|
|
3663
|
+
const { db } = confServer[host][path];
|
|
3664
|
+
db.host = options.mongoHost
|
|
3665
|
+
? options.mongoHost
|
|
3666
|
+
: options.dev
|
|
3667
|
+
? db.host
|
|
3668
|
+
: db.host.replace('127.0.0.1', 'mongodb-0.mongodb-service');
|
|
3669
|
+
|
|
3670
|
+
logger.info('env', { env: envPath, deployId, host, path });
|
|
3671
|
+
|
|
3672
|
+
await DataBaseProviderService.load({
|
|
3673
|
+
apis: ['cyberia-audio', 'cyberia-instance', 'cyberia-map', 'cyberia-map-audio-conf', 'file'],
|
|
3674
|
+
host,
|
|
3675
|
+
path,
|
|
3676
|
+
db,
|
|
3677
|
+
});
|
|
3678
|
+
|
|
3679
|
+
try {
|
|
3680
|
+
if (options.seedWorld) {
|
|
3681
|
+
// An instance names its own maps, in its own order; without one the fallback world is the
|
|
3682
|
+
// world being scored. Either way the bank and the rotation are the same.
|
|
3683
|
+
const result = options.instance
|
|
3684
|
+
? await seedInstanceAudio(
|
|
3685
|
+
{ instanceCode: options.instance, recordsPath: options.recordsPath },
|
|
3686
|
+
{ host, path },
|
|
3687
|
+
)
|
|
3688
|
+
: await seedFallbackAudio({ recordsPath: options.recordsPath }, { host, path });
|
|
3689
|
+
logger.info(
|
|
3690
|
+
`seed-audio${options.instance ? ` --instance ${options.instance}` : ''}: ` +
|
|
3691
|
+
`${result.assets.length} assets, ${result.maps.length} maps`,
|
|
3692
|
+
);
|
|
3693
|
+
} else if (options.import) {
|
|
3694
|
+
const recordsPath = options.recordsPath || DEFAULT_AUDIO_RECORDS_PATH;
|
|
3695
|
+
const codes = audioCode
|
|
3696
|
+
? audioCode
|
|
3697
|
+
.split(',')
|
|
3698
|
+
.map((code) => code.trim())
|
|
3699
|
+
.filter(Boolean)
|
|
3700
|
+
: null;
|
|
3701
|
+
const imported = await CyberiaAudioService.importRecords({ recordsPath, codes }, { host, path });
|
|
3702
|
+
logger.info(`audio --import: imported ${imported.length} asset(s) from ${recordsPath}`);
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
if (options.map) {
|
|
3706
|
+
/** @type {import('mongoose').Model} */
|
|
3707
|
+
const CyberiaMap = DataBaseProviderService.getModel('cyberia-map', { host, path });
|
|
3708
|
+
if (!(await CyberiaMap.exists({ code: options.map }))) {
|
|
3709
|
+
throw new Error(`cyberia-map not found for code="${options.map}"`);
|
|
3710
|
+
}
|
|
3711
|
+
|
|
3712
|
+
if (configuring) {
|
|
3713
|
+
const conf = await CyberiaMapAudioConfService.assign(
|
|
3714
|
+
{ mapCode: options.map, ...assignments },
|
|
3715
|
+
{ host, path },
|
|
3716
|
+
);
|
|
3717
|
+
logger.info(`audio --map: updated audio configuration for "${options.map}"`, {
|
|
3718
|
+
defaultMusic: conf.defaultMusic || null,
|
|
3719
|
+
events: conf.events.map(({ logicEventId, audioCode: code }) => `${logicEventId}:${code}`),
|
|
3720
|
+
});
|
|
3721
|
+
} else {
|
|
3722
|
+
const conf = await CyberiaMapAudioConfService.getByMapCode(options.map, { host, path });
|
|
3723
|
+
if (!conf) logger.warn(`No audio configuration for map "${options.map}"`);
|
|
3724
|
+
else console.log(JSON.stringify(conf, null, 2));
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
} finally {
|
|
3728
|
+
await DataBaseProviderService.getProvider({ host, path }, 'mongoose').close();
|
|
3729
|
+
}
|
|
3730
|
+
};
|
|
3731
|
+
|
|
3732
|
+
// ── audio: import cyberia-audio assets and configure per-map audio ──
|
|
3733
|
+
program
|
|
3734
|
+
.command('audio [audio-code]')
|
|
3735
|
+
.option('--import', 'Import <name>.wav + <name>.json pairs into MongoDB (all pairs when no id is given)')
|
|
3736
|
+
.option(
|
|
3737
|
+
'--records-path <records-path>',
|
|
3738
|
+
`Records directory to import from (default: ${DEFAULT_AUDIO_RECORDS_PATH})`,
|
|
3739
|
+
)
|
|
3740
|
+
.option('--map <map-code>', 'Target cyberia-map code to read or configure')
|
|
3741
|
+
.option('--set-default-music <audio-code>', 'Set the default background music of --map')
|
|
3742
|
+
.option(
|
|
3743
|
+
'--set-event <logic-event-id:audio-code>',
|
|
3744
|
+
'Bind an audio asset to a logic event e.g. combat:combat or shoot:shoot, repeatable',
|
|
3745
|
+
eventAudioBindingFactory('--set-event'),
|
|
3746
|
+
[],
|
|
3747
|
+
)
|
|
3748
|
+
.option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
|
|
3749
|
+
.option('--mongo-host <mongo-host>', 'Mongo host override')
|
|
3750
|
+
.option('--dev', 'Force development environment')
|
|
3751
|
+
.description('Import cyberia-audio assets into MongoDB and configure cyberia-map audio')
|
|
3752
|
+
.action(runAudioCommand);
|
|
3753
|
+
|
|
3959
3754
|
// ── generate-saga: Top-Down PCG guided by LLMs (Semantic Reverse-Engineering) ──
|
|
3960
3755
|
program
|
|
3961
3756
|
.command('generate-saga')
|
|
@@ -3965,7 +3760,7 @@ try {
|
|
|
3965
3760
|
)
|
|
3966
3761
|
.option('--import <file>', 'Load a previously generated payload file (the shape --out writes) into the database')
|
|
3967
3762
|
.option('--model <model>', 'Gemini model id (default: gemma-4-26b-a4b-it)')
|
|
3968
|
-
.option('--timeout <ms>', 'Per-request timeout in ms (default:
|
|
3763
|
+
.option('--timeout <ms>', 'Per-request timeout in ms (default: 10000)', (v) => parseInt(v, 10))
|
|
3969
3764
|
.option('--thinking-level <level>', 'Gemini thinking level: low | medium | high (default: high)')
|
|
3970
3765
|
.option(
|
|
3971
3766
|
'--lore-path <path>',
|
|
@@ -4921,6 +4716,26 @@ try {
|
|
|
4921
4716
|
|
|
4922
4717
|
const runner = program.command('run-workflow').description('Run a Cyberia script from the "scripts" directory');
|
|
4923
4718
|
|
|
4719
|
+
runner
|
|
4720
|
+
.command('seed-audio')
|
|
4721
|
+
.option('--records-path <path>', 'Recorded WAV and manifest directory', DEFAULT_AUDIO_RECORDS_PATH)
|
|
4722
|
+
.option('--records-only', 'Record the WAV and manifest pairs without touching the database')
|
|
4723
|
+
.option(
|
|
4724
|
+
'--instance <instance-code>',
|
|
4725
|
+
"Configure that instance's maps instead of the fallback world's, in its own cyberiaMapCodes order",
|
|
4726
|
+
)
|
|
4727
|
+
.option('--env-path <path>', 'Engine environment file')
|
|
4728
|
+
.option('--mongo-host <host>', 'Mongo host override')
|
|
4729
|
+
.option('--dev', 'Use the development environment')
|
|
4730
|
+
.description("Record audio, upsert generic files and audio metadata, and configure a world's maps")
|
|
4731
|
+
.action(async (options) => {
|
|
4732
|
+
// Recording writes only `records/`: the client bundles no audio and fetches every asset
|
|
4733
|
+
// from engine-cyberia by code, so seeding the database is what makes a recording reachable.
|
|
4734
|
+
await prepareFallbackAudio({ recordsPath: options.recordsPath ?? DEFAULT_AUDIO_RECORDS_PATH });
|
|
4735
|
+
if (!options.recordsOnly) await runAudioCommand(undefined, { ...options, import: true, seedWorld: true });
|
|
4736
|
+
logger.info('seed-audio complete');
|
|
4737
|
+
});
|
|
4738
|
+
|
|
4924
4739
|
runner
|
|
4925
4740
|
.command('import-default-items')
|
|
4926
4741
|
.option('--dev', 'Force development environment (loads .env.development for IPFS localhost, etc.)')
|
|
@@ -4976,15 +4791,51 @@ try {
|
|
|
4976
4791
|
.description('Packs this engine checkout as underpost-cli.tgz for a runtime image build context')
|
|
4977
4792
|
.action((options) => stageCliPackage(options.outputPath || '.'));
|
|
4978
4793
|
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4794
|
+
// Every file mirrored between this engine and a product checkout, engine path first. One
|
|
4795
|
+
// table for both directions, so a pair cannot be synced one way and forgotten the other.
|
|
4796
|
+
const cyberiaSrcSyncPairs = [
|
|
4797
|
+
['./src/client/public/cyberia-docs/CYBERIA-SERVER.md', './cyberia-server/README.md'],
|
|
4798
|
+
['./src/runtime/cyberia-server/Dockerfile', './cyberia-server/Dockerfile'],
|
|
4799
|
+
['./src/runtime/cyberia-server/Dockerfile.dev', './cyberia-server/Dockerfile.dev'],
|
|
4800
|
+
['./src/client/public/cyberia-docs/CYBERIA-CLIENT.md', './cyberia-client/README.md'],
|
|
4801
|
+
['./src/runtime/cyberia-client/Dockerfile', './cyberia-client/Dockerfile'],
|
|
4802
|
+
['./src/runtime/cyberia-client/Dockerfile.dev', './cyberia-client/Dockerfile.dev'],
|
|
4803
|
+
];
|
|
4804
|
+
|
|
4805
|
+
runner
|
|
4806
|
+
.command('sync-src')
|
|
4807
|
+
.option('--from-repo', 'Copy from the product checkouts into this engine instead of out to them')
|
|
4808
|
+
.option('--dry-run', 'Report what would be copied without writing it')
|
|
4809
|
+
.description(
|
|
4810
|
+
'Mirrors the cyberia product READMEs and runtime Dockerfiles between this engine and the product checkouts',
|
|
4811
|
+
)
|
|
4812
|
+
.action((options) => {
|
|
4813
|
+
const fromRepo = options.fromRepo === true;
|
|
4814
|
+
const dryRun = options.dryRun === true;
|
|
4815
|
+
const copied = [];
|
|
4816
|
+
const missing = [];
|
|
4817
|
+
|
|
4818
|
+
for (const [enginePath, repoPath] of cyberiaSrcSyncPairs) {
|
|
4819
|
+
const [source, target] = fromRepo ? [repoPath, enginePath] : [enginePath, repoPath];
|
|
4820
|
+
// A product checkout `setup-workspace` has not cloned yet must not leave the run
|
|
4821
|
+
// half applied, so a missing source is reported rather than thrown on.
|
|
4822
|
+
if (!fs.existsSync(source)) {
|
|
4823
|
+
missing.push(source);
|
|
4824
|
+
continue;
|
|
4825
|
+
}
|
|
4826
|
+
if (!dryRun) fs.copySync(source, target);
|
|
4827
|
+
copied.push(`${source} -> ${target}`);
|
|
4828
|
+
}
|
|
4829
|
+
|
|
4830
|
+
logger.info(`Cyberia sources synced ${fromRepo ? 'from' : 'to'} the product checkouts`, {
|
|
4831
|
+
copied,
|
|
4832
|
+
missing,
|
|
4833
|
+
dryRun,
|
|
4834
|
+
});
|
|
4835
|
+
});
|
|
4985
4836
|
|
|
4986
4837
|
runner.command('setup-workspace').action(() => {
|
|
4987
|
-
shellExec(`node bin fs src/client/public/cyberia --
|
|
4838
|
+
shellExec(`node bin fs src/client/public/cyberia --tracked --pull --deploy-id dd-cyberia`);
|
|
4988
4839
|
shellExec(`node bin/deploy.js cyberia`);
|
|
4989
4840
|
if (!fs.existsSync('./cyberia-server')) shellExec(`${cli()} clone underpostnet/cyberia-server`);
|
|
4990
4841
|
if (!fs.existsSync('./cyberia-client')) shellExec(`${cli()} clone underpostnet/cyberia-client`);
|
|
@@ -5096,7 +4947,7 @@ try {
|
|
|
5096
4947
|
.option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
|
|
5097
4948
|
.option('--mongo-host <mongo-host>', 'Mongo host override')
|
|
5098
4949
|
.option('--dev', 'Force development environment')
|
|
5099
|
-
.description('Drop all Cyberia collections and remove File documents
|
|
4950
|
+
.description('Drop all Cyberia collections and remove the File documents they reference')
|
|
5100
4951
|
.action(async (options = {}) => {
|
|
5101
4952
|
if (!options.envPath) options.envPath = `./.env`;
|
|
5102
4953
|
if (fs.existsSync(options.envPath)) dotenv.config({ path: options.envPath, override: true });
|
|
@@ -5139,30 +4990,37 @@ try {
|
|
|
5139
4990
|
'cyberia-entity-type-default',
|
|
5140
4991
|
'cyberia-client-hints',
|
|
5141
4992
|
'cyberia-saga',
|
|
4993
|
+
'cyberia-audio',
|
|
4994
|
+
'cyberia-map-audio-conf',
|
|
5142
4995
|
];
|
|
5143
4996
|
|
|
4997
|
+
// Every File _id a Cyberia document owns: instance and map thumbnails and previews, and the
|
|
4998
|
+
// recorded WAV each audio asset points at. Read from the registry that maps a model to its
|
|
4999
|
+
// File fields, so a reference added there is dropped here without editing this command, and
|
|
5000
|
+
// read before anything is dropped, so no backing File survives the collection that held it.
|
|
5001
|
+
const fileReferences = cyberiaCollections
|
|
5002
|
+
.map((api) => ({ api, fields: fileRefFields(api) }))
|
|
5003
|
+
.filter(({ fields }) => fields.length > 0);
|
|
5004
|
+
|
|
5144
5005
|
await DataBaseProviderService.load({ apis: [...cyberiaCollections, 'file'], host, path, db });
|
|
5145
5006
|
|
|
5146
5007
|
const File = DataBaseProviderService.getModel('file', { host, path });
|
|
5147
5008
|
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
const thumbnailFileIds = new Set();
|
|
5151
|
-
for (const api of ['cyberia-instance', 'cyberia-map']) {
|
|
5009
|
+
const fileIds = new Set();
|
|
5010
|
+
for (const { api, fields } of fileReferences) {
|
|
5152
5011
|
const Model = DataBaseProviderService.getModel(api, { host, path });
|
|
5153
5012
|
const docs = await Model.find(
|
|
5154
|
-
{ $or:
|
|
5155
|
-
|
|
5013
|
+
{ $or: fields.map((field) => ({ [field]: { $ne: null } })) },
|
|
5014
|
+
Object.fromEntries(fields.map((field) => [field, 1])),
|
|
5156
5015
|
).lean();
|
|
5157
5016
|
for (const doc of docs) {
|
|
5158
|
-
if (doc
|
|
5159
|
-
if (doc.preview) thumbnailFileIds.add(doc.preview.toString());
|
|
5017
|
+
for (const field of fields) if (doc[field]) fileIds.add(doc[field].toString());
|
|
5160
5018
|
}
|
|
5161
5019
|
}
|
|
5162
5020
|
|
|
5163
|
-
if (
|
|
5164
|
-
const result = await File.deleteMany({ _id: { $in: [...
|
|
5165
|
-
logger.info(`Removed ${result.deletedCount}
|
|
5021
|
+
if (fileIds.size > 0) {
|
|
5022
|
+
const result = await File.deleteMany({ _id: { $in: [...fileIds] } });
|
|
5023
|
+
logger.info(`Removed ${result.deletedCount} referenced File document(s)`);
|
|
5166
5024
|
}
|
|
5167
5025
|
|
|
5168
5026
|
for (const api of cyberiaCollections) {
|
|
@@ -5242,7 +5100,7 @@ node bin test cyberia --grep 'Cyberia load'`);
|
|
|
5242
5100
|
// no funca
|
|
5243
5101
|
if (options.loadTar) {
|
|
5244
5102
|
for (const imageId of dockerImageIds)
|
|
5245
|
-
if (imageId === id || id === '.') shellExec(`docker load -i ./${imageId}-dev_v3.3.
|
|
5103
|
+
if (imageId === id || id === '.') shellExec(`docker load -i ./${imageId}-dev_v3.3.76.tar`);
|
|
5246
5104
|
return;
|
|
5247
5105
|
}
|
|
5248
5106
|
switch (id) {
|
|
@@ -5254,7 +5112,7 @@ node bin/build dd-cyberia --update-private
|
|
|
5254
5112
|
node bin image --path src/runtime/engine-cyberia \
|
|
5255
5113
|
--docker-compose --pull-base --build \
|
|
5256
5114
|
--dockerfile-name Dockerfile.dev \
|
|
5257
|
-
--image-name engine-cyberia-dev:v3.3.
|
|
5115
|
+
--image-name engine-cyberia-dev:v3.3.76 \
|
|
5258
5116
|
--image-out-path .
|
|
5259
5117
|
`);
|
|
5260
5118
|
break;
|
|
@@ -5266,7 +5124,7 @@ cp -f src/runtime/cyberia-server/Dockerfile.dev cyberia-server/Dockerfile.dev
|
|
|
5266
5124
|
node bin image --path cyberia-server \
|
|
5267
5125
|
--docker-compose --pull-base --build \
|
|
5268
5126
|
--dockerfile-name Dockerfile.dev \
|
|
5269
|
-
--image-name cyberia-server-dev:v3.3.
|
|
5127
|
+
--image-name cyberia-server-dev:v3.3.76 \
|
|
5270
5128
|
--image-out-path .
|
|
5271
5129
|
`);
|
|
5272
5130
|
break;
|
|
@@ -5277,7 +5135,7 @@ cp -f src/runtime/cyberia-client/Dockerfile.dev cyberia-client/Dockerfile.dev
|
|
|
5277
5135
|
node bin image --path cyberia-client \
|
|
5278
5136
|
--docker-compose --pull-base --build \
|
|
5279
5137
|
--dockerfile-name Dockerfile.dev \
|
|
5280
|
-
--image-name cyberia-client-dev:v3.3.
|
|
5138
|
+
--image-name cyberia-client-dev:v3.3.76 \
|
|
5281
5139
|
--image-out-path .
|
|
5282
5140
|
`);
|
|
5283
5141
|
break;
|
|
@@ -5443,9 +5301,9 @@ node bin image --path cyberia-client \
|
|
|
5443
5301
|
|
|
5444
5302
|
const CyberiaSkill = DataBaseProviderService.getModel('cyberia-skill', { host, path });
|
|
5445
5303
|
|
|
5446
|
-
// Upsert each skill record keyed by triggerItemId — full record (logic
|
|
5447
|
-
//
|
|
5448
|
-
//
|
|
5304
|
+
// Upsert each skill record keyed by triggerItemId — the full record (logic event keys +
|
|
5305
|
+
// expanded skills metadata). The collection is deployment-wide; an instance runs the
|
|
5306
|
+
// subset its own content triggers, decided at export and boot, never stored.
|
|
5449
5307
|
let upserted = 0;
|
|
5450
5308
|
for (const sk of DefaultSkillConfig) {
|
|
5451
5309
|
await CyberiaSkill.findOneAndUpdate(
|
|
@@ -5468,6 +5326,10 @@ node bin image --path cyberia-client \
|
|
|
5468
5326
|
.command('seed-entities')
|
|
5469
5327
|
.option('--env-path <env-path>', 'Env path e.g. ./engine-private/conf/dd-cyberia/.env.development')
|
|
5470
5328
|
.option('--mongo-host <mongo-host>', 'Mongo host override')
|
|
5329
|
+
.option(
|
|
5330
|
+
'--instance <instance-code>',
|
|
5331
|
+
"Point that instance's conf at the seeded documents, replacing whatever it referenced",
|
|
5332
|
+
)
|
|
5471
5333
|
.option('--dev', 'Force development environment')
|
|
5472
5334
|
.description('Upsert ENTITY_TYPE_DEFAULTS into the cyberia-entity-type-default collection (idempotent)')
|
|
5473
5335
|
.action(async (options) => {
|
|
@@ -5499,7 +5361,12 @@ node bin image --path cyberia-client \
|
|
|
5499
5361
|
|
|
5500
5362
|
logger.info('seed-entities', { deployId, host, path });
|
|
5501
5363
|
|
|
5502
|
-
await DataBaseProviderService.load({
|
|
5364
|
+
await DataBaseProviderService.load({
|
|
5365
|
+
apis: ['cyberia-entity-type-default', 'cyberia-instance-conf'],
|
|
5366
|
+
host,
|
|
5367
|
+
path,
|
|
5368
|
+
db,
|
|
5369
|
+
});
|
|
5503
5370
|
|
|
5504
5371
|
const CyberiaEntityTypeDefault = DataBaseProviderService.getModel('cyberia-entity-type-default', { host, path });
|
|
5505
5372
|
|
|
@@ -5518,8 +5385,9 @@ node bin image --path cyberia-client \
|
|
|
5518
5385
|
// (across entity types, or within one type at different specificity). Every
|
|
5519
5386
|
// entry is upserted, idempotently, by its exact (entityType, liveItemIds) key.
|
|
5520
5387
|
let upserted = 0;
|
|
5388
|
+
const seededIds = [];
|
|
5521
5389
|
for (const ed of ENTITY_TYPE_DEFAULTS) {
|
|
5522
|
-
await CyberiaEntityTypeDefault.findOneAndUpdate(
|
|
5390
|
+
const doc = await CyberiaEntityTypeDefault.findOneAndUpdate(
|
|
5523
5391
|
{ entityType: ed.entityType, liveItemIds: ed.liveItemIds || [] },
|
|
5524
5392
|
{
|
|
5525
5393
|
$set: {
|
|
@@ -5527,12 +5395,14 @@ node bin image --path cyberia-client \
|
|
|
5527
5395
|
liveItemIds: ed.liveItemIds || [],
|
|
5528
5396
|
deadItemIds: ed.deadItemIds || [],
|
|
5529
5397
|
dropItemIds: ed.dropItemIds || [],
|
|
5530
|
-
|
|
5398
|
+
inventoryItemsIds: ed.inventoryItemsIds || [],
|
|
5399
|
+
overrideItemsIdsState: ed.overrideItemsIdsState || [],
|
|
5531
5400
|
behavior: ed.behavior || '',
|
|
5532
5401
|
},
|
|
5533
5402
|
},
|
|
5534
|
-
{ upsert: true },
|
|
5403
|
+
{ upsert: true, returnDocument: 'after' },
|
|
5535
5404
|
);
|
|
5405
|
+
if (doc?._id) seededIds.push(doc._id);
|
|
5536
5406
|
upserted++;
|
|
5537
5407
|
}
|
|
5538
5408
|
|
|
@@ -5541,6 +5411,23 @@ node bin image --path cyberia-client \
|
|
|
5541
5411
|
ENTITY_TYPE_DEFAULTS.map((e) => `${e.entityType} → [${(e.liveItemIds || []).join(', ')}]`),
|
|
5542
5412
|
);
|
|
5543
5413
|
|
|
5414
|
+
// A seeded document reaches a world only when that world's conf names it. Binding here is
|
|
5415
|
+
// what makes an edited row take effect, and it states the whole reference set so re-running
|
|
5416
|
+
// converges instead of accumulating.
|
|
5417
|
+
if (options.instance) {
|
|
5418
|
+
const CyberiaInstanceConf = DataBaseProviderService.getModel('cyberia-instance-conf', { host, path });
|
|
5419
|
+
const conf = await CyberiaInstanceConf.findOneAndUpdate(
|
|
5420
|
+
{ instanceCode: options.instance },
|
|
5421
|
+
{ $set: { entityDefaults: seededIds, updatedAt: new Date() } },
|
|
5422
|
+
{ returnDocument: 'after' },
|
|
5423
|
+
);
|
|
5424
|
+
if (!conf) {
|
|
5425
|
+
logger.error(`cyberia-instance-conf not found for instanceCode="${options.instance}"`);
|
|
5426
|
+
process.exit(1);
|
|
5427
|
+
}
|
|
5428
|
+
logger.info(`seed-entities --instance ${options.instance}: ${seededIds.length} reference(s) bound`);
|
|
5429
|
+
}
|
|
5430
|
+
|
|
5544
5431
|
await DataBaseProviderService.getProvider({ host, path }, 'mongoose').close();
|
|
5545
5432
|
});
|
|
5546
5433
|
|
|
@@ -5564,26 +5451,6 @@ node bin image --path cyberia-client \
|
|
|
5564
5451
|
'skin-vivid',
|
|
5565
5452
|
'skin-natural',
|
|
5566
5453
|
'skin-shaved',
|
|
5567
|
-
// 'resource-desert-petal',
|
|
5568
|
-
// 'resource-desert-stone',
|
|
5569
|
-
// 'resource-desert-polygon',
|
|
5570
|
-
// 'resource-desert-thread',
|
|
5571
|
-
// 'resource-grass-petal',
|
|
5572
|
-
// 'resource-grass-stone',
|
|
5573
|
-
// 'resource-grass-polygon',
|
|
5574
|
-
// 'resource-grass-thread',
|
|
5575
|
-
// 'resource-water-petal',
|
|
5576
|
-
// 'resource-water-stone',
|
|
5577
|
-
// 'resource-water-polygon',
|
|
5578
|
-
// 'resource-water-thread',
|
|
5579
|
-
// 'resource-stone-petal',
|
|
5580
|
-
// 'resource-stone-stone',
|
|
5581
|
-
// 'resource-stone-polygon',
|
|
5582
|
-
// 'resource-stone-thread',
|
|
5583
|
-
// 'resource-lava-petal',
|
|
5584
|
-
// 'resource-lava-stone',
|
|
5585
|
-
// 'resource-lava-polygon',
|
|
5586
|
-
// 'resource-lava-thread',
|
|
5587
5454
|
];
|
|
5588
5455
|
|
|
5589
5456
|
const baseSeed = options.seed || 'example';
|
|
@@ -5608,10 +5475,10 @@ node bin image --path cyberia-client \
|
|
|
5608
5475
|
// Instance id → project root. Single source of truth for the workloads this
|
|
5609
5476
|
// workflow builds: both the k8s manifests and the status page artifacts each
|
|
5610
5477
|
// project ships are resolved from this list plus conf.instances.json.
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
];
|
|
5478
|
+
// The template instances this deploy builds artifacts for. Where each one publishes is not
|
|
5479
|
+
// listed here: instanceProjectPathFactory reads it off the conf entry, the same rule
|
|
5480
|
+
// instance-build-manifest applies, so the two can never name different checkouts.
|
|
5481
|
+
const CYBERIA_INSTANCE_IDS = ['mmo-client', 'mmo-server'];
|
|
5615
5482
|
const CYBERIA_CONF_INSTANCES_PATH = './engine-private/conf/dd-cyberia/conf.instances.json';
|
|
5616
5483
|
const CYBERIA_CONF_SSR_PATH = './engine-private/conf/dd-cyberia/conf.ssr.json';
|
|
5617
5484
|
// Copy shared by the server and the client for a given status code. A status
|
|
@@ -5704,10 +5571,9 @@ node bin image --path cyberia-client \
|
|
|
5704
5571
|
const nodeFlag = options.nodeName ? ` --node-name ${options.nodeName}` : '';
|
|
5705
5572
|
|
|
5706
5573
|
// ── Dynamically resolve instance codes from conf.instances.json ──────
|
|
5707
|
-
//
|
|
5708
|
-
//
|
|
5709
|
-
//
|
|
5710
|
-
// provisions every variant's backup dir and saga at build time.
|
|
5574
|
+
// Collect the multiInstance variant codes of every game-server runtime
|
|
5575
|
+
// instance. They set the INSTANCE_CODES label in Dockerfile.dev, so the
|
|
5576
|
+
// dev image provisions each variant's backup dir and saga at build time.
|
|
5711
5577
|
//
|
|
5712
5578
|
// Only codes that have an on-disk instance backup directory are
|
|
5713
5579
|
// included. The saga file is optional — the Dockerfile's for loop
|
|
@@ -5769,14 +5635,12 @@ node bin image --path cyberia-client \
|
|
|
5769
5635
|
}
|
|
5770
5636
|
|
|
5771
5637
|
// ── Update catalog-cyberia.js privateConfPaths ───────────────────────
|
|
5772
|
-
// The array block
|
|
5773
|
-
// markers
|
|
5774
|
-
//
|
|
5775
|
-
//
|
|
5776
|
-
//
|
|
5777
|
-
//
|
|
5778
|
-
// (which uses `instances/` + `sagas/`). Only emit paths that exist on disk
|
|
5779
|
-
// so the sync never hits ENOENT on a variant without local content.
|
|
5638
|
+
// The privateConfPaths array block sits between the /** INSTANCE_CODES */
|
|
5639
|
+
// markers. Replace its content with the resolved per-code paths.
|
|
5640
|
+
// syncPrivateConf copies each entry from `./engine-private/<path>`, so the
|
|
5641
|
+
// paths follow the local engine-private layout (`cyberia-instances/<code>`,
|
|
5642
|
+
// `cyberia-sagas/<code>.json`). Emit only paths that exist on disk, so the
|
|
5643
|
+
// sync never hits ENOENT for a variant without local content.
|
|
5780
5644
|
const catalogPath = './src/projects/cyberia/catalog-cyberia.js';
|
|
5781
5645
|
try {
|
|
5782
5646
|
const catalogContent = fs.readFileSync(catalogPath, 'utf8');
|
|
@@ -5809,11 +5673,11 @@ node bin image --path cyberia-client \
|
|
|
5809
5673
|
// embed each document declared under an instance's `customStatusPages`,
|
|
5810
5674
|
// so the artifact has to exist at its `hostPath` by manifest time.
|
|
5811
5675
|
const statusPagesBuilt = [];
|
|
5812
|
-
for (const
|
|
5676
|
+
for (const id of CYBERIA_INSTANCE_IDS) {
|
|
5813
5677
|
const instance = confInstancesEntries.find((entry) => entry.id === id);
|
|
5814
5678
|
for (const page of instance?.customStatusPages || []) {
|
|
5815
5679
|
if (!page?.status || !page?.hostPath) continue;
|
|
5816
|
-
const outputPath = nodePath.normalize(`${
|
|
5680
|
+
const outputPath = nodePath.normalize(`${instanceProjectPathFactory(instance)}/${page.hostPath}`);
|
|
5817
5681
|
if (buildCyberiaStatusPage({ status: page.status, outputPath, dev: isDev }))
|
|
5818
5682
|
statusPagesBuilt.push({ instance: id, status: page.status, outputPath });
|
|
5819
5683
|
}
|
|
@@ -5826,23 +5690,22 @@ node bin image --path cyberia-client \
|
|
|
5826
5690
|
// ── Build dev manifests (always --kind --dev) ────────────────────────
|
|
5827
5691
|
{
|
|
5828
5692
|
const flags = `--kind --dev${nodeFlag}`;
|
|
5829
|
-
for (const
|
|
5830
|
-
shellExec(`node bin run instance-build-manifest
|
|
5693
|
+
for (const id of CYBERIA_INSTANCE_IDS)
|
|
5694
|
+
shellExec(`node bin run instance-build-manifest --deploy-id dd-cyberia --instance-id ${id} ${flags}`);
|
|
5831
5695
|
}
|
|
5832
5696
|
// ── Build prod manifests (--kubeadm, no --dev) ───────────────────────
|
|
5833
5697
|
if (!isDev) {
|
|
5834
5698
|
const flags = `--kubeadm${nodeFlag}`;
|
|
5835
|
-
for (const
|
|
5836
|
-
shellExec(`node bin run instance-build-manifest
|
|
5699
|
+
for (const id of CYBERIA_INSTANCE_IDS)
|
|
5700
|
+
shellExec(`node bin run instance-build-manifest --deploy-id dd-cyberia --instance-id ${id} ${flags}`);
|
|
5837
5701
|
}
|
|
5838
5702
|
|
|
5839
5703
|
// Copy canonical doc sources into the generated project READMEs.
|
|
5840
5704
|
// Edit the canonical sources; never hand-edit these generated outputs.
|
|
5841
|
-
// The mirrored deploy tree is
|
|
5842
|
-
//
|
|
5843
|
-
//
|
|
5844
|
-
//
|
|
5845
|
-
// script directory's sibling there too.
|
|
5705
|
+
// The mirrored deploy tree is generated, and rebuilt from scratch so a file
|
|
5706
|
+
// renamed upstream cannot linger. It keeps the engine layout, the
|
|
5707
|
+
// <deploy-id> directory beside lib/, because every deploy script sources
|
|
5708
|
+
// `$SCRIPT_DIR/../lib/logging.sh`.
|
|
5846
5709
|
for (const project of ['cyberia-client', 'cyberia-server']) {
|
|
5847
5710
|
const scripts = `./deploy/${project}`;
|
|
5848
5711
|
// A tree assembled before these scripts were packaged does not carry them; mirroring is
|
|
@@ -5866,9 +5729,8 @@ node bin image --path cyberia-client \
|
|
|
5866
5729
|
'./cyberia-server/.github/workflows/cyberia-server.cd.yml',
|
|
5867
5730
|
);
|
|
5868
5731
|
shellExec('cp -a ./engine-private/conf/dd-cyberia/docker-compose/cyberia/. ./src/runtime/engine-cyberia/');
|
|
5869
|
-
//
|
|
5870
|
-
//
|
|
5871
|
-
// whichever deployment `app load` ran for last.
|
|
5732
|
+
// Scope the publish to the deployment this workflow builds: read
|
|
5733
|
+
// dd-cyberia's own environment, not the working-tree `./.env`.
|
|
5872
5734
|
shellExec(
|
|
5873
5735
|
`node bin/cyberia.js instance --publish-build --env-path ${deployEnvFilePath(
|
|
5874
5736
|
'dd-cyberia',
|
|
@@ -5885,6 +5747,7 @@ node bin image --path cyberia-client \
|
|
|
5885
5747
|
if (options.dryRun) {
|
|
5886
5748
|
shellExec('node bin cmt --log --unpush cyberia-server');
|
|
5887
5749
|
shellExec('node bin cmt --log --unpush cyberia-client');
|
|
5750
|
+
shellExec('node bin cmt --log --unpush cyberia-audio');
|
|
5888
5751
|
shellExec('node bin cmt --log --unpush');
|
|
5889
5752
|
shellExec('node bin cmt --log --unpush ../cyberia-instances');
|
|
5890
5753
|
} else {
|
|
@@ -5897,6 +5760,9 @@ node bin image --path cyberia-client \
|
|
|
5897
5760
|
shellExec('node bin push cyberia-client underpostnet/cyberia-client', {
|
|
5898
5761
|
silentOnError: true,
|
|
5899
5762
|
});
|
|
5763
|
+
shellExec('node bin push cyberia-audio underpostnet/cyberia-audio', {
|
|
5764
|
+
silentOnError: true,
|
|
5765
|
+
});
|
|
5900
5766
|
shellExec('node bin run template-deploy', {
|
|
5901
5767
|
silentOnError: true,
|
|
5902
5768
|
});
|
|
@@ -5995,15 +5861,23 @@ node bin image --path cyberia-client \
|
|
|
5995
5861
|
|
|
5996
5862
|
await program.parseAsync();
|
|
5997
5863
|
} catch (error) {
|
|
5998
|
-
//
|
|
5999
|
-
// error
|
|
6000
|
-
//
|
|
6001
|
-
// exit so GitHub Actions / CI parents observe the failure. Without this
|
|
6002
|
-
// guard, a genuine build failure was being silently rerouted into the
|
|
6003
|
-
// underpost CLI and then masked behind a misleading "unknown command"
|
|
6004
|
-
// line.
|
|
5864
|
+
// Reroute only on the passthrough sentinel. Every other error — a non-zero
|
|
5865
|
+
// subprocess, a CLI parse error, a missing module — must exit non-zero, so a
|
|
5866
|
+
// CI parent sees the failure.
|
|
6005
5867
|
if (error && error.message === 'Trigger underpost passthrough') {
|
|
6006
|
-
|
|
5868
|
+
// A redundant CLI name can only appear before the command; everything from the command
|
|
5869
|
+
// onward is an argument. Filtering the whole of argv removed those too, so an option whose
|
|
5870
|
+
// value happens to be `underpost` — a storage id, a public asset path — lost it, and the
|
|
5871
|
+
// parse failed on a missing argument rather than on anything the caller wrote.
|
|
5872
|
+
const commandIndex = process.argv.findIndex(
|
|
5873
|
+
(token, index) => index >= 2 && underpostProgram.commands.some((command) => command._name === token),
|
|
5874
|
+
);
|
|
5875
|
+
if (commandIndex > 2)
|
|
5876
|
+
process.argv = [
|
|
5877
|
+
...process.argv.slice(0, 2),
|
|
5878
|
+
...process.argv.slice(2, commandIndex).filter((token) => token !== 'underpost'),
|
|
5879
|
+
...process.argv.slice(commandIndex),
|
|
5880
|
+
];
|
|
6007
5881
|
if (!process.argv.includes('--plain')) logger.info('Rerouting to underpost cli...');
|
|
6008
5882
|
try {
|
|
6009
5883
|
await underpostProgram.parseAsync();
|