bonescript-compiler 0.9.0 → 0.11.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.
@@ -0,0 +1,812 @@
1
+ "use strict";
2
+ /**
3
+ * BoneScript Colyseus Target Emitter
4
+ *
5
+ * Generates a Colyseus.io game server (v0.17) from `channel` declarations.
6
+ * One `Room` subclass per channel; authoritative state lives in a `Schema`
7
+ * class derived from the channel.
8
+ *
9
+ * Output:
10
+ * src/index.ts Colyseus Server bootstrap, registerRoom for each
11
+ * src/rooms/<name>.ts Room subclass per channel
12
+ * src/state/<name>.ts Schema class per channel
13
+ * src/auth.ts JWT verification helper (mirrors emit_runtime auth)
14
+ * package.json, tsconfig.json, README.md
15
+ *
16
+ * Why a separate target rather than a flag on the Express target:
17
+ * Colyseus rooms are authoritative process-locally. They speak msgpack-
18
+ * encoded schema deltas over WebSocket. Mixing into the Express target
19
+ * would mean co-hosting both servers, which is supported but adds
20
+ * operational complexity for users who don't need realtime games.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.ColyseusEmitter = void 0;
24
+ function toSnakeCase(s) {
25
+ return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
26
+ }
27
+ function toPascalCase(s) {
28
+ return s.replace(/(^|[-_\s])(\w)/g, (_, __, c) => c.toUpperCase());
29
+ }
30
+ // ─── Type mapping for Colyseus @type decorators ────────────────────────────────
31
+ const COLYSEUS_TYPE_MAP = {
32
+ string: '"string"',
33
+ uint: '"number"',
34
+ int: '"number"',
35
+ float: '"number"',
36
+ bool: '"boolean"',
37
+ timestamp: '"string"',
38
+ uuid: '"string"',
39
+ bytes: '"string"',
40
+ json: '"string"', // serialized JSON (Colyseus schemas don't have a JSON primitive)
41
+ };
42
+ function toColyseusType(irType) {
43
+ return COLYSEUS_TYPE_MAP[irType] || '"string"';
44
+ }
45
+ function toTsTypeForSchema(irType) {
46
+ switch (irType) {
47
+ case "string":
48
+ case "timestamp":
49
+ case "uuid":
50
+ case "bytes":
51
+ case "json":
52
+ return "string";
53
+ case "uint":
54
+ case "int":
55
+ case "float":
56
+ return "number";
57
+ case "bool":
58
+ return "boolean";
59
+ default:
60
+ return "string";
61
+ }
62
+ }
63
+ function defaultValueForType(irType) {
64
+ switch (irType) {
65
+ case "string":
66
+ case "timestamp":
67
+ case "uuid":
68
+ case "bytes":
69
+ case "json":
70
+ return '""';
71
+ case "uint":
72
+ case "int":
73
+ case "float":
74
+ return "0";
75
+ case "bool":
76
+ return "false";
77
+ default:
78
+ return '""';
79
+ }
80
+ }
81
+ // ─── Per-channel state schema ─────────────────────────────────────────────────
82
+ function emitStateSchema(channel, system) {
83
+ void system;
84
+ const lines = [];
85
+ const channelName = toPascalCase(channel.name);
86
+ const stateName = `${channelName}State`;
87
+ const playerName = `${channelName}Player`;
88
+ // Auto-derived player fields from the channel's participant entity. The
89
+ // lowering serialised these as a JSON string in config.participant_fields.
90
+ // We fall back to a minimal {userId, sessionId, joinedAt} default if the
91
+ // channel doesn't reference an entity.
92
+ let participantFields = [];
93
+ const raw = channel.config["participant_fields"];
94
+ if (typeof raw === "string" && raw.length > 0) {
95
+ try {
96
+ const parsed = JSON.parse(raw);
97
+ if (Array.isArray(parsed))
98
+ participantFields = parsed;
99
+ }
100
+ catch { /* ignore */ }
101
+ }
102
+ lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
103
+ lines.push(`// Colyseus schema for channel: ${channel.name}`);
104
+ if (channel.config["participant_entity"]) {
105
+ lines.push(`// Player schema derived from entity: ${channel.config["participant_entity"]}`);
106
+ }
107
+ lines.push(``);
108
+ lines.push(`import { Schema, type, MapSchema } from "@colyseus/schema";`);
109
+ lines.push(``);
110
+ lines.push(`export class ${playerName} extends Schema {`);
111
+ // Always-present identity fields
112
+ lines.push(` @type("string") userId: string = "";`);
113
+ lines.push(` @type("string") sessionId: string = "";`);
114
+ lines.push(` @type("number") joinedAt: number = 0;`);
115
+ // Auto-derived fields from the entity
116
+ for (const f of participantFields) {
117
+ if (["userId", "sessionId", "joinedAt", "id", "created_at", "updated_at"].includes(f.name))
118
+ continue;
119
+ const tsType = toTsTypeForSchema(f.type);
120
+ const dv = defaultValueForType(f.type);
121
+ lines.push(` @type(${toColyseusType(f.type)}) ${f.name}: ${tsType} = ${dv};`);
122
+ }
123
+ lines.push(`}`);
124
+ lines.push(``);
125
+ lines.push(`export class ${stateName} extends Schema {`);
126
+ lines.push(` @type({ map: ${playerName} }) players = new MapSchema<${playerName}>();`);
127
+ lines.push(` @type("number") tick: number = 0;`);
128
+ lines.push(` // Add channel-wide state fields here.`);
129
+ lines.push(`}`);
130
+ lines.push(``);
131
+ return {
132
+ path: `src/state/${toSnakeCase(channel.name)}.ts`,
133
+ content: lines.join("\n"),
134
+ language: "typescript",
135
+ source_module: channel.id,
136
+ };
137
+ }
138
+ // ─── Per-channel Room class ───────────────────────────────────────────────────
139
+ function emitRoom(channel, _system) {
140
+ const lines = [];
141
+ const channelName = toPascalCase(channel.name);
142
+ const roomName = `${channelName}Room`;
143
+ const stateName = `${channelName}State`;
144
+ const playerName = `${channelName}Player`;
145
+ const stateModule = toSnakeCase(channel.name);
146
+ const maxClients = channel.config["max_size"] || 16;
147
+ const ordering = channel.config["ordering"] || "fifo";
148
+ const persistence = channel.config["persistence"] || "none";
149
+ // Persistence: "last_N" → keep last N messages in room.history
150
+ // "database" → snapshot to disk on dispose, reload on create
151
+ const historyLimit = (() => {
152
+ const m = persistence.match(/^last_(\d+)$/);
153
+ if (m)
154
+ return parseInt(m[1], 10);
155
+ if (persistence === "none")
156
+ return 0;
157
+ if (persistence === "database")
158
+ return 0; // db handles durability separately
159
+ return 100;
160
+ })();
161
+ const usesDatabase = persistence === "database";
162
+ // Capabilities to expose as typed onMessage handlers (everything past the
163
+ // 3 base methods: connect, subscribe, publish).
164
+ const allMethods = channel.interfaces[0]?.methods ?? [];
165
+ const baseMethodNames = new Set(["connect", "subscribe", "publish"]);
166
+ const capabilities = allMethods.filter(m => !baseMethodNames.has(m.name));
167
+ // Participant entity name (for typed param hints in capability signatures).
168
+ const participantEntity = channel.config["participant_entity"];
169
+ lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
170
+ lines.push(`// Colyseus room for channel: ${channel.name}`);
171
+ lines.push(`// transport: ${channel.config["transport"] || "websocket"}`);
172
+ lines.push(`// ordering: ${ordering}`);
173
+ lines.push(`// persistence: ${persistence}`);
174
+ if (capabilities.length > 0) {
175
+ lines.push(`// capabilities: ${capabilities.map(c => c.name).join(", ")}`);
176
+ }
177
+ lines.push(``);
178
+ lines.push(`import { Room, Client, ServerError, AuthContext } from "colyseus";`);
179
+ lines.push(`import { ${stateName}, ${playerName} } from "../state/${stateModule}";`);
180
+ lines.push(`import { verifyToken } from "../auth";`);
181
+ if (usesDatabase) {
182
+ lines.push(`import { loadSnapshot, saveSnapshot } from "../persistence";`);
183
+ }
184
+ lines.push(``);
185
+ lines.push(`interface AuthData {`);
186
+ lines.push(` userId: string;`);
187
+ lines.push(`}`);
188
+ lines.push(``);
189
+ // Per-capability message payload interfaces. Lets handlers be type-safe.
190
+ for (const cap of capabilities) {
191
+ const inputs = cap.input.filter(i => {
192
+ if (!participantEntity)
193
+ return true;
194
+ // Drop the participant entity param itself — that's "the calling client",
195
+ // resolved from sessionId rather than wire payload.
196
+ return i.type !== participantEntity;
197
+ });
198
+ if (inputs.length === 0) {
199
+ lines.push(`type ${toPascalCase(cap.name)}Payload = Record<string, never>;`);
200
+ }
201
+ else {
202
+ lines.push(`interface ${toPascalCase(cap.name)}Payload {`);
203
+ for (const inp of inputs) {
204
+ lines.push(` ${inp.name}: ${toTsTypeForSchema(inp.type)};`);
205
+ }
206
+ lines.push(`}`);
207
+ }
208
+ }
209
+ if (capabilities.length > 0)
210
+ lines.push(``);
211
+ lines.push(`/**`);
212
+ lines.push(` * Channel: ${channel.name}`);
213
+ lines.push(` *`);
214
+ lines.push(` * Client connection example:`);
215
+ lines.push(` *`);
216
+ lines.push(` * import { Client } from "@colyseus/sdk";`);
217
+ lines.push(` * const client = new Client("ws://host:2567");`);
218
+ lines.push(` * client.auth.token = "<jwt>";`);
219
+ lines.push(` * const room = await client.joinOrCreate("${channel.name}");`);
220
+ lines.push(` * room.state.players.onAdd((player, key) => { ... });`);
221
+ if (capabilities.length > 0) {
222
+ lines.push(` * room.send("${capabilities[0].name}", { ... }); // → triggers capability`);
223
+ }
224
+ lines.push(` */`);
225
+ lines.push(`export class ${roomName} extends Room<{ state: ${stateName} }> {`);
226
+ lines.push(` maxClients = ${maxClients};`);
227
+ lines.push(``);
228
+ lines.push(` // Ring buffer of recent messages, replayed to new joiners when persistence != "none".`);
229
+ lines.push(` private history: { type: string; payload: unknown; from: string; ts: number }[] = [];`);
230
+ lines.push(` private readonly historyLimit = ${historyLimit};`);
231
+ if (usesDatabase) {
232
+ lines.push(``);
233
+ lines.push(` // Save a snapshot every ${SNAPSHOT_INTERVAL_MS}ms while the room is active.`);
234
+ lines.push(` private snapshotTimer: NodeJS.Timeout | null = null;`);
235
+ }
236
+ lines.push(``);
237
+ lines.push(` /**`);
238
+ lines.push(` * JWT-based auth. Token comes from the Authorization header set by the`);
239
+ lines.push(` * client SDK via \`client.auth.token = "..."\`.`);
240
+ lines.push(` *`);
241
+ lines.push(` * Returns the user data (userId) on success. Throws ServerError on bad`);
242
+ lines.push(` * tokens so the matchmaking call fails on the client.`);
243
+ lines.push(` */`);
244
+ lines.push(` static async onAuth(token: string, _options: Record<string, unknown>, _context: AuthContext): Promise<AuthData> {`);
245
+ lines.push(` if (!token) throw new ServerError(401, "Missing auth token");`);
246
+ lines.push(` const claims = verifyToken(token);`);
247
+ lines.push(` if (!claims) throw new ServerError(401, "Invalid auth token");`);
248
+ lines.push(` return { userId: claims.sub };`);
249
+ lines.push(` }`);
250
+ lines.push(``);
251
+ lines.push(` async onCreate(_options: Record<string, unknown>) {`);
252
+ lines.push(` this.setState(new ${stateName}());`);
253
+ if (usesDatabase) {
254
+ lines.push(``);
255
+ lines.push(` // Try to restore snapshot from durable storage on room creation.`);
256
+ lines.push(` try {`);
257
+ lines.push(` const snapshot = await loadSnapshot("${channel.name}", this.roomId);`);
258
+ lines.push(` if (snapshot && typeof snapshot === "object") {`);
259
+ lines.push(` const s = this.state as unknown as Record<string, unknown>;`);
260
+ lines.push(` for (const [k, v] of Object.entries(snapshot)) {`);
261
+ lines.push(` if (k === "players") continue; // sessions are not portable`);
262
+ lines.push(` if (k in s) (s as any)[k] = v;`);
263
+ lines.push(` }`);
264
+ lines.push(` console.log(\`[${channel.name}] restored snapshot for room \${this.roomId}\`);`);
265
+ lines.push(` }`);
266
+ lines.push(` } catch (e: any) {`);
267
+ lines.push(` console.warn(\`[${channel.name}] snapshot load failed: \${e.message}\`);`);
268
+ lines.push(` }`);
269
+ lines.push(``);
270
+ lines.push(` // Periodically save state to durable storage so a crash doesn't lose work.`);
271
+ lines.push(` this.snapshotTimer = setInterval(() => {`);
272
+ lines.push(` void saveSnapshot("${channel.name}", this.roomId, this.serializeStateForSnapshot()).catch(() => {});`);
273
+ lines.push(` }, ${SNAPSHOT_INTERVAL_MS});`);
274
+ }
275
+ // Register typed handlers for each capability
276
+ for (const cap of capabilities) {
277
+ lines.push(``);
278
+ lines.push(` // ${cap.name} — capability dispatch`);
279
+ lines.push(` this.onMessage("${cap.name}", (client, payload: ${toPascalCase(cap.name)}Payload) => {`);
280
+ lines.push(` this.handle${toPascalCase(cap.name)}(client, payload);`);
281
+ lines.push(` });`);
282
+ }
283
+ lines.push(``);
284
+ lines.push(` // Default fallback: rebroadcast unknown messages with sender attribution.`);
285
+ lines.push(` // Handlers for known message types above take precedence (Colyseus 0.17`);
286
+ lines.push(` // routes "*" only when no specific handler matches).`);
287
+ lines.push(` this.onMessage("*", (client, type, payload) => {`);
288
+ lines.push(` const userId = (client.userData as { userId?: string } | undefined)?.userId ?? client.sessionId;`);
289
+ lines.push(` const msg = { type: String(type), payload, from: userId, ts: Date.now() };`);
290
+ lines.push(` this.recordHistory(msg);`);
291
+ lines.push(` this.broadcast(String(type), msg, { except: client });`);
292
+ lines.push(` });`);
293
+ lines.push(` }`);
294
+ // Per-capability handler implementations. Each applies effects to the
295
+ // calling client's player record (or the room state) and broadcasts.
296
+ for (const cap of capabilities) {
297
+ lines.push(``);
298
+ lines.push(emitCapabilityHandler(cap, participantEntity, channel.name));
299
+ }
300
+ lines.push(``);
301
+ lines.push(` onJoin(client: Client, _options: Record<string, unknown>, auth: AuthData) {`);
302
+ lines.push(` client.userData = { userId: auth.userId };`);
303
+ lines.push(``);
304
+ lines.push(` const player = new ${playerName}();`);
305
+ lines.push(` player.userId = auth.userId;`);
306
+ lines.push(` player.sessionId = client.sessionId;`);
307
+ lines.push(` player.joinedAt = Date.now();`);
308
+ lines.push(` this.state.players.set(client.sessionId, player);`);
309
+ lines.push(``);
310
+ lines.push(` console.log(\`[${channel.name}] \${auth.userId} joined (\${this.clients.length} clients, sessionId=\${client.sessionId})\`);`);
311
+ lines.push(``);
312
+ lines.push(` // Replay history if configured`);
313
+ lines.push(` if (this.historyLimit > 0 && this.history.length > 0) {`);
314
+ lines.push(` client.send("__history__", { events: this.history });`);
315
+ lines.push(` }`);
316
+ lines.push(` }`);
317
+ lines.push(``);
318
+ lines.push(` onLeave(client: Client, _code: number) {`);
319
+ lines.push(` this.state.players.delete(client.sessionId);`);
320
+ lines.push(` const userId = (client.userData as { userId?: string } | undefined)?.userId ?? client.sessionId;`);
321
+ lines.push(` console.log(\`[${channel.name}] \${userId} left (\${this.clients.length} clients)\`);`);
322
+ lines.push(` }`);
323
+ lines.push(``);
324
+ lines.push(` async onDispose() {`);
325
+ if (usesDatabase) {
326
+ lines.push(` if (this.snapshotTimer) { clearInterval(this.snapshotTimer); this.snapshotTimer = null; }`);
327
+ lines.push(` try {`);
328
+ lines.push(` await saveSnapshot("${channel.name}", this.roomId, this.serializeStateForSnapshot());`);
329
+ lines.push(` console.log(\`[${channel.name}] saved final snapshot for room \${this.roomId}\`);`);
330
+ lines.push(` } catch (e: any) {`);
331
+ lines.push(` console.warn(\`[${channel.name}] snapshot save failed: \${e.message}\`);`);
332
+ lines.push(` }`);
333
+ }
334
+ lines.push(` console.log(\`[${channel.name}] room disposed\`);`);
335
+ lines.push(` }`);
336
+ lines.push(``);
337
+ lines.push(` private recordHistory(msg: { type: string; payload: unknown; from: string; ts: number }) {`);
338
+ lines.push(` if (this.historyLimit <= 0) return;`);
339
+ lines.push(` this.history.push(msg);`);
340
+ lines.push(` while (this.history.length > this.historyLimit) this.history.shift();`);
341
+ lines.push(` }`);
342
+ if (usesDatabase) {
343
+ lines.push(``);
344
+ lines.push(` /**`);
345
+ lines.push(` * Snapshot the channel-wide state. We exclude the players map because`);
346
+ lines.push(` * sessions don't persist across processes; players reconnect with new`);
347
+ lines.push(` * sessionIds when the room is restored.`);
348
+ lines.push(` */`);
349
+ lines.push(` private serializeStateForSnapshot(): Record<string, unknown> {`);
350
+ lines.push(` const out: Record<string, unknown> = {};`);
351
+ lines.push(` for (const [k, v] of Object.entries(this.state as unknown as Record<string, unknown>)) {`);
352
+ lines.push(` if (k === "players") continue;`);
353
+ lines.push(` if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {`);
354
+ lines.push(` out[k] = v;`);
355
+ lines.push(` }`);
356
+ lines.push(` }`);
357
+ lines.push(` return out;`);
358
+ lines.push(` }`);
359
+ }
360
+ lines.push(`}`);
361
+ lines.push(``);
362
+ return {
363
+ path: `src/rooms/${stateModule}.ts`,
364
+ content: lines.join("\n"),
365
+ language: "typescript",
366
+ source_module: channel.id,
367
+ };
368
+ }
369
+ const SNAPSHOT_INTERVAL_MS = 5000;
370
+ /**
371
+ * Emit a capability handler.
372
+ *
373
+ * Translates IR effects to TypeScript that mutates the calling client's
374
+ * player record (or the room state) and broadcasts the result. Effects
375
+ * targeting `<participantParam>.<field>` map to the player; everything
376
+ * else maps to room state.
377
+ *
378
+ * Only `assign` op is fully supported in Phase 2. `add` / `remove` on
379
+ * scalars work for numbers; `add` on collections falls back to broadcasting
380
+ * a hint without mutating state (left as TODO with a comment).
381
+ */
382
+ function emitCapabilityHandler(method, participantEntity, channelName) {
383
+ const lines = [];
384
+ const handlerName = `handle${toPascalCase(method.name)}`;
385
+ const payloadType = `${toPascalCase(method.name)}Payload`;
386
+ // Find the participant param name (used to discriminate state vs player effects).
387
+ const partParam = method.input.find(i => i.type === participantEntity);
388
+ const partParamName = partParam?.name ?? null;
389
+ lines.push(` /**`);
390
+ lines.push(` * Handle "${method.name}" capability message.`);
391
+ if (method.preconditions.length > 0) {
392
+ lines.push(` *`);
393
+ lines.push(` * Preconditions:`);
394
+ for (const pre of method.preconditions)
395
+ lines.push(` * - ${pre.expression}`);
396
+ }
397
+ if (method.effects.length > 0) {
398
+ lines.push(` *`);
399
+ lines.push(` * Effects:`);
400
+ for (const eff of method.effects) {
401
+ const opSym = eff.op === "assign" ? "=" : eff.op === "add" ? "+=" : "-=";
402
+ lines.push(` * - ${eff.target} ${opSym} ${eff.value}`);
403
+ }
404
+ }
405
+ lines.push(` */`);
406
+ lines.push(` private ${handlerName}(client: Client, payload: ${payloadType}): void {`);
407
+ lines.push(` const player = this.state.players.get(client.sessionId);`);
408
+ lines.push(` if (!player) return;`);
409
+ // Compile preconditions to runtime checks. Best-effort: we only check
410
+ // simple expressions referring to the participant or payload params.
411
+ if (method.preconditions.length > 0) {
412
+ lines.push(` // Preconditions`);
413
+ for (const pre of method.preconditions) {
414
+ const expr = compileExprForRoom(pre.expression, partParamName, method.input);
415
+ if (expr) {
416
+ lines.push(` if (!(${expr})) {`);
417
+ lines.push(` client.send("${method.name}_failed", { reason: "precondition: ${escapeStringLiteral(pre.description)}" });`);
418
+ lines.push(` return;`);
419
+ lines.push(` }`);
420
+ }
421
+ }
422
+ }
423
+ // Compile effects
424
+ for (const eff of method.effects) {
425
+ const compiled = compileEffectForRoom(eff, partParamName, method.input);
426
+ if (compiled) {
427
+ lines.push(` ${compiled}`);
428
+ }
429
+ else {
430
+ lines.push(` // TODO: effect "${eff.target} ${eff.op} ${eff.value}" — not auto-translated`);
431
+ }
432
+ }
433
+ // Broadcast the resulting change to all clients (including sender so they
434
+ // see authoritative state). Colyseus's @type system already syncs state
435
+ // diffs, so this broadcast is just for clients listening to the explicit
436
+ // "<capability>_applied" event for animation cues etc.
437
+ lines.push(` this.broadcast("${method.name}_applied", { from: player.userId, payload });`);
438
+ lines.push(` }`);
439
+ void channelName;
440
+ return lines.join("\n");
441
+ }
442
+ /**
443
+ * Compile an effect expression to a TS statement that mutates state or player.
444
+ *
445
+ * Supported targets:
446
+ * <partParam>.<field> → player.<field> (calling client's player)
447
+ * <field> → this.state.<field> (room-wide state)
448
+ * <some-other-entity>.<field> → not auto-translated; returns null
449
+ *
450
+ * Supported values:
451
+ * identifier matching a payload param name → payload.<name>
452
+ * number / string / bool literal → as-is
453
+ * simple binary expr referencing scalars → translated by compileExprForRoom
454
+ */
455
+ function compileEffectForRoom(eff, partParamName, inputs) {
456
+ const path = eff.target.split(".");
457
+ let lhs;
458
+ if (path.length === 2 && partParamName && path[0] === partParamName) {
459
+ lhs = `player.${path[1]}`;
460
+ }
461
+ else if (path.length === 1) {
462
+ lhs = `(this.state as any).${path[0]}`;
463
+ }
464
+ else {
465
+ return null; // can't safely translate cross-entity effects
466
+ }
467
+ const rhs = compileExprForRoom(eff.value, partParamName, inputs) ?? `(payload as any).${eff.value}`;
468
+ switch (eff.op) {
469
+ case "assign": return `${lhs} = ${rhs};`;
470
+ case "add": return `${lhs} = (${lhs} as number) + (${rhs} as number);`;
471
+ case "remove": return `${lhs} = (${lhs} as number) - (${rhs} as number);`;
472
+ }
473
+ }
474
+ /**
475
+ * Compile an IR expression string to a TS expression usable inside a room
476
+ * handler. Best-effort — we recognise:
477
+ * - the participant param name → `player`
478
+ * - any payload input name → `payload.<name>`
479
+ * - bare identifiers we can't resolve → null (caller falls back)
480
+ * - simple binary expressions like "x > 0", "qty <= player.capacity"
481
+ *
482
+ * For complex expressions involving function calls, returns the original
483
+ * expression unchanged. If that fails to compile, the user gets a tsc
484
+ * error pointing at the generated code; better than silent wrong behaviour.
485
+ */
486
+ function compileExprForRoom(expr, partParamName, inputs) {
487
+ const trimmed = expr.trim();
488
+ // Number literal
489
+ if (/^-?\d+(\.\d+)?$/.test(trimmed))
490
+ return trimmed;
491
+ // String literal
492
+ if (/^"[^"]*"$/.test(trimmed))
493
+ return trimmed;
494
+ // Boolean
495
+ if (trimmed === "true" || trimmed === "false")
496
+ return trimmed;
497
+ // Replace identifiers with their compiled form.
498
+ // We do a simple word-boundary substitution which is safe for the
499
+ // expressions we generate (no shadowing, no string contents to worry about).
500
+ const inputNames = new Set(inputs.map(i => i.name));
501
+ const partOk = partParamName && partParamName.length > 0;
502
+ let out = trimmed;
503
+ // <partParam>.<field> → player.<field>
504
+ if (partOk) {
505
+ const re = new RegExp(`\\b${partParamName}\\.(\\w+)`, "g");
506
+ out = out.replace(re, "player.$1");
507
+ }
508
+ // <payloadParam>.<x> or <payloadParam> → payload.<...>
509
+ for (const inp of inputs) {
510
+ if (partOk && inp.name === partParamName)
511
+ continue;
512
+ const re = new RegExp(`\\b${inp.name}\\b`, "g");
513
+ out = out.replace(re, `(payload as any).${inp.name}`);
514
+ }
515
+ // If after substitution we still have bare identifiers we don't recognise,
516
+ // we leave them in — tsc will flag them at compile time so the user can
517
+ // fix the .bone source.
518
+ void inputNames;
519
+ return out;
520
+ }
521
+ function escapeStringLiteral(s) {
522
+ return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
523
+ }
524
+ // ─── Auth helper (JWT verification) ───────────────────────────────────────────
525
+ function emitColyseusAuth() {
526
+ return [
527
+ `// Generated by BoneScript compiler. DO NOT EDIT.`,
528
+ `// JWT verification helper. Mirrors the Express target's auth.ts: HS256-only,`,
529
+ `// 1h max-age cap, sub must be a non-empty string.`,
530
+ ``,
531
+ `import jwt from "jsonwebtoken";`,
532
+ ``,
533
+ `const JWT_SECRET = (() => {`,
534
+ ` const secret = process.env.JWT_SECRET;`,
535
+ ` if (!secret) {`,
536
+ ` if (process.env.NODE_ENV === "production") {`,
537
+ ` console.error("[FATAL] JWT_SECRET is not set. Refusing to start in production.");`,
538
+ ` process.exit(1);`,
539
+ ` }`,
540
+ ` console.warn("[WARN] JWT_SECRET is not set. Using insecure default — do not use in production.");`,
541
+ ` return "bonescript-dev-secret-do-not-use-in-production";`,
542
+ ` }`,
543
+ ` if (secret.length < 32) {`,
544
+ ` console.warn("[WARN] JWT_SECRET is shorter than 32 characters.");`,
545
+ ` }`,
546
+ ` return secret;`,
547
+ `})();`,
548
+ ``,
549
+ `export interface JwtClaims {`,
550
+ ` sub: string;`,
551
+ `}`,
552
+ ``,
553
+ `export function verifyToken(token: string): JwtClaims | null {`,
554
+ ` try {`,
555
+ ` const decoded = jwt.verify(token, JWT_SECRET, {`,
556
+ ` algorithms: ["HS256"],`,
557
+ ` maxAge: process.env.JWT_MAX_AGE || "1h",`,
558
+ ` }) as { sub?: unknown };`,
559
+ ` if (typeof decoded.sub !== "string" || decoded.sub.length === 0) return null;`,
560
+ ` return { sub: decoded.sub };`,
561
+ ` } catch {`,
562
+ ` return null;`,
563
+ ` }`,
564
+ `}`,
565
+ ``,
566
+ ].join("\n");
567
+ }
568
+ function emitColyseusPersistence() {
569
+ // Filesystem-based snapshot store. Default location: ./snapshots/<channel>/<roomId>.json.
570
+ // Override SNAPSHOT_DIR env var. To swap in a real DB (Postgres, Redis,
571
+ // etc.), replace the body of loadSnapshot/saveSnapshot — the interface
572
+ // is intentionally minimal.
573
+ return [
574
+ `// Generated by BoneScript compiler. DO NOT EDIT.`,
575
+ `// Snapshot persistence for rooms with \`persistence: database\`.`,
576
+ `//`,
577
+ `// Default implementation: JSON files under SNAPSHOT_DIR (./snapshots).`,
578
+ `// Replace with your DB / Redis / object store as needed — the only`,
579
+ `// contract is loadSnapshot/saveSnapshot returning Promise<unknown | null>.`,
580
+ ``,
581
+ `import * as fs from "fs/promises";`,
582
+ `import * as path from "path";`,
583
+ ``,
584
+ `const ROOT = path.resolve(process.env.SNAPSHOT_DIR || "./snapshots");`,
585
+ ``,
586
+ `function snapshotPath(channel: string, roomId: string): string {`,
587
+ ` // Filenames are limited to a safe character set so a malicious roomId`,
588
+ ` // can't escape the snapshot dir. Colyseus roomIds are alphanumeric so`,
589
+ ` // this is belt-and-braces.`,
590
+ ` const safeChannel = channel.replace(/[^a-zA-Z0-9_-]/g, "_");`,
591
+ ` const safeRoom = roomId.replace(/[^a-zA-Z0-9_-]/g, "_");`,
592
+ ` return path.join(ROOT, safeChannel, safeRoom + ".json");`,
593
+ `}`,
594
+ ``,
595
+ `export async function loadSnapshot(channel: string, roomId: string): Promise<unknown | null> {`,
596
+ ` const file = snapshotPath(channel, roomId);`,
597
+ ` try {`,
598
+ ` const data = await fs.readFile(file, "utf-8");`,
599
+ ` return JSON.parse(data);`,
600
+ ` } catch (e: any) {`,
601
+ ` if (e.code === "ENOENT") return null;`,
602
+ ` throw e;`,
603
+ ` }`,
604
+ `}`,
605
+ ``,
606
+ `export async function saveSnapshot(channel: string, roomId: string, state: unknown): Promise<void> {`,
607
+ ` const file = snapshotPath(channel, roomId);`,
608
+ ` await fs.mkdir(path.dirname(file), { recursive: true });`,
609
+ ` // Write to a temp file then rename — atomic on POSIX, near-atomic on Windows.`,
610
+ ` const tmp = file + ".tmp";`,
611
+ ` await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8");`,
612
+ ` await fs.rename(tmp, file);`,
613
+ `}`,
614
+ ``,
615
+ `export async function deleteSnapshot(channel: string, roomId: string): Promise<void> {`,
616
+ ` const file = snapshotPath(channel, roomId);`,
617
+ ` try { await fs.unlink(file); } catch { /* swallow */ }`,
618
+ `}`,
619
+ ``,
620
+ ].join("\n");
621
+ }
622
+ // ─── Server entry point ───────────────────────────────────────────────────────
623
+ function emitColyseusIndex(system) {
624
+ const channels = system.modules.filter(m => m.kind === "realtime_service");
625
+ const lines = [];
626
+ lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
627
+ lines.push(`// Colyseus server entry point.`);
628
+ lines.push(``);
629
+ lines.push(`require("dotenv").config();`);
630
+ lines.push(`import { Server } from "colyseus";`);
631
+ lines.push(`import { WebSocketTransport } from "@colyseus/ws-transport";`);
632
+ lines.push(`import { createServer } from "http";`);
633
+ for (const ch of channels) {
634
+ const stateModule = toSnakeCase(ch.name);
635
+ const roomName = `${toPascalCase(ch.name)}Room`;
636
+ lines.push(`import { ${roomName} } from "./rooms/${stateModule}";`);
637
+ }
638
+ lines.push(``);
639
+ lines.push(`const PORT = parseInt(process.env.PORT || "2567", 10);`);
640
+ lines.push(``);
641
+ lines.push(`const httpServer = createServer();`);
642
+ lines.push(`const gameServer = new Server({`);
643
+ lines.push(` transport: new WebSocketTransport({ server: httpServer }),`);
644
+ lines.push(`});`);
645
+ lines.push(``);
646
+ for (const ch of channels) {
647
+ const roomName = `${toPascalCase(ch.name)}Room`;
648
+ lines.push(`gameServer.define("${ch.name}", ${roomName});`);
649
+ }
650
+ lines.push(``);
651
+ lines.push(`gameServer.listen(PORT).then(() => {`);
652
+ lines.push(` console.log(\`[${system.name}] Colyseus listening on ws://localhost:\${PORT}\`);`);
653
+ for (const ch of channels) {
654
+ lines.push(` console.log(\` - room: ${ch.name}\`);`);
655
+ }
656
+ lines.push(`});`);
657
+ lines.push(``);
658
+ lines.push(`process.on("SIGTERM", () => { gameServer.gracefullyShutdown().then(() => process.exit(0)); });`);
659
+ lines.push(`process.on("SIGINT", () => { gameServer.gracefullyShutdown().then(() => process.exit(0)); });`);
660
+ lines.push(``);
661
+ return lines.join("\n");
662
+ }
663
+ // ─── Package config ──────────────────────────────────────────────────────────
664
+ function emitColyseusPackageJson(system) {
665
+ return JSON.stringify({
666
+ name: toSnakeCase(system.name),
667
+ version: system.version,
668
+ private: true,
669
+ scripts: {
670
+ build: "tsc",
671
+ start: "node dist/index.js",
672
+ dev: "ts-node src/index.ts",
673
+ },
674
+ dependencies: {
675
+ // Pinned to known-working 0.17.x minor versions. These are the line
676
+ // Colyseus moved to in 2026; @colyseus/schema is now 4.x. Bump together.
677
+ colyseus: "^0.17.10",
678
+ "@colyseus/schema": "^4.0.25",
679
+ "@colyseus/ws-transport": "^0.17.13",
680
+ jsonwebtoken: "9.0.2",
681
+ dotenv: "16.4.7",
682
+ },
683
+ devDependencies: {
684
+ "@types/node": "20.14.0",
685
+ "@types/jsonwebtoken": "9.0.7",
686
+ typescript: "5.6.3",
687
+ "ts-node": "10.9.2",
688
+ // Tests / clients use the new SDK package name.
689
+ "@colyseus/sdk": "^0.17.42",
690
+ },
691
+ }, null, 2);
692
+ }
693
+ function emitColyseusTsConfig() {
694
+ // Colyseus schemas use class decorators; experimentalDecorators is required
695
+ // for @type to register at runtime.
696
+ return JSON.stringify({
697
+ compilerOptions: {
698
+ target: "ES2020",
699
+ module: "commonjs",
700
+ lib: ["ES2020"],
701
+ outDir: "./dist",
702
+ rootDir: "./src",
703
+ strict: true,
704
+ esModuleInterop: true,
705
+ skipLibCheck: true,
706
+ experimentalDecorators: true,
707
+ emitDecoratorMetadata: true,
708
+ forceConsistentCasingInFileNames: true,
709
+ declaration: true,
710
+ sourceMap: true,
711
+ },
712
+ include: ["src/**/*"],
713
+ exclude: ["node_modules", "dist"],
714
+ }, null, 2);
715
+ }
716
+ function emitColyseusReadme(system) {
717
+ const channels = system.modules.filter(m => m.kind === "realtime_service");
718
+ const channelDocs = channels.length === 0
719
+ ? "_No channels declared._"
720
+ : channels.map(c => `- \`${c.name}\` (max ${c.config["max_size"] ?? 16} clients, ${c.config["persistence"] ?? "none"})`).join("\n");
721
+ return `# ${system.name} (Colyseus target)
722
+
723
+ Generated by BoneScript compiler. Source hash: ${system.source_hash}
724
+
725
+ ## Quick Start
726
+
727
+ \`\`\`bash
728
+ npm install
729
+ npm run dev
730
+ \`\`\`
731
+
732
+ The server listens on \`ws://localhost:2567\` by default. Set \`PORT\` to change.
733
+ Set \`JWT_SECRET\` to your token-signing secret; \`onAuth\` enforces HS256 + 1h max-age.
734
+
735
+ ## Rooms
736
+
737
+ ${channelDocs}
738
+
739
+ ## Client example
740
+
741
+ \`\`\`typescript
742
+ import { Client } from "@colyseus/sdk";
743
+
744
+ const client = new Client("ws://localhost:2567");
745
+ client.auth.token = "<jwt>"; // signed with JWT_SECRET, alg HS256, sub = user id
746
+ const room = await client.joinOrCreate("${channels[0]?.name ?? "lobby"}");
747
+
748
+ room.state.players.onAdd((player, key) => {
749
+ console.log("player joined:", player.userId);
750
+ });
751
+
752
+ room.send("chat", { text: "hello" });
753
+ \`\`\`
754
+
755
+ ## What this target generates
756
+
757
+ - One \`Room\` subclass per channel (\`src/rooms/\`)
758
+ - One \`Schema\` state class per channel (\`src/state/\`)
759
+ - JWT auth helper (\`src/auth.ts\`) — same algorithm pinning as the Express target
760
+ - A Colyseus \`Server\` bootstrap (\`src/index.ts\`) that registers all rooms
761
+
762
+ ## What this target does NOT generate
763
+
764
+ - HTTP REST routes — use the default Express target alongside this
765
+ - Persistence to a database — the room state lives in memory; bridge to your
766
+ own DB or compile the Express target separately for CRUD
767
+ - Lobby matchmaking beyond \`joinOrCreate\` — see the
768
+ [Colyseus matchmaking guide](https://docs.colyseus.io/matchmaker)
769
+ for advanced patterns
770
+ `;
771
+ }
772
+ // ─── Top-level emitter ────────────────────────────────────────────────────────
773
+ class ColyseusEmitter {
774
+ emit(system) {
775
+ const files = [];
776
+ const channels = system.modules.filter(m => m.kind === "realtime_service");
777
+ files.push({ path: "package.json", content: emitColyseusPackageJson(system), language: "json", source_module: "root" });
778
+ files.push({ path: "tsconfig.json", content: emitColyseusTsConfig(), language: "json", source_module: "root" });
779
+ files.push({ path: "README.md", content: emitColyseusReadme(system), language: "yaml", source_module: "root" });
780
+ files.push({ path: ".env.example", content: this.emitEnvExample(), language: "yaml", source_module: "root" });
781
+ files.push({ path: "src/auth.ts", content: emitColyseusAuth(), language: "typescript", source_module: "infra" });
782
+ files.push({ path: "src/index.ts", content: emitColyseusIndex(system), language: "typescript", source_module: "root" });
783
+ // Emit the persistence bridge only when at least one channel needs it.
784
+ const needsPersistence = channels.some(c => c.config["persistence"] === "database");
785
+ if (needsPersistence) {
786
+ files.push({
787
+ path: "src/persistence.ts",
788
+ content: emitColyseusPersistence(),
789
+ language: "typescript",
790
+ source_module: "infra",
791
+ });
792
+ }
793
+ for (const ch of channels) {
794
+ files.push(emitStateSchema(ch, system));
795
+ files.push(emitRoom(ch, system));
796
+ }
797
+ return files;
798
+ }
799
+ emitEnvExample() {
800
+ return `# JWT verification secret. Required in production.
801
+ # Generate with: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
802
+ JWT_SECRET=
803
+
804
+ # Colyseus server port (default 2567).
805
+ PORT=2567
806
+
807
+ NODE_ENV=development
808
+ `;
809
+ }
810
+ }
811
+ exports.ColyseusEmitter = ColyseusEmitter;
812
+ //# sourceMappingURL=emit_colyseus.js.map