@forgeax/engine-scene 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +50 -0
  2. package/dist/__tests__/keyed-scene.integration.test.d.ts +2 -0
  3. package/dist/__tests__/keyed-scene.integration.test.d.ts.map +1 -0
  4. package/dist/assets/scene-decoder.d.ts.map +1 -1
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.mjs +848 -229
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/instances/binding.d.ts +7 -5
  10. package/dist/instances/binding.d.ts.map +1 -1
  11. package/dist/instances/externalization.d.ts +1 -1
  12. package/dist/instances/externalization.d.ts.map +1 -1
  13. package/dist/instances/keyed.d.ts +38 -0
  14. package/dist/instances/keyed.d.ts.map +1 -0
  15. package/dist/instances/legacy.d.ts +20 -0
  16. package/dist/instances/legacy.d.ts.map +1 -0
  17. package/dist/instances/runtime-types.d.ts +20 -0
  18. package/dist/instances/runtime-types.d.ts.map +1 -0
  19. package/dist/instances/scene-instances.d.ts +27 -37
  20. package/dist/instances/scene-instances.d.ts.map +1 -1
  21. package/dist/instances/state.d.ts +6 -1
  22. package/dist/instances/state.d.ts.map +1 -1
  23. package/package.json +5 -5
  24. package/src/__tests__/asset-owner.integration.test.ts +4 -7
  25. package/src/__tests__/flat-propagation.perf.test.ts +18 -17
  26. package/src/__tests__/keyed-scene.integration.test.ts +269 -0
  27. package/src/__tests__/scene-binding.integration.test.ts +141 -32
  28. package/src/__tests__/structural.test.ts +4 -1
  29. package/src/assets/scene-decoder.ts +123 -33
  30. package/src/collect-subtree.ts +2 -2
  31. package/src/index.ts +10 -1
  32. package/src/instances/binding.ts +26 -19
  33. package/src/instances/externalization.ts +115 -97
  34. package/src/instances/keyed.ts +505 -0
  35. package/src/instances/legacy.ts +108 -0
  36. package/src/instances/runtime-types.ts +21 -0
  37. package/src/instances/scene-instances.ts +220 -83
  38. package/src/instances/state.ts +6 -1
@@ -8,8 +8,9 @@ import {
8
8
  type Result,
9
9
  type SceneAsset,
10
10
  type SceneEntity,
11
- type SceneInstanceMount,
11
+ type SceneInstanceOverride,
12
12
  } from '@forgeax/engine-types';
13
+ import { normalizeLegacySceneAsset } from '../instances/legacy.js';
13
14
 
