@defold-typescript/transpiler 0.30.0 → 0.31.0

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 (37) hide show
  1. package/dist/animation-sources.d.ts +6 -0
  2. package/dist/{sprite-animation-index.d.ts → component-animation-index.d.ts} +4 -4
  3. package/dist/config-key-index.d.ts +1 -0
  4. package/dist/gui-flipbook-index.d.ts +8 -0
  5. package/dist/gui-node-index.d.ts +21 -0
  6. package/dist/index.d.ts +19 -9
  7. package/dist/index.js +1329 -400
  8. package/dist/library-dependencies.d.ts +47 -0
  9. package/dist/scene-address-declaration.d.ts +4 -2
  10. package/dist/scene-collection-roles.d.ts +30 -0
  11. package/dist/scene-component-index.d.ts +20 -0
  12. package/dist/scene-documents.d.ts +3 -0
  13. package/dist/scene-naming-context.d.ts +66 -0
  14. package/dist/scene-object-path-index.d.ts +4 -1
  15. package/dist/url-address-literals.d.ts +10 -0
  16. package/dist/url-address-slots.d.ts +3 -1
  17. package/dist/url-cross-world-address.d.ts +41 -0
  18. package/dist/url-fragment-reachability.d.ts +3 -0
  19. package/package.json +2 -2
  20. package/src/animation-sources.ts +90 -0
  21. package/src/component-animation-index.ts +399 -0
  22. package/src/config-key-index.ts +28 -2
  23. package/src/gui-flipbook-index.ts +91 -0
  24. package/src/gui-node-index.ts +42 -4
  25. package/src/index.ts +52 -10
  26. package/src/library-dependencies.ts +337 -0
  27. package/src/scene-address-declaration.ts +29 -12
  28. package/src/scene-collection-roles.ts +267 -0
  29. package/src/scene-component-index.ts +74 -6
  30. package/src/scene-documents.ts +66 -6
  31. package/src/scene-naming-context.ts +130 -0
  32. package/src/scene-object-path-index.ts +172 -33
  33. package/src/url-address-literals.ts +115 -0
  34. package/src/url-address-slots.ts +23 -2
  35. package/src/url-cross-world-address.ts +109 -0
  36. package/src/url-fragment-reachability.ts +72 -93
  37. package/src/sprite-animation-index.ts +0 -241
