@air-jam/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -0
- package/dist/chunk-NWAVO5AJ.js +749 -0
- package/dist/chunk-NWAVO5AJ.js.map +1 -0
- package/dist/cli.js +11 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
controllerInputSchema,
|
|
4
|
+
controllerJoinSchema,
|
|
5
|
+
controllerLeaveSchema,
|
|
6
|
+
controllerStateSchema,
|
|
7
|
+
controllerSystemSchema,
|
|
8
|
+
ErrorCode,
|
|
9
|
+
hostJoinAsChildSchema,
|
|
10
|
+
hostRegisterSystemSchema,
|
|
11
|
+
hostRegistrationSchema,
|
|
12
|
+
systemLaunchGameSchema
|
|
13
|
+
} from "@air-jam/sdk/protocol";
|
|
14
|
+
import Color from "color";
|
|
15
|
+
import cors from "cors";
|
|
16
|
+
import express from "express";
|
|
17
|
+
import { createServer } from "http";
|
|
18
|
+
import { Server } from "socket.io";
|
|
19
|
+
import { v4 as uuidv4 } from "uuid";
|
|
20
|
+
|
|
21
|
+
// src/services/auth-service.ts
|
|
22
|
+
import { and, eq } from "drizzle-orm";
|
|
23
|
+
|
|
24
|
+
// src/db.ts
|
|
25
|
+
import * as dotenv from "dotenv";
|
|
26
|
+
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
27
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
28
|
+
import postgres from "postgres";
|
|
29
|
+
dotenv.config();
|
|
30
|
+
var apiKeys = pgTable("api_keys", {
|
|
31
|
+
id: text("id").primaryKey(),
|
|
32
|
+
gameId: text("game_id").notNull().unique(),
|
|
33
|
+
// One API key per game
|
|
34
|
+
key: text("key").notNull().unique(),
|
|
35
|
+
isActive: boolean("is_active").default(true).notNull(),
|
|
36
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
37
|
+
lastUsedAt: timestamp("last_used_at")
|
|
38
|
+
});
|
|
39
|
+
var connectionString = process.env.DATABASE_URL;
|
|
40
|
+
var client = connectionString ? postgres(connectionString) : null;
|
|
41
|
+
var db = client ? drizzle(client) : null;
|
|
42
|
+
|
|
43
|
+
// src/services/auth-service.ts
|
|
44
|
+
var AuthService = class {
|
|
45
|
+
masterKey;
|
|
46
|
+
databaseUrl;
|
|
47
|
+
isDevMode;
|
|
48
|
+
constructor() {
|
|
49
|
+
this.masterKey = process.env.AIR_JAM_MASTER_KEY;
|
|
50
|
+
this.databaseUrl = process.env.DATABASE_URL;
|
|
51
|
+
this.isDevMode = !this.masterKey && !this.databaseUrl;
|
|
52
|
+
if (this.isDevMode) {
|
|
53
|
+
console.log(
|
|
54
|
+
"[server] Running in development mode - authentication disabled"
|
|
55
|
+
);
|
|
56
|
+
} else if (this.masterKey && !this.databaseUrl) {
|
|
57
|
+
console.log(
|
|
58
|
+
"[server] Running with master key authentication (no database required)"
|
|
59
|
+
);
|
|
60
|
+
} else if (this.databaseUrl) {
|
|
61
|
+
console.log("[server] Running with database authentication");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Verify an API key
|
|
66
|
+
* Returns verification result with optional error message
|
|
67
|
+
* In dev mode, always returns success
|
|
68
|
+
*/
|
|
69
|
+
async verifyApiKey(apiKey) {
|
|
70
|
+
if (this.isDevMode) {
|
|
71
|
+
return { isVerified: true };
|
|
72
|
+
}
|
|
73
|
+
if (!apiKey) {
|
|
74
|
+
return {
|
|
75
|
+
isVerified: false,
|
|
76
|
+
error: "Unauthorized: Invalid or Missing API Key"
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (this.masterKey && apiKey === this.masterKey) {
|
|
80
|
+
return { isVerified: true };
|
|
81
|
+
}
|
|
82
|
+
if (!this.databaseUrl || !db) {
|
|
83
|
+
return {
|
|
84
|
+
isVerified: false,
|
|
85
|
+
error: "Unauthorized: Invalid or Missing API Key"
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const [keyRecord] = await db.select().from(apiKeys).where(and(eq(apiKeys.key, apiKey), eq(apiKeys.isActive, true))).limit(1);
|
|
90
|
+
if (keyRecord) {
|
|
91
|
+
db.update(apiKeys).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(eq(apiKeys.id, keyRecord.id)).catch(
|
|
92
|
+
(err) => console.error("[server] Failed to update lastUsedAt", err)
|
|
93
|
+
);
|
|
94
|
+
return { isVerified: true };
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
isVerified: false,
|
|
98
|
+
error: "Unauthorized: Invalid or Missing API Key"
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error("[server] Database error during key verification", error);
|
|
102
|
+
return {
|
|
103
|
+
isVerified: false,
|
|
104
|
+
error: "Internal Server Error"
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
var authService = new AuthService();
|
|
110
|
+
|
|
111
|
+
// src/services/room-manager.ts
|
|
112
|
+
var RoomManager = class {
|
|
113
|
+
rooms = /* @__PURE__ */ new Map();
|
|
114
|
+
hostIndex = /* @__PURE__ */ new Map();
|
|
115
|
+
controllerIndex = /* @__PURE__ */ new Map();
|
|
116
|
+
/**
|
|
117
|
+
* Get a room by ID
|
|
118
|
+
*/
|
|
119
|
+
getRoom(roomId) {
|
|
120
|
+
return this.rooms.get(roomId);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Create or update a room
|
|
124
|
+
*/
|
|
125
|
+
setRoom(roomId, session) {
|
|
126
|
+
this.rooms.set(roomId, session);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Delete a room
|
|
130
|
+
*/
|
|
131
|
+
deleteRoom(roomId) {
|
|
132
|
+
this.rooms.delete(roomId);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Get room ID by host socket ID
|
|
136
|
+
*/
|
|
137
|
+
getRoomByHostId(socketId) {
|
|
138
|
+
return this.hostIndex.get(socketId);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Associate a host socket with a room
|
|
142
|
+
*/
|
|
143
|
+
setHostRoom(socketId, roomId) {
|
|
144
|
+
this.hostIndex.set(socketId, roomId);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Remove host association
|
|
148
|
+
*/
|
|
149
|
+
deleteHost(socketId) {
|
|
150
|
+
this.hostIndex.delete(socketId);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Get controller info by socket ID
|
|
154
|
+
*/
|
|
155
|
+
getControllerInfo(socketId) {
|
|
156
|
+
return this.controllerIndex.get(socketId);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Associate a controller socket with room and controller ID
|
|
160
|
+
*/
|
|
161
|
+
setController(socketId, entry) {
|
|
162
|
+
this.controllerIndex.set(socketId, entry);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Remove controller association
|
|
166
|
+
*/
|
|
167
|
+
deleteController(socketId) {
|
|
168
|
+
this.controllerIndex.delete(socketId);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Get the active host socket ID based on focus
|
|
172
|
+
*/
|
|
173
|
+
getActiveHostId(session) {
|
|
174
|
+
return session.focus === "GAME" && session.childHostSocketId ? session.childHostSocketId : session.masterHostSocketId;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Remove a room and clean up all associations
|
|
178
|
+
*/
|
|
179
|
+
removeRoom(roomId, io2, reason) {
|
|
180
|
+
const session = this.rooms.get(roomId);
|
|
181
|
+
if (!session) return;
|
|
182
|
+
io2.to(roomId).emit("server:hostLeft", { roomId, reason });
|
|
183
|
+
session.controllers.forEach((controller) => {
|
|
184
|
+
this.controllerIndex.delete(controller.socketId);
|
|
185
|
+
});
|
|
186
|
+
this.hostIndex.delete(session.masterHostSocketId);
|
|
187
|
+
if (session.childHostSocketId) {
|
|
188
|
+
this.hostIndex.delete(session.childHostSocketId);
|
|
189
|
+
}
|
|
190
|
+
this.rooms.delete(roomId);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Get all rooms (for debugging/monitoring)
|
|
194
|
+
*/
|
|
195
|
+
getAllRooms() {
|
|
196
|
+
return this.rooms;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
var roomManager = new RoomManager();
|
|
200
|
+
|
|
201
|
+
// src/index.ts
|
|
202
|
+
var PORT = Number(process.env.PORT ?? 4e3);
|
|
203
|
+
var app = express();
|
|
204
|
+
app.use(cors());
|
|
205
|
+
app.use(express.json());
|
|
206
|
+
app.get("/health", (_, res) => {
|
|
207
|
+
res.json({ ok: true });
|
|
208
|
+
});
|
|
209
|
+
var httpServer = createServer(app);
|
|
210
|
+
var io = new Server(httpServer, {
|
|
211
|
+
cors: {
|
|
212
|
+
origin: "*"
|
|
213
|
+
},
|
|
214
|
+
pingInterval: 2e3,
|
|
215
|
+
pingTimeout: 5e3
|
|
216
|
+
});
|
|
217
|
+
var emitError = (socketId, payload) => {
|
|
218
|
+
io.to(socketId).emit("server:error", payload);
|
|
219
|
+
};
|
|
220
|
+
io.on(
|
|
221
|
+
"connection",
|
|
222
|
+
(socket) => {
|
|
223
|
+
socket.on(
|
|
224
|
+
"host:registerSystem",
|
|
225
|
+
async (payload, callback) => {
|
|
226
|
+
const parsed = hostRegisterSystemSchema.safeParse(payload);
|
|
227
|
+
if (!parsed.success) {
|
|
228
|
+
callback({
|
|
229
|
+
ok: false,
|
|
230
|
+
message: parsed.error.message,
|
|
231
|
+
code: ErrorCode.INVALID_PAYLOAD
|
|
232
|
+
});
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const { roomId, apiKey } = parsed.data;
|
|
236
|
+
const verification = await authService.verifyApiKey(apiKey);
|
|
237
|
+
if (!verification.isVerified) {
|
|
238
|
+
console.warn(
|
|
239
|
+
`[server] Unauthorized host registration attempt for room ${roomId}`
|
|
240
|
+
);
|
|
241
|
+
callback({
|
|
242
|
+
ok: false,
|
|
243
|
+
message: verification.error,
|
|
244
|
+
code: ErrorCode.INVALID_API_KEY
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
let session = roomManager.getRoom(roomId);
|
|
249
|
+
if (session) {
|
|
250
|
+
session.masterHostSocketId = socket.id;
|
|
251
|
+
roomManager.setRoom(roomId, session);
|
|
252
|
+
} else {
|
|
253
|
+
console.log(`[server] Creating room ${roomId}`);
|
|
254
|
+
session = {
|
|
255
|
+
roomId,
|
|
256
|
+
masterHostSocketId: socket.id,
|
|
257
|
+
focus: "SYSTEM",
|
|
258
|
+
controllers: /* @__PURE__ */ new Map(),
|
|
259
|
+
maxPlayers: 32,
|
|
260
|
+
// Default increased to 32 to allow for observers/queue
|
|
261
|
+
gameState: "paused"
|
|
262
|
+
};
|
|
263
|
+
roomManager.setRoom(roomId, session);
|
|
264
|
+
}
|
|
265
|
+
roomManager.setHostRoom(socket.id, roomId);
|
|
266
|
+
socket.join(roomId);
|
|
267
|
+
callback({ ok: true, roomId });
|
|
268
|
+
io.to(roomId).emit("server:roomReady", { roomId });
|
|
269
|
+
}
|
|
270
|
+
);
|
|
271
|
+
socket.on(
|
|
272
|
+
"system:launchGame",
|
|
273
|
+
(payload, callback) => {
|
|
274
|
+
const parsed = systemLaunchGameSchema.safeParse(payload);
|
|
275
|
+
if (!parsed.success) {
|
|
276
|
+
callback({
|
|
277
|
+
ok: false,
|
|
278
|
+
message: parsed.error.message,
|
|
279
|
+
code: ErrorCode.INVALID_PAYLOAD
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const { roomId, gameUrl } = parsed.data;
|
|
284
|
+
const session = roomManager.getRoom(roomId);
|
|
285
|
+
if (!session) {
|
|
286
|
+
callback({
|
|
287
|
+
ok: false,
|
|
288
|
+
message: "Room not found",
|
|
289
|
+
code: ErrorCode.ROOM_NOT_FOUND
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (session.masterHostSocketId !== socket.id) {
|
|
294
|
+
callback({
|
|
295
|
+
ok: false,
|
|
296
|
+
message: "Unauthorized: Not System Host",
|
|
297
|
+
code: ErrorCode.UNAUTHORIZED
|
|
298
|
+
});
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (session.childHostSocketId) {
|
|
302
|
+
callback({
|
|
303
|
+
ok: false,
|
|
304
|
+
message: "Game already active",
|
|
305
|
+
code: ErrorCode.ALREADY_CONNECTED
|
|
306
|
+
});
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (session.joinToken && !session.childHostSocketId) {
|
|
310
|
+
callback({ ok: true, joinToken: session.joinToken });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const joinToken = uuidv4();
|
|
314
|
+
session.joinToken = joinToken;
|
|
315
|
+
session.activeControllerUrl = gameUrl;
|
|
316
|
+
console.log(`[server] Launching game in room ${roomId}`);
|
|
317
|
+
io.to(roomId).emit("client:loadUi", { url: gameUrl });
|
|
318
|
+
callback({ ok: true, joinToken });
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
socket.on(
|
|
322
|
+
"host:joinAsChild",
|
|
323
|
+
(payload, callback) => {
|
|
324
|
+
const parsed = hostJoinAsChildSchema.safeParse(payload);
|
|
325
|
+
if (!parsed.success) {
|
|
326
|
+
callback({
|
|
327
|
+
ok: false,
|
|
328
|
+
message: parsed.error.message,
|
|
329
|
+
code: ErrorCode.INVALID_PAYLOAD
|
|
330
|
+
});
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const { roomId, joinToken } = parsed.data;
|
|
334
|
+
const session = roomManager.getRoom(roomId);
|
|
335
|
+
if (!session) {
|
|
336
|
+
callback({
|
|
337
|
+
ok: false,
|
|
338
|
+
message: "Room not found",
|
|
339
|
+
code: ErrorCode.ROOM_NOT_FOUND
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (session.joinToken !== joinToken) {
|
|
344
|
+
console.warn(
|
|
345
|
+
`[server] Invalid join token for room ${roomId}. Expected ${session.joinToken}, got ${joinToken}`
|
|
346
|
+
);
|
|
347
|
+
callback({
|
|
348
|
+
ok: false,
|
|
349
|
+
message: "Invalid Join Token",
|
|
350
|
+
code: ErrorCode.INVALID_TOKEN
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
console.log(`[server] Game host joined room ${roomId}`);
|
|
355
|
+
session.childHostSocketId = socket.id;
|
|
356
|
+
session.focus = "GAME";
|
|
357
|
+
roomManager.setHostRoom(socket.id, roomId);
|
|
358
|
+
socket.join(roomId);
|
|
359
|
+
setTimeout(() => {
|
|
360
|
+
session.controllers.forEach((c) => {
|
|
361
|
+
const notice = {
|
|
362
|
+
controllerId: c.controllerId,
|
|
363
|
+
nickname: c.nickname,
|
|
364
|
+
player: c.playerProfile
|
|
365
|
+
};
|
|
366
|
+
socket.emit("server:controllerJoined", notice);
|
|
367
|
+
});
|
|
368
|
+
const statePayload = {
|
|
369
|
+
roomId,
|
|
370
|
+
state: {
|
|
371
|
+
gameState: session.gameState
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
socket.emit("server:state", statePayload);
|
|
375
|
+
}, 100);
|
|
376
|
+
callback({ ok: true, roomId });
|
|
377
|
+
}
|
|
378
|
+
);
|
|
379
|
+
socket.on("system:closeGame", (payload) => {
|
|
380
|
+
const { roomId } = payload;
|
|
381
|
+
const session = roomManager.getRoom(roomId);
|
|
382
|
+
if (!session) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (session.masterHostSocketId !== socket.id) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
console.log(`[server] Closing game in room ${roomId}`);
|
|
389
|
+
if (session.childHostSocketId) {
|
|
390
|
+
const childSocket = io.sockets.sockets.get(session.childHostSocketId);
|
|
391
|
+
if (childSocket) {
|
|
392
|
+
childSocket.disconnect(true);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
session.focus = "SYSTEM";
|
|
396
|
+
session.childHostSocketId = void 0;
|
|
397
|
+
session.joinToken = void 0;
|
|
398
|
+
session.activeControllerUrl = void 0;
|
|
399
|
+
io.to(roomId).emit("client:unloadUi");
|
|
400
|
+
});
|
|
401
|
+
socket.on(
|
|
402
|
+
"host:register",
|
|
403
|
+
async (payload, callback) => {
|
|
404
|
+
const parsed = hostRegistrationSchema.safeParse(payload);
|
|
405
|
+
if (!parsed.success) {
|
|
406
|
+
callback({
|
|
407
|
+
ok: false,
|
|
408
|
+
message: parsed.error.message,
|
|
409
|
+
code: ErrorCode.INVALID_PAYLOAD
|
|
410
|
+
});
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const { roomId, maxPlayers } = parsed.data;
|
|
414
|
+
let session = roomManager.getRoom(roomId);
|
|
415
|
+
if (session) {
|
|
416
|
+
session.masterHostSocketId = socket.id;
|
|
417
|
+
session.focus = "SYSTEM";
|
|
418
|
+
} else {
|
|
419
|
+
console.log(`[server] Creating standalone room ${roomId}`);
|
|
420
|
+
session = {
|
|
421
|
+
roomId,
|
|
422
|
+
masterHostSocketId: socket.id,
|
|
423
|
+
focus: "SYSTEM",
|
|
424
|
+
controllers: /* @__PURE__ */ new Map(),
|
|
425
|
+
maxPlayers,
|
|
426
|
+
gameState: "paused"
|
|
427
|
+
};
|
|
428
|
+
roomManager.setRoom(roomId, session);
|
|
429
|
+
}
|
|
430
|
+
roomManager.setHostRoom(socket.id, roomId);
|
|
431
|
+
socket.join(roomId);
|
|
432
|
+
callback({ ok: true, roomId });
|
|
433
|
+
io.to(roomId).emit("server:roomReady", { roomId });
|
|
434
|
+
}
|
|
435
|
+
);
|
|
436
|
+
socket.on("controller:join", (payload, callback) => {
|
|
437
|
+
const parsed = controllerJoinSchema.safeParse(payload);
|
|
438
|
+
if (!parsed.success) {
|
|
439
|
+
callback({
|
|
440
|
+
ok: false,
|
|
441
|
+
message: parsed.error.message,
|
|
442
|
+
code: ErrorCode.INVALID_PAYLOAD
|
|
443
|
+
});
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const { roomId, controllerId, nickname } = parsed.data;
|
|
447
|
+
const session = roomManager.getRoom(roomId);
|
|
448
|
+
if (!session) {
|
|
449
|
+
callback({
|
|
450
|
+
ok: false,
|
|
451
|
+
message: "Room not found",
|
|
452
|
+
code: ErrorCode.ROOM_NOT_FOUND
|
|
453
|
+
});
|
|
454
|
+
emitError(socket.id, {
|
|
455
|
+
code: ErrorCode.ROOM_NOT_FOUND,
|
|
456
|
+
message: "Room not found"
|
|
457
|
+
});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (session.controllers.size >= session.maxPlayers) {
|
|
461
|
+
callback({
|
|
462
|
+
ok: false,
|
|
463
|
+
message: "Room full",
|
|
464
|
+
code: ErrorCode.ROOM_FULL
|
|
465
|
+
});
|
|
466
|
+
emitError(socket.id, {
|
|
467
|
+
code: ErrorCode.ROOM_FULL,
|
|
468
|
+
message: "Room is full"
|
|
469
|
+
});
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const existing = session.controllers.get(controllerId);
|
|
473
|
+
if (existing) {
|
|
474
|
+
roomManager.deleteController(existing.socketId);
|
|
475
|
+
}
|
|
476
|
+
const PLAYER_COLORS = [
|
|
477
|
+
"#38bdf8",
|
|
478
|
+
"#a78bfa",
|
|
479
|
+
"#f472b6",
|
|
480
|
+
"#34d399",
|
|
481
|
+
"#fbbf24",
|
|
482
|
+
"#60a5fa",
|
|
483
|
+
"#c084fc",
|
|
484
|
+
"#fb7185",
|
|
485
|
+
"#4ade80",
|
|
486
|
+
"#f87171",
|
|
487
|
+
"#22d3ee",
|
|
488
|
+
"#a855f7",
|
|
489
|
+
"#ec4899",
|
|
490
|
+
"#10b981",
|
|
491
|
+
"#f59e0b",
|
|
492
|
+
"#3b82f6",
|
|
493
|
+
"#8b5cf6",
|
|
494
|
+
"#ef4444",
|
|
495
|
+
"#14b8a6",
|
|
496
|
+
"#f97316"
|
|
497
|
+
];
|
|
498
|
+
const colorHex = PLAYER_COLORS[session.controllers.size % PLAYER_COLORS.length];
|
|
499
|
+
let color;
|
|
500
|
+
try {
|
|
501
|
+
color = Color(colorHex).hex();
|
|
502
|
+
} catch {
|
|
503
|
+
color = Color("#38bdf8").hex();
|
|
504
|
+
}
|
|
505
|
+
const playerProfile = {
|
|
506
|
+
id: controllerId,
|
|
507
|
+
label: nickname ?? `Player ${session.controllers.size}`,
|
|
508
|
+
color
|
|
509
|
+
};
|
|
510
|
+
const controllerSession = {
|
|
511
|
+
controllerId,
|
|
512
|
+
nickname,
|
|
513
|
+
socketId: socket.id,
|
|
514
|
+
playerProfile
|
|
515
|
+
};
|
|
516
|
+
session.controllers.set(controllerId, controllerSession);
|
|
517
|
+
roomManager.setController(socket.id, { roomId, controllerId });
|
|
518
|
+
socket.join(roomId);
|
|
519
|
+
const notice = {
|
|
520
|
+
controllerId,
|
|
521
|
+
nickname,
|
|
522
|
+
player: playerProfile
|
|
523
|
+
};
|
|
524
|
+
io.to(roomManager.getActiveHostId(session)).emit(
|
|
525
|
+
"server:controllerJoined",
|
|
526
|
+
notice
|
|
527
|
+
);
|
|
528
|
+
callback({ ok: true, controllerId, roomId });
|
|
529
|
+
const welcomePayload = {
|
|
530
|
+
controllerId,
|
|
531
|
+
roomId,
|
|
532
|
+
player: playerProfile
|
|
533
|
+
};
|
|
534
|
+
socket.emit("server:welcome", welcomePayload);
|
|
535
|
+
const statePayload = {
|
|
536
|
+
roomId,
|
|
537
|
+
state: {
|
|
538
|
+
gameState: session.gameState
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
socket.emit("server:state", statePayload);
|
|
542
|
+
if (session.activeControllerUrl) {
|
|
543
|
+
socket.emit("client:loadUi", { url: session.activeControllerUrl });
|
|
544
|
+
}
|
|
545
|
+
console.log(
|
|
546
|
+
`[server] Controller joined room ${roomId} (${session.controllers.size}/${session.maxPlayers} players)`
|
|
547
|
+
);
|
|
548
|
+
});
|
|
549
|
+
socket.on("controller:leave", (payload) => {
|
|
550
|
+
const parsed = controllerLeaveSchema.safeParse(payload);
|
|
551
|
+
if (!parsed.success) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const { roomId, controllerId } = parsed.data;
|
|
555
|
+
const session = roomManager.getRoom(roomId);
|
|
556
|
+
if (!session) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
session.controllers.delete(controllerId);
|
|
560
|
+
roomManager.deleteController(socket.id);
|
|
561
|
+
const notice = { controllerId };
|
|
562
|
+
io.to(roomManager.getActiveHostId(session)).emit(
|
|
563
|
+
"server:controllerLeft",
|
|
564
|
+
notice
|
|
565
|
+
);
|
|
566
|
+
socket.leave(roomId);
|
|
567
|
+
});
|
|
568
|
+
socket.on("controller:input", (payload) => {
|
|
569
|
+
const result = controllerInputSchema.safeParse(payload);
|
|
570
|
+
if (!result.success) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const { roomId } = result.data;
|
|
574
|
+
const session = roomManager.getRoom(roomId);
|
|
575
|
+
if (!session) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const targetHostId = roomManager.getActiveHostId(session);
|
|
579
|
+
if (targetHostId) {
|
|
580
|
+
io.to(targetHostId).emit("server:input", result.data);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
socket.on("controller:system", (payload) => {
|
|
584
|
+
const parsed = controllerSystemSchema.safeParse(payload);
|
|
585
|
+
if (!parsed.success) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const { roomId, command } = parsed.data;
|
|
589
|
+
const session = roomManager.getRoom(roomId);
|
|
590
|
+
if (!session) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (command === "exit") {
|
|
594
|
+
console.log(`[server] Controller exit request in room ${roomId}`);
|
|
595
|
+
if (session.childHostSocketId) {
|
|
596
|
+
const childSocket = io.sockets.sockets.get(session.childHostSocketId);
|
|
597
|
+
if (childSocket) {
|
|
598
|
+
childSocket.disconnect(true);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
session.focus = "SYSTEM";
|
|
602
|
+
session.childHostSocketId = void 0;
|
|
603
|
+
session.joinToken = void 0;
|
|
604
|
+
session.activeControllerUrl = void 0;
|
|
605
|
+
session.gameState = "paused";
|
|
606
|
+
io.to(roomId).emit("client:unloadUi");
|
|
607
|
+
if (session.masterHostSocketId) {
|
|
608
|
+
io.to(session.masterHostSocketId).emit("server:closeChild");
|
|
609
|
+
}
|
|
610
|
+
} else if (command === "toggle_pause") {
|
|
611
|
+
session.gameState = session.gameState === "playing" ? "paused" : "playing";
|
|
612
|
+
const statePayload = {
|
|
613
|
+
roomId,
|
|
614
|
+
state: {
|
|
615
|
+
gameState: session.gameState
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
io.to(roomId).emit("server:state", statePayload);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
socket.on("host:system", (payload) => {
|
|
622
|
+
const parsed = controllerSystemSchema.safeParse(payload);
|
|
623
|
+
if (!parsed.success) {
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const { roomId, command } = parsed.data;
|
|
627
|
+
const session = roomManager.getRoom(roomId);
|
|
628
|
+
if (!session) {
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (command === "toggle_pause") {
|
|
632
|
+
session.gameState = session.gameState === "playing" ? "paused" : "playing";
|
|
633
|
+
const statePayload = {
|
|
634
|
+
roomId,
|
|
635
|
+
state: {
|
|
636
|
+
gameState: session.gameState
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
io.to(roomId).emit("server:state", statePayload);
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
socket.on("host:state", (payload) => {
|
|
643
|
+
const result = controllerStateSchema.safeParse(payload);
|
|
644
|
+
if (!result.success) return;
|
|
645
|
+
const { roomId, state } = result.data;
|
|
646
|
+
const session = roomManager.getRoom(roomId);
|
|
647
|
+
if (session) {
|
|
648
|
+
if (state.gameState) {
|
|
649
|
+
session.gameState = state.gameState;
|
|
650
|
+
}
|
|
651
|
+
session.controllers.forEach((c) => {
|
|
652
|
+
io.to(c.socketId).emit("server:state", result.data);
|
|
653
|
+
});
|
|
654
|
+
if (session.masterHostSocketId) {
|
|
655
|
+
io.to(session.masterHostSocketId).emit("server:state", result.data);
|
|
656
|
+
}
|
|
657
|
+
if (session.childHostSocketId) {
|
|
658
|
+
io.to(session.childHostSocketId).emit("server:state", result.data);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
socket.on("host:signal", (payload) => {
|
|
663
|
+
const roomId = roomManager.getRoomByHostId(socket.id);
|
|
664
|
+
if (!roomId) return;
|
|
665
|
+
const session = roomManager.getRoom(roomId);
|
|
666
|
+
if (!session) return;
|
|
667
|
+
if (payload.targetId) {
|
|
668
|
+
const controller = session.controllers.get(payload.targetId);
|
|
669
|
+
if (controller) {
|
|
670
|
+
io.to(controller.socketId).emit("server:signal", payload);
|
|
671
|
+
}
|
|
672
|
+
} else {
|
|
673
|
+
socket.to(roomId).emit("server:signal", payload);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
socket.on("host:play_sound", (payload) => {
|
|
677
|
+
const { roomId, targetControllerId, soundId, volume, loop } = payload;
|
|
678
|
+
const session = roomManager.getRoom(roomId);
|
|
679
|
+
if (!session) return;
|
|
680
|
+
const message = { id: soundId, volume, loop };
|
|
681
|
+
if (targetControllerId) {
|
|
682
|
+
const controller = session.controllers.get(targetControllerId);
|
|
683
|
+
if (controller) {
|
|
684
|
+
io.to(controller.socketId).emit("server:playSound", message);
|
|
685
|
+
}
|
|
686
|
+
} else {
|
|
687
|
+
socket.to(roomId).emit("server:playSound", message);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
socket.on("controller:play_sound", (payload) => {
|
|
691
|
+
const { roomId, soundId, volume, loop } = payload;
|
|
692
|
+
const session = roomManager.getRoom(roomId);
|
|
693
|
+
if (!session) return;
|
|
694
|
+
io.to(roomManager.getActiveHostId(session)).emit("server:playSound", {
|
|
695
|
+
id: soundId,
|
|
696
|
+
volume,
|
|
697
|
+
loop
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
socket.on("disconnect", () => {
|
|
701
|
+
const roomId = roomManager.getRoomByHostId(socket.id);
|
|
702
|
+
if (roomId) {
|
|
703
|
+
const session = roomManager.getRoom(roomId);
|
|
704
|
+
if (!session) {
|
|
705
|
+
roomManager.deleteHost(socket.id);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (socket.id === session.childHostSocketId) {
|
|
709
|
+
console.log(`[server] Game host disconnected from room ${roomId}`);
|
|
710
|
+
session.childHostSocketId = void 0;
|
|
711
|
+
session.focus = "SYSTEM";
|
|
712
|
+
session.joinToken = void 0;
|
|
713
|
+
session.activeControllerUrl = void 0;
|
|
714
|
+
io.to(roomId).emit("client:unloadUi");
|
|
715
|
+
} else if (socket.id === session.masterHostSocketId) {
|
|
716
|
+
console.log(`[server] Host disconnected from room ${roomId}`);
|
|
717
|
+
setTimeout(() => {
|
|
718
|
+
const currentSession = roomManager.getRoom(roomId);
|
|
719
|
+
if (currentSession && currentSession.masterHostSocketId === socket.id) {
|
|
720
|
+
console.log(`[server] Removing room ${roomId}`);
|
|
721
|
+
roomManager.removeRoom(roomId, io, "Host disconnected");
|
|
722
|
+
}
|
|
723
|
+
}, 3e3);
|
|
724
|
+
}
|
|
725
|
+
roomManager.deleteHost(socket.id);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const controller = roomManager.getControllerInfo(socket.id);
|
|
729
|
+
if (controller) {
|
|
730
|
+
const session = roomManager.getRoom(controller.roomId);
|
|
731
|
+
if (session) {
|
|
732
|
+
session.controllers.delete(controller.controllerId);
|
|
733
|
+
const notice = {
|
|
734
|
+
controllerId: controller.controllerId
|
|
735
|
+
};
|
|
736
|
+
io.to(roomManager.getActiveHostId(session)).emit(
|
|
737
|
+
"server:controllerLeft",
|
|
738
|
+
notice
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
roomManager.deleteController(socket.id);
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
);
|
|
746
|
+
httpServer.listen(PORT, () => {
|
|
747
|
+
console.log(`[air-jam] server listening on http://localhost:${PORT}`);
|
|
748
|
+
});
|
|
749
|
+
//# sourceMappingURL=chunk-NWAVO5AJ.js.map
|