@underpostnet/cyberia 3.3.73 → 3.3.77
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 +10 -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/engine-cyberia.ci.yml +1 -1
- package/.github/workflows/publish.cyberia.ci.yml +44 -0
- package/AGENTS.md +61 -12
- package/CHANGELOG.md +159 -161
- package/CLI-HELP.md +20 -13
- package/Dockerfile +1 -1
- package/Dockerfile.dev +1 -1
- package/Dockerfile.test +2 -2
- package/README.md +28 -28
- package/bin/build.js +96 -21
- package/bin/cyberia.js +1247 -1373
- package/bin/index.js +1247 -1373
- package/compose.env +17 -5
- package/conf.js +7 -4
- 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 +28 -19
- 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/docs/coverage/cyberia/api/cyberia-instance/cyberia-fallback-capture.js.html +1591 -0
- package/docs/coverage/cyberia/api/cyberia-instance/index.html +116 -0
- package/docs/coverage/cyberia/api/cyberia-server-defaults/cyberia-server-defaults.js.html +4138 -0
- package/docs/coverage/cyberia/api/cyberia-server-defaults/index.html +116 -0
- package/docs/coverage/cyberia/api/object-layer/index.html +116 -0
- package/docs/coverage/cyberia/api/object-layer/object-layer.model.js.html +1393 -0
- package/docs/coverage/cyberia/base.css +362 -0
- package/docs/coverage/cyberia/block-navigation.js +82 -0
- package/docs/coverage/cyberia/favicon.png +0 -0
- package/docs/coverage/cyberia/index.html +161 -0
- package/docs/coverage/cyberia/prettify.css +101 -0
- package/docs/coverage/cyberia/prettify.js +937 -0
- package/docs/coverage/cyberia/projects/cyberia/atlas-sprite-sheet-generator.js.html +1315 -0
- package/docs/coverage/cyberia/projects/cyberia/index.html +176 -0
- package/docs/coverage/cyberia/projects/cyberia/instance-backup.js.html +715 -0
- package/docs/coverage/cyberia/projects/cyberia/instance-data.js.html +2182 -0
- package/docs/coverage/cyberia/projects/cyberia/shape-generator.js.html +3265 -0
- package/docs/coverage/cyberia/projects/cyberia/stat-balance.js.html +328 -0
- package/docs/coverage/cyberia/sort-arrow-sprite.png +0 -0
- package/docs/coverage/cyberia/sorter.js +205 -0
- 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 +80 -18
- package/src/cli/deploy.js +215 -34
- 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/release.js +3 -1
- package/src/cli/repository.js +35 -9
- package/src/cli/run.js +80 -199
- package/src/cli/test.js +4 -0
- package/src/cli/wireguard.js +32 -31
- package/src/client/components/core/Badge.js +0 -1
- package/src/client/components/core/CssCore.js +8 -1
- package/src/client/components/core/Docs.js +77 -100
- package/src/client/components/core/Modal.js +77 -23
- package/src/client/components/core/ToolTip.js +15 -0
- package/src/client/components/cryptokoyn/AppShellCryptokoyn.js +0 -2
- 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/RouterCyberiaPortal.js +0 -4
- package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +0 -4
- package/src/client/components/default/AppShellDefault.js +1 -4
- package/src/client/components/itemledger/AppShellItemledger.js +0 -2
- package/src/client/components/underpost/AppShellUnderpost.js +1 -1
- 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/ssr/views/Cyberia404.js +3 -3
- 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/network/underpost-gateway.js +113 -14
- package/src/server/ops/logger.js +70 -35
- package/src/server/runtime/conf.js +40 -9
- package/src/server/runtime/start.js +11 -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/integration/infra/4-ingress/underpost-ingress.test.js +1 -1
- package/test/unit/catalog.test.js +13 -2
- 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 +58 -7
- package/test/unit/coverage-artifact.test.js +120 -29
- package/test/unit/cyberia/instance-backup.test.js +183 -0
- package/test/unit/cyberia/instance-data.test.js +314 -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-legacy-gateway-sweep.test.js +63 -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/gateway-pod-fetch.test.js +95 -0
- package/test/unit/letsencrypt-issuer.test.js +101 -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/release-bump.test.js +3 -1
- package/test/unit/start-options.test.js +12 -0
- 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
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { Types } from 'mongoose';
|
|
3
|
+
import { DataBaseProviderService } from '../../../../src/db/DataBaseProvider.js';
|
|
4
|
+
import { CyberiaEntityTypeDefaultModel } from '../../../../src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js';
|
|
5
|
+
import {
|
|
6
|
+
applyDefaultsToEntities,
|
|
7
|
+
CyberiaEntityTypeDefaultService,
|
|
8
|
+
findDuplicateBuilds,
|
|
9
|
+
} from '../../../../src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js';
|
|
10
|
+
import { CyberiaInstanceConfModel } from '../../../../src/api/cyberia-instance-conf/cyberia-instance-conf.model.js';
|
|
11
|
+
import {
|
|
12
|
+
CYBERIA_INSTANCE_CONF_DEFAULTS,
|
|
13
|
+
ENTITY_TYPE_DEFAULTS,
|
|
14
|
+
resolveEntityInventory,
|
|
15
|
+
} from '../../../../src/api/cyberia-server-defaults/cyberia-server-defaults.js';
|
|
16
|
+
|
|
17
|
+
// Two worlds built on the same art: identical entityType, identical liveItemIds, different
|
|
18
|
+
// wiring. This is the shape that used to make item-id matching resolve one into the other.
|
|
19
|
+
const FOREST_BOT = {
|
|
20
|
+
_id: new Types.ObjectId(),
|
|
21
|
+
entityType: 'bot',
|
|
22
|
+
liveItemIds: ['purple'],
|
|
23
|
+
deadItemIds: ['forest-ghost'],
|
|
24
|
+
dropItemIds: [],
|
|
25
|
+
inventoryItemsIds: ['forest-coin'],
|
|
26
|
+
behavior: 'hostile',
|
|
27
|
+
};
|
|
28
|
+
const CAVERN_BOT = {
|
|
29
|
+
_id: new Types.ObjectId(),
|
|
30
|
+
entityType: 'bot',
|
|
31
|
+
liveItemIds: ['purple'],
|
|
32
|
+
deadItemIds: ['cavern-ghost'],
|
|
33
|
+
dropItemIds: ['amethyst'],
|
|
34
|
+
inventoryItemsIds: [],
|
|
35
|
+
behavior: 'passive',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('entity-type defaults are resolved by reference', () => {
|
|
39
|
+
const options = { host: 'entity-default-test', path: '/' };
|
|
40
|
+
let docs;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
docs = new Map([FOREST_BOT, CAVERN_BOT].map((doc) => [String(doc._id), doc]));
|
|
44
|
+
vi.spyOn(DataBaseProviderService, 'getModel').mockImplementation((name) => {
|
|
45
|
+
if (name.toLowerCase() === 'cyberiaentitytypedefault') return CyberiaEntityTypeDefaultModel;
|
|
46
|
+
throw new Error(`Unexpected model: ${name}`);
|
|
47
|
+
});
|
|
48
|
+
vi.spyOn(CyberiaEntityTypeDefaultModel, 'find').mockImplementation((query) => ({
|
|
49
|
+
lean: async () => [...docs.values()].filter((doc) => query._id.$in.includes(String(doc._id))),
|
|
50
|
+
}));
|
|
51
|
+
});
|
|
52
|
+
afterEach(() => vi.restoreAllMocks());
|
|
53
|
+
|
|
54
|
+
it('reads only the documents an instance names, never a lookalike', async () => {
|
|
55
|
+
const forest = await CyberiaEntityTypeDefaultService.resolve([FOREST_BOT._id], options);
|
|
56
|
+
expect(forest).toHaveLength(1);
|
|
57
|
+
expect(forest[0]).toMatchObject({ deadItemIds: ['forest-ghost'], behavior: 'hostile' });
|
|
58
|
+
// The other world shares entityType and liveItemIds and still cannot be reached from here.
|
|
59
|
+
expect(forest[0].dropItemIds).toEqual([]);
|
|
60
|
+
|
|
61
|
+
const cavern = await CyberiaEntityTypeDefaultService.resolve([CAVERN_BOT._id], options);
|
|
62
|
+
expect(cavern[0]).toMatchObject({ deadItemIds: ['cavern-ghost'], behavior: 'passive' });
|
|
63
|
+
expect(cavern[0].dropItemIds).toEqual(['amethyst']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('keeps reference order, so the most specific document an operator listed first stays first', async () => {
|
|
67
|
+
const resolved = await CyberiaEntityTypeDefaultService.resolve([CAVERN_BOT._id, FOREST_BOT._id], options);
|
|
68
|
+
expect(resolved.map((d) => d.behavior)).toEqual(['passive', 'hostile']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('drops a reference whose document is gone instead of resolving something else', async () => {
|
|
72
|
+
const orphan = new Types.ObjectId();
|
|
73
|
+
const resolved = await CyberiaEntityTypeDefaultService.resolve([orphan, FOREST_BOT._id], options);
|
|
74
|
+
expect(resolved).toHaveLength(1);
|
|
75
|
+
expect(resolved[0].behavior).toBe('hostile');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('resolves nothing for a conf that references nothing', async () => {
|
|
79
|
+
for (const input of [[], null, undefined]) {
|
|
80
|
+
expect(await CyberiaEntityTypeDefaultService.resolve(input, options)).toEqual([]);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('completes the referenced set with every canonical type it does not cover', async () => {
|
|
85
|
+
const completed = await CyberiaEntityTypeDefaultService.resolveWithCanonical([FOREST_BOT._id], options);
|
|
86
|
+
// The referenced document owns 'bot'; the canonical entry for that type must not come back too.
|
|
87
|
+
expect(completed.filter((d) => d.entityType === 'bot')).toHaveLength(1);
|
|
88
|
+
expect(completed[0]).toMatchObject({ behavior: 'hostile', deadItemIds: ['forest-ghost'] });
|
|
89
|
+
for (const canonical of ENTITY_TYPE_DEFAULTS) {
|
|
90
|
+
if (canonical.entityType === 'bot') continue;
|
|
91
|
+
expect(completed.some((d) => d.entityType === canonical.entityType)).toBe(true);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('orphaned references are impossible to keep', () => {
|
|
97
|
+
const options = { host: 'entity-default-test', path: '/' };
|
|
98
|
+
let docs;
|
|
99
|
+
let confs;
|
|
100
|
+
|
|
101
|
+
const confModel = {
|
|
102
|
+
find: (filter) => ({
|
|
103
|
+
lean: async () =>
|
|
104
|
+
[...confs.values()].filter((conf) => !filter?.instanceCode || conf.instanceCode === filter.instanceCode),
|
|
105
|
+
}),
|
|
106
|
+
updateOne: async ({ _id }, { $set }) => {
|
|
107
|
+
confs.get(String(_id)).entityDefaults = $set.entityDefaults;
|
|
108
|
+
return { modifiedCount: 1 };
|
|
109
|
+
},
|
|
110
|
+
updateMany: async (filter, update) => {
|
|
111
|
+
let modifiedCount = 0;
|
|
112
|
+
for (const conf of confs.values()) {
|
|
113
|
+
if (update.$pull) {
|
|
114
|
+
const before = conf.entityDefaults.length;
|
|
115
|
+
conf.entityDefaults = conf.entityDefaults.filter((ref) => String(ref) !== update.$pull.entityDefaults);
|
|
116
|
+
if (conf.entityDefaults.length !== before) modifiedCount++;
|
|
117
|
+
} else if (update.$set?.entityDefaults) {
|
|
118
|
+
if (conf.entityDefaults.length > 0) modifiedCount++;
|
|
119
|
+
conf.entityDefaults = [...update.$set.entityDefaults];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { modifiedCount };
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
docs = new Map([FOREST_BOT, CAVERN_BOT].map((doc) => [String(doc._id), doc]));
|
|
128
|
+
confs = new Map([
|
|
129
|
+
['c1', { _id: 'c1', instanceCode: 'forest', entityDefaults: [String(FOREST_BOT._id), String(CAVERN_BOT._id)] }],
|
|
130
|
+
['c2', { _id: 'c2', instanceCode: 'cavern', entityDefaults: [String(CAVERN_BOT._id)] }],
|
|
131
|
+
]);
|
|
132
|
+
vi.spyOn(DataBaseProviderService, 'getModel').mockImplementation((name) => {
|
|
133
|
+
if (name.toLowerCase() === 'cyberiaentitytypedefault') return CyberiaEntityTypeDefaultModel;
|
|
134
|
+
if (name.toLowerCase() === 'cyberiainstanceconf') return confModel;
|
|
135
|
+
throw new Error(`Unexpected model: ${name}`);
|
|
136
|
+
});
|
|
137
|
+
vi.spyOn(CyberiaEntityTypeDefaultModel, 'find').mockImplementation((query) => ({
|
|
138
|
+
lean: async () => [...docs.values()].filter((doc) => query._id.$in.includes(String(doc._id))),
|
|
139
|
+
}));
|
|
140
|
+
vi.spyOn(CyberiaEntityTypeDefaultModel, 'findByIdAndDelete').mockImplementation(async (id) => {
|
|
141
|
+
const doc = docs.get(String(id));
|
|
142
|
+
docs.delete(String(id));
|
|
143
|
+
return doc;
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
afterEach(() => vi.restoreAllMocks());
|
|
147
|
+
|
|
148
|
+
it('unlinks a default from every conf before deleting it', async () => {
|
|
149
|
+
await CyberiaEntityTypeDefaultService.delete({ params: { id: String(CAVERN_BOT._id) } }, null, options);
|
|
150
|
+
// The document is gone and no conf still points at it — which is the only way this
|
|
151
|
+
// collection produced orphans.
|
|
152
|
+
expect(docs.has(String(CAVERN_BOT._id))).toBe(false);
|
|
153
|
+
expect(confs.get('c1').entityDefaults).toEqual([String(FOREST_BOT._id)]);
|
|
154
|
+
expect(confs.get('c2').entityDefaults).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('drops a reference whose document vanished behind its back', async () => {
|
|
158
|
+
// A document removed straight from the collection, leaving both confs dangling.
|
|
159
|
+
docs.delete(String(CAVERN_BOT._id));
|
|
160
|
+
const compacted = await CyberiaEntityTypeDefaultService.compactInstanceRefs(options);
|
|
161
|
+
expect(compacted).toEqual([
|
|
162
|
+
{ instanceCode: 'forest', dropped: [String(CAVERN_BOT._id)] },
|
|
163
|
+
{ instanceCode: 'cavern', dropped: [String(CAVERN_BOT._id)] },
|
|
164
|
+
]);
|
|
165
|
+
expect(confs.get('c1').entityDefaults).toEqual([String(FOREST_BOT._id)]);
|
|
166
|
+
expect(confs.get('c2').entityDefaults).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('compacts only the instance it is scoped to', async () => {
|
|
170
|
+
docs.delete(String(CAVERN_BOT._id));
|
|
171
|
+
const compacted = await CyberiaEntityTypeDefaultService.compactInstanceRefs(options, { instanceCode: 'cavern' });
|
|
172
|
+
expect(compacted.map((entry) => entry.instanceCode)).toEqual(['cavern']);
|
|
173
|
+
expect(confs.get('c1').entityDefaults).toHaveLength(2);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('leaves a consistent collection untouched', async () => {
|
|
177
|
+
expect(await CyberiaEntityTypeDefaultService.compactInstanceRefs(options)).toEqual([]);
|
|
178
|
+
expect(confs.get('c1').entityDefaults).toHaveLength(2);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('syncing an instance to the defaults its maps place', () => {
|
|
183
|
+
const options = { host: 'entity-default-test', path: '/' };
|
|
184
|
+
let docs;
|
|
185
|
+
let confs;
|
|
186
|
+
let maps;
|
|
187
|
+
let actions;
|
|
188
|
+
let quests;
|
|
189
|
+
|
|
190
|
+
// A purple bot carrying the pistol matches both the bare-purple default and the armed one;
|
|
191
|
+
// the runtime resolves the most specific, and the sync must reference both so it can.
|
|
192
|
+
const ARMED_BOT = { _id: new Types.ObjectId(), entityType: 'bot', liveItemIds: ['purple', 'atlas_pistol_mk2'] };
|
|
193
|
+
const PLAYER = { _id: new Types.ObjectId(), entityType: 'player', liveItemIds: ['anon'], inventoryItemsIds: ['coin'] };
|
|
194
|
+
// What the collection defines: a skill for a trigger the world carries, and one for a trigger
|
|
195
|
+
// nothing in it does.
|
|
196
|
+
const SKILLS = [
|
|
197
|
+
{ triggerItemId: 'atlas_pistol_mk2', logicEventIds: ['projectile'] },
|
|
198
|
+
{ triggerItemId: 'coin', logicEventIds: ['coin_drop_or_transaction'] },
|
|
199
|
+
{ triggerItemId: 'hatchet', logicEventIds: ['projectile'] },
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
const model = (store, project) => ({
|
|
203
|
+
find: (filter) => ({
|
|
204
|
+
lean: async () => [...store.values()].filter((doc) => !filter?.code || filter.code.$in.includes(doc.code)),
|
|
205
|
+
}),
|
|
206
|
+
findOne: (filter) => ({
|
|
207
|
+
select: () => ({ lean: async () => [...store.values()].find((d) => d.code === filter.code) ?? null }),
|
|
208
|
+
}),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
beforeEach(() => {
|
|
212
|
+
docs = new Map(
|
|
213
|
+
[FOREST_BOT, CAVERN_BOT, ARMED_BOT, PLAYER].map((doc) => [String(doc._id), doc]),
|
|
214
|
+
);
|
|
215
|
+
confs = new Map([
|
|
216
|
+
['c1', { _id: 'c1', instanceCode: 'forest', entityDefaults: [String(PLAYER._id)] }],
|
|
217
|
+
// Another world already owns its purple bot — same art, same live item ids.
|
|
218
|
+
['c2', { _id: 'c2', instanceCode: 'cavern', entityDefaults: [String(CAVERN_BOT._id)] }],
|
|
219
|
+
]);
|
|
220
|
+
maps = new Map([
|
|
221
|
+
['m1', { _id: 'm1', code: 'm1', entities: [{ entityType: 'bot', objectLayerItemIds: ['purple', 'atlas_pistol_mk2'] }] }],
|
|
222
|
+
['m2', { _id: 'm2', code: 'm2', entities: [{ entityType: 'floor', objectLayerItemIds: ['grass'] }] }],
|
|
223
|
+
]);
|
|
224
|
+
actions = [];
|
|
225
|
+
quests = [];
|
|
226
|
+
vi.spyOn(DataBaseProviderService, 'getModel').mockImplementation((name) => {
|
|
227
|
+
switch (name.toLowerCase()) {
|
|
228
|
+
case 'cyberiaentitytypedefault':
|
|
229
|
+
return {
|
|
230
|
+
find: (filter) => ({
|
|
231
|
+
lean: async () =>
|
|
232
|
+
[...docs.values()].filter(
|
|
233
|
+
(doc) => !filter?._id?.$in || filter._id.$in.includes(String(doc._id)),
|
|
234
|
+
),
|
|
235
|
+
}),
|
|
236
|
+
};
|
|
237
|
+
case 'cyberiaskill':
|
|
238
|
+
return { find: () => ({ lean: async () => SKILLS }) };
|
|
239
|
+
case 'cyberiaaction':
|
|
240
|
+
return { find: (filter) => ({ lean: async () => actions.filter((a) => filter.sourceMapCode.$in.includes(a.sourceMapCode)) }) };
|
|
241
|
+
case 'cyberiaquest':
|
|
242
|
+
return { find: (filter) => ({ lean: async () => quests.filter((q) => filter.sourceMapCode.$in.includes(q.sourceMapCode)) }) };
|
|
243
|
+
case 'cyberiainstance':
|
|
244
|
+
return model(new Map([['i1', { code: 'forest', cyberiaMapCodes: ['m1'] }]]));
|
|
245
|
+
case 'cyberiamap':
|
|
246
|
+
return model(maps);
|
|
247
|
+
case 'objectlayer':
|
|
248
|
+
// The types a contested equipment slot is settled by; this world's items are unarguable.
|
|
249
|
+
return {
|
|
250
|
+
find: () => ({
|
|
251
|
+
lean: async () =>
|
|
252
|
+
[
|
|
253
|
+
['purple', 'skin'],
|
|
254
|
+
['atlas_pistol_mk2', 'weapon'],
|
|
255
|
+
['grass', 'floor'],
|
|
256
|
+
['coin', 'coin'],
|
|
257
|
+
['hatchet', 'weapon'],
|
|
258
|
+
].map(([id, type]) => ({ data: { item: { id, type } } })),
|
|
259
|
+
}),
|
|
260
|
+
};
|
|
261
|
+
case 'cyberiainstanceconf':
|
|
262
|
+
return {
|
|
263
|
+
find: () => ({ lean: async () => [...confs.values()] }),
|
|
264
|
+
updateOne: async ({ _id }, { $set }) => {
|
|
265
|
+
Object.assign(confs.get(String(_id)), $set);
|
|
266
|
+
return { modifiedCount: 1 };
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
default:
|
|
270
|
+
throw new Error(`Unexpected model: ${name}`);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
afterEach(() => vi.restoreAllMocks());
|
|
275
|
+
|
|
276
|
+
it('references every unclaimed default whose live items the maps actually place', async () => {
|
|
277
|
+
const result = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
278
|
+
// Both purple defaults match the armed bot by subset containment; only the unclaimed ones
|
|
279
|
+
// are linked.
|
|
280
|
+
expect(result.linked.sort()).toEqual([String(FOREST_BOT._id), String(ARMED_BOT._id)].sort());
|
|
281
|
+
// The hand-linked player default survives: a player is never placed on a map, so a wholesale
|
|
282
|
+
// replace would have discarded it.
|
|
283
|
+
expect(result.entityDefaults).toContain(String(PLAYER._id));
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('never adopts a default another instance already references', async () => {
|
|
287
|
+
const result = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
288
|
+
// The cavern bot matches by item ids — identical live ids to the forest one — and is exactly
|
|
289
|
+
// the cross-instance contamination the reference model exists to prevent.
|
|
290
|
+
expect(result.skipped).toEqual([String(CAVERN_BOT._id)]);
|
|
291
|
+
expect(result.entityDefaults).not.toContain(String(CAVERN_BOT._id));
|
|
292
|
+
expect(confs.get('c2').entityDefaults).toEqual([String(CAVERN_BOT._id)]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('never displaces the default this instance already references with an unowned lookalike', async () => {
|
|
296
|
+
// The reported bug: a second `resource`/`wood-1` document, edited later and referenced by no
|
|
297
|
+
// world, matched the placed wood and was adopted — so the instance ran on, and exported,
|
|
298
|
+
// wiring its operator never linked to it.
|
|
299
|
+
const mine = { _id: new Types.ObjectId(), entityType: 'resource', liveItemIds: ['wood-1'] };
|
|
300
|
+
const stray = {
|
|
301
|
+
_id: new Types.ObjectId(),
|
|
302
|
+
entityType: 'resource',
|
|
303
|
+
liveItemIds: ['wood-1'],
|
|
304
|
+
inventoryItemsIds: ['tim-knife'],
|
|
305
|
+
overrideItemsIdsState: [{ itemId: 'tim-knife', active: true }],
|
|
306
|
+
};
|
|
307
|
+
docs.set(String(mine._id), mine);
|
|
308
|
+
docs.set(String(stray._id), stray);
|
|
309
|
+
confs.get('c1').entityDefaults = [String(mine._id)];
|
|
310
|
+
maps.get('m1').entities.push({ entityType: 'resource', objectLayerItemIds: ['wood-1'] });
|
|
311
|
+
|
|
312
|
+
const result = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
313
|
+
expect(result.conflicts).toEqual([String(stray._id)]);
|
|
314
|
+
expect(result.linked).not.toContain(String(stray._id));
|
|
315
|
+
expect(result.entityDefaults).toContain(String(mine._id));
|
|
316
|
+
expect(result.entityDefaults).not.toContain(String(stray._id));
|
|
317
|
+
// The instance's own reference is untouched, so nothing the stray declares reaches the map.
|
|
318
|
+
const wood = maps.get('m1').entities.find((entity) => entity.entityType === 'resource');
|
|
319
|
+
expect(wood.objectLayerItemIds).toEqual(['wood-1']);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('links a lookalike no reference answers for, and only one of them', async () => {
|
|
323
|
+
// The guard is about a build already answered, not about the item ids: with nothing
|
|
324
|
+
// referenced for it, the first match is adopted the way every other match is. The second
|
|
325
|
+
// would only add an ambiguity this sync invented, so it is reported instead.
|
|
326
|
+
const first = { _id: new Types.ObjectId(), entityType: 'resource', liveItemIds: ['wood-1'] };
|
|
327
|
+
const second = { _id: new Types.ObjectId(), entityType: 'resource', liveItemIds: ['wood-1'] };
|
|
328
|
+
docs.set(String(first._id), first);
|
|
329
|
+
docs.set(String(second._id), second);
|
|
330
|
+
maps.get('m1').entities.push({ entityType: 'resource', objectLayerItemIds: ['wood-1'] });
|
|
331
|
+
|
|
332
|
+
const result = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
333
|
+
expect(result.linked).toContain(String(first._id));
|
|
334
|
+
expect(result.conflicts).toEqual([String(second._id)]);
|
|
335
|
+
// The conf never ends up carrying two answers for one build unless an operator put them there.
|
|
336
|
+
expect(result.duplicates).toEqual([]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('withdraws no reference an operator made, however redundant', async () => {
|
|
340
|
+
// Two references for the same build inside one conf is an authoring mistake worth reporting,
|
|
341
|
+
// and still not something to resolve by guessing which the operator meant.
|
|
342
|
+
const a = { _id: new Types.ObjectId(), entityType: 'resource', liveItemIds: ['wood-1'] };
|
|
343
|
+
const b = { _id: new Types.ObjectId(), entityType: 'resource', liveItemIds: ['wood-1'] };
|
|
344
|
+
docs.set(String(a._id), a);
|
|
345
|
+
docs.set(String(b._id), b);
|
|
346
|
+
confs.get('c1').entityDefaults = [String(a._id), String(b._id)];
|
|
347
|
+
|
|
348
|
+
const result = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
349
|
+
expect(result.duplicates).toEqual([String(b._id)]);
|
|
350
|
+
expect(result.entityDefaults).toEqual(expect.arrayContaining([String(a._id), String(b._id)]));
|
|
351
|
+
expect(confs.get('c1').entityDefaults).toEqual(expect.arrayContaining([String(a._id), String(b._id)]));
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('is idempotent — a second run links nothing new', async () => {
|
|
355
|
+
await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
356
|
+
const again = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
357
|
+
expect(again.linked).toEqual([]);
|
|
358
|
+
expect(again.dropped).toEqual([]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('syncs against the map codes it is given, not only the stored ones', async () => {
|
|
362
|
+
// m2 places nothing any default matches, so an explicit selection links nothing.
|
|
363
|
+
const result = await CyberiaEntityTypeDefaultService.syncInstance(
|
|
364
|
+
{ instanceCode: 'forest', mapCodes: ['m2'] },
|
|
365
|
+
options,
|
|
366
|
+
);
|
|
367
|
+
expect(result.mapCodes).toEqual(['m2']);
|
|
368
|
+
expect(result.linked).toEqual([]);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('drops a reference whose document is gone while it syncs', async () => {
|
|
372
|
+
const orphan = String(new Types.ObjectId());
|
|
373
|
+
confs.get('c1').entityDefaults = [String(PLAYER._id), orphan];
|
|
374
|
+
const result = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
375
|
+
expect(result.dropped).toEqual([orphan]);
|
|
376
|
+
expect(result.entityDefaults).not.toContain(orphan);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('runs the skills its entities trigger, and reports them without storing them', async () => {
|
|
380
|
+
const result = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
381
|
+
// atlas_pistol_mk2 is carried by the armed bot this sync linked, and coin by the player
|
|
382
|
+
// default's inventory. hatchet is triggered by nothing this world names.
|
|
383
|
+
expect(result.skills).toEqual(['atlas_pistol_mk2', 'coin']);
|
|
384
|
+
// Nothing is written: the conf owns references, not a second copy of the skill list.
|
|
385
|
+
expect(confs.get('c1')).not.toHaveProperty('skillConfig');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('runs a skill only a quest objective names', async () => {
|
|
389
|
+
// The reported case. No entity in this world wears a hatchet and no default wires one, so a
|
|
390
|
+
// rule reading entity loadouts alone called the skill unreachable — while the export shipped
|
|
391
|
+
// it and the simulation ran it, because a player can be handed one to satisfy the quest.
|
|
392
|
+
quests = [
|
|
393
|
+
{ sourceMapCode: 'm1', steps: [{ objectives: [{ itemId: 'hatchet' }] }], rewards: [] },
|
|
394
|
+
];
|
|
395
|
+
const result = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
396
|
+
expect(result.skills).toContain('hatchet');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('runs a skill only a vendor or an assembler names', async () => {
|
|
400
|
+
actions = [
|
|
401
|
+
{ sourceMapCode: 'm1', shopItems: [{ itemId: 'hatchet', priceItemId: 'coin' }], craftRecipes: [] },
|
|
402
|
+
];
|
|
403
|
+
expect(
|
|
404
|
+
(await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options)).skills,
|
|
405
|
+
).toContain('hatchet');
|
|
406
|
+
|
|
407
|
+
actions = [
|
|
408
|
+
{
|
|
409
|
+
sourceMapCode: 'm1',
|
|
410
|
+
shopItems: [],
|
|
411
|
+
craftRecipes: [{ ingredients: [{ itemId: 'wood-1' }], outputItems: [{ itemId: 'hatchet' }] }],
|
|
412
|
+
},
|
|
413
|
+
];
|
|
414
|
+
expect(
|
|
415
|
+
(await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options)).skills,
|
|
416
|
+
).toContain('hatchet');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('leaves out a skill whose trigger nothing in the world names', async () => {
|
|
420
|
+
const result = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
421
|
+
expect(result.skills).not.toContain('hatchet');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("ignores content bound to another instance's maps", async () => {
|
|
425
|
+
quests = [{ sourceMapCode: 'elsewhere', steps: [{ objectives: [{ itemId: 'hatchet' }] }], rewards: [] }];
|
|
426
|
+
const result = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
427
|
+
expect(result.skills).not.toContain('hatchet');
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('keeps a skill whose trigger the canonical defaults supply', async () => {
|
|
431
|
+
// The instance references no player default of its own, so anon/atlas_pistol_mk2 reach it
|
|
432
|
+
// through the canonical set — leaving their skills out would disarm the player.
|
|
433
|
+
confs.get('c1').entityDefaults = [];
|
|
434
|
+
const result = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
435
|
+
expect(result.skills).toContain('atlas_pistol_mk2');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('reports the same skills on a second run', async () => {
|
|
439
|
+
const first = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
440
|
+
const again = await CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'forest' }, options);
|
|
441
|
+
expect(again.skills).toEqual(first.skills);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('refuses an instance with no conf', async () => {
|
|
445
|
+
await expect(CyberiaEntityTypeDefaultService.syncInstance({ instanceCode: 'absent' }, options)).rejects.toThrow(
|
|
446
|
+
'cyberia-instance-conf not found',
|
|
447
|
+
);
|
|
448
|
+
await expect(CyberiaEntityTypeDefaultService.syncInstance({}, options)).rejects.toThrow('instanceCode is required');
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
describe('one default answers for one set of entities', () => {
|
|
453
|
+
const doc = (id, entityType, liveItemIds) => ({ _id: id, entityType, liveItemIds });
|
|
454
|
+
|
|
455
|
+
it('names the second reference that answers for a build, and keeps both', () => {
|
|
456
|
+
// Which of the two applies depends on where the conf lists them, and that is not a decision
|
|
457
|
+
// anyone made — so it is reported. Choosing one by timestamp would throw away a reference an
|
|
458
|
+
// operator deliberately made, which is the more expensive mistake.
|
|
459
|
+
const defaults = [
|
|
460
|
+
doc('mine', 'resource', ['wood-1']),
|
|
461
|
+
doc('theirs', 'resource', ['wood-1']),
|
|
462
|
+
doc('wood-2', 'resource', ['wood-2']),
|
|
463
|
+
];
|
|
464
|
+
expect(findDuplicateBuilds(['mine', 'theirs', 'wood-2'], defaults)).toEqual(['theirs']);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('leaves a genuinely more specific default alone', () => {
|
|
468
|
+
// An armed bot requires a larger set than a bare one: different keys, both meaningful.
|
|
469
|
+
const defaults = [
|
|
470
|
+
doc('bare', 'bot', ['kishins']),
|
|
471
|
+
doc('armed', 'bot', ['kishins', 'atlas_pistol_mk2']),
|
|
472
|
+
];
|
|
473
|
+
expect(findDuplicateBuilds(['bare', 'armed'], defaults)).toEqual([]);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('reads the same set in any order, and says nothing about a reference whose document is gone', () => {
|
|
477
|
+
const defaults = [
|
|
478
|
+
doc('a', 'bot', ['purple', 'atlas_pistol_mk2']),
|
|
479
|
+
doc('b', 'bot', ['atlas_pistol_mk2', 'purple']),
|
|
480
|
+
];
|
|
481
|
+
expect(findDuplicateBuilds(['a', 'b'], defaults)).toEqual(['b']);
|
|
482
|
+
// A reference the collection no longer has is the `dropped` path's business, not this one.
|
|
483
|
+
expect(findDuplicateBuilds(['ghost'], defaults)).toEqual([]);
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
describe('placed entities carry what their default wears', () => {
|
|
488
|
+
const defaults = [
|
|
489
|
+
{
|
|
490
|
+
entityType: 'resource',
|
|
491
|
+
liveItemIds: ['wood-1'],
|
|
492
|
+
deadItemIds: ['wood-extracted-1'],
|
|
493
|
+
dropItemIds: ['wood-drop-1'],
|
|
494
|
+
inventoryItemsIds: ['tim-knife'],
|
|
495
|
+
overrideItemsIdsState: [
|
|
496
|
+
{ itemId: 'wood-drop-1', active: false, quantity: 2, dropChance: 1 },
|
|
497
|
+
{ itemId: 'tim-knife', active: true, quantity: 1, dropChance: 1 },
|
|
498
|
+
],
|
|
499
|
+
},
|
|
500
|
+
{ entityType: 'resource', liveItemIds: ['wood-2'], deadItemIds: ['wood-extracted-2'], dropItemIds: ['wood-drop-2'] },
|
|
501
|
+
];
|
|
502
|
+
const itemTypes = { 'wood-1': 'resource', 'wood-2': 'resource', 'tim-knife': 'weapon' };
|
|
503
|
+
const entity = (objectLayerItemIds) => ({ entityType: 'resource', objectLayerItemIds });
|
|
504
|
+
|
|
505
|
+
it('adds what an override activates to the entity that resolves to it', () => {
|
|
506
|
+
const { entities, changed } = applyDefaultsToEntities([entity(['wood-1'])], defaults, itemTypes);
|
|
507
|
+
expect(entities[0].objectLayerItemIds).toEqual(['wood-1', 'tim-knife']);
|
|
508
|
+
expect(changed).toBe(1);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('leaves an entity whose own default carries nothing extra', () => {
|
|
512
|
+
const { entities, changed } = applyDefaultsToEntities([entity(['wood-2'])], defaults, itemTypes);
|
|
513
|
+
expect(entities[0].objectLayerItemIds).toEqual(['wood-2']);
|
|
514
|
+
expect(changed).toBe(0);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('replaces what a matched entity carries, keeping nothing else', () => {
|
|
518
|
+
// The map holds the projection of a default, not a scrapbook: an id no default names cannot be
|
|
519
|
+
// told apart from one an earlier sync wrote, so a matched entity is stated whole.
|
|
520
|
+
const { entities } = applyDefaultsToEntities([entity(['wood-1', 'banner'])], defaults, itemTypes);
|
|
521
|
+
expect(entities[0].objectLayerItemIds).toEqual(['wood-1', 'tim-knife']);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('takes an item back off the world when the default stops naming it', () => {
|
|
525
|
+
const once = applyDefaultsToEntities([entity(['wood-1'])], defaults, itemTypes);
|
|
526
|
+
expect(once.entities[0].objectLayerItemIds).toEqual(['wood-1', 'tim-knife']);
|
|
527
|
+
expect(applyDefaultsToEntities(once.entities, defaults, itemTypes).changed).toBe(0);
|
|
528
|
+
|
|
529
|
+
// The override withdrawn: still carried by the default, no longer worn.
|
|
530
|
+
const unworn = defaults.map((entry, index) =>
|
|
531
|
+
index === 0 ? { ...entry, overrideItemsIdsState: [{ itemId: 'tim-knife', active: false }] } : entry,
|
|
532
|
+
);
|
|
533
|
+
expect(applyDefaultsToEntities(once.entities, unworn, itemTypes).entities[0].objectLayerItemIds).toEqual([
|
|
534
|
+
'wood-1',
|
|
535
|
+
]);
|
|
536
|
+
|
|
537
|
+
// The item removed from the default outright — the case a merge could never undo.
|
|
538
|
+
const gone = defaults.map((entry, index) =>
|
|
539
|
+
index === 0 ? { ...entry, inventoryItemsIds: [], overrideItemsIdsState: [] } : entry,
|
|
540
|
+
);
|
|
541
|
+
const cleaned = applyDefaultsToEntities(once.entities, gone, itemTypes);
|
|
542
|
+
expect(cleaned.entities[0].objectLayerItemIds).toEqual(['wood-1']);
|
|
543
|
+
expect(cleaned.changed).toBe(1);
|
|
544
|
+
expect(applyDefaultsToEntities(cleaned.entities, gone, itemTypes).changed).toBe(0);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('leaves an entity that carries no default’s live set exactly as placed', () => {
|
|
548
|
+
// resolveEntityDefaultBuild answers with the first of the type when nothing contains, which
|
|
549
|
+
// describes the type rather than this entity — not something to rewrite an author's work with.
|
|
550
|
+
const { entities, changed } = applyDefaultsToEntities([entity(['wood-9'])], defaults, itemTypes);
|
|
551
|
+
expect(entities[0].objectLayerItemIds).toEqual(['wood-9']);
|
|
552
|
+
expect(changed).toBe(0);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('resolves the most specific default, the way the simulation does at spawn', () => {
|
|
556
|
+
const armed = [
|
|
557
|
+
{ entityType: 'bot', liveItemIds: ['purple'], inventoryItemsIds: ['coin'] },
|
|
558
|
+
{
|
|
559
|
+
entityType: 'bot',
|
|
560
|
+
liveItemIds: ['purple', 'atlas_pistol_mk2'],
|
|
561
|
+
inventoryItemsIds: ['hatchet'],
|
|
562
|
+
overrideItemsIdsState: [{ itemId: 'hatchet', active: true }],
|
|
563
|
+
},
|
|
564
|
+
];
|
|
565
|
+
const { entities } = applyDefaultsToEntities(
|
|
566
|
+
[{ entityType: 'bot', objectLayerItemIds: ['purple', 'atlas_pistol_mk2'] }],
|
|
567
|
+
armed,
|
|
568
|
+
{ purple: 'skin', atlas_pistol_mk2: 'weapon', hatchet: 'weapon' },
|
|
569
|
+
);
|
|
570
|
+
// The larger live set wins, and its hatchet joins. The pistol stays because it is half of the
|
|
571
|
+
// key this entity resolves by — which of the two weapons is worn is settled at spawn.
|
|
572
|
+
expect(entities[0].objectLayerItemIds).toEqual(['purple', 'atlas_pistol_mk2', 'hatchet']);
|
|
573
|
+
// And it holds still: a second pass resolves the same default and writes the same set.
|
|
574
|
+
expect(applyDefaultsToEntities(entities, armed, { purple: 'skin', atlas_pistol_mk2: 'weapon', hatchet: 'weapon' }).changed).toBe(0);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('leaves an entity type no default covers alone', () => {
|
|
578
|
+
const { entities, changed } = applyDefaultsToEntities(
|
|
579
|
+
[{ entityType: 'portal', objectLayerItemIds: ['portal-gate'] }],
|
|
580
|
+
defaults,
|
|
581
|
+
itemTypes,
|
|
582
|
+
);
|
|
583
|
+
expect(entities[0].objectLayerItemIds).toEqual(['portal-gate']);
|
|
584
|
+
expect(changed).toBe(0);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe('the canonical inventory contract', () => {
|
|
589
|
+
it('carries the union of every list an entity default names, once each', () => {
|
|
590
|
+
const inventory = resolveEntityInventory({
|
|
591
|
+
liveItemIds: ['skin', 'weapon'],
|
|
592
|
+
deadItemIds: ['ghost', 'skin'],
|
|
593
|
+
dropItemIds: ['loot'],
|
|
594
|
+
inventoryItemsIds: ['coin', 'weapon'],
|
|
595
|
+
});
|
|
596
|
+
// One slot per id, in list order: live, then dead, then drop, then the inventory-only extras.
|
|
597
|
+
expect(inventory.map((row) => row.itemId)).toEqual(['skin', 'weapon', 'ghost', 'loot', 'coin']);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('derives activation from the discriminator, never from a stored flag', () => {
|
|
601
|
+
const inventory = resolveEntityInventory({
|
|
602
|
+
liveItemIds: ['skin'],
|
|
603
|
+
deadItemIds: ['ghost'],
|
|
604
|
+
dropItemIds: ['loot'],
|
|
605
|
+
inventoryItemsIds: ['coin'],
|
|
606
|
+
});
|
|
607
|
+
// Spawn state is alive, so the live ids are the worn ones. Inventory-only ids are stock the
|
|
608
|
+
// entity holds, so they start as one unit a player can bank. Dead and drop ids are lifecycle
|
|
609
|
+
// slots carried empty, which is what lets the runtime activate one instead of appending it.
|
|
610
|
+
expect(inventory).toEqual([
|
|
611
|
+
{ itemId: 'skin', active: true, quantity: 1, dropChance: 1 },
|
|
612
|
+
{ itemId: 'ghost', active: false, quantity: 0, dropChance: 1 },
|
|
613
|
+
{ itemId: 'loot', active: false, quantity: 0, dropChance: 1 },
|
|
614
|
+
{ itemId: 'coin', active: false, quantity: 1, dropChance: 1 },
|
|
615
|
+
]);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('gives every canonical default an inventory that covers its own lifecycle ids', () => {
|
|
619
|
+
for (const entityDefault of ENTITY_TYPE_DEFAULTS) {
|
|
620
|
+
const carried = new Set(resolveEntityInventory(entityDefault).map((row) => row.itemId));
|
|
621
|
+
for (const id of [
|
|
622
|
+
...(entityDefault.liveItemIds || []),
|
|
623
|
+
...(entityDefault.deadItemIds || []),
|
|
624
|
+
...(entityDefault.dropItemIds || []),
|
|
625
|
+
...(entityDefault.inventoryItemsIds || []),
|
|
626
|
+
]) {
|
|
627
|
+
expect(carried.has(id), `${entityDefault.entityType} must carry ${id}`).toBe(true);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('forces the spawn state of a carried id', () => {
|
|
633
|
+
const inventory = resolveEntityInventory({
|
|
634
|
+
liveItemIds: ['skin'],
|
|
635
|
+
deadItemIds: ['ghost'],
|
|
636
|
+
overrideItemsIdsState: [{ itemId: 'ghost', active: true }],
|
|
637
|
+
});
|
|
638
|
+
// The equipment rules would leave a dead id inactive; the override says otherwise, and the
|
|
639
|
+
// quantity follows the state it lands in.
|
|
640
|
+
expect(inventory).toEqual([
|
|
641
|
+
{ itemId: 'skin', active: true, quantity: 1, dropChance: 1 },
|
|
642
|
+
{ itemId: 'ghost', active: true, quantity: 1, dropChance: 1 },
|
|
643
|
+
]);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('sizes a drop stack without activating it', () => {
|
|
647
|
+
const [, drop] = resolveEntityInventory({
|
|
648
|
+
liveItemIds: ['wood'],
|
|
649
|
+
dropItemIds: ['wood-drop'],
|
|
650
|
+
overrideItemsIdsState: [{ itemId: 'wood-drop', quantity: 5 }],
|
|
651
|
+
});
|
|
652
|
+
// The stack the server scatters, still carried inactive by the resource itself.
|
|
653
|
+
expect(drop).toEqual({ itemId: 'wood-drop', active: false, quantity: 5, dropChance: 1 });
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('states how often every row drops, and defaults to always', () => {
|
|
657
|
+
// One field on every row, so the simulation never reads a missing one. A world that authored
|
|
658
|
+
// nothing keeps scattering everything, which is what every world did before this existed.
|
|
659
|
+
const inventory = resolveEntityInventory({
|
|
660
|
+
liveItemIds: ['skin'],
|
|
661
|
+
dropItemIds: ['common', 'rare'],
|
|
662
|
+
overrideItemsIdsState: [{ itemId: 'rare', dropChance: 0.15 }],
|
|
663
|
+
});
|
|
664
|
+
expect(inventory.map(({ itemId, dropChance }) => [itemId, dropChance])).toEqual([
|
|
665
|
+
['skin', 1],
|
|
666
|
+
['common', 1],
|
|
667
|
+
['rare', 0.15],
|
|
668
|
+
]);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('keeps a drop chance of zero, which is the whole point of authoring one', () => {
|
|
672
|
+
const [drop] = resolveEntityInventory({
|
|
673
|
+
dropItemIds: ['never'],
|
|
674
|
+
overrideItemsIdsState: [{ itemId: 'never', dropChance: 0 }],
|
|
675
|
+
});
|
|
676
|
+
expect(drop.dropChance).toBe(0);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('honours a drop chance only for an id the drop list carries', () => {
|
|
680
|
+
// Drop chance answers "how often does this scatter on death". An id that never scatters has
|
|
681
|
+
// no such question to answer, so an override naming one is inert rather than misleading.
|
|
682
|
+
const inventory = resolveEntityInventory({
|
|
683
|
+
liveItemIds: ['skin'],
|
|
684
|
+
inventoryItemsIds: ['knife'],
|
|
685
|
+
dropItemIds: ['loot'],
|
|
686
|
+
overrideItemsIdsState: [
|
|
687
|
+
{ itemId: 'skin', dropChance: 0.1 },
|
|
688
|
+
{ itemId: 'knife', dropChance: 0.2 },
|
|
689
|
+
{ itemId: 'loot', dropChance: 0.3 },
|
|
690
|
+
],
|
|
691
|
+
});
|
|
692
|
+
expect(inventory.map(({ itemId, dropChance }) => [itemId, dropChance])).toEqual([
|
|
693
|
+
['skin', 1],
|
|
694
|
+
['loot', 0.3],
|
|
695
|
+
['knife', 1],
|
|
696
|
+
]);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('clamps an out-of-range drop chance instead of passing it on', () => {
|
|
700
|
+
const rows = resolveEntityInventory({
|
|
701
|
+
dropItemIds: ['low', 'high'],
|
|
702
|
+
overrideItemsIdsState: [
|
|
703
|
+
{ itemId: 'low', dropChance: -2 },
|
|
704
|
+
{ itemId: 'high', dropChance: 4 },
|
|
705
|
+
],
|
|
706
|
+
});
|
|
707
|
+
expect(rows.map((row) => row.dropChance)).toEqual([0, 1]);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('ignores an override for an id no list carries', () => {
|
|
711
|
+
// Membership is the union's to decide; an override only adjusts a member, so a rule naming an
|
|
712
|
+
// id no list carries does nothing at all.
|
|
713
|
+
expect(
|
|
714
|
+
resolveEntityInventory({ liveItemIds: ['skin'], overrideItemsIdsState: [{ itemId: 'absent', quantity: 9 }] }),
|
|
715
|
+
).toEqual([{ itemId: 'skin', active: true, quantity: 1, dropChance: 1 }]);
|
|
716
|
+
expect(
|
|
717
|
+
resolveEntityInventory(
|
|
718
|
+
{ liveItemIds: ['skin'], overrideItemsIdsState: [{ itemId: 'absent', active: true }] },
|
|
719
|
+
{ itemTypes: { skin: 'skin', absent: 'skin' } },
|
|
720
|
+
),
|
|
721
|
+
).toEqual([{ itemId: 'skin', active: true, quantity: 1, dropChance: 1 }]);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it('puts an overridden carried item on a live entity, whatever its type', () => {
|
|
725
|
+
// A resource whose default carries a knife: the knife is inventory, the override activates it,
|
|
726
|
+
// and a skin and a weapon are different slots, so the equipment rules allow the combination.
|
|
727
|
+
expect(
|
|
728
|
+
resolveEntityInventory(
|
|
729
|
+
{
|
|
730
|
+
entityType: 'resource',
|
|
731
|
+
liveItemIds: ['wood-1'],
|
|
732
|
+
deadItemIds: ['wood-extracted-1'],
|
|
733
|
+
dropItemIds: ['wood-drop-1'],
|
|
734
|
+
inventoryItemsIds: ['tim-knife'],
|
|
735
|
+
overrideItemsIdsState: [
|
|
736
|
+
{ itemId: 'wood-drop-1', active: false, quantity: 2, dropChance: 1 },
|
|
737
|
+
{ itemId: 'tim-knife', active: true, quantity: 1, dropChance: 1 },
|
|
738
|
+
],
|
|
739
|
+
},
|
|
740
|
+
{ itemTypes: { 'wood-1': 'skin', 'wood-extracted-1': 'skin', 'wood-drop-1': 'weapon', 'tim-knife': 'weapon' } },
|
|
741
|
+
),
|
|
742
|
+
).toEqual([
|
|
743
|
+
{ itemId: 'wood-1', active: true, quantity: 1, dropChance: 1 },
|
|
744
|
+
{ itemId: 'wood-extracted-1', active: false, quantity: 0, dropChance: 1 },
|
|
745
|
+
{ itemId: 'wood-drop-1', active: false, quantity: 2, dropChance: 1 },
|
|
746
|
+
{ itemId: 'tim-knife', active: true, quantity: 1, dropChance: 1 },
|
|
747
|
+
]);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('gives a contested slot to the item the override names', () => {
|
|
751
|
+
const itemTypes = { anon: 'skin', punk: 'skin', pistol: 'weapon' };
|
|
752
|
+
// One skin at a time: activating a carried skin is a decision about which skin is worn.
|
|
753
|
+
expect(
|
|
754
|
+
resolveEntityInventory(
|
|
755
|
+
{ liveItemIds: ['anon', 'pistol'], inventoryItemsIds: ['punk'], overrideItemsIdsState: [{ itemId: 'punk', active: true }] },
|
|
756
|
+
{ itemTypes },
|
|
757
|
+
),
|
|
758
|
+
).toEqual([
|
|
759
|
+
// Taken off, not taken away: the skin stays in the inventory with its stack intact, which is
|
|
760
|
+
// what lets the player bank it or put it back on.
|
|
761
|
+
{ itemId: 'anon', active: false, quantity: 1, dropChance: 1 },
|
|
762
|
+
{ itemId: 'pistol', active: true, quantity: 1, dropChance: 1 },
|
|
763
|
+
{ itemId: 'punk', active: true, quantity: 1, dropChance: 1 },
|
|
764
|
+
]);
|
|
765
|
+
// With no types to read, every row keeps the state its list derives.
|
|
766
|
+
expect(
|
|
767
|
+
resolveEntityInventory({
|
|
768
|
+
liveItemIds: ['anon'],
|
|
769
|
+
inventoryItemsIds: ['punk'],
|
|
770
|
+
overrideItemsIdsState: [{ itemId: 'punk', active: true }],
|
|
771
|
+
}).map((row) => row.active),
|
|
772
|
+
).toEqual([true, true]);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it('keeps a deactivated skin storable, and an unworn slot empty', () => {
|
|
776
|
+
// The reported player default: an override wears kaneki, so anon comes off — still carried,
|
|
777
|
+
// quantity intact. A dead id nobody wears is a seeded empty slot, and stays one.
|
|
778
|
+
expect(
|
|
779
|
+
resolveEntityInventory(
|
|
780
|
+
{
|
|
781
|
+
entityType: 'player',
|
|
782
|
+
liveItemIds: ['anon', 'atlas_pistol_mk2'],
|
|
783
|
+
deadItemIds: ['fragmentation'],
|
|
784
|
+
inventoryItemsIds: ['kaneki'],
|
|
785
|
+
overrideItemsIdsState: [{ itemId: 'kaneki', active: true, quantity: 1, dropChance: 1 }],
|
|
786
|
+
},
|
|
787
|
+
{ itemTypes: { anon: 'skin', kaneki: 'skin', atlas_pistol_mk2: 'weapon', fragmentation: 'skin' } },
|
|
788
|
+
),
|
|
789
|
+
).toEqual([
|
|
790
|
+
{ itemId: 'anon', active: false, quantity: 1, dropChance: 1 },
|
|
791
|
+
{ itemId: 'atlas_pistol_mk2', active: true, quantity: 1, dropChance: 1 },
|
|
792
|
+
{ itemId: 'fragmentation', active: false, quantity: 0, dropChance: 1 },
|
|
793
|
+
{ itemId: 'kaneki', active: true, quantity: 1, dropChance: 1 },
|
|
794
|
+
]);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('leaves a type the rules do not govern alone', () => {
|
|
798
|
+
// Two resource visuals are not equipment; nothing contests a slot neither of them claims.
|
|
799
|
+
expect(
|
|
800
|
+
resolveEntityInventory(
|
|
801
|
+
{ liveItemIds: ['wood-1', 'wood-2'] },
|
|
802
|
+
{ itemTypes: { 'wood-1': 'resource', 'wood-2': 'resource' } },
|
|
803
|
+
).map((row) => row.active),
|
|
804
|
+
).toEqual([true, true]);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('leaves the derived state alone where an override says nothing', () => {
|
|
808
|
+
const inventory = resolveEntityInventory({
|
|
809
|
+
liveItemIds: ['skin'],
|
|
810
|
+
inventoryItemsIds: ['coin'],
|
|
811
|
+
overrideItemsIdsState: [{ itemId: 'coin', quantity: 250 }],
|
|
812
|
+
});
|
|
813
|
+
// Quantity only: coin stays inactive, as its list derives.
|
|
814
|
+
expect(inventory.at(-1)).toEqual({ itemId: 'coin', active: false, quantity: 250, dropChance: 1 });
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it('resolves nothing for a default that names no ids', () => {
|
|
818
|
+
expect(resolveEntityInventory({})).toEqual([]);
|
|
819
|
+
expect(resolveEntityInventory({ liveItemIds: [], deadItemIds: [] })).toEqual([]);
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
describe('CyberiaInstanceConf.entityDefaults', () => {
|
|
824
|
+
it('stores references and refuses an embedded document', () => {
|
|
825
|
+
const id = new Types.ObjectId();
|
|
826
|
+
const conf = new CyberiaInstanceConfModel({ instanceCode: 'forest', entityDefaults: [String(id)] });
|
|
827
|
+
expect(conf.validateSync()).toBe(undefined);
|
|
828
|
+
expect(conf.entityDefaults.map(String)).toEqual([String(id)]);
|
|
829
|
+
|
|
830
|
+
// The shape that caused the duplication cannot be stored any more.
|
|
831
|
+
const embedded = new CyberiaInstanceConfModel({
|
|
832
|
+
instanceCode: 'forest',
|
|
833
|
+
entityDefaults: [{ entityType: 'bot', liveItemIds: ['purple'] }],
|
|
834
|
+
});
|
|
835
|
+
expect(embedded.validateSync()?.errors).toHaveProperty('entityDefaults.0');
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('defaults to referencing nothing, which is a complete world', () => {
|
|
839
|
+
expect(CYBERIA_INSTANCE_CONF_DEFAULTS.entityDefaults).toEqual([]);
|
|
840
|
+
expect(new CyberiaInstanceConfModel({ instanceCode: 'fresh' }).entityDefaults).toEqual([]);
|
|
841
|
+
});
|
|
842
|
+
});
|