@adobe/ecs-persistence 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +89 -2
- package/dist/index.js +754 -0
- package/dist/index.js.map +1 -0
- package/dist/types/decode.d.ts +11 -0
- package/dist/types/encode.d.ts +6 -0
- package/dist/types/failure.d.ts +4 -0
- package/dist/types/format/format.d.ts +10 -0
- package/dist/types/guide/01-saving.d.ts +40 -0
- package/dist/types/guide/02-copying.d.ts +23 -0
- package/dist/types/guide/03-chunks.d.ts +23 -0
- package/dist/types/guide/04-settings.d.ts +45 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/match/keys.d.ts +36 -0
- package/dist/types/match/match.d.ts +13 -0
- package/dist/types/persist.d.ts +14 -0
- package/dist/types/program/contents.d.ts +19 -0
- package/dist/types/program/program.d.ts +21 -0
- package/dist/types/selection/selection.d.ts +24 -0
- package/dist/types/snapshot.d.ts +28 -0
- package/dist/types/spawn.d.ts +42 -0
- package/dist/types/values/values.d.ts +5 -0
- package/package.json +46 -3
- package/src/decode.ts +16 -0
- package/src/encode.ts +10 -0
- package/src/failure.ts +4 -0
- package/src/format/format.ts +185 -0
- package/src/guide/01-saving.md +18 -0
- package/src/guide/01-saving.out +4 -0
- package/src/guide/01-saving.ts +55 -0
- package/src/guide/02-copying.md +15 -0
- package/src/guide/02-copying.out +4 -0
- package/src/guide/02-copying.ts +70 -0
- package/src/guide/03-chunks.md +15 -0
- package/src/guide/03-chunks.out +3 -0
- package/src/guide/03-chunks.ts +75 -0
- package/src/guide/04-settings.md +15 -0
- package/src/guide/04-settings.out +4 -0
- package/src/guide/04-settings.ts +75 -0
- package/src/guide/README.md +10 -0
- package/src/index.ts +11 -0
- package/src/match/keys.ts +105 -0
- package/src/match/match.ts +165 -0
- package/src/program/contents.ts +19 -0
- package/src/program/program.ts +45 -0
- package/src/selection/selection.ts +110 -0
- package/src/snapshot.ts +30 -0
- package/src/spawn.ts +79 -0
- package/src/values/values.ts +97 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
|
|
3
|
+
/** A table's key, a column of bytes for each part, `stride` bytes per row: a row's key is its bytes in each. */
|
|
4
|
+
export type KeyColumns = readonly { readonly bytes: Uint8Array; readonly stride: number }[];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Keys of rows of tables, each with a number: an open-addressing table of slots, linear probing, at most half full,
|
|
8
|
+
* each slot two words, a key's hash and its entry plus one, or 0 for none, so a probe reads its slots until a hash
|
|
9
|
+
* matches. An entry is a row of the tables it was added from, whose bytes are its key.
|
|
10
|
+
*/
|
|
11
|
+
export type KeyTable = {
|
|
12
|
+
readonly mask: number;
|
|
13
|
+
readonly slots: Int32Array;
|
|
14
|
+
readonly tables: Int32Array;
|
|
15
|
+
readonly rows: Int32Array;
|
|
16
|
+
readonly values: Float64Array;
|
|
17
|
+
readonly columns: KeyColumns[];
|
|
18
|
+
count: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** A table for up to `capacity` keys. */
|
|
22
|
+
export const createKeyTable = (capacity: number): KeyTable => {
|
|
23
|
+
let size = 16;
|
|
24
|
+
while (size < 2 * capacity) size *= 2;
|
|
25
|
+
return {
|
|
26
|
+
mask: size - 1,
|
|
27
|
+
slots: new Int32Array(2 * size),
|
|
28
|
+
tables: new Int32Array(capacity),
|
|
29
|
+
rows: new Int32Array(capacity),
|
|
30
|
+
values: new Float64Array(capacity),
|
|
31
|
+
columns: [],
|
|
32
|
+
count: 0,
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** FNV-1a over a row's bytes in each column, then mixed so its low bits, which pick a slot, depend on all of them. */
|
|
37
|
+
const hashOf = (columns: KeyColumns, row: number): number => {
|
|
38
|
+
let hash = 0x811c9dc5;
|
|
39
|
+
for (let c = 0; c < columns.length; c++) {
|
|
40
|
+
const { bytes, stride } = columns[c];
|
|
41
|
+
for (let at = row * stride, end = at + stride; at < end; at++) hash = Math.imul(hash ^ bytes[at], 0x01000193);
|
|
42
|
+
}
|
|
43
|
+
hash ^= hash >>> 16;
|
|
44
|
+
hash = Math.imul(hash, 0x85ebca6b);
|
|
45
|
+
hash ^= hash >>> 13;
|
|
46
|
+
hash = Math.imul(hash, 0xc2b2ae35);
|
|
47
|
+
return hash ^ (hash >>> 16);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const equalAt = (a: KeyColumns, rowA: number, b: KeyColumns, rowB: number): boolean => {
|
|
51
|
+
if (a.length !== b.length) return false;
|
|
52
|
+
for (let c = 0; c < a.length; c++) {
|
|
53
|
+
const { bytes: left, stride } = a[c];
|
|
54
|
+
const { bytes: right, stride: other } = b[c];
|
|
55
|
+
if (stride !== other) return false;
|
|
56
|
+
for (let k = 0, i = rowA * stride, j = rowB * stride; k < stride; k++) if (left[i + k] !== right[j + k]) return false;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The slot holding `row`'s key, or the empty one where it would go. */
|
|
62
|
+
const slotOf = (table: KeyTable, columns: KeyColumns, row: number, hash: number): number => {
|
|
63
|
+
const { mask, slots, tables, rows } = table;
|
|
64
|
+
for (let slot = hash & mask; ; slot = (slot + 1) & mask) {
|
|
65
|
+
const entry = slots[2 * slot + 1] - 1;
|
|
66
|
+
if (entry < 0) return slot;
|
|
67
|
+
if (slots[2 * slot] === hash && equalAt(table.columns[tables[entry]], rows[entry], columns, row)) return slot;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Begins adding the rows of a table of `columns`; `addKey` then takes rows of it. Each table's columns are kept, since
|
|
73
|
+
* its entries' keys are their rows' bytes.
|
|
74
|
+
*/
|
|
75
|
+
export const addKeyColumns = (table: KeyTable, columns: KeyColumns): void => {
|
|
76
|
+
table.columns.push(columns);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Adds row `row` of the table `addKeyColumns` began last, with `value`, unless a key equal to its is there: whether
|
|
81
|
+
* it was added. Takes at most the capacity the table was created with.
|
|
82
|
+
*/
|
|
83
|
+
export const addKey = (table: KeyTable, row: number, value: number): boolean => {
|
|
84
|
+
const at = table.columns.length - 1;
|
|
85
|
+
const columns = table.columns[at];
|
|
86
|
+
const hash = hashOf(columns, row);
|
|
87
|
+
const slot = slotOf(table, columns, row, hash);
|
|
88
|
+
if (table.slots[2 * slot + 1] !== 0) return false;
|
|
89
|
+
const entry = table.count++;
|
|
90
|
+
table.slots[2 * slot] = hash;
|
|
91
|
+
table.slots[2 * slot + 1] = entry + 1;
|
|
92
|
+
table.tables[entry] = at;
|
|
93
|
+
table.rows[entry] = row;
|
|
94
|
+
table.values[entry] = value;
|
|
95
|
+
return true;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The entry whose key is equal to row `row`'s of `columns`, or -1 for none. `guess` is an entry to compare first, such
|
|
100
|
+
* as the one after the entry found for the row before: rows in the order they were added are found without hashing.
|
|
101
|
+
*/
|
|
102
|
+
export const findKey = (table: KeyTable, columns: KeyColumns, row: number, guess: number): number => {
|
|
103
|
+
if (guess >= 0 && guess < table.count && equalAt(table.columns[table.tables[guess]], table.rows[guess], columns, row)) return guess;
|
|
104
|
+
return table.slots[2 * slotOf(table, columns, row, hashOf(columns, row)) + 1] - 1;
|
|
105
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import { Index, System, type Component, type Schema, type World } from "@adobe/ecs";
|
|
3
|
+
import type { Failure } from "../failure.js";
|
|
4
|
+
import type { Contents } from "../program/contents.js";
|
|
5
|
+
import { toJson } from "../values/values.js";
|
|
6
|
+
import { addKey, addKeyColumns, createKeyTable, findKey, type KeyColumns } from "./keys.js";
|
|
7
|
+
|
|
8
|
+
/** An index whose entities are the same in two worlds where their keys are: a unique one. */
|
|
9
|
+
export type Matching = Index<{ readonly unique: true }>;
|
|
10
|
+
|
|
11
|
+
/** What a match reads of a world: the tables of the entities an index holds, with its key's columns. */
|
|
12
|
+
const keysSystemOf = (declaration: Index.Declared) => System.create({
|
|
13
|
+
readonly: true,
|
|
14
|
+
tables: { components: declaration.key, with: [...declaration.key, ...declaration.components.with ?? []], not: declaration.components.not ?? [] },
|
|
15
|
+
}, ({ tables }) => tables.archetypes);
|
|
16
|
+
|
|
17
|
+
/** A part of an index's key: its component, and that component's schema as the snapshot holds it. */
|
|
18
|
+
type Part = { readonly key: Component.Key; readonly schema: Schema };
|
|
19
|
+
|
|
20
|
+
/** Bytes as text, a character each, so equal bytes give equal text and a row's are a slice of its column's. */
|
|
21
|
+
const bytesDecoder = new TextDecoder("latin1");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Each row's key in a table, as text, or null for a table without a column of each part: each part's column, its bytes
|
|
25
|
+
* in the world's layout, which two worlds of one program share, or its value as JSON by its schema. Equal keys give
|
|
26
|
+
* equal text.
|
|
27
|
+
*/
|
|
28
|
+
const keysOf = (table: World.Table, parts: readonly Part[]): ((row: number) => string) | null => {
|
|
29
|
+
const columns: ((row: number) => string)[] = [];
|
|
30
|
+
for (const { key, schema } of parts) {
|
|
31
|
+
const column = table.columns[key];
|
|
32
|
+
if (column === undefined) return null;
|
|
33
|
+
if (column.kind === "bytes") {
|
|
34
|
+
const text = bytesDecoder.decode(column.bytes);
|
|
35
|
+
const { stride } = column;
|
|
36
|
+
columns.push(row => text.slice(row * stride, (row + 1) * stride));
|
|
37
|
+
}
|
|
38
|
+
else if (column.kind === "values") columns.push(row => JSON.stringify(toJson(schema, column.values[row])));
|
|
39
|
+
else {
|
|
40
|
+
const text = JSON.stringify(toJson(schema, column.value));
|
|
41
|
+
columns.push(() => text);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const [only] = columns;
|
|
45
|
+
return columns.length === 1 && only !== undefined ? only : row => columns.map(part => part(row)).join("\u0000");
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Whether a table holds the entities an index holds: every key part and `with` component, and none in `not`. */
|
|
49
|
+
const covers = (table: World.Table, declaration: Index.Declared): boolean =>
|
|
50
|
+
[...declaration.key, ...declaration.components.with ?? []].every(key => table.columns[key] !== undefined)
|
|
51
|
+
&& (declaration.components.not ?? []).every(key => table.columns[key] === undefined);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Adds to `existing` each of the snapshot's entities an index holds, to the world's with its key, or fails, as data,
|
|
55
|
+
* for two of the snapshot's with one key; `name` is the index's.
|
|
56
|
+
*/
|
|
57
|
+
type Matched = (name: string, existing: Map<number, number>) => Promise<Failure | undefined>;
|
|
58
|
+
|
|
59
|
+
const twoWithOneKey = (name: string): Failure => ({ failure: `Index "${name}" can't match: two of the snapshot's entities have one key.` });
|
|
60
|
+
|
|
61
|
+
const readWorld = (world: World, declaration: Index.Declared): Promise<readonly World.Table[] | Failure> =>
|
|
62
|
+
world.run(keysSystemOf(declaration)).catch((error: unknown) => ({ failure: error instanceof Error ? error.message : String(error) }));
|
|
63
|
+
|
|
64
|
+
/** Two of the world's entities with one key: the one with the lower entity index, as its unique index keeps. */
|
|
65
|
+
const keep = (existing: Map<number, number>, entity: number, candidate: number): void => {
|
|
66
|
+
const kept = existing.get(entity);
|
|
67
|
+
if (kept === undefined || (candidate >>> 0) < (kept >>> 0)) existing.set(entity, candidate);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** A table's columns of each part, or null for one without a column of bytes for each. */
|
|
71
|
+
const bytesOf = (table: World.Table, parts: readonly Part[]): KeyColumns | null => {
|
|
72
|
+
const columns: KeyColumns[number][] = [];
|
|
73
|
+
for (const { key } of parts) {
|
|
74
|
+
const column = table.columns[key];
|
|
75
|
+
if (column === undefined || column.kind !== "bytes") return null;
|
|
76
|
+
columns.push(column);
|
|
77
|
+
}
|
|
78
|
+
return columns;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Matches by the bytes of keys' columns, without making text of them, when every table of the snapshot holding the
|
|
83
|
+
* index's entities has columns of bytes for its key; null otherwise. The world's tables, of the same program, then do too.
|
|
84
|
+
*/
|
|
85
|
+
const matchBytes = (world: World, from: Contents, declaration: Index.Declared, parts: readonly Part[]): Matched | null => {
|
|
86
|
+
const tables = from.tables.filter(table => covers(table, declaration));
|
|
87
|
+
const columns = tables.map(table => bytesOf(table, parts));
|
|
88
|
+
if (columns.some(item => item === null)) return null;
|
|
89
|
+
return async (name, existing) => {
|
|
90
|
+
const saved = createKeyTable(tables.reduce((sum, table) => sum + table.count, 0));
|
|
91
|
+
for (let t = 0; t < tables.length; t++) {
|
|
92
|
+
const keys = columns[t];
|
|
93
|
+
if (keys === null) continue;
|
|
94
|
+
addKeyColumns(saved, keys);
|
|
95
|
+
const { entities, count } = tables[t];
|
|
96
|
+
for (let row = 0; row < count; row++) if (!addKey(saved, row, entities[row])) return twoWithOneKey(name);
|
|
97
|
+
}
|
|
98
|
+
if (saved.count === 0) return undefined;
|
|
99
|
+
const read = await readWorld(world, declaration);
|
|
100
|
+
if ("failure" in read) return read;
|
|
101
|
+
for (const table of read) {
|
|
102
|
+
const keys = bytesOf(table, parts);
|
|
103
|
+
if (keys === null) continue;
|
|
104
|
+
for (let row = 0, entry = -1; row < table.count; row++) {
|
|
105
|
+
entry = findKey(saved, keys, row, entry + 1);
|
|
106
|
+
if (entry >= 0) keep(existing, saved.values[entry], table.entities[row]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Matches by keys as text, which any column gives. */
|
|
114
|
+
const matchText = (world: World, from: Contents, declaration: Index.Declared, parts: readonly Part[]): Matched => async (name, existing) => {
|
|
115
|
+
const saved = new Map<string, number>();
|
|
116
|
+
for (const table of from.tables) {
|
|
117
|
+
if (!covers(table, declaration)) continue;
|
|
118
|
+
const keyAt = keysOf(table, parts);
|
|
119
|
+
if (keyAt === null) continue;
|
|
120
|
+
for (let row = 0; row < table.count; row++) {
|
|
121
|
+
const key = keyAt(row);
|
|
122
|
+
if (saved.has(key)) return twoWithOneKey(name);
|
|
123
|
+
saved.set(key, table.entities[row]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (saved.size === 0) return undefined;
|
|
127
|
+
const read = await readWorld(world, declaration);
|
|
128
|
+
if ("failure" in read) return read;
|
|
129
|
+
for (const table of read) {
|
|
130
|
+
const keyAt = keysOf(table, parts);
|
|
131
|
+
if (keyAt === null) continue;
|
|
132
|
+
for (let row = 0; row < table.count; row++) {
|
|
133
|
+
const entity = saved.get(keyAt(row));
|
|
134
|
+
if (entity !== undefined) keep(existing, entity, table.entities[row]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Each of the snapshot's entities that `indexes` say is the same as one of `world`'s, to that one: an index holds both,
|
|
142
|
+
* with equal keys. Fails, as data, for an index whose key, `with` or `not` names a component the snapshot's trait doesn't
|
|
143
|
+
* cover, or two of the snapshot's entities one index holds with one key.
|
|
144
|
+
*/
|
|
145
|
+
export const existingFor = async (world: World, from: Contents, indexes: readonly Matching[]): Promise<ReadonlyMap<number, number> | Failure> => {
|
|
146
|
+
const existing = new Map<number, number>();
|
|
147
|
+
const persisted = new Map(from.components.map(item => [item.key, item]));
|
|
148
|
+
for (const index of indexes) {
|
|
149
|
+
const declaration = Index.declarationOf(index);
|
|
150
|
+
if (declaration === undefined) return { failure: `${index.name} isn't an index.` };
|
|
151
|
+
const parts: Part[] = [];
|
|
152
|
+
for (const key of declaration.key) {
|
|
153
|
+
const found = persisted.get(key);
|
|
154
|
+
if (found === undefined) return { failure: `Index "${index.name}" can't match: a part of its key isn't one the snapshot's trait covers, so the snapshot holds no key for it.` };
|
|
155
|
+
parts.push({ key, schema: found.schema });
|
|
156
|
+
}
|
|
157
|
+
if ([...declaration.components.with ?? [], ...declaration.components.not ?? []].some(key => !persisted.has(key))) {
|
|
158
|
+
return { failure: `Index "${index.name}" can't match: its with or not names a component the snapshot's trait doesn't cover, so the snapshot can't tell which of its entities the index holds.` };
|
|
159
|
+
}
|
|
160
|
+
const matched = matchBytes(world, from, declaration, parts) ?? matchText(world, from, declaration, parts);
|
|
161
|
+
const found = await matched(index.name, existing);
|
|
162
|
+
if (found !== undefined) return found;
|
|
163
|
+
}
|
|
164
|
+
return existing;
|
|
165
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import type { Component, Resource, World } from "@adobe/ecs";
|
|
3
|
+
import type { Persisted } from "./program.js";
|
|
4
|
+
|
|
5
|
+
/** Where a snapshot holds what it holds, a key only this package uses. */
|
|
6
|
+
export const contents: unique symbol = Symbol("snapshot contents");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a trait covers of some of a world's entities: of each archetype, its entities and a column of each component
|
|
10
|
+
* the trait covers that it holds, keyed by this program's keys; and the values of the resources it covers.
|
|
11
|
+
*/
|
|
12
|
+
export type Contents = {
|
|
13
|
+
/** The key of the trait it was taken or read by. */
|
|
14
|
+
readonly by: symbol;
|
|
15
|
+
/** Each component the trait covers that a table of it may hold. */
|
|
16
|
+
readonly components: readonly Persisted<Component.Key>[];
|
|
17
|
+
readonly tables: readonly World.Table[];
|
|
18
|
+
readonly resources: readonly { readonly persisted: Persisted<Resource.Key>; readonly value: unknown }[];
|
|
19
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import { Component, Resource, type Schema, type Traits } from "@adobe/ecs";
|
|
3
|
+
|
|
4
|
+
/** A key a trait covers, with the name the trait saves it under and its schema. */
|
|
5
|
+
export type Persisted<K extends symbol = symbol> = { readonly name: string; readonly key: K; readonly schema: Schema };
|
|
6
|
+
|
|
7
|
+
/** What a trait covers: its components and resources by name. */
|
|
8
|
+
export type Program = {
|
|
9
|
+
readonly by: symbol;
|
|
10
|
+
readonly components: ReadonlyMap<string, Persisted<Component.Key>>;
|
|
11
|
+
readonly resources: ReadonlyMap<string, Persisted<Resource.Key>>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type Keyed = Component.Key | Resource.Key;
|
|
15
|
+
|
|
16
|
+
/** A trait of an application's own whose value names a key: its key a symbol, its value a string. */
|
|
17
|
+
export type NamingTrait = { readonly [T in keyof Traits<Keyed>]: T extends symbol ? [Traits<Keyed>[T]] extends [string] ? T : never : never }[keyof Traits<Keyed>];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The keys of `declarations` with the trait `by`, by the name it gives each, or why one can't be saved: the trait's
|
|
21
|
+
* value isn't a string, or the key isn't data, is derived, or has a name another has.
|
|
22
|
+
*/
|
|
23
|
+
const namedIn = <K extends symbol>(declarations: ReadonlyMap<K, Component.Declared>, kind: string, by: symbol): ReadonlyMap<string, Persisted<K>> | string => {
|
|
24
|
+
const named = new Map<string, Persisted<K>>();
|
|
25
|
+
for (const [key, declaration] of declarations) {
|
|
26
|
+
const name: unknown = Reflect.get(declaration, by);
|
|
27
|
+
if (name === undefined) continue;
|
|
28
|
+
if (typeof name !== "string") return `A ${kind}'s value of the trait isn't a name: ${String(name)}.`;
|
|
29
|
+
if (declaration.type === undefined) return `The ${kind} the trait names "${name}" isn't data: it has no schema.`;
|
|
30
|
+
if (declaration.derived === true) return `The ${kind} the trait names "${name}" is derived, which its rule derives again once its entity is loaded.`;
|
|
31
|
+
if (named.has(name)) return `The trait names two ${kind}s "${name}".`;
|
|
32
|
+
named.set(name, { name, key, schema: declaration.type });
|
|
33
|
+
}
|
|
34
|
+
return named;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** What the trait `by` covers, as the modules registered so far declare it, or why it can't be saved. */
|
|
38
|
+
export const programOf = (by: unknown): Program | string => {
|
|
39
|
+
if (typeof by !== "symbol") return "`by` is the key of the trait to save by, a symbol.";
|
|
40
|
+
const components = namedIn(Component.declarations(), "component", by);
|
|
41
|
+
if (typeof components === "string") return components;
|
|
42
|
+
const resources = namedIn(Resource.declarations(), "resource", by);
|
|
43
|
+
if (typeof resources === "string") return resources;
|
|
44
|
+
return { by, components, resources };
|
|
45
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import { Component, System, type Entity, type Resource, type World } from "@adobe/ecs";
|
|
3
|
+
import type { Failure } from "../failure.js";
|
|
4
|
+
import type { Contents } from "../program/contents.js";
|
|
5
|
+
import type { Program } from "../program/program.js";
|
|
6
|
+
|
|
7
|
+
/** Values of shared components, which archetypes hold one of each: `where` as a system's of values. */
|
|
8
|
+
type Where = { readonly [K in Component.With<"shared">]?: Component<K> };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Which entities a snapshot takes: those matching `with`, `not` and `where`, every one holding a component its trait
|
|
12
|
+
* covers by default; or `root` and every entity it owns, directly or through others, by owner references its trait
|
|
13
|
+
* covers.
|
|
14
|
+
*/
|
|
15
|
+
export type Selection =
|
|
16
|
+
| { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; readonly where?: Where; readonly resources?: boolean }
|
|
17
|
+
| { readonly root: Entity };
|
|
18
|
+
|
|
19
|
+
/** Which tables a snapshot reads: those of the components its trait covers, matching `with`, `not` and `where`, and its resources. */
|
|
20
|
+
type Reading = {
|
|
21
|
+
readonly components: readonly Component.Key[];
|
|
22
|
+
readonly with?: readonly Component.Key[];
|
|
23
|
+
readonly not?: readonly Component.Key[];
|
|
24
|
+
readonly where?: Where;
|
|
25
|
+
readonly resources?: readonly Resource.Key[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A readonly system reading the tables `declaration` names. */
|
|
29
|
+
const tablesSystemOf = (declaration: Reading) =>
|
|
30
|
+
System.create({ readonly: true, tables: declaration }, ({ tables }) => tables);
|
|
31
|
+
|
|
32
|
+
/** The rows of `table` whose entities `keep` takes, as a table of their own. */
|
|
33
|
+
const rowsOf = (table: World.Table, keep: (entity: number) => boolean): World.Table | null => {
|
|
34
|
+
const rows: number[] = [];
|
|
35
|
+
for (let row = 0; row < table.count; row++) if (keep(table.entities[row])) rows.push(row);
|
|
36
|
+
if (rows.length === table.count) return table;
|
|
37
|
+
if (rows.length === 0) return null;
|
|
38
|
+
const columns: { [key: symbol]: World.Table.Column } = {};
|
|
39
|
+
for (const key of Object.getOwnPropertySymbols(table.columns)) {
|
|
40
|
+
const column: World.Table.Column | undefined = Reflect.get(table.columns, key);
|
|
41
|
+
if (column === undefined) continue;
|
|
42
|
+
if (column.kind === "value") columns[key] = column;
|
|
43
|
+
else if (column.kind === "values") columns[key] = { kind: "values", values: rows.map(row => column.values[row]) };
|
|
44
|
+
else {
|
|
45
|
+
const { stride } = column;
|
|
46
|
+
const bytes = new Uint8Array(rows.length * stride);
|
|
47
|
+
rows.forEach((row, at) => bytes.set(column.bytes.subarray(row * stride, (row + 1) * stride), at * stride));
|
|
48
|
+
columns[key] = { kind: "bytes", bytes, stride };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { count: rows.length, entities: Float64Array.from(rows, row => table.entities[row]), columns };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The entities `root` owns, itself included, through the owner references among `owners` in the tables. */
|
|
55
|
+
const subtreeOf = (tables: readonly World.Table[], owners: readonly Component.Key[], root: Entity): ReadonlySet<number> => {
|
|
56
|
+
const children = new Map<number, number[]>();
|
|
57
|
+
for (const table of tables) {
|
|
58
|
+
for (const reference of owners) {
|
|
59
|
+
const key: symbol = reference;
|
|
60
|
+
const column: World.Table.Column | undefined = Reflect.get(table.columns, key);
|
|
61
|
+
if (column?.kind !== "bytes") continue;
|
|
62
|
+
const view = new DataView(column.bytes.buffer, column.bytes.byteOffset, column.bytes.byteLength);
|
|
63
|
+
for (let row = 0; row < table.count; row++) {
|
|
64
|
+
const owner = view.getFloat64(row * column.stride, true);
|
|
65
|
+
const list = children.get(owner);
|
|
66
|
+
if (list === undefined) children.set(owner, [table.entities[row]]);
|
|
67
|
+
else list.push(table.entities[row]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const taken = new Set<number>([root]);
|
|
72
|
+
const waiting: number[] = [root];
|
|
73
|
+
for (let next = waiting.pop(); next !== undefined; next = waiting.pop()) {
|
|
74
|
+
for (const child of children.get(next) ?? []) {
|
|
75
|
+
if (taken.has(child)) continue;
|
|
76
|
+
taken.add(child);
|
|
77
|
+
waiting.push(child);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return taken;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** What `program`'s trait covers of the entities `selection` takes from `world`, on its thread, or why it can't be read. */
|
|
84
|
+
export const select = async (world: World, program: Program, selection: Selection): Promise<Contents | Failure> => {
|
|
85
|
+
const components = [...program.components.values()];
|
|
86
|
+
const keys = components.map(persisted => persisted.key);
|
|
87
|
+
const subtree = "root" in selection;
|
|
88
|
+
const resources = subtree || selection.resources === false ? [] : [...program.resources.values()];
|
|
89
|
+
const declaration: Reading = subtree
|
|
90
|
+
? { components: keys }
|
|
91
|
+
: { components: keys, with: selection.with ?? [], not: selection.not ?? [], where: selection.where ?? {}, resources: resources.map(persisted => persisted.key) };
|
|
92
|
+
const failed = (error: unknown): Failure => ({ failure: error instanceof Error ? error.message : String(error) });
|
|
93
|
+
let system: ReturnType<typeof tablesSystemOf>;
|
|
94
|
+
try {
|
|
95
|
+
system = tablesSystemOf(declaration);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
// A `where` naming what isn't a shared component, or giving one a value not of its type.
|
|
99
|
+
return failed(error);
|
|
100
|
+
}
|
|
101
|
+
const read = await world.run(system).catch(failed);
|
|
102
|
+
if ("failure" in read) return read;
|
|
103
|
+
let tables = read.archetypes;
|
|
104
|
+
if (subtree) {
|
|
105
|
+
const owners = keys.filter(key => Component.declarations().get(key)?.reference === "owner");
|
|
106
|
+
const taken = subtreeOf(tables, owners, selection.root);
|
|
107
|
+
tables = tables.flatMap(table => rowsOf(table, entity => taken.has(entity)) ?? []);
|
|
108
|
+
}
|
|
109
|
+
return { by: program.by, components, tables, resources: resources.map(persisted => ({ persisted, value: read.resources[persisted.key] })) };
|
|
110
|
+
};
|
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import type { World } from "@adobe/ecs";
|
|
3
|
+
import type { Failure } from "./failure.js";
|
|
4
|
+
import { contents, type Contents } from "./program/contents.js";
|
|
5
|
+
import { programOf, type NamingTrait } from "./program/program.js";
|
|
6
|
+
import { select, type Selection } from "./selection/selection.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a trait covers of some of a world's entities and of its resources, which `encode` and `spawn` take, and the
|
|
10
|
+
* trait it was taken or read by.
|
|
11
|
+
*/
|
|
12
|
+
export type Snapshot = { readonly failure?: undefined; readonly [contents]: Contents };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A snapshot of some of `world`'s entities, read on its thread, by the trait `by`: a trait of the application's own,
|
|
16
|
+
* whose value on a component's or resource's declaration is the name it's saved under. Of each entity it takes, it
|
|
17
|
+
* takes the components the trait covers. By default it takes every entity holding one of them, and the resources the
|
|
18
|
+
* trait covers; with `with` and `not`, those a query matches, and the resources unless `resources` is false; with
|
|
19
|
+
* `where`, those of the archetypes holding its values of shared components, as a system's `where` of values keeps it to
|
|
20
|
+
* them, read whole, so a snapshot of one value's entities costs what they hold, not the world; with `root`, it and every
|
|
21
|
+
* entity it owns, through owner references the trait covers, without resources. Fails, as data, when `by` isn't a
|
|
22
|
+
* symbol, what the trait covers can't be saved, `where` names what isn't a shared component or gives one a value not
|
|
23
|
+
* of its type, or the world can't be read.
|
|
24
|
+
*/
|
|
25
|
+
export const snapshot = async (input: { readonly world: World; readonly by: NamingTrait } & Selection): Promise<Snapshot | Failure> => {
|
|
26
|
+
const program = programOf(input.by);
|
|
27
|
+
if (typeof program === "string") return { failure: program };
|
|
28
|
+
const taken = await select(input.world, program, input);
|
|
29
|
+
return "failure" in taken ? taken : { [contents]: taken };
|
|
30
|
+
};
|
package/src/spawn.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import { System, type Entity, type World } from "@adobe/ecs";
|
|
3
|
+
import type { Failure } from "./failure.js";
|
|
4
|
+
import { existingFor, type Matching } from "./match/match.js";
|
|
5
|
+
import { contents } from "./program/contents.js";
|
|
6
|
+
import type { Snapshot } from "./snapshot.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What `spawn` made: how many of the snapshot's entities it didn't spawn, those owned by an entity outside it; and,
|
|
10
|
+
* unless asked not to, `entities`, each of the snapshot's entities to the one spawned for it, the one `match` found
|
|
11
|
+
* for it, or null for one it didn't spawn.
|
|
12
|
+
*/
|
|
13
|
+
export type Spawned<Entities extends boolean = true> = { readonly failure?: undefined; readonly dropped: number }
|
|
14
|
+
& (Entities extends false ? {} : { readonly entities: ReadonlyMap<Entity, Entity | null> });
|
|
15
|
+
|
|
16
|
+
const isTables = (value: unknown): value is readonly World.Table[] => Array.isArray(value) && value.every(table =>
|
|
17
|
+
typeof table === "object" && table !== null && typeof Reflect.get(table, "count") === "number" && Reflect.get(table, "entities") instanceof Float64Array
|
|
18
|
+
&& typeof Reflect.get(table, "columns") === "object");
|
|
19
|
+
|
|
20
|
+
const isKeyed = (value: unknown): value is { readonly [key: symbol]: unknown } => typeof value === "object" && value !== null;
|
|
21
|
+
|
|
22
|
+
/** `spawnTables` takes entities by their numbers: a map of the snapshot's to the world's holds only numbers. */
|
|
23
|
+
const isEntities = (value: unknown): value is ReadonlyMap<Entity, Entity> => {
|
|
24
|
+
if (!(value instanceof Map)) return false;
|
|
25
|
+
for (const [saved, found] of value) if (typeof saved !== "number" || typeof found !== "number") return false;
|
|
26
|
+
return true;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Spawns its input's tables and sets its resources, a snapshot's, rewriting their entities, those `existing` maps standing for theirs. */
|
|
30
|
+
const spawnTables = System.create({}, ({ commands }, input: { readonly tables: unknown; readonly resources: unknown; readonly existing: unknown }) => {
|
|
31
|
+
if (!isTables(input.tables) || !isKeyed(input.resources) || !isEntities(input.existing)) throw new Error("A snapshot holds tables and resources.");
|
|
32
|
+
return commands.spawnTables(input.tables, { resources: input.resources, existing: input.existing });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Spawns an entity in `world` for each of the snapshot's, with the components its trait covers, and sets the resources
|
|
37
|
+
* it holds, on the world's thread, as the world's references would leave them had the entities outside the snapshot
|
|
38
|
+
* despawned: each entity value naming one of the snapshot's names the one spawned for it; an entity owned, directly
|
|
39
|
+
* or through others, by an entity outside the snapshot isn't spawned, which the result counts; a weak reference to
|
|
40
|
+
* one is left out; and any other entity value naming one is `Entity.none`.
|
|
41
|
+
* - `match`: unique indexes saying which of the snapshot's entities are already in `world`: one an index holds there,
|
|
42
|
+
* with the key of one of the snapshot's it holds, stands for it, keeping its own values, and isn't spawned again. It
|
|
43
|
+
* fails for an index whose key, `with` or `not` names a component the snapshot's trait doesn't cover, or two of the
|
|
44
|
+
* snapshot's entities with one key.
|
|
45
|
+
* - `entities: false` leaves out the result's map of entities, which costs about 85 ms and 50 MB at a million.
|
|
46
|
+
*
|
|
47
|
+
* Fails, as data, for a column or resource that doesn't fit, spawning nothing.
|
|
48
|
+
*/
|
|
49
|
+
export function spawn(input: Spawning & { readonly entities: false }): Promise<Spawned<false> | Failure>;
|
|
50
|
+
export function spawn(input: Spawning & { readonly entities?: true }): Promise<Spawned | Failure>;
|
|
51
|
+
export async function spawn(input: Spawning & { readonly entities?: boolean }): Promise<Spawned<boolean> | Failure> {
|
|
52
|
+
const from = input.snapshot[contents];
|
|
53
|
+
const { tables, resources } = from;
|
|
54
|
+
const values: { [key: symbol]: unknown } = {};
|
|
55
|
+
for (const { persisted, value } of resources) values[persisted.key] = value;
|
|
56
|
+
const existing = input.match === undefined || input.match.length === 0 ? new Map<number, number>() : await existingFor(input.world, from, input.match);
|
|
57
|
+
if ("failure" in existing) return existing;
|
|
58
|
+
const spawned = await input.world.run(spawnTables, { tables, resources: values, existing }).catch((error: unknown) => ({ failure: error instanceof Error ? error.message : String(error) }));
|
|
59
|
+
if ("failure" in spawned) return spawned;
|
|
60
|
+
if (input.entities === false) return { dropped: spawned.dropped };
|
|
61
|
+
return { dropped: spawned.dropped, entities: entitiesOf(tables, spawned.entities) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What `spawn` takes: the world spawned into, the snapshot, and the indexes saying which of its entities are there already. */
|
|
65
|
+
type Spawning = { readonly world: World; readonly snapshot: Snapshot; readonly match?: readonly Matching[] };
|
|
66
|
+
|
|
67
|
+
/** Each of the tables' entities to the one spawned for it, the one existing for it, or null for one that wasn't. */
|
|
68
|
+
const entitiesOf = (tables: readonly World.Table[], spawned: readonly Float64Array[]): ReadonlyMap<Entity, Entity | null> => {
|
|
69
|
+
const entities = new Map<Entity, Entity | null>();
|
|
70
|
+
tables.forEach((table, t) => {
|
|
71
|
+
const made = spawned[t];
|
|
72
|
+
for (let row = 0; row < table.count; row++) {
|
|
73
|
+
// A table's entities and `spawnTables`' are entities, `Entity.none`, 0, for a row it didn't spawn.
|
|
74
|
+
const entity = made[row] as Entity;
|
|
75
|
+
entities.set(table.entities[row] as Entity, entity === 0 ? null : entity);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return entities;
|
|
79
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import type { Schema } from "@adobe/ecs";
|
|
3
|
+
|
|
4
|
+
// Values of components stored as values, strings and other data that isn't a fixed layout, as JSON their schema reads
|
|
5
|
+
// back: a map as its entries, a set as its items, numbers JSON lacks as text, and a union's value as its member's
|
|
6
|
+
// index and its JSON.
|
|
7
|
+
|
|
8
|
+
/** A number JSON can't hold, as text: `NaN`, `Infinity`, `-Infinity` or `-0`. */
|
|
9
|
+
const numberToJson = (value: number): number | string =>
|
|
10
|
+
Number.isNaN(value) ? "NaN" : value === Infinity ? "Infinity" : value === -Infinity ? "-Infinity" : Object.is(value, -0) ? "-0" : value;
|
|
11
|
+
|
|
12
|
+
const numberFromJson = (json: unknown): unknown => {
|
|
13
|
+
if (typeof json !== "string") return json;
|
|
14
|
+
switch (json) {
|
|
15
|
+
case "NaN": return NaN;
|
|
16
|
+
case "Infinity": return Infinity;
|
|
17
|
+
case "-Infinity": return -Infinity;
|
|
18
|
+
case "-0": return -0;
|
|
19
|
+
default: return json;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Whether a value is of the JSON kind a schema's values are, to pick a union's member. */
|
|
24
|
+
const fits = (schema: Schema, value: unknown): boolean => {
|
|
25
|
+
if ("const" in schema) return Object.is(schema.const, value) || JSON.stringify(schema.const) === JSON.stringify(value);
|
|
26
|
+
if ("anyOf" in schema) return schema.anyOf.some(member => fits(member, value));
|
|
27
|
+
if ("allOf" in schema) return schema.allOf.every(member => fits(member, value));
|
|
28
|
+
if (!("type" in schema)) return true;
|
|
29
|
+
switch (schema.type) {
|
|
30
|
+
case "number": case "integer": return typeof value === "number";
|
|
31
|
+
case "string": return typeof value === "string";
|
|
32
|
+
case "boolean": return typeof value === "boolean";
|
|
33
|
+
case "null": return value === null;
|
|
34
|
+
case "array": return Array.isArray(value);
|
|
35
|
+
case "set": return value instanceof Set;
|
|
36
|
+
case "map": return value instanceof Map;
|
|
37
|
+
case "object": return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Set) && !(value instanceof Map);
|
|
38
|
+
default: return true;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** A value of `schema` as JSON. */
|
|
43
|
+
export const toJson = (schema: Schema, value: unknown): unknown => {
|
|
44
|
+
if ("anyOf" in schema) {
|
|
45
|
+
const index = schema.anyOf.findIndex(candidate => fits(candidate, value));
|
|
46
|
+
const member = schema.anyOf[index];
|
|
47
|
+
return member === undefined ? [-1, value] : [index, toJson(member, value)];
|
|
48
|
+
}
|
|
49
|
+
if (typeof value === "number") return numberToJson(value);
|
|
50
|
+
if (!("type" in schema)) return value;
|
|
51
|
+
switch (schema.type) {
|
|
52
|
+
case "array": return Array.isArray(value) ? value.map(item => toJson(schema.items, item)) : value;
|
|
53
|
+
case "set": return value instanceof Set ? Array.from(value, item => toJson(schema.items, item)) : value;
|
|
54
|
+
case "map": return value instanceof Map ? Array.from(value, ([key, item]) => [toJson(schema.keys, key), toJson(schema.values, item)]) : value;
|
|
55
|
+
case "object": {
|
|
56
|
+
if (typeof value !== "object" || value === null) return value;
|
|
57
|
+
const json: { [name: string]: unknown } = {};
|
|
58
|
+
for (const name of Object.keys(value)) {
|
|
59
|
+
const field = schema.properties[name];
|
|
60
|
+
const item: unknown = Reflect.get(value, name);
|
|
61
|
+
if (item !== undefined) json[name] = field === undefined ? item : toJson(field, item);
|
|
62
|
+
}
|
|
63
|
+
return json;
|
|
64
|
+
}
|
|
65
|
+
default: return value;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** A value of `schema` read back from its JSON. */
|
|
70
|
+
export const fromJson = (schema: Schema, json: unknown): unknown => {
|
|
71
|
+
if ("anyOf" in schema) {
|
|
72
|
+
if (!Array.isArray(json) || typeof json[0] !== "number") return json;
|
|
73
|
+
const member = schema.anyOf[json[0]];
|
|
74
|
+
return member === undefined ? json[1] : fromJson(member, json[1]);
|
|
75
|
+
}
|
|
76
|
+
if ("const" in schema) return schema.const;
|
|
77
|
+
if (!("type" in schema)) return json;
|
|
78
|
+
switch (schema.type) {
|
|
79
|
+
case "number": case "integer": return numberFromJson(json);
|
|
80
|
+
case "array": return Array.isArray(json) ? json.map(item => fromJson(schema.items, item)) : json;
|
|
81
|
+
case "set": return Array.isArray(json) ? new Set(json.map(item => fromJson(schema.items, item))) : json;
|
|
82
|
+
case "map": return Array.isArray(json)
|
|
83
|
+
? new Map(json.map(entry => Array.isArray(entry) ? [fromJson(schema.keys, entry[0]), fromJson(schema.values, entry[1])] : [entry, undefined]))
|
|
84
|
+
: json;
|
|
85
|
+
case "object": {
|
|
86
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) return json;
|
|
87
|
+
const value: { [name: string]: unknown } = {};
|
|
88
|
+
for (const name of Object.keys(json)) {
|
|
89
|
+
const field = schema.properties[name];
|
|
90
|
+
const item: unknown = Reflect.get(json, name);
|
|
91
|
+
value[name] = field === undefined ? item : fromJson(field, item);
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
default: return json;
|
|
96
|
+
}
|
|
97
|
+
};
|