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.
- package/dist/cli.js +79 -8
- package/dist/cli.js.map +1 -1
- package/dist/emit_colyseus.d.ts +26 -0
- package/dist/emit_colyseus.js +812 -0
- package/dist/emit_colyseus.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lowering.js +77 -8
- package/dist/lowering.js.map +1 -1
- package/package.json +4 -2
- package/src/cli.ts +87 -9
- package/src/emit_colyseus.ts +867 -0
- package/src/index.ts +1 -0
- package/src/lowering.ts +81 -9
|
@@ -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
|