@vampgg/cli 1.0.0-beta.1
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 +20 -0
- package/dist/generate-mutation-schema-DPVvPX4h.mjs +1080 -0
- package/dist/index.d.mts +153 -0
- package/dist/index.mjs +2 -0
- package/dist/vamp.d.mts +1 -0
- package/dist/vamp.mjs +279 -0
- package/package.json +54 -0
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
5
|
+
import { BinarySchema } from "bebop";
|
|
6
|
+
//#region src/config/loader.ts
|
|
7
|
+
/** Parse jsonc, throwing a clear error (with offsets) on any syntax error. */
|
|
8
|
+
function parseJsoncStrict(raw, resolved) {
|
|
9
|
+
const errors = [];
|
|
10
|
+
const config = parse(raw, errors, { allowTrailingComma: true });
|
|
11
|
+
if (errors.length) throw new Error(`Failed to parse ${resolved}: ${errors.map((e) => `${printParseErrorCode(e.error)}@${e.offset}`).join(", ")}`);
|
|
12
|
+
if (!config) throw new Error(`Failed to parse ${resolved}: empty document`);
|
|
13
|
+
return config;
|
|
14
|
+
}
|
|
15
|
+
function loadBebopConfig(cwd, configPath) {
|
|
16
|
+
const resolved = resolve(cwd, configPath ?? "bebop.json");
|
|
17
|
+
let raw;
|
|
18
|
+
try {
|
|
19
|
+
raw = readFileSync(resolved, "utf-8");
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error(`bebop.json not found at ${resolved}`);
|
|
22
|
+
}
|
|
23
|
+
return parseJsoncStrict(raw, resolved);
|
|
24
|
+
}
|
|
25
|
+
function loadVampConfig(cwd) {
|
|
26
|
+
const configPath = resolve(cwd, "vamp.json");
|
|
27
|
+
let raw;
|
|
28
|
+
try {
|
|
29
|
+
raw = readFileSync(configPath, "utf-8");
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error(`vamp.json not found in ${cwd}`);
|
|
32
|
+
}
|
|
33
|
+
const config = parseJsoncStrict(raw, configPath);
|
|
34
|
+
const { schemas, outFile } = config;
|
|
35
|
+
if (!schemas?.entity || !schemas?.actions || !schemas?.state || !schemas?.tags) throw new Error("vamp.schemas must define entity, actions, state, and tags paths");
|
|
36
|
+
if (!outFile) throw new Error("vamp.outFile is required");
|
|
37
|
+
return config;
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/generators/parse-bop.ts
|
|
41
|
+
const WireBaseType = {
|
|
42
|
+
[-1]: "bool",
|
|
43
|
+
[-2]: "byte",
|
|
44
|
+
[-3]: "uint16",
|
|
45
|
+
[-4]: "int16",
|
|
46
|
+
[-5]: "uint32",
|
|
47
|
+
[-6]: "int32",
|
|
48
|
+
[-7]: "uint64",
|
|
49
|
+
[-8]: "int64",
|
|
50
|
+
[-9]: "float32",
|
|
51
|
+
[-10]: "float64",
|
|
52
|
+
[-11]: "string",
|
|
53
|
+
[-12]: "guid",
|
|
54
|
+
[-13]: "date"
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Scalar type names that only appear in *source* form (never as a distinct
|
|
58
|
+
* wire base type). `uint8` is a bebop alias for `byte`; both are accepted in
|
|
59
|
+
* `.bop` source but compile to the same wire type.
|
|
60
|
+
*/
|
|
61
|
+
const SOURCE_ONLY_SCALARS = ["uint8"];
|
|
62
|
+
const WireTypeKind = {
|
|
63
|
+
Struct: 1,
|
|
64
|
+
Message: 2,
|
|
65
|
+
Union: 3,
|
|
66
|
+
Enum: 4
|
|
67
|
+
};
|
|
68
|
+
function resolveTypeName(typeId, schema) {
|
|
69
|
+
if (typeId < 0) return WireBaseType[typeId] ?? `unknown(${typeId})`;
|
|
70
|
+
return schema.getDefinition(typeId).name;
|
|
71
|
+
}
|
|
72
|
+
function parseField(name, field, schema) {
|
|
73
|
+
const props = field.fieldProperties;
|
|
74
|
+
const isArray = props.type === "array";
|
|
75
|
+
const isMap = props.type === "map";
|
|
76
|
+
let typeName;
|
|
77
|
+
let memberTypeName;
|
|
78
|
+
let keyTypeName;
|
|
79
|
+
let valueTypeName;
|
|
80
|
+
if (isArray) {
|
|
81
|
+
typeName = "array";
|
|
82
|
+
memberTypeName = resolveTypeName(props.memberTypeId, schema);
|
|
83
|
+
} else if (isMap) {
|
|
84
|
+
typeName = "map";
|
|
85
|
+
keyTypeName = resolveTypeName(props.keyTypeId, schema);
|
|
86
|
+
valueTypeName = resolveTypeName(props.valueTypeId, schema);
|
|
87
|
+
} else typeName = resolveTypeName(field.typeId, schema);
|
|
88
|
+
return {
|
|
89
|
+
name,
|
|
90
|
+
typeId: field.typeId,
|
|
91
|
+
isArray,
|
|
92
|
+
isMap,
|
|
93
|
+
typeName,
|
|
94
|
+
memberTypeName,
|
|
95
|
+
keyTypeName,
|
|
96
|
+
valueTypeName,
|
|
97
|
+
constantValue: field.constantValue
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function parseSchema(bebopSchemaBytes) {
|
|
101
|
+
const schema = new BinarySchema(bebopSchemaBytes);
|
|
102
|
+
schema.get();
|
|
103
|
+
const ast = schema.ast;
|
|
104
|
+
const definitions = /* @__PURE__ */ new Map();
|
|
105
|
+
for (const [name, def] of Object.entries(ast.definitions)) {
|
|
106
|
+
if (def.kind === WireTypeKind.Enum) {
|
|
107
|
+
definitions.set(name, {
|
|
108
|
+
name,
|
|
109
|
+
kind: "enum",
|
|
110
|
+
fields: []
|
|
111
|
+
});
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (def.kind === WireTypeKind.Union) {
|
|
115
|
+
const branches = def.branches.map((b) => ({
|
|
116
|
+
discriminator: b.discriminator,
|
|
117
|
+
typeName: schema.getDefinition(b.typeId).name
|
|
118
|
+
}));
|
|
119
|
+
definitions.set(name, {
|
|
120
|
+
name,
|
|
121
|
+
kind: "union",
|
|
122
|
+
fields: [],
|
|
123
|
+
branches
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const kind = def.kind === WireTypeKind.Struct ? "struct" : "message";
|
|
128
|
+
const fields = [];
|
|
129
|
+
const defWithFields = def;
|
|
130
|
+
if (defWithFields.fields) for (const [fieldName, field] of Object.entries(defWithFields.fields)) fields.push(parseField(fieldName, field, schema));
|
|
131
|
+
definitions.set(name, {
|
|
132
|
+
name,
|
|
133
|
+
kind,
|
|
134
|
+
fields
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return { definitions };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Recover the compiled `BEBOP_SCHEMA` byte array from a generated `bebop.ts`.
|
|
141
|
+
*
|
|
142
|
+
* Every token is validated to be an integer in 0-255; a non-numeric token, a
|
|
143
|
+
* trailing comma, or any format drift throws (naming the bad token) instead of
|
|
144
|
+
* silently coercing `NaN` to `0` and corrupting the binary schema.
|
|
145
|
+
*/
|
|
146
|
+
function scrapeSchema(content, path) {
|
|
147
|
+
const match = content.match(/export const BEBOP_SCHEMA\s*=\s*new Uint8Array\s*\(\s*\[\s*([\s\S]*?)\s*\]\s*\)/);
|
|
148
|
+
if (!match) throw new Error(`Could not find BEBOP_SCHEMA in ${path}`);
|
|
149
|
+
const bytes = match[1].split(",").map((s) => s.trim()).filter((s) => s.length > 0).map((s, i) => {
|
|
150
|
+
const n = Number(s);
|
|
151
|
+
if (!Number.isInteger(n) || n < 0 || n > 255) throw new Error(`BEBOP_SCHEMA byte ${i} ('${s}') in ${path} is not an integer in 0-255; the generated schema is corrupt — re-run bebopc build.`);
|
|
152
|
+
return n;
|
|
153
|
+
});
|
|
154
|
+
return new Uint8Array(bytes);
|
|
155
|
+
}
|
|
156
|
+
function loadSchemaFromFile(bebopTsPath) {
|
|
157
|
+
return scrapeSchema(readFileSync(bebopTsPath, "utf-8"), bebopTsPath);
|
|
158
|
+
}
|
|
159
|
+
function loadAndParseSchema(bebopTsPath) {
|
|
160
|
+
return parseSchema(loadSchemaFromFile(bebopTsPath));
|
|
161
|
+
}
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/generators/emit-components.ts
|
|
164
|
+
/**
|
|
165
|
+
* Emit the component-id map. Ids are derived from the bebop field tag
|
|
166
|
+
* (`constantValue`), NOT the array position, so they are stable across field
|
|
167
|
+
* reorder/insert/remove and align with the wire format. This is a deliberate,
|
|
168
|
+
* breaking change from the previous positional scheme — see plan 14 §4.6.
|
|
169
|
+
*/
|
|
170
|
+
function emitComponents(entity) {
|
|
171
|
+
const entries = [];
|
|
172
|
+
for (const f of entity.fields) {
|
|
173
|
+
if (f.name === "tags") continue;
|
|
174
|
+
const tag = f.constantValue;
|
|
175
|
+
if (tag == null) throw new Error(`Entity field '${f.name}' has no bebop field tag; cannot derive a stable component id.`);
|
|
176
|
+
entries.push(`${f.name}: ${tag}`);
|
|
177
|
+
}
|
|
178
|
+
return `/**
|
|
179
|
+
* Component-id map for this schema, keyed by {@link Entity} field name. Ids come
|
|
180
|
+
* from the bebop field tag (stable across field reorder), not array position.
|
|
181
|
+
* Pass these to queries (\`q.every(components.health)\`) and \`world.get\`/\`put\`.
|
|
182
|
+
*/
|
|
183
|
+
export const components = { ${entries.join(", ")} } as const satisfies Record<keyof Omit<Entity, "tags">, number>;`;
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region src/generators/emit-delta.ts
|
|
187
|
+
/**
|
|
188
|
+
* Canonical scalar vocabulary, derived from the single source of truth
|
|
189
|
+
* (`WireBaseType` in parse-bop.ts) plus the source-only aliases. Deriving it
|
|
190
|
+
* here — rather than re-declaring a parallel list — guarantees the parser and
|
|
191
|
+
* the emitter agree on what counts as a scalar. A drift-guard test asserts
|
|
192
|
+
* every `WireBaseType` name also has a `scalarToTs` case.
|
|
193
|
+
*/
|
|
194
|
+
const SCALAR_TYPES = new Set([...Object.values(WireBaseType), ...SOURCE_ONLY_SCALARS]);
|
|
195
|
+
function scalarToTs(type) {
|
|
196
|
+
switch (type) {
|
|
197
|
+
case "bool": return "boolean";
|
|
198
|
+
case "byte":
|
|
199
|
+
case "uint8":
|
|
200
|
+
case "int16":
|
|
201
|
+
case "uint16":
|
|
202
|
+
case "int32":
|
|
203
|
+
case "uint32":
|
|
204
|
+
case "float32":
|
|
205
|
+
case "float64": return "number";
|
|
206
|
+
case "int64":
|
|
207
|
+
case "uint64": return "bigint";
|
|
208
|
+
case "string":
|
|
209
|
+
case "guid": return "string";
|
|
210
|
+
case "date": return "Date";
|
|
211
|
+
default: return type;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function isScalar(typeName) {
|
|
215
|
+
return SCALAR_TYPES.has(typeName);
|
|
216
|
+
}
|
|
217
|
+
function deltaTypeForField$1(field, schema) {
|
|
218
|
+
if (field.isArray) {
|
|
219
|
+
const itemType = scalarToTs(field.memberTypeName);
|
|
220
|
+
return `{ set?: ${itemType}[]; add?: ${itemType}[]; remove?: ${itemType}[] }`;
|
|
221
|
+
}
|
|
222
|
+
if (isScalar(field.typeName)) return scalarToTs(field.typeName);
|
|
223
|
+
const deltaName = `${field.typeName}Delta`;
|
|
224
|
+
if (schema.definitions.has(deltaName)) return deltaName;
|
|
225
|
+
throw new Error(`No '${deltaName}' found in the compiled schema for Entity field '${field.name}'. The mutation schema generation step should have produced it; re-run 'vamp generate'.`);
|
|
226
|
+
}
|
|
227
|
+
function emitDelta(entity, schema) {
|
|
228
|
+
return `/**
|
|
229
|
+
* Partial, CRDT-style mutation over an {@link Entity}: scalars are
|
|
230
|
+
* last-writer-wins, array fields carry \`set\`/\`add\`/\`remove\`, and pool/vector
|
|
231
|
+
* fields use additive \`*Delta\` counters. Apply with {@link materializeDelta} or
|
|
232
|
+
* {@link mergeDelta}; combine with {@link accumulateDelta}.
|
|
233
|
+
*/
|
|
234
|
+
export type EntityDelta = {\n${entity.fields.map((f) => {
|
|
235
|
+
if (f.name === "tags") return " tags?: Tags[];";
|
|
236
|
+
return ` ${f.name}?: ${deltaTypeForField$1(f, schema)};`;
|
|
237
|
+
}).join("\n")}\n};`;
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/generators/emit-helpers.ts
|
|
241
|
+
function hasDeltaDef(field, schema) {
|
|
242
|
+
return schema.definitions.has(`${field.typeName}Delta`);
|
|
243
|
+
}
|
|
244
|
+
/** True when the entity has at least one array field (so the shared array applier is needed). */
|
|
245
|
+
function needsArrayHelper(entity) {
|
|
246
|
+
return entity.fields.some((f) => f.isArray);
|
|
247
|
+
}
|
|
248
|
+
function needsPoolHelper(entity, schema) {
|
|
249
|
+
return entity.fields.some((f) => !f.isArray && !isScalar(f.typeName) && hasDeltaDef(f, schema));
|
|
250
|
+
}
|
|
251
|
+
/** Type-correct default literal for a component sub-field (used by materializeDelta). */
|
|
252
|
+
function defaultForField(bf) {
|
|
253
|
+
if (bf.isArray) return "[]";
|
|
254
|
+
if (isScalar(bf.typeName)) {
|
|
255
|
+
if (bf.typeName === "string" || bf.typeName === "guid") return "''";
|
|
256
|
+
if (bf.typeName === "bool") return "false";
|
|
257
|
+
if (bf.typeName === "date") return "new Date(0)";
|
|
258
|
+
return "0";
|
|
259
|
+
}
|
|
260
|
+
return "undefined as any";
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Import of the canonical delta-algebra appliers from `@vampgg/ecs`, narrowed to
|
|
264
|
+
* exactly the helpers this entity's fields use. The set/add/remove (array) and
|
|
265
|
+
* additive (pool) semantics live in `@vampgg/ecs` so `materializeDelta`,
|
|
266
|
+
* `mergeDelta`, and `accumulateDelta` cannot drift; the generated code only
|
|
267
|
+
* dispatches per field. Returns "" when the entity has neither array nor pool
|
|
268
|
+
* fields (so no unused import is emitted).
|
|
269
|
+
*/
|
|
270
|
+
function emitHelperImports(entity, schema) {
|
|
271
|
+
const names = [];
|
|
272
|
+
if (needsArrayHelper(entity)) names.push("applyArrayDelta", "accumulateArrayDelta");
|
|
273
|
+
if (needsPoolHelper(entity, schema)) names.push("applyPoolDelta", "accumulatePoolDelta");
|
|
274
|
+
if (names.length === 0) return "";
|
|
275
|
+
return `import { ${names.join(", ")} } from "@vampgg/ecs";`;
|
|
276
|
+
}
|
|
277
|
+
function emitHelpers(entity, schema) {
|
|
278
|
+
return [
|
|
279
|
+
emitMaterializeDelta(entity, schema),
|
|
280
|
+
emitMergeDelta(entity, schema),
|
|
281
|
+
emitAccumulateDelta(entity, schema)
|
|
282
|
+
].filter(Boolean).join("\n\n");
|
|
283
|
+
}
|
|
284
|
+
function emitMaterializeDelta(entity, schema) {
|
|
285
|
+
return `/**
|
|
286
|
+
* Build a full {@link Entity} from a {@link EntityDelta} and optional \`base\`,
|
|
287
|
+
* honoring array set/add/remove and additive pool semantics.
|
|
288
|
+
*/
|
|
289
|
+
export function materializeDelta(delta: EntityDelta, base?: Partial<Entity>): Entity {
|
|
290
|
+
return {
|
|
291
|
+
${entity.fields.map((f) => {
|
|
292
|
+
if (f.name === "tags") return ` tags: delta.tags ?? base?.tags ?? []`;
|
|
293
|
+
if (f.isArray) return ` ${f.name}: applyArrayDelta(base?.${f.name} ?? [], delta.${f.name})`;
|
|
294
|
+
if (!isScalar(f.typeName) && hasDeltaDef(f, schema)) {
|
|
295
|
+
const baseMsg = schema.definitions.get(f.typeName);
|
|
296
|
+
const defaultFields = baseMsg ? baseMsg.fields.map((bf) => `${bf.name}: ${defaultForField(bf)}`).join(", ") : "";
|
|
297
|
+
return ` ${f.name}: delta.${f.name} ? applyPoolDelta(base?.${f.name} ?? { ${defaultFields} }, delta.${f.name} as Record<string, number>) : base?.${f.name} ?? { ${defaultFields} }`;
|
|
298
|
+
}
|
|
299
|
+
if (isScalar(f.typeName)) {
|
|
300
|
+
const defaultVal = f.typeName === "string" || f.typeName === "guid" ? "''" : "0";
|
|
301
|
+
return ` ${f.name}: delta.${f.name} ?? base?.${f.name} ?? ${defaultVal}`;
|
|
302
|
+
}
|
|
303
|
+
return ` ${f.name}: delta.${f.name} ? { ...base?.${f.name}, ...delta.${f.name} } as any : base?.${f.name} as any`;
|
|
304
|
+
}).join(",\n")},
|
|
305
|
+
} as Entity;
|
|
306
|
+
}`;
|
|
307
|
+
}
|
|
308
|
+
function emitMergeDelta(entity, schema) {
|
|
309
|
+
return `/**
|
|
310
|
+
* Apply \`delta\` onto \`entity\` in place — same set/add/remove and additive pool
|
|
311
|
+
* rules as {@link materializeDelta}.
|
|
312
|
+
*/
|
|
313
|
+
export function mergeDelta(entity: Entity, delta: EntityDelta): void {
|
|
314
|
+
${entity.fields.map((f) => {
|
|
315
|
+
if (f.name === "tags") return ` if (delta.tags !== undefined) entity.tags = delta.tags;`;
|
|
316
|
+
if (f.isArray) return ` if (delta.${f.name}) entity.${f.name} = applyArrayDelta(entity.${f.name} ?? [], delta.${f.name});`;
|
|
317
|
+
if (!isScalar(f.typeName) && hasDeltaDef(f, schema)) return ` if (delta.${f.name}) entity.${f.name} = applyPoolDelta((entity.${f.name} ?? {}) as Record<string, number>, delta.${f.name} as Record<string, number>) as Entity[${JSON.stringify(f.name)}];`;
|
|
318
|
+
if (isScalar(f.typeName)) return ` if (delta.${f.name} !== undefined) entity.${f.name} = delta.${f.name};`;
|
|
319
|
+
return ` if (delta.${f.name}) Object.assign(entity.${f.name} ??= {} as any, delta.${f.name});`;
|
|
320
|
+
}).join("\n")}
|
|
321
|
+
}`;
|
|
322
|
+
}
|
|
323
|
+
function emitAccumulateDelta(entity, schema) {
|
|
324
|
+
return `/**
|
|
325
|
+
* Fold \`from\` into \`to\`, collapsing two {@link EntityDelta}s into one equivalent
|
|
326
|
+
* delta (array/pool deltas combine additively). Returns the mutated \`to\`.
|
|
327
|
+
*/
|
|
328
|
+
export function accumulateDelta(from: EntityDelta, to: EntityDelta): EntityDelta {
|
|
329
|
+
${entity.fields.map((f) => {
|
|
330
|
+
if (f.name === "tags") return ` if (from.tags !== undefined) to.tags = from.tags;`;
|
|
331
|
+
if (f.isArray) return ` if (from.${f.name}) to.${f.name} = accumulateArrayDelta(to.${f.name}, from.${f.name});`;
|
|
332
|
+
if (!isScalar(f.typeName) && hasDeltaDef(f, schema)) return ` if (from.${f.name}) to.${f.name} = accumulatePoolDelta(to.${f.name} as Record<string, number> | undefined, from.${f.name} as Record<string, number>) as EntityDelta[${JSON.stringify(f.name)}];`;
|
|
333
|
+
if (isScalar(f.typeName)) return ` if (from.${f.name} !== undefined) to.${f.name} = from.${f.name};`;
|
|
334
|
+
return ` if (from.${f.name}) to.${f.name} = { ...to.${f.name}, ...from.${f.name} };`;
|
|
335
|
+
}).join("\n")}
|
|
336
|
+
return to;
|
|
337
|
+
}`;
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/generators/emit-factory.ts
|
|
341
|
+
function emitFactory() {
|
|
342
|
+
return `/**
|
|
343
|
+
* {@link ECSOptions} for this schema — wires the {@link components} map and the
|
|
344
|
+
* delta algebra ({@link materializeDelta}/{@link mergeDelta}/{@link accumulateDelta})
|
|
345
|
+
* into an ECS world. \`createId\` mints new entity ids.
|
|
346
|
+
*/
|
|
347
|
+
export function createECSOptions(createId: () => string): ECSOptions<Entity, EntityDelta> {
|
|
348
|
+
return { createId, components, materializeDelta, mergeDelta, accumulateDelta };
|
|
349
|
+
}`;
|
|
350
|
+
}
|
|
351
|
+
//#endregion
|
|
352
|
+
//#region src/generators/emit-classes.ts
|
|
353
|
+
function emitClasses(tagsType = "number") {
|
|
354
|
+
return `/**
|
|
355
|
+
* App-typed {@link ECSDurableObject}: this schema's \`Actions\`/\`Tags\`/\`Entity\`/
|
|
356
|
+
* \`EntityDelta\` are baked in, leaving \`UserSession\`/\`Context\`/\`UpdateArguments\`
|
|
357
|
+
* open. Subclass this as your game's durable object.
|
|
358
|
+
*/
|
|
359
|
+
export class GameECS<
|
|
360
|
+
UserSession extends {} = {},
|
|
361
|
+
Context extends {} = {},
|
|
362
|
+
UpdateArguments extends Array<unknown> = [],
|
|
363
|
+
> extends ECSDurableObject<
|
|
364
|
+
UserSession,
|
|
365
|
+
Context,
|
|
366
|
+
UpdateArguments,
|
|
367
|
+
Actions,
|
|
368
|
+
${tagsType},
|
|
369
|
+
Entity,
|
|
370
|
+
EntityDelta,
|
|
371
|
+
Cloudflare.Env
|
|
372
|
+
> {}
|
|
373
|
+
|
|
374
|
+
/** App-typed {@link ECSStorage} over this schema's {@link Entity}. */
|
|
375
|
+
export class GameStorage extends ECSStorage<Entity> {}`;
|
|
376
|
+
}
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region src/generators/emit-game-context.ts
|
|
379
|
+
/**
|
|
380
|
+
* Emits the `GameContext` type alias exported near the bottom of the generated
|
|
381
|
+
* file. It describes the server-environment context provided to RPC method
|
|
382
|
+
* implementations, i.e. the tuple `[ECS<...>, WebSocket]` that the durable
|
|
383
|
+
* object hands to the tempo router. RPC methods retrieve it via
|
|
384
|
+
* `const [ecs, ws] = ctx.getEnvironment<GameContext>()`.
|
|
385
|
+
*/
|
|
386
|
+
function emitGameContext() {
|
|
387
|
+
return `/**
|
|
388
|
+
* Server environment handed to RPC methods, app-typed for this schema. Retrieve
|
|
389
|
+
* it inside a method with \`const [ecs, ws] = ctx.getEnvironment<GameContext>()\` —
|
|
390
|
+
* the \`[ECS, WebSocket]\` tuple carrying the live world.
|
|
391
|
+
*/
|
|
392
|
+
export type GameContext<
|
|
393
|
+
UserSession extends {} = {},
|
|
394
|
+
Context extends Record<string, unknown> = {},
|
|
395
|
+
UpdateArguments extends Array<unknown> = [],
|
|
396
|
+
> = RPCContext<UserSession, Context, UpdateArguments, Actions, Tags, Entity, EntityDelta>;`;
|
|
397
|
+
}
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/generators/emit-interest.ts
|
|
400
|
+
/**
|
|
401
|
+
* Emits the app-typed interest-managed broadcast wrapper exported near the bottom
|
|
402
|
+
* of the generated file. It binds the schema's concrete `Entity`/`EntityDelta`/
|
|
403
|
+
* `MutationScope` into the worker's generic `createInterestBroadcast` and supplies
|
|
404
|
+
* the two app-specific defaults a developer would otherwise hand-write: the bebop
|
|
405
|
+
* `encodeBatch` codec (which the worker package cannot import — it is per-app
|
|
406
|
+
* codegen), and a first-key `resolveViewer` (the convention where the client
|
|
407
|
+
* passes its viewer entity id as the first key of the observe request scope's
|
|
408
|
+
* `mutations` map). The interest policy (`canSee`) is left to the app; with no
|
|
409
|
+
* args every observer sees every mutation (global broadcast).
|
|
410
|
+
*
|
|
411
|
+
* A wrapper *function* (not a type alias) is required so the bound defaults exist
|
|
412
|
+
* at runtime and `UserSession`/`Context`/`UpdateArguments` stay open at the call
|
|
413
|
+
* site, mirroring `defineGameECSRuntime`/`GameContext`.
|
|
414
|
+
*/
|
|
415
|
+
function emitInterest() {
|
|
416
|
+
return `/**
|
|
417
|
+
* Interest-managed mutation broadcast bound to this schema. Supplies the bebop
|
|
418
|
+
* \`encodeBatch\` codec and a first-key \`resolveViewer\` default; pass \`canSee\` to
|
|
419
|
+
* scope what each observer sees, or omit \`config\` for a global broadcast.
|
|
420
|
+
*/
|
|
421
|
+
export function createGameInterestBroadcast<
|
|
422
|
+
UserSession extends {} = {},
|
|
423
|
+
Context extends Record<string, unknown> = {},
|
|
424
|
+
UpdateArguments extends Array<unknown> = [],
|
|
425
|
+
>(
|
|
426
|
+
config: Partial<
|
|
427
|
+
InterestBroadcastConfig<
|
|
428
|
+
GameContext<UserSession, Context, UpdateArguments>[0],
|
|
429
|
+
MutationScope,
|
|
430
|
+
Entity,
|
|
431
|
+
EntityDelta
|
|
432
|
+
>
|
|
433
|
+
> = {},
|
|
434
|
+
) {
|
|
435
|
+
return createInterestBroadcast<
|
|
436
|
+
GameContext<UserSession, Context, UpdateArguments>[0],
|
|
437
|
+
MutationScope,
|
|
438
|
+
MutationScope,
|
|
439
|
+
Entity,
|
|
440
|
+
EntityDelta
|
|
441
|
+
>({
|
|
442
|
+
encodeBatch: (batch: MutationBatch<Entity, EntityDelta>): Uint8Array =>
|
|
443
|
+
new Uint8Array(
|
|
444
|
+
MutationScope.encode(
|
|
445
|
+
MutationScope({ mutations: batch as unknown as Map<string, MutationRecord> }),
|
|
446
|
+
),
|
|
447
|
+
),
|
|
448
|
+
resolveViewer: (record: MutationScope): string | undefined => {
|
|
449
|
+
if (!record.mutations) return undefined;
|
|
450
|
+
for (const key of record.mutations.keys()) return key;
|
|
451
|
+
return undefined;
|
|
452
|
+
},
|
|
453
|
+
...config,
|
|
454
|
+
});
|
|
455
|
+
}`;
|
|
456
|
+
}
|
|
457
|
+
//#endregion
|
|
458
|
+
//#region src/generators/emit-runtime.ts
|
|
459
|
+
/**
|
|
460
|
+
* Emits the app-typed runtime wrappers exported near the bottom of the generated
|
|
461
|
+
* file. These bake the schema's concrete `Actions`/`Tags`/`Entity`/`EntityDelta`
|
|
462
|
+
* into the worker's generic {@link ECSRuntimeConfiguration}/{@link ECSRuntimeProvider}
|
|
463
|
+
* (leaving `UserSession`/`Context`/`UpdateArguments` open, mirroring `GameECS`/
|
|
464
|
+
* `GameContext`), and provide a `defineGameECSRuntime` wrapper so the worker entry
|
|
465
|
+
* gets call-site type safety on `ecs`, `context`, `registerSystems`, `tickArgs`,
|
|
466
|
+
* and `broadcastTick`.
|
|
467
|
+
*
|
|
468
|
+
* A wrapper *function* is required: a type alias alone cannot constrain the
|
|
469
|
+
* worker's generic `defineECSRuntime`, so the provider's return type would not be
|
|
470
|
+
* inferred at the call site.
|
|
471
|
+
*/
|
|
472
|
+
function emitRuntime() {
|
|
473
|
+
return `/** App-typed {@link ECSRuntimeConfiguration} — the worker runtime config with this schema's types baked in. */
|
|
474
|
+
export type GameECSRuntimeConfiguration<
|
|
475
|
+
UserSession extends {} = {},
|
|
476
|
+
Context extends Record<string, unknown> = {},
|
|
477
|
+
UpdateArguments extends Array<unknown> = [],
|
|
478
|
+
> = ECSRuntimeConfiguration<UserSession, Context, UpdateArguments, Actions, Tags, Entity, EntityDelta>;
|
|
479
|
+
|
|
480
|
+
/** Factory returning a {@link GameECSRuntimeConfiguration}; pass it to {@link defineGameECSRuntime}. */
|
|
481
|
+
export type GameECSRuntimeProvider<
|
|
482
|
+
UserSession extends {} = {},
|
|
483
|
+
Context extends Record<string, unknown> = {},
|
|
484
|
+
UpdateArguments extends Array<unknown> = [],
|
|
485
|
+
> = () => GameECSRuntimeConfiguration<UserSession, Context, UpdateArguments>;
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Register the worker's runtime provider with call-site type safety on \`ecs\`,
|
|
489
|
+
* \`context\`, \`registerSystems\`, \`tickArgs\`, and \`broadcastTick\`. Call once from
|
|
490
|
+
* the worker entry.
|
|
491
|
+
*/
|
|
492
|
+
export function defineGameECSRuntime<
|
|
493
|
+
UserSession extends {} = {},
|
|
494
|
+
Context extends Record<string, unknown> = {},
|
|
495
|
+
UpdateArguments extends Array<unknown> = [],
|
|
496
|
+
>(provider: GameECSRuntimeProvider<UserSession, Context, UpdateArguments>): void {
|
|
497
|
+
defineECSRuntime(provider);
|
|
498
|
+
}`;
|
|
499
|
+
}
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region src/generators/emit-systems.ts
|
|
502
|
+
/**
|
|
503
|
+
* Emits app-typed aliases for the generic system types and factories from
|
|
504
|
+
* `@vampgg/ecs` (`System.ts`). Each underlying type is generic over
|
|
505
|
+
* `<State, UpdateArguments, Actions, Tags, E, D>`; this bakes the schema's
|
|
506
|
+
* concrete `Actions`/`Tags`/`Entity`/`EntityDelta` into the last four slots and
|
|
507
|
+
* leaves `State`/`UpdateArguments` open (mirroring `GameECS`/`GameContext`). The
|
|
508
|
+
* point is developer ergonomics: game code calls `createGameEntitySystem(...)`
|
|
509
|
+
* and gets a fully-typed `world` inside the executor without ever restating
|
|
510
|
+
* `Tags`, `Entity`, or `EntityDelta`.
|
|
511
|
+
*
|
|
512
|
+
* The `createGame*` wrappers are *functions* (not just aliases) so the call site
|
|
513
|
+
* keeps inference on `State`/`UpdateArguments` while the concrete types stay
|
|
514
|
+
* pinned — a type alias alone cannot constrain the generic factory. Wrapper
|
|
515
|
+
* parameter types are read back off the emitted aliases (e.g.
|
|
516
|
+
* `GameEntitySystem<...>["execute"]`) so they cannot drift from `System.ts` and
|
|
517
|
+
* we avoid importing `ECS`/`Archetype`/`CustomAction` just to restate them.
|
|
518
|
+
*/
|
|
519
|
+
function emitSystems() {
|
|
520
|
+
return `/**
|
|
521
|
+
* {@link EntitySystem} for this schema — runs per matching entity each update.
|
|
522
|
+
* \`Actions\`/\`Tags\`/\`Entity\`/\`EntityDelta\` are baked in; \`State\`/\`UpdateArguments\`
|
|
523
|
+
* stay open. Build one with {@link createGameEntitySystem}.
|
|
524
|
+
*/
|
|
525
|
+
export type GameEntitySystem<
|
|
526
|
+
State extends Record<string, unknown> = {},
|
|
527
|
+
UpdateArguments extends Array<unknown> = [],
|
|
528
|
+
> = EntitySystem<State, UpdateArguments, Actions, Tags, Entity, EntityDelta>;
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* {@link ArchetypeSystem} for this schema — runs once per update over every
|
|
532
|
+
* matching archetype. Build one with {@link createGameArchetypeSystem}.
|
|
533
|
+
*/
|
|
534
|
+
export type GameArchetypeSystem<
|
|
535
|
+
State extends Record<string, unknown> = {},
|
|
536
|
+
UpdateArguments extends Array<unknown> = [],
|
|
537
|
+
> = ArchetypeSystem<State, UpdateArguments, Actions, Tags, Entity, EntityDelta>;
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* {@link Behavior} for this schema — an event-driven handler keyed by an action
|
|
541
|
+
* tag. Build one with {@link createGameBehavior}.
|
|
542
|
+
*/
|
|
543
|
+
export type GameBehavior<
|
|
544
|
+
State extends Record<string, unknown> = {},
|
|
545
|
+
UpdateArguments extends Array<unknown> = [],
|
|
546
|
+
> = Behavior<State, UpdateArguments, Actions, Tags, Entity, EntityDelta>;
|
|
547
|
+
|
|
548
|
+
/** Any system for this schema: entity | archetype | event | lifecycle | behavior. */
|
|
549
|
+
export type GameSystem<
|
|
550
|
+
State extends Record<string, unknown> = {},
|
|
551
|
+
UpdateArguments extends Array<unknown> = [],
|
|
552
|
+
> = System<State, UpdateArguments, Actions, Tags, Entity, EntityDelta>;
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Create a per-entity system. \`world\` and the entity shape are typed for this
|
|
556
|
+
* schema, so the executor never restates \`Tags\`/\`Entity\`/\`EntityDelta\`. Specify
|
|
557
|
+
* \`<State, UpdateArguments>\` only when the system reads context or update args.
|
|
558
|
+
*/
|
|
559
|
+
export function createGameEntitySystem<
|
|
560
|
+
State extends Record<string, unknown> = {},
|
|
561
|
+
UpdateArguments extends Array<unknown> = [],
|
|
562
|
+
>(
|
|
563
|
+
execute: GameEntitySystem<State, UpdateArguments>["execute"],
|
|
564
|
+
query: Query | ((buildQuery: QueryBuilder) => QueryBuilder),
|
|
565
|
+
): GameEntitySystem<State, UpdateArguments> {
|
|
566
|
+
return createEntitySystem(execute, query);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Create an archetype system — runs once per update over the matching
|
|
571
|
+
* archetypes. \`world\` is typed for this schema; see {@link createGameEntitySystem}.
|
|
572
|
+
*/
|
|
573
|
+
export function createGameArchetypeSystem<
|
|
574
|
+
State extends Record<string, unknown> = {},
|
|
575
|
+
UpdateArguments extends Array<unknown> = [],
|
|
576
|
+
>(
|
|
577
|
+
execute: GameArchetypeSystem<State, UpdateArguments>["execute"],
|
|
578
|
+
query: Query | ((buildQuery: QueryBuilder) => QueryBuilder),
|
|
579
|
+
): GameArchetypeSystem<State, UpdateArguments> {
|
|
580
|
+
return createArchetypeSystem(execute, query);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Create an action-tag behavior. \`handler(world, entity, event)\` runs when
|
|
585
|
+
* \`act(tag)\` strikes a matching entity; higher \`priority\` runs first.
|
|
586
|
+
*/
|
|
587
|
+
export function createGameBehavior<
|
|
588
|
+
State extends Record<string, unknown> = {},
|
|
589
|
+
UpdateArguments extends Array<unknown> = [],
|
|
590
|
+
>(
|
|
591
|
+
tag: number,
|
|
592
|
+
handler: GameBehavior<State, UpdateArguments>["handler"],
|
|
593
|
+
query: Query | ((buildQuery: QueryBuilder) => QueryBuilder),
|
|
594
|
+
priority?: number,
|
|
595
|
+
): GameBehavior<State, UpdateArguments> {
|
|
596
|
+
return createBehavior(tag, handler, query, priority);
|
|
597
|
+
}`;
|
|
598
|
+
}
|
|
599
|
+
//#endregion
|
|
600
|
+
//#region src/generators/codegen.ts
|
|
601
|
+
function generate(cwd, config, vampConfig) {
|
|
602
|
+
const schema = loadAndParseSchema(resolve(cwd, config.generators?.ts?.outFile ?? "./src/bebop.ts"));
|
|
603
|
+
const entityDef = schema.definitions.get("Entity");
|
|
604
|
+
if (!entityDef) throw new Error("No 'Entity' definition found in schema");
|
|
605
|
+
if (!schema.definitions.get("Actions")) throw new Error("No 'Actions' definition found in schema");
|
|
606
|
+
if (!schema.definitions.get("State")) throw new Error("No 'State' definition found in schema");
|
|
607
|
+
if (!schema.definitions.get("Tags")) throw new Error("No 'Tags' definition found in schema");
|
|
608
|
+
const bebopImportTypes = collectBebopImportTypes(entityDef, schema);
|
|
609
|
+
const bebopImport = "./bebop";
|
|
610
|
+
const helperImports = emitHelperImports(entityDef, schema);
|
|
611
|
+
const output = [
|
|
612
|
+
`// This file is auto-generated by @vampgg/cli. Do not edit manually.`,
|
|
613
|
+
`import type { ECSOptions, MutationBatch, EntitySystem, ArchetypeSystem, Behavior, System, Query, QueryBuilder } from "@vampgg/ecs";`,
|
|
614
|
+
`import { createEntitySystem, createArchetypeSystem, createBehavior } from "@vampgg/ecs";`,
|
|
615
|
+
...helperImports ? [helperImports] : [],
|
|
616
|
+
`import { defineECSRuntime, ECSDurableObject, ECSStorage, type ECSRuntimeConfiguration, type RPCContext } from "@vampgg/worker";`,
|
|
617
|
+
`import { createInterestBroadcast, type InterestBroadcastConfig } from "@vampgg/worker/interest";`,
|
|
618
|
+
`import type { Entity, Actions, Tags${bebopImportTypes} } from "${bebopImport}";`,
|
|
619
|
+
`import { MutationScope, type MutationRecord } from "${bebopImport}";`,
|
|
620
|
+
"",
|
|
621
|
+
emitComponents(entityDef),
|
|
622
|
+
"",
|
|
623
|
+
emitDelta(entityDef, schema),
|
|
624
|
+
"",
|
|
625
|
+
emitHelpers(entityDef, schema),
|
|
626
|
+
"",
|
|
627
|
+
emitFactory(),
|
|
628
|
+
"",
|
|
629
|
+
emitClasses("Tags"),
|
|
630
|
+
"",
|
|
631
|
+
emitGameContext(),
|
|
632
|
+
"",
|
|
633
|
+
emitRuntime(),
|
|
634
|
+
"",
|
|
635
|
+
emitSystems(),
|
|
636
|
+
"",
|
|
637
|
+
emitInterest(),
|
|
638
|
+
""
|
|
639
|
+
].join("\n");
|
|
640
|
+
const outPath = resolve(cwd, vampConfig.outFile);
|
|
641
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
642
|
+
writeFileSync(outPath, output, "utf-8");
|
|
643
|
+
return outPath;
|
|
644
|
+
}
|
|
645
|
+
/** Candidate custom type names a field references (base, array member, or map value). */
|
|
646
|
+
function candidateTypeNames(field) {
|
|
647
|
+
if (field.isArray) return field.memberTypeName ? [field.memberTypeName] : [];
|
|
648
|
+
if (field.isMap) return field.valueTypeName ? [field.valueTypeName] : [];
|
|
649
|
+
return [field.typeName];
|
|
650
|
+
}
|
|
651
|
+
function collectBebopImportTypes(entityDef, schema) {
|
|
652
|
+
const types = /* @__PURE__ */ new Set();
|
|
653
|
+
for (const field of entityDef.fields) for (const typeName of candidateTypeNames(field)) {
|
|
654
|
+
const def = schema.definitions.get(typeName);
|
|
655
|
+
if (def && def.name !== "Entity" && def.name !== "Actions" && def.name !== "State" && def.name !== "Tags") {
|
|
656
|
+
const deltaName = `${def.name}Delta`;
|
|
657
|
+
if (schema.definitions.has(deltaName)) types.add(deltaName);
|
|
658
|
+
else types.add(def.name);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
if (types.size === 0) return "";
|
|
662
|
+
return ", " + [...types].join(", ");
|
|
663
|
+
}
|
|
664
|
+
//#endregion
|
|
665
|
+
//#region src/generators/parse-bop-source.ts
|
|
666
|
+
/** A bebop identifier per the grammar `[A-Za-z_][A-Za-z0-9_]*`. */
|
|
667
|
+
const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
668
|
+
/** Escape regex metacharacters so an arbitrary name can be embedded in a RegExp. */
|
|
669
|
+
function escapeRegExp(s) {
|
|
670
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
671
|
+
}
|
|
672
|
+
/** Strip line comments (`//`) and block comments (`/* */`) from bebop source. */
|
|
673
|
+
function stripComments(source) {
|
|
674
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Remove nested brace blocks (inline messages/structs) from a message body,
|
|
678
|
+
* leaving only top-level declarations. This prevents an inline `message X {...}`
|
|
679
|
+
* inside `Entity` from leaking its fields in as top-level Entity fields.
|
|
680
|
+
*/
|
|
681
|
+
function stripNestedBlocks(body) {
|
|
682
|
+
let out = "";
|
|
683
|
+
let depth = 0;
|
|
684
|
+
for (const ch of body) {
|
|
685
|
+
if (ch === "{") {
|
|
686
|
+
depth++;
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (ch === "}") {
|
|
690
|
+
depth--;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (depth === 0) out += ch;
|
|
694
|
+
}
|
|
695
|
+
return out;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Extract the body of `message <name> { ... }` from bebop source text.
|
|
699
|
+
* Returns null if the message is not found.
|
|
700
|
+
*/
|
|
701
|
+
function extractMessageBody(source, messageName) {
|
|
702
|
+
if (!IDENTIFIER_RE.test(messageName)) throw new Error(`Invalid message name '${messageName}': bebop identifiers must match [A-Za-z_][A-Za-z0-9_]*.`);
|
|
703
|
+
const clean = stripComments(source);
|
|
704
|
+
const match = new RegExp(`(?:^|\\b)message\\s+${escapeRegExp(messageName)}\\s*\\{`, "m").exec(clean);
|
|
705
|
+
if (!match) return null;
|
|
706
|
+
let depth = 0;
|
|
707
|
+
let start = -1;
|
|
708
|
+
for (let i = match.index + match[0].length - 1; i < clean.length; i++) {
|
|
709
|
+
const ch = clean[i];
|
|
710
|
+
if (ch === "{") {
|
|
711
|
+
if (depth === 0) start = i + 1;
|
|
712
|
+
depth++;
|
|
713
|
+
} else if (ch === "}") {
|
|
714
|
+
depth--;
|
|
715
|
+
if (depth === 0) return clean.slice(start, i);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
function classifyType(rawType) {
|
|
721
|
+
const type = rawType.trim();
|
|
722
|
+
const mapMatch = /^map\s*\[\s*([^,\]]+?)\s*,\s*(.+)\s*\]$/.exec(type);
|
|
723
|
+
if (mapMatch) {
|
|
724
|
+
const keyRaw = mapMatch[1].trim();
|
|
725
|
+
const valueRaw = mapMatch[2].trim();
|
|
726
|
+
return {
|
|
727
|
+
typeName: "map",
|
|
728
|
+
isArray: false,
|
|
729
|
+
isMap: true,
|
|
730
|
+
keyType: keyRaw,
|
|
731
|
+
valueType: valueRaw,
|
|
732
|
+
valueClassified: classifyType(valueRaw),
|
|
733
|
+
isScalar: false
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
if (type.endsWith("[]")) {
|
|
737
|
+
const member = type.slice(0, -2).trim();
|
|
738
|
+
return {
|
|
739
|
+
typeName: member,
|
|
740
|
+
isArray: true,
|
|
741
|
+
isMap: false,
|
|
742
|
+
memberType: member,
|
|
743
|
+
memberClassified: classifyType(member),
|
|
744
|
+
isScalar: isScalar(member)
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
typeName: type,
|
|
749
|
+
isArray: false,
|
|
750
|
+
isMap: false,
|
|
751
|
+
isScalar: isScalar(type)
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Parse `message <name>` field declarations from bebop source text.
|
|
756
|
+
*
|
|
757
|
+
* This is intentionally a lightweight, syntax-focused parser (no import
|
|
758
|
+
* resolution). It only understands the constrained field syntax used in
|
|
759
|
+
* generated/authored entity schemas: `<index> -> <type> <name>;`.
|
|
760
|
+
*
|
|
761
|
+
* Nested/inline message blocks inside the message body are stripped before
|
|
762
|
+
* field extraction; their fields are NOT emitted as fields of this message. A
|
|
763
|
+
* warning is logged so the user knows an inline component was ignored.
|
|
764
|
+
*/
|
|
765
|
+
function parseMessage(source, messageName) {
|
|
766
|
+
const rawBody = extractMessageBody(source, messageName);
|
|
767
|
+
if (rawBody === null) return null;
|
|
768
|
+
if (/\bmessage\s+[A-Za-z_]|\bstruct\s+[A-Za-z_]/.test(rawBody)) console.warn(`Warning: an inline message/struct block inside '${messageName}' was ignored. Inline components are not supported; declare it as a top-level message and reference it by name.`);
|
|
769
|
+
const body = stripNestedBlocks(rawBody);
|
|
770
|
+
const fields = [];
|
|
771
|
+
const fieldRe = /(\d+)\s*->\s*(.+?)\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/g;
|
|
772
|
+
let m;
|
|
773
|
+
while ((m = fieldRe.exec(body)) !== null) {
|
|
774
|
+
const index = Number.parseInt(m[1], 10);
|
|
775
|
+
const rawType = m[2].trim();
|
|
776
|
+
const name = m[3];
|
|
777
|
+
fields.push({
|
|
778
|
+
index,
|
|
779
|
+
name,
|
|
780
|
+
rawType,
|
|
781
|
+
...classifyType(rawType)
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
return {
|
|
785
|
+
name: messageName,
|
|
786
|
+
fields
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
/** Convenience wrapper for the `Entity` message. */
|
|
790
|
+
function parseEntityMessage(source) {
|
|
791
|
+
const entity = parseMessage(source, "Entity");
|
|
792
|
+
if (!entity) throw new Error("No 'message Entity { ... }' found in entity schema source");
|
|
793
|
+
return entity;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Collect the names of every top-level `message <Name>` (not inline) declared
|
|
797
|
+
* in the given source. Used to discover user-supplied `<Type>Delta` messages
|
|
798
|
+
* and component message definitions.
|
|
799
|
+
*/
|
|
800
|
+
function collectMessageNames(source) {
|
|
801
|
+
const clean = stripComments(source);
|
|
802
|
+
const names = /* @__PURE__ */ new Set();
|
|
803
|
+
const re = /\bmessage\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/g;
|
|
804
|
+
let depth = 0;
|
|
805
|
+
let lastIndex = 0;
|
|
806
|
+
let m;
|
|
807
|
+
const tally = (from, to) => {
|
|
808
|
+
for (let i = from; i < to; i++) if (clean[i] === "{") depth++;
|
|
809
|
+
else if (clean[i] === "}") depth--;
|
|
810
|
+
};
|
|
811
|
+
while ((m = re.exec(clean)) !== null) {
|
|
812
|
+
tally(lastIndex, m.index);
|
|
813
|
+
if (depth === 0) names.add(m[1]);
|
|
814
|
+
lastIndex = m.index;
|
|
815
|
+
}
|
|
816
|
+
return names;
|
|
817
|
+
}
|
|
818
|
+
//#endregion
|
|
819
|
+
//#region src/generators/emit-mutation-bop.ts
|
|
820
|
+
const HEADER = `// AUTO-GENERATED by @vampgg/cli. Do not edit manually.
|
|
821
|
+
// Regenerated from the Entity message on every \`vamp generate\`.`;
|
|
822
|
+
function capitalize(name) {
|
|
823
|
+
return name.length === 0 ? name : name[0].toUpperCase() + name.slice(1);
|
|
824
|
+
}
|
|
825
|
+
/** Message name for a per-member-type array delta (e.g. "guid" -> "GuidArrayDelta"). */
|
|
826
|
+
function arrayDeltaName(memberType) {
|
|
827
|
+
return `${capitalize(memberType)}ArrayDelta`;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* True for a component sub-field that should be treated as a CRDT signed
|
|
831
|
+
* counter in a synthesized delta (any integer/float scalar). Non-numeric
|
|
832
|
+
* scalars (string/guid/bool/date) and custom fields are last-write-wins.
|
|
833
|
+
*/
|
|
834
|
+
function isCounterField(field) {
|
|
835
|
+
if (!field.isScalar) return false;
|
|
836
|
+
return field.typeName !== "string" && field.typeName !== "guid" && field.typeName !== "bool" && field.typeName !== "date";
|
|
837
|
+
}
|
|
838
|
+
function planCustomDelta(field, userDeltas) {
|
|
839
|
+
const deltaName = `${field.typeName}Delta`;
|
|
840
|
+
if (userDeltas.has(deltaName)) return {
|
|
841
|
+
deltaName,
|
|
842
|
+
emit: false
|
|
843
|
+
};
|
|
844
|
+
return {
|
|
845
|
+
deltaName,
|
|
846
|
+
emit: true
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Synthesize a `<Type>Delta` message from a component message. Numeric fields
|
|
851
|
+
* become signed `int32` CRDT deltas (mirroring the hand-written `PoolDelta`);
|
|
852
|
+
* non-numeric fields are last-write-wins (their raw type).
|
|
853
|
+
*/
|
|
854
|
+
function emitSynthesizedDelta(component) {
|
|
855
|
+
const fields = component.fields.map((f) => {
|
|
856
|
+
const t = isCounterField(f) ? "int32" : f.rawType;
|
|
857
|
+
return ` ${f.index} -> ${t} ${f.name};`;
|
|
858
|
+
}).join("\n");
|
|
859
|
+
return `message ${component.name}Delta {\n${fields}\n}`;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Resolve the bebop delta type for an Entity field. Mirrors the TS delta
|
|
863
|
+
* mapping in emit-delta.ts so the bebop wire type stays structurally aligned
|
|
864
|
+
* with the in-memory TS EntityDelta type.
|
|
865
|
+
*/
|
|
866
|
+
function deltaTypeForField(field, userDeltas) {
|
|
867
|
+
if (field.name === "tags") return field.rawType;
|
|
868
|
+
if (field.isArray) {
|
|
869
|
+
if (field.isScalar) return arrayDeltaName(field.memberType);
|
|
870
|
+
return field.rawType;
|
|
871
|
+
}
|
|
872
|
+
if (field.isMap) return field.rawType;
|
|
873
|
+
if (field.isScalar) return field.typeName;
|
|
874
|
+
return planCustomDelta(field, userDeltas).deltaName;
|
|
875
|
+
}
|
|
876
|
+
/** Collect the distinct scalar-array member types that need a delta message. */
|
|
877
|
+
function collectArrayDeltaMembers(entity) {
|
|
878
|
+
const members = /* @__PURE__ */ new Set();
|
|
879
|
+
for (const field of entity.fields) {
|
|
880
|
+
if (field.name === "tags") continue;
|
|
881
|
+
if (field.isArray && field.isScalar) members.add(field.memberType);
|
|
882
|
+
}
|
|
883
|
+
return [...members];
|
|
884
|
+
}
|
|
885
|
+
function emitArrayDeltaMessage(memberType) {
|
|
886
|
+
return `message ${arrayDeltaName(memberType)} {
|
|
887
|
+
1 -> ${memberType}[] set;
|
|
888
|
+
2 -> ${memberType}[] add;
|
|
889
|
+
3 -> ${memberType}[] remove;
|
|
890
|
+
}`;
|
|
891
|
+
}
|
|
892
|
+
function emitEntityDelta(entity, userDeltas) {
|
|
893
|
+
return `message EntityDelta {\n${entity.fields.map((f) => ` ${f.index} -> ${deltaTypeForField(f, userDeltas)} ${f.name};`).join("\n")}\n}`;
|
|
894
|
+
}
|
|
895
|
+
const MUTATION_TYPE = `enum MutationType {
|
|
896
|
+
Insert = 1;
|
|
897
|
+
Update = 2;
|
|
898
|
+
Delete = 3;
|
|
899
|
+
}`;
|
|
900
|
+
const MUTATION_RECORD = `union MutationRecord {
|
|
901
|
+
1 -> struct Insert {
|
|
902
|
+
Entity entity;
|
|
903
|
+
}
|
|
904
|
+
2 -> struct Update {
|
|
905
|
+
EntityDelta delta;
|
|
906
|
+
}
|
|
907
|
+
3 -> struct Delete {
|
|
908
|
+
Entity entity;
|
|
909
|
+
}
|
|
910
|
+
}`;
|
|
911
|
+
const MUTATION_SCOPE = `message MutationScope {
|
|
912
|
+
1 -> map[guid, MutationRecord] mutations;
|
|
913
|
+
}`;
|
|
914
|
+
function planMutationSchema(entity, userDeltas, components, entitySchemaPath) {
|
|
915
|
+
const synthesized = [];
|
|
916
|
+
const seen = /* @__PURE__ */ new Set();
|
|
917
|
+
for (const field of entity.fields) {
|
|
918
|
+
if (field.name === "tags" || field.isArray || field.isMap || field.isScalar) continue;
|
|
919
|
+
const plan = planCustomDelta(field, userDeltas);
|
|
920
|
+
if (!plan.emit) continue;
|
|
921
|
+
const component = components.get(field.typeName);
|
|
922
|
+
if (!component) throw new Error(`Entity field '${field.name}: ${field.typeName}' is a custom component but no '${plan.deltaName}' was found and its definition could not be located to synthesize one. Declare a '${plan.deltaName}' message or move it into a schema file reachable from '${entitySchemaPath}'.`);
|
|
923
|
+
const nonNumeric = component.fields.filter((f) => !isCounterField(f));
|
|
924
|
+
if (nonNumeric.length > 0) throw new Error(`Cannot synthesize '${plan.deltaName}' for Entity field '${field.name}': component '${field.typeName}' has non-numeric field(s) [${nonNumeric.map((f) => `${f.name}: ${f.rawType}`).join(", ")}]. Declare a '${plan.deltaName}' message explicitly in a schema reachable from '${entitySchemaPath}'.`);
|
|
925
|
+
if (!seen.has(plan.deltaName)) {
|
|
926
|
+
seen.add(plan.deltaName);
|
|
927
|
+
synthesized.push(emitSynthesizedDelta(component));
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return { synthesized };
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Emit the bebop schema providing serializable EntityDelta + MutationScope
|
|
934
|
+
* types, derived from the Entity message.
|
|
935
|
+
*
|
|
936
|
+
* @param entity Parsed Entity message.
|
|
937
|
+
* @param entityImportPath Import path to the entity schema, relative to the
|
|
938
|
+
* emitted file (e.g. "./entity.bop").
|
|
939
|
+
* @param userDeltas Set of `<Type>Delta` message names already reachable from
|
|
940
|
+
* the user's schema (so they are reused verbatim, not re-synthesized).
|
|
941
|
+
* @param components Map of component name -> parsed component message, used to
|
|
942
|
+
* synthesize a `<Type>Delta` when one is not user-supplied.
|
|
943
|
+
* @param entitySchemaPath Absolute path to the entity schema (for error text).
|
|
944
|
+
*/
|
|
945
|
+
function emitMutationSchema(entity, entityImportPath, userDeltas = /* @__PURE__ */ new Set(), components = /* @__PURE__ */ new Map(), entitySchemaPath = "<entity schema>") {
|
|
946
|
+
const plan = planMutationSchema(entity, userDeltas, components, entitySchemaPath);
|
|
947
|
+
const sections = [HEADER, `import "${entityImportPath}"`];
|
|
948
|
+
for (const member of collectArrayDeltaMembers(entity)) sections.push(emitArrayDeltaMessage(member));
|
|
949
|
+
for (const block of plan.synthesized) sections.push(block);
|
|
950
|
+
sections.push(emitEntityDelta(entity, userDeltas), MUTATION_TYPE, MUTATION_RECORD, MUTATION_SCOPE);
|
|
951
|
+
return sections.join("\n\n") + "\n";
|
|
952
|
+
}
|
|
953
|
+
//#endregion
|
|
954
|
+
//#region src/generators/resolve-imports.ts
|
|
955
|
+
/**
|
|
956
|
+
* Extract the import paths from a bebop source file. Bebop imports look like
|
|
957
|
+
* `import "../relative/path.bop"` or `import "@scope/pkg/schema/foo.bop"`.
|
|
958
|
+
*/
|
|
959
|
+
function extractImportPaths(source) {
|
|
960
|
+
const re = /^\s*import\s+"([^"]+)"\s*;?\s*$/gm;
|
|
961
|
+
const paths = [];
|
|
962
|
+
let m;
|
|
963
|
+
while ((m = re.exec(source)) !== null) paths.push(m[1]);
|
|
964
|
+
return paths;
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Resolve a single bebop import specifier to an absolute filesystem path.
|
|
968
|
+
*
|
|
969
|
+
* - Relative specifiers (`./`, `../`) resolve against `fromDir`.
|
|
970
|
+
* - Bare/package specifiers resolve via Node module resolution, falling back to
|
|
971
|
+
* resolving the package root and joining the subpath when the package's
|
|
972
|
+
* `exports` map blocks subpath access (the common case for `.bop` files).
|
|
973
|
+
*
|
|
974
|
+
* Returns null if the specifier cannot be resolved.
|
|
975
|
+
*/
|
|
976
|
+
function resolveBebopImport(specifier, fromDir) {
|
|
977
|
+
if (specifier.startsWith(".") || isAbsolute(specifier)) {
|
|
978
|
+
const abs = isAbsolute(specifier) ? specifier : resolve(fromDir, specifier);
|
|
979
|
+
return existsSync(abs) ? abs : null;
|
|
980
|
+
}
|
|
981
|
+
const req = createRequire(resolve(fromDir, "noop.js"));
|
|
982
|
+
try {
|
|
983
|
+
const direct = req.resolve(specifier);
|
|
984
|
+
if (existsSync(direct)) return direct;
|
|
985
|
+
} catch {}
|
|
986
|
+
const segments = specifier.split("/");
|
|
987
|
+
const pkgName = specifier.startsWith("@") ? `${segments[0]}/${segments[1]}` : segments[0];
|
|
988
|
+
const subpath = segments.slice(specifier.startsWith("@") ? 2 : 1).join("/");
|
|
989
|
+
try {
|
|
990
|
+
const joined = resolve(dirname(req.resolve(`${pkgName}/package.json`)), subpath);
|
|
991
|
+
if (existsSync(joined)) return joined;
|
|
992
|
+
} catch {}
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Transitively read the entity source plus every reachable imported `.bop` file,
|
|
997
|
+
* returning their concatenated source text. Used so the lightweight message
|
|
998
|
+
* parser can see component messages and `<Type>Delta` definitions that live in
|
|
999
|
+
* imported files (e.g. `PoolDelta` in `@vampgg/utils/schema/pool.bop`).
|
|
1000
|
+
*
|
|
1001
|
+
* Unresolved imports are skipped (the caller fails loudly later if a referenced
|
|
1002
|
+
* type is missing). Visited files are de-duplicated to avoid cycles.
|
|
1003
|
+
*/
|
|
1004
|
+
function collectReachableSource(entityPath) {
|
|
1005
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1006
|
+
const parts = [];
|
|
1007
|
+
const visit = (filePath) => {
|
|
1008
|
+
const abs = resolve(filePath);
|
|
1009
|
+
if (visited.has(abs)) return;
|
|
1010
|
+
visited.add(abs);
|
|
1011
|
+
let src;
|
|
1012
|
+
try {
|
|
1013
|
+
src = readFileSync(abs, "utf-8");
|
|
1014
|
+
} catch {
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
parts.push(src);
|
|
1018
|
+
for (const spec of extractImportPaths(src)) {
|
|
1019
|
+
const resolved = resolveBebopImport(spec, dirname(abs));
|
|
1020
|
+
if (resolved) visit(resolved);
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
visit(entityPath);
|
|
1024
|
+
return parts.join("\n\n");
|
|
1025
|
+
}
|
|
1026
|
+
//#endregion
|
|
1027
|
+
//#region src/generators/generate-mutation-schema.ts
|
|
1028
|
+
/** Default mutation schema path: a `mutation.bop` sibling of the entity schema. */
|
|
1029
|
+
function resolveMutationPath(cwd, vampConfig) {
|
|
1030
|
+
if (vampConfig.schemas.mutation) return resolve(cwd, vampConfig.schemas.mutation);
|
|
1031
|
+
return resolve(dirname(resolve(cwd, vampConfig.schemas.entity)), "mutation.bop");
|
|
1032
|
+
}
|
|
1033
|
+
/** Compute the bebop import path from the mutation file to the entity file. */
|
|
1034
|
+
function entityImportPath(entityPath, mutationPath) {
|
|
1035
|
+
const normalized = relative(dirname(mutationPath), entityPath).split("\\").join("/");
|
|
1036
|
+
return normalized.startsWith(".") ? normalized : `./${normalized}`;
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* From the reachable schema source, collect:
|
|
1040
|
+
* - `userDeltas`: every `<Type>Delta` message name already declared.
|
|
1041
|
+
* - `components`: every custom-component message referenced by Entity, parsed
|
|
1042
|
+
* so a missing `<Type>Delta` can be synthesized.
|
|
1043
|
+
*/
|
|
1044
|
+
function collectSchemaContext(entity, reachableSource) {
|
|
1045
|
+
const declared = collectMessageNames(reachableSource);
|
|
1046
|
+
const userDeltas = new Set([...declared].filter((n) => n.endsWith("Delta")));
|
|
1047
|
+
const components = /* @__PURE__ */ new Map();
|
|
1048
|
+
for (const field of entity.fields) {
|
|
1049
|
+
if (field.name === "tags" || field.isArray || field.isMap || field.isScalar) continue;
|
|
1050
|
+
if (components.has(field.typeName)) continue;
|
|
1051
|
+
if (!declared.has(field.typeName)) continue;
|
|
1052
|
+
const parsed = parseMessage(reachableSource, field.typeName);
|
|
1053
|
+
if (parsed) components.set(field.typeName, parsed);
|
|
1054
|
+
}
|
|
1055
|
+
return {
|
|
1056
|
+
userDeltas,
|
|
1057
|
+
components
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Parse the user's entity schema and (re)generate the bebop mutation schema
|
|
1062
|
+
* (EntityDelta, MutationType, MutationRecord, MutationScope) next to it.
|
|
1063
|
+
*
|
|
1064
|
+
* IMPORTANT: this must run BEFORE `bebopc build` so the generated schema is
|
|
1065
|
+
* picked up and compiled into serializable types.
|
|
1066
|
+
*
|
|
1067
|
+
* @returns the absolute path of the written mutation schema.
|
|
1068
|
+
*/
|
|
1069
|
+
function generateMutationSchema(cwd, vampConfig) {
|
|
1070
|
+
const entityPath = resolve(cwd, vampConfig.schemas.entity);
|
|
1071
|
+
const entity = parseEntityMessage(readFileSync(entityPath, "utf-8"));
|
|
1072
|
+
const { userDeltas, components } = collectSchemaContext(entity, collectReachableSource(entityPath));
|
|
1073
|
+
const mutationPath = resolveMutationPath(cwd, vampConfig);
|
|
1074
|
+
const output = emitMutationSchema(entity, entityImportPath(entityPath, mutationPath), userDeltas, components, entityPath);
|
|
1075
|
+
mkdirSync(dirname(mutationPath), { recursive: true });
|
|
1076
|
+
writeFileSync(mutationPath, output, "utf-8");
|
|
1077
|
+
return mutationPath;
|
|
1078
|
+
}
|
|
1079
|
+
//#endregion
|
|
1080
|
+
export { extractMessageBody as a, generate as c, parseSchema as d, loadBebopConfig as f, emitMutationSchema as i, loadAndParseSchema as l, resolveMutationPath as n, parseEntityMessage as o, loadVampConfig as p, resolveBebopImport as r, parseMessage as s, generateMutationSchema as t, loadSchemaFromFile as u };
|