@@ -0,0 +1,267 @@
1
+ import { readGameProjectSetting } from "./config-key-index";
2
+ import { parseSceneTextFormat, type SceneMessage, SceneTextFormatError } from "./scene-text-format";
3
+
4
+ // Which world each collection is, so an address can say where it resolves. A
5
+ // game object's address has two axes — which world, then which path inside it —
6
+ // and only these roles can tell them apart. Shaped like the indexes it feeds: a
7
+ // non-empty `incomplete` means the classification is not provably whole, so no
8
+ // consumer may conclude a collection has no role.
9
+ export interface SceneCollectionRoles {
10
+ /** The one collection `[bootstrap] main_collection` names, whose paths are bare. */
11
+ readonly bootstrap?: string | undefined;
12
+ /** Proxied collections, mapped to the socket `CollectionDesc.name` gives them. */
13
+ readonly sockets: ReadonlyMap<string, string>;
14
+ /** Collections another collection instances, whose paths exist only under that id. */
15
+ readonly instanced: ReadonlySet<string>;
16
+ /** Collection-factory prototypes, whose objects have no static path at all. */
17
+ readonly factoryPrototypes: ReadonlySet<string>;
18
+ readonly incomplete: readonly string[];
19
+ }
20
+
21
+ export interface SceneCollectionRolesInput {
22
+ /** The `.go`/`.collection` universe, keyed by display path. */
23
+ readonly documents: ReadonlyMap<string, string>;
24
+ /** The `.collectionproxy`/`.collectionfactory` documents, keyed by display path. */
25
+ readonly references: ReadonlyMap<string, string>;
26
+ /** `game.project`'s text, or `undefined` when the walk could not read it. */
27
+ readonly gameProject: string | undefined;
28
+ }
29
+
30
+ // The two places Defold embeds a whole escaped document in `data:`, the same
31
+ // rule `buildSceneComponentIndex` states — every other `data:` is a plain scalar.
32
+ const EMBEDDING_BLOCKS = new Set(["embedded_instances", "embedded_components"]);
33
+
34
+ // A component reference and the field its document holds the target under: a
35
+ // proxy names the collection it opens as a world, a factory names the prototype
36
+ // it spawns copies of.
37
+ const REFERENCE_KINDS = [
38
+ { extension: ".collectionproxy", type: "collectionproxy", field: "collection" },
39
+ { extension: ".collectionfactory", type: "collectionfactory", field: "prototype" },
40
+ ] as const;
41
+
42
+ type ReferenceKind = (typeof REFERENCE_KINDS)[number];
43
+
44
+ // Matches `scene-object-path-index.ts`: resource paths inside a scene are
45
+ // project-absolute, documents are keyed by project-relative display path.
46
+ function resourceKey(resource: string): string {
47
+ return resource.startsWith("/") ? resource.slice(1) : resource;
48
+ }
49
+
50
+ // `game.project` and a `.collectionproxy` name the *compiled* resource the
51
+ // engine loads (`/main/main.collectionc`), not the source the walk read — the
52
+ // same normalisation `resolveBootPathScripts` applies to its own boot walk.
53
+ // Without it every real project fails to find its bootstrap world.
54
+ function collectionKey(resource: string): string {
55
+ return resourceKey(resource).replace(/\.collectionc$/, ".collection");
56
+ }
57
+
58
+ function firstField(message: SceneMessage, name: string): string | undefined {
59
+ return (message.fields.get(name) ?? [])[0];
60
+ }
61
+
62
+ // Only a `.collection` is a world. A `.go` is a prototype, so running the role
63
+ // rules over it would make every prototype in the project a role-less
64
+ // "reachable no way" entry and drown the honest ones.
65
+ function isCollection(displayPath: string): boolean {
66
+ return displayPath.endsWith(".collection");
67
+ }
68
+
69
+ /**
70
+ * Classify every collection the project declares into the world it is: the one
71
+ * bootstrap root, a proxy world addressed through its own socket, a collection
72
+ * instanced inside another, or a factory prototype with no static address.
73
+ *
74
+ * Pure, like the indexes it feeds — the filesystem walk belongs to the caller.
75
+ * There is deliberately no everything-else-is-a-root fallback: that fallback is
76
+ * what offers a bare `/enemy` for an object living in another world, and losing
77
+ * a completion is safe where offering a false one is not.
78
+ */
79
+ export function buildSceneCollectionRoles(input: SceneCollectionRolesInput): SceneCollectionRoles {
80
+ const { documents, references, gameProject } = input;
81
+ const incomplete: string[] = [];
82
+
83
+ const parsed = new Map<string, SceneMessage>();
84
+ for (const [displayPath, text] of documents) {
85
+ try {
86
+ parsed.set(displayPath, parseSceneTextFormat(text));
87
+ } catch (error) {
88
+ if (!(error instanceof SceneTextFormatError)) throw error;
89
+ incomplete.push(`${displayPath}: could not be parsed (${error.message})`);
90
+ }
91
+ }
92
+
93
+ const instanced = new Set<string>();
94
+ for (const document of parsed.values()) {
95
+ for (const block of document.messages.get("collection_instances") ?? []) {
96
+ const collection = firstField(block, "collection");
97
+ if (collection !== undefined) instanced.add(resourceKey(collection));
98
+ }
99
+ }
100
+
101
+ const proxied = new Set<string>();
102
+ const factoryPrototypes = new Set<string>();
103
+
104
+ function record(kind: ReferenceKind, resource: string): void {
105
+ (kind.type === "collectionproxy" ? proxied : factoryPrototypes).add(collectionKey(resource));
106
+ }
107
+
108
+ // A standalone reference is a component file of its own; its target lives in
109
+ // the reference document rather than in the scene that points at it, so a
110
+ // reference the walk never read is a named gap and not a dropped edge.
111
+ function followReferenceDocument(displayPath: string, resource: string): void {
112
+ const kind = REFERENCE_KINDS.find((candidate) => resource.endsWith(candidate.extension));
113
+ if (kind === undefined) return;
114
+ const text = references.get(resourceKey(resource));
115
+ if (text === undefined) {
116
+ incomplete.push(
117
+ `${displayPath}: the component ${resource} is not among the project's readable ${kind.extension} documents, so the collection it names is unclassified`,
118
+ );
119
+ return;
120
+ }
121
+ let document: SceneMessage;
122
+ try {
123
+ document = parseSceneTextFormat(text);
124
+ } catch (error) {
125
+ if (!(error instanceof SceneTextFormatError)) throw error;
126
+ incomplete.push(`${resourceKey(resource)}: could not be parsed (${error.message})`);
127
+ return;
128
+ }
129
+ const target = firstField(document, kind.field);
130
+ if (target === undefined) {
131
+ incomplete.push(
132
+ `${resourceKey(resource)}: declares no ${kind.field}:, so the collection it names is unclassified`,
133
+ );
134
+ return;
135
+ }
136
+ record(kind, target);
137
+ }
138
+
139
+ function walk(
140
+ message: SceneMessage,
141
+ blockName: string,
142
+ displayPath: string,
143
+ depth: number,
144
+ ): void {
145
+ if (blockName === "components") {
146
+ const component = firstField(message, "component");
147
+ if (component !== undefined) followReferenceDocument(displayPath, component);
148
+ }
149
+
150
+ const payloads: SceneMessage[] = [];
151
+ if (EMBEDDING_BLOCKS.has(blockName)) {
152
+ for (const payload of message.fields.get("data") ?? []) {
153
+ try {
154
+ payloads.push(parseSceneTextFormat(payload));
155
+ } catch (error) {
156
+ if (!(error instanceof SceneTextFormatError)) throw error;
157
+ incomplete.push(
158
+ `${displayPath}: embedded payload at depth ${depth + 1} could not be parsed (${error.message})`,
159
+ );
160
+ }
161
+ }
162
+ }
163
+
164
+ if (blockName === "embedded_components") {
165
+ const type = firstField(message, "type");
166
+ const kind = REFERENCE_KINDS.find((candidate) => candidate.type === type);
167
+ if (kind !== undefined) {
168
+ for (const payload of payloads) {
169
+ const target = firstField(payload, kind.field);
170
+ if (target === undefined) {
171
+ incomplete.push(
172
+ `${displayPath}: the embedded ${kind.type} declares no ${kind.field}:, so the collection it names is unclassified`,
173
+ );
174
+ continue;
175
+ }
176
+ record(kind, target);
177
+ }
178
+ }
179
+ }
180
+
181
+ for (const payload of payloads) walk(payload, "", displayPath, depth + 1);
182
+ for (const [name, nested] of message.messages) {
183
+ for (const child of nested) walk(child, name, displayPath, depth);
184
+ }
185
+ }
186
+
187
+ for (const [displayPath, document] of parsed) walk(document, "", displayPath, 0);
188
+
189
+ // The socket is the proxied collection's own `name:` — `CollectionDesc.name`,
190
+ // which the editor writes on the collection itself and which is deliberately
191
+ // neither the proxy component's id nor the file's basename.
192
+ const sockets = new Map<string, string>();
193
+ const byName = new Map<string, string[]>();
194
+ for (const displayPath of [...proxied].sort()) {
195
+ const document = parsed.get(displayPath);
196
+ if (document === undefined) {
197
+ incomplete.push(
198
+ `${displayPath}: is opened as a collection proxy but is not among the project's readable scene documents, so its world has no name`,
199
+ );
200
+ continue;
201
+ }
202
+ const name = firstField(document, "name");
203
+ if (name === undefined || name === "") {
204
+ incomplete.push(
205
+ `${displayPath}: is opened as a collection proxy but declares no name:, so its world cannot be addressed`,
206
+ );
207
+ continue;
208
+ }
209
+ sockets.set(displayPath, name);
210
+ byName.set(name, [...(byName.get(name) ?? []), displayPath]);
211
+ }
212
+ for (const [name, holders] of byName) {
213
+ if (holders.length > 1) {
214
+ incomplete.push(
215
+ `${holders.join(", ")}: all declare name: "${name}", so an address under that socket does not say which world it means`,
216
+ );
217
+ }
218
+ }
219
+
220
+ // A project the walk found no collection in has no world to name, so an
221
+ // absent `[bootstrap]` says nothing a reader could act on — the same posture
222
+ // `sceneIndexForBuild` takes towards a scene-less project. A project that does
223
+ // have collections is a different case: there the absence is exactly why no
224
+ // address is offered bare, so it is always named.
225
+ const hasCollections = [...parsed.keys()].some(isCollection);
226
+
227
+ let bootstrap: string | undefined;
228
+ if (gameProject === undefined) {
229
+ if (hasCollections) {
230
+ incomplete.push(
231
+ "game.project could not be read, so no collection is known to be the bootstrap world and no address is offered unqualified",
232
+ );
233
+ }
234
+ } else {
235
+ const main = readGameProjectSetting(gameProject, "bootstrap", "main_collection");
236
+ if (main === undefined) {
237
+ if (hasCollections) {
238
+ incomplete.push(
239
+ "game.project declares no [bootstrap] main_collection, so no collection is the bootstrap world",
240
+ );
241
+ }
242
+ } else if (!parsed.has(collectionKey(main))) {
243
+ incomplete.push(
244
+ `game.project names ${main} as [bootstrap] main_collection, which is not among the project's readable scene documents`,
245
+ );
246
+ } else {
247
+ bootstrap = collectionKey(main);
248
+ }
249
+ }
250
+
251
+ for (const displayPath of parsed.keys()) {
252
+ if (!isCollection(displayPath)) continue;
253
+ if (
254
+ displayPath === bootstrap ||
255
+ sockets.has(displayPath) ||
256
+ instanced.has(displayPath) ||
257
+ factoryPrototypes.has(displayPath)
258
+ ) {
259
+ continue;
260
+ }
261
+ incomplete.push(
262
+ `${displayPath}: is reached by no bootstrap, proxy, instance or factory reference, so which world its objects live in is unknown`,
263
+ );
264
+ }
265
+
266
+ return { bootstrap, sockets, instanced, factoryPrototypes, incomplete };
267
+ }
@@ -20,23 +20,56 @@ const COMPONENT_BLOCKS = new Set(["components", "embedded_components"]);
20
20
  // projects as unreadable and suppress the check for good.
