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