14
15
  export const sceneAssetKind: AssetKind<SceneAsset, 'scene'> = {
15
16
  kind: 'scene',
@@ -18,7 +19,7 @@ export const sceneAssetKind: AssetKind<SceneAsset, 'scene'> = {
18
19
  function invalidScene(guid: string, reason: string): Result<SceneAsset, AssetLoadError> {
19
20
  return err({
20
21
  code: 'asset-package-invalid',
21
- expected: 'a scene payload with an entities array',
22
+ expected: 'a scene payload with keyed entities',
22
23
  hint: 'recook the SceneAsset and publish its complete envelope',
23
24
  detail: { guid, reason },
24
25
  });
@@ -54,24 +55,16 @@ function resolveWireRef(
54
55
  return { ok: true, value: guid };
55
56
  }
56
57
 
57
- function resolveMounts(
58
- mounts: readonly SceneInstanceMount[] | undefined,
58
+ function resolveInstanceSource(
59
+ source: unknown,
59
60
  refs: readonly string[],
60
- ):
61
- | { readonly ok: true; readonly value: readonly SceneInstanceMount[] | undefined }
62
- | { readonly ok: false; readonly reason: string } {
63
- if (mounts === undefined) return { ok: true, value: undefined };
64
- const resolved: SceneInstanceMount[] = [];
65
- for (const mount of mounts) {
66
- if (typeof mount.source !== 'number' || !Number.isInteger(mount.source)) {
67
- resolved.push(mount);
68
- continue;
69
- }
70
- const ref = resolveWireRef(refs, mount.source, `mount ${mount.localId} source`);
71
- if (!ref.ok) return ref;
72
- resolved.push({ ...mount, source: ref.value });
61
+ location: string,
62
+ ): { readonly ok: true; readonly value: string } | { readonly ok: false; readonly reason: string } {
63
+ if (typeof source === 'string' && source.length > 0) return { ok: true, value: source };
64
+ if (typeof source !== 'number' || !Number.isInteger(source)) {
65
+ return { ok: false, reason: `${location} must be a GUID or refs index` };
73
66
  }
74
- return { ok: true, value: resolved };
67
+ return resolveWireRef(refs, source, location);
75
68
  }
76
69
 
77
70
  function resolveSkinGuids(
@@ -98,11 +91,46 @@ function resolveSkinGuids(
98
91
  return { ok: true, value: resolved };
99
92
  }
100
93
 
101
- function resolveSceneWireRefs(payload: SceneAsset, refs: readonly string[]): SceneWireRefResult {
102
- const entities: SceneEntity[] = [];
103
- for (const entity of payload.entities) {
94
+ function resolveSceneWireRefs(
95
+ payload: { readonly entities: unknown; readonly skinGuids?: unknown },
96
+ refs: readonly string[],
97
+ ): SceneWireRefResult {
98
+ const normalized = normalizeLegacySceneAsset({ kind: 'scene', entities: payload.entities });
99
+ const rawEntities = normalized.entities;
100
+ if (rawEntities === null || typeof rawEntities !== 'object' || Array.isArray(rawEntities)) {
101
+ return { ok: false, reason: 'entities must be a keyed object' };
102
+ }
103
+ const entities: Record<string, SceneEntity> = {};
104
+ for (const [key, rawEntity] of Object.entries(rawEntities as Record<string, unknown>)) {
105
+ const entity = rawEntity as
106
+ | {
107
+ readonly components?: unknown;
108
+ readonly instance?: {
109
+ readonly source?: unknown;
110
+ readonly overrides?: unknown;
111
+ };
112
+ }
113
+ | undefined;
114
+ if (key.length === 0 || entity === undefined || typeof entity !== 'object') {
115
+ return { ok: false, reason: `entities[${JSON.stringify(key)}] is malformed` };
116
+ }
117
+ if (
118
+ entity.components === null ||
119
+ typeof entity.components !== 'object' ||
120
+ Array.isArray(entity.components)
121
+ ) {
122
+ return { ok: false, reason: `entities.${key}.components must be an object` };
123
+ }
104
124
  const components: Record<string, Record<string, unknown>> = {};
105
- for (const [componentName, rawFields] of Object.entries(entity.components)) {
125
+ for (const [componentName, rawFields] of Object.entries(
126
+ entity.components as Record<string, unknown>,
127
+ )) {
128
+ if (rawFields === null || typeof rawFields !== 'object' || Array.isArray(rawFields)) {
129
+ return {
130
+ ok: false,
131
+ reason: `entities.${key}.components.${componentName} must be an object`,
132
+ };
133
+ }
106
134
  // Component schema lookup is World-local after the ECS core reduction.
107
135
  // The runtime projection owns the World-local schema and converts
108
136
  // authored GUID fields into World.sharedRefs handles. Keep this loader
@@ -110,17 +138,77 @@ function resolveSceneWireRefs(payload: SceneAsset, refs: readonly string[]): Sce
110
138
  // component registry.
111
139
  components[componentName] = { ...(rawFields as Record<string, unknown>) };
112
140
  }
113
- entities.push({
114
- localId: entity.localId,
115
- ...(entity.bindingKey === undefined ? {} : { bindingKey: entity.bindingKey }),
141
+ const instance = entity.instance;
142
+ let resolvedInstance: SceneEntity['instance'];
143
+ if (instance !== undefined) {
144
+ if (instance === null || typeof instance !== 'object') {
145
+ return { ok: false, reason: `entities.${key}.instance must be an object` };
146
+ }
147
+ const source = resolveInstanceSource(
148
+ instance.source,
149
+ refs,
150
+ `entities.${key}.instance.source`,
151
+ );
152
+ if (!source.ok) return source;
153
+ if (instance.overrides !== undefined && !Array.isArray(instance.overrides)) {
154
+ return { ok: false, reason: `entities.${key}.instance.overrides must be an array` };
155
+ }
156
+ let overrides: NonNullable<SceneEntity['instance']>['overrides'] | undefined;
157
+ if (instance.overrides === undefined) {
158
+ overrides = undefined;
159
+ } else {
160
+ const resolvedOverrides: SceneInstanceOverride[] = [];
161
+ for (const [index, rawOverride] of (instance.overrides as readonly unknown[]).entries()) {
162
+ if (
163
+ rawOverride === null ||
164
+ typeof rawOverride !== 'object' ||
165
+ Array.isArray(rawOverride) ||
166
+ !Array.isArray((rawOverride as { readonly target?: unknown }).target) ||
167
+ (rawOverride as { readonly target?: unknown[] }).target?.some(
168
+ (part) => typeof part !== 'string' || part.length === 0,
169
+ )
170
+ ) {
171
+ return {
172
+ ok: false,
173
+ reason: `entities.${key}.instance.overrides[${index}] is malformed`,
174
+ };
175
+ }
176
+ const target = (rawOverride as { readonly target: readonly string[] }).target;
177
+ const rawComponents = (rawOverride as { readonly components?: unknown }).components;
178
+ if (
179
+ rawComponents === null ||
180
+ typeof rawComponents !== 'object' ||
181
+ Array.isArray(rawComponents)
182
+ ) {
183
+ return {
184
+ ok: false,
185
+ reason: `entities.${key}.instance.overrides[${index}].components is malformed`,
186
+ };
187
+ }
188
+ resolvedOverrides.push({
189
+ target: [...target] as [string, ...string[]],
190
+ components: rawComponents as SceneEntity['components'],
191
+ });
192
+ }
193
+ overrides = resolvedOverrides;
194
+ }
195
+ resolvedInstance = {
196
+ source: source.value,
197
+ ...(overrides === undefined ? {} : { overrides }),
198
+ };
199
+ }
200
+ entities[key] = {
116
201
  components,
117
- });
202
+ ...(resolvedInstance === undefined ? {} : { instance: resolvedInstance }),
203
+ };
118
204
  }
119
205
 
120
- const mounts = resolveMounts(payload.mounts, refs);
121
- if (!mounts.ok) return mounts;
122
206
  const skinGuids = resolveSkinGuids(
123
- payload.skinGuids as readonly (number | string)[] | undefined,
207
+ Array.isArray(payload.skinGuids)
208
+ ? (payload.skinGuids as readonly (number | string)[])
209
+ : payload.skinGuids === undefined
210
+ ? undefined
211
+ : ([] as readonly (number | string)[]),
124
212
  refs,
125
213
  );
126
214
  if (!skinGuids.ok) return skinGuids;
@@ -129,9 +217,7 @@ function resolveSceneWireRefs(payload: SceneAsset, refs: readonly string[]): Sce
129
217
  ok: true,
130
218
  value: {
131
219
  kind: 'scene',
132
- ...(payload.sourceKey === undefined ? {} : { sourceKey: payload.sourceKey }),
133
220
  entities,
134
- ...(mounts.value === undefined ? {} : { mounts: mounts.value }),
135
221
  ...(skinGuids.value === undefined ? {} : { skinGuids: skinGuids.value }),
136
222
  },
137
223
  };
@@ -141,8 +227,12 @@ function resolveSceneWireRefs(payload: SceneAsset, refs: readonly string[]): Sce
141
227
  export const sceneAssetDecoder: AssetDecoder<SceneAsset> = {
142
228
  async decode({ envelope }): Promise<Result<SceneAsset, AssetLoadError>> {
143
229
  const payload = envelope.payload;
144
- if (payload.kind !== 'scene' || !Array.isArray(payload.entities)) {
145
- return invalidScene(envelope.guid, 'scene payload is missing entities');
230
+ if (
231
+ payload.kind !== 'scene' ||
232
+ payload.entities === null ||
233
+ typeof payload.entities !== 'object'
234
+ ) {
235
+ return invalidScene(envelope.guid, 'scene payload is missing keyed entities');
146
236
  }
147
237
  const resolved = resolveSceneWireRefs(payload, envelope.refs);
148
238
  if (!resolved.ok) return invalidScene(envelope.guid, resolved.reason);
@@ -14,8 +14,8 @@ export function collectSubtree(
14
14
  if (visited.has(spawnRoot as number)) return visited;
15
15
  const queue: number[] = [spawnRoot as number];
16
16
  visited.add(spawnRoot as number);
17
- while (queue.length > 0) {
18
- const current = queue.shift() as number;
17
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
18
+ const current = queue[cursor] as number;
19
19
  const children = world.get(current as EntityHandle, Children);
20
20
  if (!children.ok) continue;
21
21
  const entities = children.value.entities as ArrayLike<number>;
package/src/index.ts CHANGED
@@ -17,7 +17,8 @@ export {
17
17
  type SceneBindingError,
18
18
  type SceneEntityRef,
19
19
  sceneEntity,
20
- validateSceneBindings,
20
+ sceneEntityAddressKey,
21
+ validateSceneEntityKeys,
21
22
  } from './instances/binding';
22
23
  export { SCENE_COLLECT_PROFILE, type SceneCollectProfile } from './instances/collect-profile';
23
24
  export {
@@ -26,6 +27,14 @@ export {
26
27
  type SceneComponentSchemaResolver,
27
28
  type SceneExternalizationError,
28
29
  } from './instances/externalization';
30
+ export {
31
+ type CompiledSceneAsset,
32
+ type CompiledSceneEntity,
33
+ type CompiledSceneResult,
34
+ compileKeyedSceneAsset,
35
+ type KeyedSceneCompileContext,
36
+ } from './instances/keyed';
37
+ export type { MountOverride, SceneInstanceMount } from './instances/runtime-types';
29
38
  export {
30
39
  type SceneAssetResolver,
31
40
  type SceneInstanceStatePayload,
@@ -1,5 +1,5 @@
1
1
  import type { EntityHandle } from '@forgeax/engine-ecs';
2
- import type { SceneEntityRef } from '@forgeax/engine-types';
2
+ import type { SceneEntityAddress, SceneEntityRef } from '@forgeax/engine-types';
3
3
  import { err, ok, type Result } from '@forgeax/engine-types';
4
4
 
5
5
  export type { SceneEntityRef } from '@forgeax/engine-types';
@@ -8,19 +8,19 @@ export type SceneBindingError = {
8
8
  readonly code: 'scene-binding-missing' | 'scene-binding-wrong-instance';
9
9
  readonly expected: string;
10
10
  readonly hint: string;
11
- readonly detail: { readonly sceneSourceKey: string; readonly bindingKey: string };
11
+ readonly detail: { readonly sceneSourceKey: string; readonly address: SceneEntityAddress };
12
12
  };
13
13
 
14
14
  export type SceneBindingDeclarationError = {
15
15
  readonly code: 'scene-binding-duplicate' | 'scene-binding-source-missing';
16
16
  readonly expected: string;
17
17
  readonly hint: string;
18
- readonly detail: { readonly sceneSourceKey?: string; readonly bindingKey?: string };
18
+ readonly detail: { readonly sceneSourceKey?: string; readonly address?: SceneEntityAddress };
19
19
  };
20
20
 
21
- export function validateSceneBindings(
21
+ export function validateSceneEntityKeys(
22
22
  sceneSourceKey: string,
23
- bindingKeys: readonly string[],
23
+ entityKeys: readonly string[],
24
24
  ): Result<readonly string[], SceneBindingDeclarationError> {
25
25
  if (sceneSourceKey.length === 0) {
26
26
  return err({
@@ -31,22 +31,29 @@ export function validateSceneBindings(
31
31
  });
32
32
  }
33
33
  const seen = new Set<string>();
34
- for (const bindingKey of bindingKeys) {
35
- if (bindingKey.length === 0 || seen.has(bindingKey)) {
34
+ for (const entityKey of entityKeys) {
35
+ if (entityKey.length === 0 || seen.has(entityKey)) {
36
36
  return err({
37
37
  code: 'scene-binding-duplicate',
38
- expected: 'unique non-empty bindingKey values within one scene',
39
- hint: 'rename the duplicate bindingKey in the scene producer',
40
- detail: { sceneSourceKey, bindingKey },
38
+ expected: 'unique non-empty entity keys within one scene',
39
+ hint: 'rename the duplicate entity key in the scene producer',
40
+ detail: { sceneSourceKey, address: entityKey },
41
41
  });
42
42
  }
43
- seen.add(bindingKey);
43
+ seen.add(entityKey);
44
44
  }
45
- return ok([...bindingKeys]);
45
+ return ok([...entityKeys]);
46
46
  }
47
47
 
48
- export function sceneEntity(sceneSourceKey: string, bindingKey: string): SceneEntityRef {
49
- return { sceneSourceKey, bindingKey };
48
+ export function sceneEntity(sceneSourceKey: string, address: SceneEntityAddress): SceneEntityRef {
49
+ return { sceneSourceKey, address };
50
+ }
51
+
52
+ /** Stable map key shared by direct and nested SceneEntityRef addresses. */
53
+ export function sceneEntityAddressKey(address: SceneEntityAddress): string {
54
+ if (typeof address === 'string') return `s:${JSON.stringify(address)}`;
55
+ if (address.length === 1) return `s:${JSON.stringify(address[0] ?? '')}`;
56
+ return `a:${JSON.stringify(address)}`;
50
57
  }
51
58
 
52
59
  export function resolveSceneEntity(
@@ -61,16 +68,16 @@ export function resolveSceneEntity(
61
68
  code: 'scene-binding-wrong-instance',
62
69
  expected: `scene instance ${ref.sceneSourceKey}`,
63
70
  hint: 'resolve the SceneEntityRef against its owning SceneInstance',
64
- detail: { sceneSourceKey: ref.sceneSourceKey, bindingKey: ref.bindingKey },
71
+ detail: { sceneSourceKey: ref.sceneSourceKey, address: ref.address },
65
72
  });
66
73
  }
67
- const value = instance.bindings.get(ref.bindingKey);
74
+ const value = instance.bindings.get(sceneEntityAddressKey(ref.address));
68
75
  if (value === undefined) {
69
76
  return err({
70
77
  code: 'scene-binding-missing',
71
- expected: 'bindingKey declared by the scene producer',
72
- hint: 'declare the bindingKey in the scene producer before consuming it',
73
- detail: { sceneSourceKey: ref.sceneSourceKey, bindingKey: ref.bindingKey },
78
+ expected: 'entity key declared by the scene producer',
79
+ hint: 'declare the entity key in the scene producer before consuming it',
80
+ detail: { sceneSourceKey: ref.sceneSourceKey, address: ref.address },
74
81
  });
75
82
  }
76
83
  return ok(value);
@@ -1,5 +1,6 @@
1
- import type { AssetRef, MountOverride, SceneAsset } from '@forgeax/engine-types';
1
+ import type { AssetRef, SceneAsset, SceneInstanceOverride } from '@forgeax/engine-types';
2
2
  import { err, ok, type Result } from '@forgeax/engine-types';
3
+ import { migrateLegacySceneComponentFields, normalizeLegacySceneAsset } from './legacy.js';
3
4
 
4
5
  export type SceneComponentSchemaResolver = (
5
6
  componentName: string,
@@ -21,123 +22,140 @@ function sharedKind(type: string | undefined): 'one' | 'many' | undefined {
21
22
  return undefined;
22
23
  }
23
24
 
24
- function overrideGuids(
25
- override: MountOverride,
25
+ interface RefContext {
26
+ readonly refs: AssetRef[];
27
+ readonly indexByGuid: Map<string, number>;
28
+ }
29
+
30
+ function addRef(
31
+ context: RefContext,
32
+ guid: string,
33
+ sourceField: NonNullable<AssetRef['sourceField']>,
34
+ sceneEntityKey?: string,
35
+ ): number {
36
+ const prior = context.indexByGuid.get(guid);
37
+ if (prior !== undefined) return prior;
38
+ const index = context.refs.length;
39
+ context.refs.push({
40
+ guid,
41
+ sourceField,
42
+ ...(sceneEntityKey === undefined ? {} : { sceneEntityKey }),
43
+ } as AssetRef);
44
+ context.indexByGuid.set(guid, index);
45
+ return index;
46
+ }
47
+
48
+ function externalizeFields(
49
+ componentName: string,
50
+ source: Record<string, unknown>,
26
51
  resolveSchema: SceneComponentSchemaResolver,
27
- ): readonly { field: string; guid: string }[] {
28
- const schema = resolveSchema(override.comp);
29
- const values =
30
- override.field !== undefined
31
- ? [[override.field, override.value] as const]
32
- : override.value !== null &&
33
- typeof override.value === 'object' &&
34
- !Array.isArray(override.value)
35
- ? Object.entries(override.value as Record<string, unknown>)
36
- : [];
37
- return values.flatMap(([field, value]) => {
38
- const kind = sharedKind(schema?.[field]);
39
- if (kind === 'one' && typeof value === 'string') return [{ field, guid: value }];
40
- if (kind === 'many' && Array.isArray(value)) {
41
- return value.flatMap((item) => (typeof item === 'string' ? [{ field, guid: item }] : []));
52
+ context: RefContext,
53
+ sceneEntityKey: string | undefined,
54
+ ): Record<string, unknown> {
55
+ const schema = resolveSchema(componentName);
56
+ const fields: Record<string, unknown> = {};
57
+ for (const [fieldName, value] of Object.entries(
58
+ migrateLegacySceneComponentFields(componentName, source),
59
+ )) {
60
+ if (value === undefined) continue;
61
+ const kind = sharedKind(schema?.[fieldName]);
62
+ if (kind === 'one' && typeof value === 'string') {
63
+ fields[fieldName] = addRef(context, value, { componentName, fieldName }, sceneEntityKey);
64
+ } else if (kind === 'many' && Array.isArray(value)) {
65
+ fields[fieldName] = value.map((item, arrayIndex) =>
66
+ typeof item === 'string'
67
+ ? addRef(context, item, { componentName, fieldName, arrayIndex }, sceneEntityKey)
68
+ : item,
69
+ );
70
+ } else {
71
+ fields[fieldName] = value;
42
72
  }
43
- return [];
44
- });
73
+ }
74
+ return fields;
45
75
  }
46
76
 
47
- /** Project a SceneAsset's shared asset fields into a payload plus indexed refs. */
77
+ function externalizeOverride(
78
+ override: SceneInstanceOverride,
79
+ resolveSchema: SceneComponentSchemaResolver,
80
+ context: RefContext,
81
+ sceneEntityKey: string,
82
+ ): SceneInstanceOverride {
83
+ const components: Record<string, Record<string, unknown>> = {};
84
+ for (const [componentName, rawFields] of Object.entries(override.components)) {
85
+ components[componentName] = externalizeFields(
86
+ componentName,
87
+ { ...(rawFields as Record<string, unknown>) },
88
+ resolveSchema,
89
+ context,
90
+ sceneEntityKey,
91
+ );
92
+ }
93
+ return {
94
+ target: [...override.target],
95
+ components,
96
+ };
97
+ }
98
+
99
+ /** Project a keyed SceneAsset's shared asset fields into a payload plus refs. */
48
100
  export function externalizeSceneAsset(
49
101
  scene: SceneAsset,
50
102
  resolveSchema: SceneComponentSchemaResolver,
51
103
  ): Result<ExternalizedSceneAsset, SceneExternalizationError> {
52
- const refs: AssetRef[] = [];
53
- const indexByGuid = new Map<string, number>();
54
- const addRef = (
55
- guid: string,
56
- sourceField: NonNullable<AssetRef['sourceField']>,
57
- sceneEntityId?: number,
58
- ): number => {
59
- const prior = indexByGuid.get(guid);
60
- if (prior !== undefined) return prior;
61
- const index = refs.length;
62
- refs.push({ guid, sourceField, ...(sceneEntityId === undefined ? {} : { sceneEntityId }) });
63
- indexByGuid.set(guid, index);
64
- return index;
65
- };
66
-
67
- const entities = scene.entities.map((entity) => {
104
+ const normalized = normalizeLegacySceneAsset(scene);
105
+ const context: RefContext = { refs: [], indexByGuid: new Map() };
106
+ const entities: Record<string, Record<string, unknown>> = {};
107
+ for (const [key, entity] of Object.entries(normalized.entities)) {
68
108
  const components: Record<string, Record<string, unknown>> = {};
69
- for (const componentName of Object.keys(entity.components)) {
70
- const schema = resolveSchema(componentName);
71
- const source = entity.components[componentName] as Record<string, unknown> | undefined;
109
+ for (const [componentName, raw] of Object.entries(entity.components)) {
110
+ const source = raw as Record<string, unknown> | undefined;
72
111
  if (source === undefined) continue;
73
- const fields: Record<string, unknown> = {};
74
- for (const fieldName of Object.keys(source)) {
75
- const value = source[fieldName];
76
- if (value === undefined) continue;
77
- const kind = sharedKind(schema?.[fieldName]);
78
- if (kind === 'one' && typeof value === 'string') {
79
- fields[fieldName] = addRef(value, { componentName, fieldName }, entity.localId as number);
80
- } else if (kind === 'many' && Array.isArray(value)) {
81
- fields[fieldName] = value.map((item, arrayIndex) =>
82
- typeof item === 'string'
83
- ? addRef(item, { componentName, fieldName, arrayIndex }, entity.localId as number)
84
- : item,
85
- );
86
- } else {
87
- fields[fieldName] = value;
88
- }
89
- }
90
- if (Object.keys(fields).length > 0 || Object.keys(schema ?? {}).length === 0) {
91
- components[componentName] = fields;
92
- }
112
+ components[componentName] = externalizeFields(
113
+ componentName,
114
+ source,
115
+ resolveSchema,
116
+ context,
117
+ key,
118
+ );
93
119
  }
94
- return {
95
- localId: entity.localId as number,
96
- ...(entity.bindingKey === undefined ? {} : { bindingKey: entity.bindingKey }),
120
+ const instance = entity.instance;
121
+ entities[key] = {
97
122
  components,
98
- };
99
- });
100
-
101
- const mounts = scene.mounts?.map((mount) => {
102
- const source =
103
- typeof mount.source === 'string'
104
- ? addRef(
105
- mount.source,
106
- { componentName: 'SceneInstance', fieldName: 'source' },
107
- mount.localId as number,
108
- )
109
- : (mount.source as number);
110
- for (const { field, guid } of (mount.overrides ?? []).flatMap((override) =>
111
- overrideGuids(override, resolveSchema),
112
- )) {
113
- addRef(guid, { componentName: 'SceneInstance', fieldName: `overrides.${field}` });
114
- }
115
- return {
116
- localId: mount.localId as number,
117
- source,
118
- memberFirst: mount.memberFirst as number,
119
- memberCount: mount.memberCount,
120
- ...(mount.parent === undefined ? {} : { parent: mount.parent as number }),
121
- ...(mount.publicationFence === undefined ? {} : { publicationFence: mount.publicationFence }),
122
- ...(mount.overrides === undefined
123
+ ...(instance === undefined
123
124
  ? {}
124
- : { overrides: mount.overrides.map((item) => ({ ...item })) }),
125
+ : {
126
+ instance: {
127
+ source: addRef(
128
+ context,
129
+ instance.source,
130
+ { componentName: 'SceneInstance', fieldName: 'source' },
131
+ key,
132
+ ),
133
+ ...(instance.overrides === undefined
134
+ ? {}
135
+ : {
136
+ overrides: instance.overrides.map((override) =>
137
+ externalizeOverride(override, resolveSchema, context, key),
138
+ ),
139
+ }),
140
+ },
141
+ }),
125
142
  };
126
- });
127
- for (const [arrayIndex, guid] of (scene.skinGuids ?? []).entries()) {
143
+ }
144
+
145
+ for (const [arrayIndex, guid] of (normalized.skinGuids ?? []).entries()) {
128
146
  if (typeof guid !== 'string') return err({ field: 'skinGuids', value: guid });
129
- addRef(guid, { componentName: '<scene>', fieldName: 'skinGuids', arrayIndex });
147
+ addRef(context, guid, { componentName: '<scene>', fieldName: 'skinGuids', arrayIndex });
130
148
  }
131
149
  return ok({
132
150
  payload: {
133
151
  kind: 'scene',
134
- ...(scene.sourceKey === undefined ? {} : { sourceKey: scene.sourceKey }),
135
152
  entities,
136
- ...(mounts === undefined || mounts.length === 0 ? {} : { mounts }),
137
- ...(scene.skinGuids === undefined
153
+ ...(normalized.skinGuids === undefined
138
154
  ? {}
139
- : { skinGuids: scene.skinGuids.map((guid) => indexByGuid.get(guid) as number) }),
155
+ : {
156
+ skinGuids: normalized.skinGuids.map((guid) => context.indexByGuid.get(guid) as number),
157
+ }),
140
158
  },
141
- refs,
159
+ refs: context.refs,
142
160
  });
143
161
  }