21
21
  const EMBEDDING_BLOCKS = new Set(["embedded_instances", "embedded_components"]);
22
22
 
23
+ // A component id and the resource it references, where one is referenced at all.
24
+ // The two axes are gathered by one pass because they are read off the same
25
+ // block: a second walk over the same documents is exactly how the id universe
26
+ // and the resource universe drift apart.
27
+ export interface ComponentDeclarations {
28
+ readonly ids: Set<string>;
29
+ readonly resources: Map<string, string>;
30
+ }
31
+
32
+ // Resource paths inside a scene are project-absolute; documents are keyed by
33
+ // project-relative display path, the same convention `resourceKey` follows in
34
+ // `scene-object-path-index.ts`.
35
+ function resourceKey(resource: string): string {
36
+ return resource.startsWith("/") ? resource.slice(1) : resource;
37
+ }
38
+
23
39
  function collect(
24
40
  message: SceneMessage,
25
41
  blockName: string,
26
42
  depth: number,
27
43
  displayPath: string,
28
- ids: Set<string>,
44
+ declarations: ComponentDeclarations,
29
45
  incomplete: string[],
30
46
  ): void {
31
47
  if (COMPONENT_BLOCKS.has(blockName)) {
32
- for (const id of message.fields.get("id") ?? []) {
33
- ids.add(id);
48
+ const declared = message.fields.get("id") ?? [];
49
+ for (const id of declared) {
50
+ declarations.ids.add(id);
51
+ }
52
+ // `embedded_components` is skipped on purpose: its payload *is* the
53
+ // component, so it names no resource and must never be credited with one.
54
+ if (blockName === "components") {
55
+ const [id] = declared;
56
+ const [component] = message.fields.get("component") ?? [];
57
+ if (id !== undefined && id !== "" && component !== undefined) {
58
+ declarations.resources.set(id, resourceKey(component));
59
+ }
34
60
  }
35
61
  }
36
62
  if (EMBEDDING_BLOCKS.has(blockName)) {
37
63
  for (const payload of message.fields.get("data") ?? []) {
38
64
  try {
39
- collect(parseSceneTextFormat(payload), "", depth + 1, displayPath, ids, incomplete);
65
+ collect(
66
+ parseSceneTextFormat(payload),
67
+ "",
68
+ depth + 1,
69
+ displayPath,
70
+ declarations,
71
+ incomplete,
72
+ );
40
73
  } catch (error) {
41
74
  if (!(error instanceof SceneTextFormatError)) throw error;
42
75
  incomplete.push(
@@ -47,11 +80,44 @@ function collect(
47
80
  }
48
81
  for (const [name, nested] of message.messages) {
49
82
  for (const child of nested) {
50
- collect(child, name, depth, displayPath, ids, incomplete);
83
+ collect(child, name, depth, displayPath, declarations, incomplete);
51
84
  }
52
85
  }
53
86
  }
54
87
 
88
+ /**
89
+ * Every component id one already-parsed scene message declares, plus the
90
+ * resource each *referenced* component names, following the two `data:`
91
+ * payloads Defold embeds a whole document in. Exported because the game-object
92
+ * path walk attributes components to the object that owns them and must read
93
+ * the same rule this index reads — a second collector is exactly how the two
94
+ * universes drift apart.
95
+ *
96
+ * `blockName` names the block `message` was taken from, because the `data:`
97
+ * re-parse is keyed off it: a whole document is `""`, while a single
98
+ * `embedded_instances` block has to say so or its payload is never opened.
99
+ */
100
+ export function collectComponentDeclarations(
101
+ message: SceneMessage,
102
+ displayPath: string,
103
+ incomplete: string[],
104
+ blockName = "",
105
+ ): ComponentDeclarations {
106
+ const declarations: ComponentDeclarations = { ids: new Set(), resources: new Map() };
107
+ collect(message, blockName, 0, displayPath, declarations, incomplete);
108
+ return declarations;
109
+ }
110
+
111
+ /** The id half of {@link collectComponentDeclarations}, for callers that join no resources. */
112
+ export function collectComponentIds(
113
+ message: SceneMessage,
114
+ displayPath: string,
115
+ incomplete: string[],
116
+ blockName = "",
117
+ ): Set<string> {
118
+ return collectComponentDeclarations(message, displayPath, incomplete, blockName).ids;
119
+ }
120
+
55
121
  // Build the component-id universe from already-read scene sources: keys are
56
122
  // display paths, values are file text. Pure — the filesystem walk belongs to the
57
123
  // caller, so a test can drive this from inline strings and a build can drive it
@@ -69,7 +135,9 @@ export function buildSceneComponentIndex(
69
135
 
70
136
  for (const [displayPath, text] of documents) {
71
137
  try {
72
- collect(parseSceneTextFormat(text), "", 0, displayPath, ids, incomplete);
138
+ for (const id of collectComponentIds(parseSceneTextFormat(text), displayPath, incomplete)) {
139
+ ids.add(id);
140
+ }
73
141
  } catch (error) {
74
142
  if (!(error instanceof SceneTextFormatError)) throw error;
75
143
  incomplete.push(`${displayPath}: could not be parsed (${error.message})`);
@@ -1,3 +1,4 @@
1
+ import { readLibrarySceneDocuments } from "./library-dependencies";
1
2
  import { isDefignoredPath } from "./project-resources";
2
3
 
3
4
  // The narrow slice of `ts.server.ServerHost` this needs: the real host
@@ -18,9 +19,42 @@ export const SCENE_EXTENSIONS = [".go", ".collection"];
18
19
  export const GUI_EXTENSIONS = [".gui"];
19
20
  export const INPUT_BINDING_EXTENSIONS = [".input_binding"];
20
21
 
21
- // The documents an animation id is declared by: an atlas or tile source names
22
- // the ids, and a `.sprite` is the hop a referenced component takes to reach one.
23
- export const ANIMATION_ASSET_EXTENSIONS = [".atlas", ".tilesource", ".sprite"];
22
+ // The component files that name a collection without instancing it: a
23
+ // `.collectionproxy` opens it as its own world, a `.collectionfactory` spawns
24
+ // copies of it at runtime. Deliberately not folded into `SCENE_EXTENSIONS`,
25
+ // which would feed non-scene text to `buildSceneComponentIndex`.
26
+ export const COLLECTION_REFERENCE_EXTENSIONS = [".collectionproxy", ".collectionfactory"];
27
+
28
+ // The documents an animation id is declared by, plus every hop taken to reach
29
+ // one: an atlas or tile source names a sprite's ids outright, a `.animationset`
30
+ // names the files whose basenames are a model's ids, and a `.sprite` or
31
+ // `.model` is the hop a referenced component takes to reach either.
32
+ export const ANIMATION_ASSET_EXTENSIONS = [
33
+ ".atlas",
34
+ ".tilesource",
35
+ ".sprite",
36
+ ".model",
37
+ ".animationset",
38
+ ];
39
+
40
+ // The file kinds a library shares into the address universe: the documents that
41
+ // declare ids, plus the ones that *classify* a collection into the world it is —
42
+ // a library's own `.collectionproxy` is what says its collection is a proxy
43
+ // world rather than reached by nothing. `.input_binding` and `.project` are
44
+ // deliberately absent: `game.project` names exactly one active binding file, and
45
+ // a vendored `*.project` declares keys this project's readers cannot resolve —
46
+ // the rule `PROJECT_EXTENSIONS` states below.
47
+ //
48
+ // It lives here rather than beside its reader for the reason the sets above do,
49
+ // plus one more: `library-dependencies.ts` and this file import each other, and
50
+ // a constant built from these arrays at that module's top level would read them
51
+ // before this one had run.
52
+ export const LIBRARY_SCENE_EXTENSIONS = [
53
+ ...SCENE_EXTENSIONS,
54
+ ...GUI_EXTENSIONS,
55
+ ...ANIMATION_ASSET_EXTENSIONS,
56
+ ...COLLECTION_REFERENCE_EXTENSIONS,
57
+ ];
24
58
 
25
59
  // The walk returns every `.project` the project holds, so the lookup is by
26
60
  // display path — a vendored `*.project` declares keys this project's readers
@@ -70,18 +104,32 @@ export function isExcludedProjectPath(displayPath: string): boolean {
70
104
  // silent hole cannot be reasoned about. `paths` names the host paths actually
71
105
  // read — what a watcher must be registered on, which cannot be reconstructed
72
106
  // from a display path for a file outside the project root.
107
+ //
108
+ // The universe is the project plus whatever `resolve` last materialized from its
109
+ // library dependencies: a library document joins it under the merged Defold
110
+ // resource path it is addressed by, with its origin recorded in `origins` rather
111
+ // than folded into the key, and the project's own file wins a collision. Which
112
+ // makes the universe only as current as the last `resolve` — so a dependency
113
+ // that is declared but unresolved, unreadable or shadowed is a named
114
+ // `unreadable` line, never a silent omission.
73
115
  export function readSceneDocuments(
74
116
  host: SceneReadHost,
75
117
  projectRoot: string,
76
118
  extensions: readonly string[] = SCENE_EXTENSIONS,
77
- ): { documents: Map<string, string>; unreadable: string[]; paths: string[] } {
119
+ ): {
120
+ documents: Map<string, string>;
121
+ origins: Map<string, string>;
122
+ unreadable: string[];
123
+ paths: string[];
124
+ } {
78
125
  const documents = new Map<string, string>();
126
+ const origins = new Map<string, string>();
79
127
  const unreadable: string[] = [];
80
128
  const paths: string[] = [];
81
129
 
82
130
  if (!host.readDirectory) {
83
131
  unreadable.push("the editor host cannot enumerate project files, so no scene source was read");
84
- return { documents, unreadable, paths };
132
+ return { documents, origins, unreadable, paths };
85
133
  }
86
134
 
87
135
  for (const filePath of host.readDirectory(projectRoot, extensions)) {
@@ -96,7 +144,19 @@ export function readSceneDocuments(
96
144
  paths.push(filePath);
97
145
  }
98
146
 
99
- return { documents, unreadable, paths };
147
+ const library = readLibrarySceneDocuments(host, projectRoot, extensions);
148
+ unreadable.push(...library.unreadable);
149
+ for (const { displayPath, hostPath, url, text } of library.documents) {
150
+ if (documents.has(displayPath)) {
151
+ unreadable.push(`${displayPath}: also declared by ${url}; the project's own file is used`);
152
+ continue;
153
+ }
154
+ documents.set(displayPath, text);
155
+ origins.set(displayPath, url);
156
+ paths.push(hostPath);
157
+ }
158
+
159
+ return { documents, origins, unreadable, paths };
100
160
  }
101
161
 
102
162
  // The project's own files of the given kinds, as the `/`-prefixed
@@ -0,0 +1,130 @@
1
+ import type { SceneObjectPathIndex } from "./scene-object-path-index";
2
+
3
+ /**
4
+ * One place a script runs, and the addressing frame it runs in. At runtime an
5
+ * instance carries `m_CollectionPathHashState` seeded from its compiled id up
6
+ * to and including the last `/`, and `GetAbsoluteIdentifier` resolves a
7
+ * relative string by continuing that hash — so a relative address written in a
8
+ * script means one thing from this object and nothing anywhere else.
9
+ */
10
+ export interface NamingContext {
11
+ /** The world-qualified composed path of the object hosting the script. */
12
+ readonly object: string;
13
+ /** The proxy socket this object lives behind, or `undefined` in the bootstrap world. */
14
+ readonly socket: string | undefined;
15
+ /**
16
+ * `object` through its last `/`, inclusive — the collection path a relative
17
+ * address continues. World-qualified by construction, so a relative address
18
+ * can never resolve across a world.
19
+ */
20
+ readonly prefix: string;
21
+ }
22
+
23
+ const SCRIPT_SUFFIX = ".script";
24
+ const GUI_SUFFIX = ".gui";
25
+
26
+ /**
27
+ * The proxy socket an address names, or `undefined` when it names none — a
28
+ * bootstrap-world path, a relative path, a bare `#fragment`.
29
+ *
30
+ * Defold's address grammar is `[socket:][path][#fragment]` with both bracketed
31
+ * halves optional, so `mylevel:`, `mylevel:enemy` and `mylevel:#body` are as
32
+ * socket-qualified as `mylevel:/path`. The socket is therefore read from the
33
+ * `:` delimiter rather than from a following `/`: the head is the address up to
34
+ * the first `/` or `#` (the whole address when it carries neither), and the
35
+ * socket is the text before that head's first `:`. A `:` inside a path segment
36
+ * (`/level:1/enemy`) is not a socket, because the `/` closes the head first,
37
+ * and an empty socket (`:path`) is none.
38
+ *
39
+ * Exported because a written address literal and a composed object path are the
40
+ * same shape: the cross-world check reads a socket out of source text with this
41
+ * one rule rather than a second reader that could disagree with the contexts it
42
+ * compares against.
43
+ */
44
+ export function socketOfAddress(address: string): string | undefined {
45
+ const pathStart = address.indexOf("/");
46
+ const fragmentStart = address.indexOf("#");
47
+ const ends = [pathStart, fragmentStart, address.length].filter((end) => end >= 0);
48
+ const headEnd = Math.min(...ends);
49
+ const delimiter = address.indexOf(":");
50
+ return delimiter > 0 && delimiter < headEnd ? address.slice(0, delimiter) : undefined;
51
+ }
52
+
53
+ function contextOf(object: string): NamingContext {
54
+ return {
55
+ object,
56
+ socket: socketOfAddress(object),
57
+ prefix: object.slice(0, object.lastIndexOf("/") + 1),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Every naming context each script resource runs in, keyed by that resource and
63
+ * sorted by object path.
64
+ *
65
+ * `guiScripts` maps a `.gui` display path to the gui-script resource it names,
66
+ * which the caller builds with `guiScriptResourceOf` — the walk needs no `.gui`
67
+ * documents of its own.
68
+ *
69
+ * A script hosted by several objects has several contexts, and that is not
70
+ * ambiguity: a `.go` instanced in two collections genuinely runs in both, and
71
+ * it is the consumer's provenance that tells an author which context an entry
72
+ * resolves from.
73
+ */
74
+ export function buildScriptNamingContexts(
75
+ index: SceneObjectPathIndex,
76
+ guiScripts: ReadonlyMap<string, string>,
77
+ ): ReadonlyMap<string, readonly NamingContext[]> {
78
+ const contexts = new Map<string, NamingContext[]>();
79
+
80
+ for (const [object, resources] of index.componentResourcesOf) {
81
+ for (const resource of resources.values()) {
82
+ const script = resource.endsWith(SCRIPT_SUFFIX)
83
+ ? resource
84
+ : resource.endsWith(GUI_SUFFIX)
85
+ ? guiScripts.get(resource)
86
+ : undefined;
87
+ if (script === undefined) continue;
88
+ const hosted = contexts.get(script);
89
+ if (hosted === undefined) {
90
+ contexts.set(script, [contextOf(object)]);
91
+ } else if (!hosted.some((context) => context.object === object)) {
92
+ hosted.push(contextOf(object));
93
+ }
94
+ }
95
+ }
96
+
97
+ for (const hosted of contexts.values()) {
98
+ hosted.sort((a, b) => a.object.localeCompare(b.object));
99
+ }
100
+ return contexts;
101
+ }
102
+
103
+ /**
104
+ * The relative forms a script may write from one naming context: every object
105
+ * path under the context's prefix with that prefix stripped, and each of those
106
+ * joined to the components its object owns.
107
+ *
108
+ * An object `componentsOf` withheld still contributes its path — the object is
109
+ * provably there — but no `#component` join, because which components it owns
110
+ * was never read.
111
+ */
112
+ export function relativeAddressesFrom(
113
+ index: SceneObjectPathIndex,
114
+ context: NamingContext,
115
+ ): { readonly paths: ReadonlySet<string>; readonly addresses: ReadonlySet<string> } {
116
+ const paths = new Set<string>();
117
+ const addresses = new Set<string>();
118
+
119
+ for (const key of index.paths) {
120
+ if (!key.startsWith(context.prefix)) continue;
121
+ const relative = key.slice(context.prefix.length);
122
+ if (relative === "") continue;
123
+ paths.add(relative);
124
+ for (const id of index.componentsOf.get(key) ?? []) {
125
+ addresses.add(`${relative}#${id}`);
126
+ }
127
+ }
128
+
129
+ return { paths, addresses };
130
+ }