@jaysonder/tts-server 1.0.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/.env.example +10 -0
- package/.github/workflows/publish-validation.yml +49 -0
- package/.prettierrc +7 -0
- package/AGENTS.md +41 -0
- package/Dockerfile +22 -0
- package/README.md +61 -0
- package/deploy-to-gcp.sh +105 -0
- package/eslint.config.js +30 -0
- package/nest-cli.json +10 -0
- package/package.json +50 -0
- package/packages/validation/README.md +25 -0
- package/packages/validation/package-lock.json +43 -0
- package/packages/validation/package.json +47 -0
- package/packages/validation/src/index.ts +120 -0
- package/packages/validation/src/validation-helpers.ts +90 -0
- package/packages/validation/tsconfig.json +17 -0
- package/src/app.module.ts +14 -0
- package/src/common/pipes/zod-validation.pipe.ts +15 -0
- package/src/common/schemas/index.ts +37 -0
- package/src/common/types/index.ts +53 -0
- package/src/common/utils/rate-limiter.ts +87 -0
- package/src/common/utils/room-code.ts +13 -0
- package/src/game/dto/game.dto.ts +12 -0
- package/src/game/dto/index.ts +1 -0
- package/src/game/game.controller.ts +42 -0
- package/src/game/game.gateway.ts +363 -0
- package/src/game/game.module.ts +11 -0
- package/src/game/services/game-engine.service.ts +281 -0
- package/src/game/services/index.ts +4 -0
- package/src/game/services/room-manager.service.ts +152 -0
- package/src/game/services/scoring.service.ts +46 -0
- package/src/game/services/twister-generator.service.ts +119 -0
- package/src/main.ts +22 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WebSocketGateway,
|
|
3
|
+
WebSocketServer,
|
|
4
|
+
SubscribeMessage,
|
|
5
|
+
OnGatewayConnection,
|
|
6
|
+
OnGatewayDisconnect,
|
|
7
|
+
MessageBody,
|
|
8
|
+
ConnectedSocket,
|
|
9
|
+
} from '@nestjs/websockets';
|
|
10
|
+
import { BadRequestException } from '@nestjs/common';
|
|
11
|
+
import { Server, Socket } from 'socket.io';
|
|
12
|
+
import { Logger } from '@nestjs/common';
|
|
13
|
+
import { ZodSchema } from 'zod';
|
|
14
|
+
import { GameEngineService, AUTO_ADVANCE_DELAY } from './services/game-engine.service.js';
|
|
15
|
+
import { RoomManagerService } from './services/room-manager.service.js';
|
|
16
|
+
import {
|
|
17
|
+
CreateRoomSchema,
|
|
18
|
+
JoinRoomSchema,
|
|
19
|
+
SubmitAnswerSchema,
|
|
20
|
+
type CreateRoomDto,
|
|
21
|
+
type JoinRoomDto,
|
|
22
|
+
type SubmitAnswerDto,
|
|
23
|
+
} from './dto/game.dto.js';
|
|
24
|
+
import { openaiRateLimiter, roomCreationRateLimiter, roomJoinRateLimiter, answerSubmissionRateLimiter } from '../common/utils/rate-limiter.js';
|
|
25
|
+
import type { GameSettings } from '../common/types/index.js';
|
|
26
|
+
|
|
27
|
+
function parseDto<T>(schema: ZodSchema<T>, data: unknown): T {
|
|
28
|
+
const result = schema.safeParse(data);
|
|
29
|
+
if (!result.success) {
|
|
30
|
+
const messages = result.error.issues.map((e) => e.message);
|
|
31
|
+
throw new BadRequestException(messages.join(', '));
|
|
32
|
+
}
|
|
33
|
+
return result.data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@WebSocketGateway({
|
|
37
|
+
cors: {
|
|
38
|
+
origin: process.env.CLIENT_URL,
|
|
39
|
+
methods: ['GET', 'POST'],
|
|
40
|
+
credentials: true,
|
|
41
|
+
},
|
|
42
|
+
pingTimeout: 20000,
|
|
43
|
+
pingInterval: 25000,
|
|
44
|
+
})
|
|
45
|
+
export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|
46
|
+
@WebSocketServer()
|
|
47
|
+
server: Server;
|
|
48
|
+
|
|
49
|
+
private readonly logger = new Logger(GameGateway.name);
|
|
50
|
+
private socketRoomMap = new Map<string, { roomCode: string | null; playerId: string | null }>();
|
|
51
|
+
|
|
52
|
+
constructor(
|
|
53
|
+
private readonly gameEngine: GameEngineService,
|
|
54
|
+
private readonly roomManager: RoomManagerService,
|
|
55
|
+
) {}
|
|
56
|
+
|
|
57
|
+
handleConnection(client: Socket): void {
|
|
58
|
+
this.logger.log(`Client connected: ${client.id}`);
|
|
59
|
+
this.socketRoomMap.set(client.id, { roomCode: null, playerId: null });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
handleDisconnect(client: Socket): void {
|
|
63
|
+
const socketData = this.socketRoomMap.get(client.id);
|
|
64
|
+
this.logger.log(`Client disconnected: ${client.id}`);
|
|
65
|
+
|
|
66
|
+
if (socketData?.roomCode && socketData?.playerId) {
|
|
67
|
+
const roomBefore = this.roomManager.getRoom(socketData.roomCode);
|
|
68
|
+
const playersBefore = roomBefore?.game.players.length ?? 0;
|
|
69
|
+
|
|
70
|
+
this.roomManager.removePlayer(socketData.roomCode, socketData.playerId);
|
|
71
|
+
|
|
72
|
+
const room = this.roomManager.getRoom(socketData.roomCode);
|
|
73
|
+
if (room) {
|
|
74
|
+
this.logger.log(`Player removed from room`, {
|
|
75
|
+
roomCode: socketData.roomCode,
|
|
76
|
+
playerId: socketData.playerId,
|
|
77
|
+
remainingPlayers: room.game.players.length,
|
|
78
|
+
});
|
|
79
|
+
this.server.to(socketData.roomCode).emit('player-left', {
|
|
80
|
+
playerId: socketData.playerId,
|
|
81
|
+
players: room.game.players,
|
|
82
|
+
});
|
|
83
|
+
} else {
|
|
84
|
+
this.logger.log(`Room deleted (last player left)`, { roomCode: socketData.roomCode, playersBefore });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.socketRoomMap.delete(client.id);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@SubscribeMessage('create-room')
|
|
92
|
+
handleCreateRoom(
|
|
93
|
+
@MessageBody() rawData: unknown,
|
|
94
|
+
@ConnectedSocket() client: Socket,
|
|
95
|
+
): { success: boolean; error?: string; roomCode?: string; player?: unknown; game?: unknown } {
|
|
96
|
+
if (!roomCreationRateLimiter.check(client.id)) {
|
|
97
|
+
this.logger.warn(`create-room rate limited: ${client.id}`);
|
|
98
|
+
return { success: false, error: 'Too many room creation attempts. Please try again later.' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let data: CreateRoomDto;
|
|
102
|
+
try {
|
|
103
|
+
data = parseDto(CreateRoomSchema, rawData);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return { success: false, error: error instanceof Error ? error.message : 'Validation failed' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.logger.log(`create-room event`, { playerName: data.playerName, settings: data.settings });
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const settings: GameSettings = {
|
|
112
|
+
topic: data.settings.topic,
|
|
113
|
+
length: data.settings.length,
|
|
114
|
+
customLength: data.settings.customLength,
|
|
115
|
+
rounds: data.settings.rounds,
|
|
116
|
+
roundTimeLimit: data.settings.roundTimeLimit,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const room = this.roomManager.createRoom(data.playerName, settings);
|
|
120
|
+
this.socketRoomMap.set(client.id, { roomCode: room.code, playerId: room.game.players[0].id });
|
|
121
|
+
void client.join(room.code);
|
|
122
|
+
|
|
123
|
+
this.logger.log(`Room created`, { roomCode: room.code, playerId: room.game.players[0].id });
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
success: true,
|
|
127
|
+
roomCode: room.code,
|
|
128
|
+
player: room.game.players[0],
|
|
129
|
+
game: room.game,
|
|
130
|
+
};
|
|
131
|
+
} catch (error) {
|
|
132
|
+
this.logger.error(`create-room failed`, { error: error instanceof Error ? error.message : String(error) });
|
|
133
|
+
return { success: false, error: error instanceof Error ? error.message : 'Failed to create room' };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@SubscribeMessage('join-room')
|
|
138
|
+
handleJoinRoom(
|
|
139
|
+
@MessageBody() rawData: unknown,
|
|
140
|
+
@ConnectedSocket() client: Socket,
|
|
141
|
+
): { success: boolean; error?: string; roomCode?: string; player?: unknown; game?: unknown } {
|
|
142
|
+
if (!roomJoinRateLimiter.check(client.id)) {
|
|
143
|
+
this.logger.warn(`join-room rate limited: ${client.id}`);
|
|
144
|
+
return { success: false, error: 'Too many join attempts. Please try again later.' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let data: JoinRoomDto;
|
|
148
|
+
try {
|
|
149
|
+
data = parseDto(JoinRoomSchema, rawData);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return { success: false, error: error instanceof Error ? error.message : 'Validation failed' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
this.logger.log(`join-room event`, { roomCode: data.roomCode, playerName: data.playerName });
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const result = this.roomManager.joinRoom(data.roomCode, data.playerName);
|
|
158
|
+
|
|
159
|
+
if (!result) {
|
|
160
|
+
this.logger.warn(`join-room failed - room not found or full`, { roomCode: data.roomCode });
|
|
161
|
+
return { success: false, error: 'Room not found or full' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.socketRoomMap.set(client.id, { roomCode: result.room.code, playerId: result.player.id });
|
|
165
|
+
void client.join(result.room.code);
|
|
166
|
+
|
|
167
|
+
this.logger.log(`Player joined room`, {
|
|
168
|
+
roomCode: result.room.code,
|
|
169
|
+
playerId: result.player.id,
|
|
170
|
+
playerName: data.playerName,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
this.server.to(result.room.code).emit('player-joined', {
|
|
174
|
+
player: result.player,
|
|
175
|
+
players: result.room.game.players,
|
|
176
|
+
game: result.room.game,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
success: true,
|
|
181
|
+
roomCode: result.room.code,
|
|
182
|
+
player: result.player,
|
|
183
|
+
game: result.room.game,
|
|
184
|
+
};
|
|
185
|
+
} catch (error) {
|
|
186
|
+
this.logger.error(`join-room failed`, { error: error instanceof Error ? error.message : String(error) });
|
|
187
|
+
return { success: false, error: error instanceof Error ? error.message : 'Failed to join room' };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@SubscribeMessage('start-game')
|
|
192
|
+
async handleStartGame(@ConnectedSocket() client: Socket): Promise<{ success: boolean; error?: string }> {
|
|
193
|
+
const socketData = this.socketRoomMap.get(client.id);
|
|
194
|
+
|
|
195
|
+
if (!openaiRateLimiter.check(client.id)) {
|
|
196
|
+
this.logger.warn(`start-game rate limited: ${client.id}`);
|
|
197
|
+
return { success: false, error: 'Too many game starts. Please wait before trying again.' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
this.logger.log(`start-game event`, { roomCode: socketData?.roomCode, playerId: socketData?.playerId });
|
|
201
|
+
|
|
202
|
+
if (!socketData?.roomCode) {
|
|
203
|
+
return { success: false, error: 'Not in a room' };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const room = this.roomManager.getRoom(socketData.roomCode);
|
|
207
|
+
if (!room || room.hostId !== socketData.playerId) {
|
|
208
|
+
this.logger.warn(`start-game failed - not host or room not found`, {
|
|
209
|
+
roomCode: socketData.roomCode,
|
|
210
|
+
playerId: socketData.playerId,
|
|
211
|
+
});
|
|
212
|
+
return { success: false, error: 'Only host can start game' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const roomCode = socketData.roomCode;
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const success = await this.gameEngine.startGame(roomCode, this.server);
|
|
219
|
+
|
|
220
|
+
if (success) {
|
|
221
|
+
this.logger.log(`Game started`, { roomCode, rounds: room.game.twisters.length });
|
|
222
|
+
this.server.to(roomCode).emit('game-started', {
|
|
223
|
+
game: room.game,
|
|
224
|
+
currentTwister: room.game.twisters[0],
|
|
225
|
+
roundStartTime: room.game.currentTwisterStartTime,
|
|
226
|
+
roundTimeLimit: room.game.roundTimeLimit,
|
|
227
|
+
});
|
|
228
|
+
return { success: true };
|
|
229
|
+
} else {
|
|
230
|
+
return { success: false, error: 'Failed to start game' };
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
this.logger.error(`start-game error`, { error: error instanceof Error ? error.message : String(error) });
|
|
234
|
+
return { success: false, error: error instanceof Error ? error.message : 'Failed to start game' };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
@SubscribeMessage('submit-answer')
|
|
239
|
+
handleSubmitAnswer(
|
|
240
|
+
@MessageBody() rawData: unknown,
|
|
241
|
+
@ConnectedSocket() client: Socket,
|
|
242
|
+
): { success: boolean; error?: string; similarity?: number } {
|
|
243
|
+
const socketData = this.socketRoomMap.get(client.id);
|
|
244
|
+
|
|
245
|
+
if (!answerSubmissionRateLimiter.check(client.id)) {
|
|
246
|
+
this.logger.warn(`submit-answer rate limited: ${client.id}`);
|
|
247
|
+
return { success: false, error: 'Too many submissions. Please slow down.' };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let data: SubmitAnswerDto;
|
|
251
|
+
try {
|
|
252
|
+
data = parseDto(SubmitAnswerSchema, rawData);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return { success: false, error: error instanceof Error ? error.message : 'Validation failed' };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this.logger.debug(`submit-answer event`, {
|
|
258
|
+
roomCode: socketData?.roomCode,
|
|
259
|
+
playerId: socketData?.playerId,
|
|
260
|
+
transcript: data.transcript.substring(0, 50),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (!socketData?.roomCode || !socketData?.playerId) {
|
|
264
|
+
return { success: false, error: 'Not in a room' };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const result = this.gameEngine.submitAnswer(
|
|
268
|
+
socketData.roomCode,
|
|
269
|
+
socketData.playerId,
|
|
270
|
+
data.transcript,
|
|
271
|
+
data.timestamp,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
if (!result) {
|
|
275
|
+
this.logger.warn(`submit-answer failed - cannot submit`, {
|
|
276
|
+
roomCode: socketData.roomCode,
|
|
277
|
+
playerId: socketData.playerId,
|
|
278
|
+
});
|
|
279
|
+
return { success: false, error: 'Cannot submit answer' };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.logger.log(`Answer submitted`, {
|
|
283
|
+
roomCode: socketData.roomCode,
|
|
284
|
+
playerId: socketData.playerId,
|
|
285
|
+
similarity: result.similarity,
|
|
286
|
+
isComplete: result.isComplete,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
this.server.to(socketData.roomCode).emit('player-submitted', {
|
|
290
|
+
playerId: socketData.playerId,
|
|
291
|
+
similarity: result.similarity,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
if (result.isComplete) {
|
|
295
|
+
const roomCode = socketData.roomCode;
|
|
296
|
+
this.logger.log(`All players submitted, advancing round`, { roomCode });
|
|
297
|
+
setTimeout(() => {
|
|
298
|
+
this.gameEngine.advanceRound(roomCode, this.server);
|
|
299
|
+
}, AUTO_ADVANCE_DELAY);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return { success: true, similarity: result.similarity };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
@SubscribeMessage('pause-game')
|
|
306
|
+
handlePauseGame(@ConnectedSocket() client: Socket): { success: boolean; error?: string } {
|
|
307
|
+
const socketData = this.socketRoomMap.get(client.id);
|
|
308
|
+
|
|
309
|
+
this.logger.log(`pause-game event`, { roomCode: socketData?.roomCode, playerId: socketData?.playerId });
|
|
310
|
+
|
|
311
|
+
if (!socketData?.roomCode || !socketData?.playerId) {
|
|
312
|
+
return { success: false, error: 'Not in a room' };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
const success = this.gameEngine.pauseGame(socketData.roomCode, socketData.playerId, this.server);
|
|
317
|
+
this.logger.log(`pause-game result`, { roomCode: socketData.roomCode, success });
|
|
318
|
+
return { success };
|
|
319
|
+
} catch (error) {
|
|
320
|
+
this.logger.error(`pause-game error`, { error: error instanceof Error ? error.message : String(error) });
|
|
321
|
+
return { success: false, error: 'Failed to pause game' };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
@SubscribeMessage('resume-game')
|
|
326
|
+
handleResumeGame(@ConnectedSocket() client: Socket): { success: boolean; error?: string } {
|
|
327
|
+
const socketData = this.socketRoomMap.get(client.id);
|
|
328
|
+
|
|
329
|
+
this.logger.log(`resume-game event`, { roomCode: socketData?.roomCode });
|
|
330
|
+
|
|
331
|
+
if (!socketData?.roomCode) {
|
|
332
|
+
return { success: false, error: 'Not in a room' };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const success = this.gameEngine.resumeGame(socketData.roomCode, this.server);
|
|
337
|
+
this.logger.log(`resume-game result`, { roomCode: socketData.roomCode, success });
|
|
338
|
+
return { success };
|
|
339
|
+
} catch (error) {
|
|
340
|
+
this.logger.error(`resume-game error`, { error: error instanceof Error ? error.message : String(error) });
|
|
341
|
+
return { success: false, error: 'Failed to resume game' };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
@SubscribeMessage('get-room-state')
|
|
346
|
+
handleGetRoomState(@ConnectedSocket() client: Socket): { success: boolean; error?: string; game?: unknown; playerId?: string | null } {
|
|
347
|
+
const socketData = this.socketRoomMap.get(client.id);
|
|
348
|
+
|
|
349
|
+
this.logger.debug(`get-room-state event`, { roomCode: socketData?.roomCode });
|
|
350
|
+
|
|
351
|
+
if (!socketData?.roomCode) {
|
|
352
|
+
return { success: false, error: 'Not in a room' };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const room = this.roomManager.getRoom(socketData.roomCode);
|
|
356
|
+
if (!room) {
|
|
357
|
+
this.logger.warn(`get-room-state failed - room not found`, { roomCode: socketData.roomCode });
|
|
358
|
+
return { success: false, error: 'Room not found' };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return { success: true, game: room.game, playerId: socketData.playerId };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { GameGateway } from './game.gateway.js';
|
|
3
|
+
import { GameController } from './game.controller.js';
|
|
4
|
+
import { GameEngineService, RoomManagerService, TwisterGeneratorService } from './services/index.js';
|
|
5
|
+
|
|
6
|
+
@Module({
|
|
7
|
+
controllers: [GameController],
|
|
8
|
+
providers: [GameGateway, GameEngineService, RoomManagerService, TwisterGeneratorService],
|
|
9
|
+
exports: [GameEngineService, RoomManagerService, TwisterGeneratorService],
|
|
10
|
+
})
|
|
11
|
+
export class GameModule {}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { Injectable, Logger } from '@nestjs/common';
|
|
2
|
+
import { Server } from 'socket.io';
|
|
3
|
+
import { TranscriptSchema } from '@nemsae/tts-validation';
|
|
4
|
+
import type { ZodIssue } from 'zod';
|
|
5
|
+
import type { Room, RoundResult, Player } from '../../common/types/index.js';
|
|
6
|
+
import { RoomManagerService } from './room-manager.service.js';
|
|
7
|
+
import { TwisterGeneratorService } from './twister-generator.service.js';
|
|
8
|
+
import { scoreTwister } from './scoring.service.js';
|
|
9
|
+
|
|
10
|
+
export const AUTO_ADVANCE_DELAY = 2000;
|
|
11
|
+
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class GameEngineService {
|
|
14
|
+
private readonly logger = new Logger(GameEngineService.name);
|
|
15
|
+
private roundTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly roomManager: RoomManagerService,
|
|
19
|
+
private readonly twisterGenerator: TwisterGeneratorService,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
async startGame(roomCode: string, io: Server): Promise<boolean> {
|
|
23
|
+
const room = this.roomManager.getRoom(roomCode);
|
|
24
|
+
if (!room || room.game.status !== 'lobby') {
|
|
25
|
+
this.logger.warn(`startGame failed - invalid state, roomCode: ${roomCode}, status: ${room?.game.status}`);
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
this.logger.log(`Starting game, roomCode: ${roomCode}, topic: ${room.game.settings.topic}, rounds: ${room.game.settings.rounds}`);
|
|
30
|
+
|
|
31
|
+
const twisters = await this.twisterGenerator.generateTwisters(
|
|
32
|
+
room.game.settings.topic,
|
|
33
|
+
room.game.settings.length,
|
|
34
|
+
room.game.settings.customLength,
|
|
35
|
+
room.game.settings.rounds,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
room.game.twisters = twisters;
|
|
39
|
+
room.game.currentRound = 0;
|
|
40
|
+
room.game.status = 'playing';
|
|
41
|
+
room.game.startedAt = Date.now();
|
|
42
|
+
room.game.currentTwisterStartTime = Date.now();
|
|
43
|
+
room.game.roundTimeLimit = room.game.settings.roundTimeLimit ?? null;
|
|
44
|
+
|
|
45
|
+
if (room.game.roundTimeLimit !== null) {
|
|
46
|
+
this.startRoundTimer(roomCode, io);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.logger.log(`Game started successfully, roomCode: ${roomCode}, twistersGenerated: ${twisters.length}`);
|
|
50
|
+
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
submitAnswer(
|
|
55
|
+
roomCode: string,
|
|
56
|
+
playerId: string,
|
|
57
|
+
transcript: string,
|
|
58
|
+
_clientTimestamp: number,
|
|
59
|
+
): { similarity: number; isComplete: boolean } | null {
|
|
60
|
+
const transcriptResult = TranscriptSchema.safeParse(transcript);
|
|
61
|
+
if (!transcriptResult.success) {
|
|
62
|
+
this.logger.warn(`submitAnswer failed - invalid transcript, roomCode: ${roomCode}, playerId: ${playerId}, error: ${transcriptResult.error.issues.map((e: ZodIssue) => e.message).join(', ')}`);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const sanitizedTranscript = transcriptResult.data;
|
|
66
|
+
|
|
67
|
+
const room = this.roomManager.getRoom(roomCode);
|
|
68
|
+
if (!room || room.game.status !== 'playing') {
|
|
69
|
+
this.logger.warn(`submitAnswer failed - game not in playing state, roomCode: ${roomCode}, status: ${room?.game.status}`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (room.game.pausedAt !== null) {
|
|
73
|
+
this.logger.warn(`submitAnswer failed - game paused, roomCode: ${roomCode}`);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const currentTwister = room.game.twisters[room.game.currentRound];
|
|
78
|
+
if (!currentTwister) {
|
|
79
|
+
this.logger.warn(`submitAnswer failed - no current twister, roomCode: ${roomCode}, round: ${room.game.currentRound}`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const roundElapsed = Date.now() - (room.game.currentTwisterStartTime || 0);
|
|
84
|
+
if (room.game.roundTimeLimit !== null && roundElapsed > room.game.roundTimeLimit) {
|
|
85
|
+
this.logger.warn(`submitAnswer failed - round time exceeded, roomCode: ${roomCode}, roundElapsed: ${roundElapsed}, limit: ${room.game.roundTimeLimit}`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { similarity } = scoreTwister(sanitizedTranscript, currentTwister.text);
|
|
90
|
+
|
|
91
|
+
this.logger.debug(`Answer scored, roomCode: ${roomCode}, playerId: ${playerId}, similarity: ${similarity}`);
|
|
92
|
+
|
|
93
|
+
const result: RoundResult = {
|
|
94
|
+
playerId,
|
|
95
|
+
twisterId: currentTwister.id,
|
|
96
|
+
similarity,
|
|
97
|
+
completedAt: Date.now(),
|
|
98
|
+
};
|
|
99
|
+
room.game.roundResults.push(result);
|
|
100
|
+
|
|
101
|
+
const player = room.game.players.find((p: Player) => p.id === playerId);
|
|
102
|
+
if (player) {
|
|
103
|
+
player.currentScore = similarity;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const submittedPlayerIds = new Set(
|
|
107
|
+
room.game.roundResults.filter((r: RoundResult) => r.twisterId === currentTwister.id).map((r: RoundResult) => r.playerId),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const allPlayersSubmitted = room.game.players.every((p: Player) => submittedPlayerIds.has(p.id));
|
|
111
|
+
|
|
112
|
+
this.logger.log(`Answer submitted, roomCode: ${roomCode}, playerId: ${playerId}, playerName: ${player?.name}, similarity: ${similarity}, allSubmitted: ${allPlayersSubmitted}`);
|
|
113
|
+
|
|
114
|
+
return { similarity, isComplete: allPlayersSubmitted };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
advanceRound(roomCode: string, io: Server): boolean {
|
|
118
|
+
const room = this.roomManager.getRoom(roomCode);
|
|
119
|
+
if (!room) {
|
|
120
|
+
this.logger.warn(`advanceRound failed - room not found, roomCode: ${roomCode}`);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
room.game.currentRound++;
|
|
125
|
+
|
|
126
|
+
if (room.game.currentRound >= room.game.twisters.length) {
|
|
127
|
+
this.logger.log(`Game over - all rounds completed, roomCode: ${roomCode}, totalRounds: ${room.game.twisters.length}`);
|
|
128
|
+
room.game.status = 'game-over';
|
|
129
|
+
this.clearRoundTimer(roomCode);
|
|
130
|
+
this.endGame(roomCode, io);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
room.game.currentTwisterStartTime = Date.now();
|
|
135
|
+
const currentTwister = room.game.twisters[room.game.currentRound];
|
|
136
|
+
|
|
137
|
+
this.logger.log(`Round advanced, roomCode: ${roomCode}, round: ${room.game.currentRound}, twisterId: ${currentTwister?.id}`);
|
|
138
|
+
|
|
139
|
+
io.to(roomCode).emit('round-advanced', {
|
|
140
|
+
currentRound: room.game.currentRound,
|
|
141
|
+
currentTwister,
|
|
142
|
+
roundStartTime: room.game.currentTwisterStartTime,
|
|
143
|
+
roundTimeLimit: room.game.roundTimeLimit,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (room.game.roundTimeLimit !== null) {
|
|
147
|
+
this.startRoundTimer(roomCode, io);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pauseGame(roomCode: string, playerId: string, io: Server): boolean {
|
|
154
|
+
const room = this.roomManager.getRoom(roomCode);
|
|
155
|
+
if (!room || room.game.status !== 'playing') {
|
|
156
|
+
this.logger.warn(`pauseGame failed - invalid state, roomCode: ${roomCode}, status: ${room?.game.status}`);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
if (room.game.pausedAt !== null) {
|
|
160
|
+
this.logger.warn(`pauseGame failed - already paused, roomCode: ${roomCode}`);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.clearRoundTimer(roomCode);
|
|
165
|
+
|
|
166
|
+
room.game.pausedAt = Date.now();
|
|
167
|
+
room.game.status = 'paused';
|
|
168
|
+
|
|
169
|
+
this.logger.log(`Game paused, roomCode: ${roomCode}, playerId: ${playerId}, pausedAt: ${room.game.pausedAt}`);
|
|
170
|
+
|
|
171
|
+
io.to(roomCode).emit('game-paused', {
|
|
172
|
+
pausedAt: room.game.pausedAt,
|
|
173
|
+
pausedBy: playerId,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
resumeGame(roomCode: string, io: Server): boolean {
|
|
180
|
+
const room = this.roomManager.getRoom(roomCode);
|
|
181
|
+
if (!room || room.game.status !== 'paused') {
|
|
182
|
+
this.logger.warn(`resumeGame failed - invalid state, roomCode: ${roomCode}, status: ${room?.game.status}`);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
if (room.game.pausedAt === null) {
|
|
186
|
+
this.logger.warn(`resumeGame failed - not paused, roomCode: ${roomCode}`);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const pauseDuration = Date.now() - room.game.pausedAt;
|
|
191
|
+
room.game.totalPausedTime += pauseDuration;
|
|
192
|
+
room.game.status = 'playing';
|
|
193
|
+
room.game.pausedAt = null;
|
|
194
|
+
|
|
195
|
+
if (room.game.currentTwisterStartTime !== null) {
|
|
196
|
+
room.game.currentTwisterStartTime += pauseDuration;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
this.logger.log(`Game resumed, roomCode: ${roomCode}, pauseDuration: ${pauseDuration}, totalPausedTime: ${room.game.totalPausedTime}`);
|
|
200
|
+
|
|
201
|
+
io.to(roomCode).emit('game-resumed', {
|
|
202
|
+
resumedAt: Date.now(),
|
|
203
|
+
totalPausedTime: room.game.totalPausedTime,
|
|
204
|
+
roundStartTime: room.game.currentTwisterStartTime,
|
|
205
|
+
roundTimeLimit: room.game.roundTimeLimit,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (room.game.roundTimeLimit !== null) {
|
|
209
|
+
this.startRoundTimer(roomCode, io);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
endGame(roomCode: string, io: Server): void {
|
|
216
|
+
const room = this.roomManager.getRoom(roomCode);
|
|
217
|
+
if (!room) {
|
|
218
|
+
this.logger.warn(`endGame failed - room not found, roomCode: ${roomCode}`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
room.game.status = 'game-over';
|
|
223
|
+
this.clearRoundTimer(roomCode);
|
|
224
|
+
|
|
225
|
+
const leaderboard = this.calculateLeaderboard(room);
|
|
226
|
+
|
|
227
|
+
this.logger.log(`Game ended, roomCode: ${roomCode}, leaderboard: ${JSON.stringify(leaderboard.map((e: { player: Player; accuracy: number; time: number }) => ({ name: e.player.name, accuracy: e.accuracy })))}`);
|
|
228
|
+
|
|
229
|
+
io.to(roomCode).emit('game-ended', { leaderboard });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private calculateLeaderboard(room: Room) {
|
|
233
|
+
return room.game.players
|
|
234
|
+
.map((player: Player) => {
|
|
235
|
+
const playerResults = room.game.roundResults.filter((r: RoundResult) => r.playerId === player.id);
|
|
236
|
+
const totalSimilarity = playerResults.reduce((sum: number, r: RoundResult) => sum + r.similarity, 0);
|
|
237
|
+
const accuracy = playerResults.length > 0 ? Math.round(totalSimilarity / playerResults.length) : 0;
|
|
238
|
+
|
|
239
|
+
const totalTime = room.game.startedAt ? Date.now() - room.game.startedAt - room.game.totalPausedTime : 0;
|
|
240
|
+
|
|
241
|
+
return { player, accuracy, time: totalTime };
|
|
242
|
+
})
|
|
243
|
+
.sort((a: { accuracy: number; time: number }, b: { accuracy: number; time: number }) => b.accuracy - a.accuracy || a.time - b.time);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private startRoundTimer(roomCode: string, io: Server): void {
|
|
247
|
+
this.clearRoundTimer(roomCode);
|
|
248
|
+
|
|
249
|
+
const room = this.roomManager.getRoom(roomCode);
|
|
250
|
+
if (!room || room.game.roundTimeLimit === null) return;
|
|
251
|
+
|
|
252
|
+
const elapsed = Date.now() - (room.game.currentTwisterStartTime || Date.now());
|
|
253
|
+
const remaining = Math.max(0, room.game.roundTimeLimit - elapsed);
|
|
254
|
+
|
|
255
|
+
this.logger.debug(`Starting round timer, roomCode: ${roomCode}, remaining: ${remaining}, totalDuration: ${room.game.roundTimeLimit}`);
|
|
256
|
+
|
|
257
|
+
const timer = setTimeout(() => {
|
|
258
|
+
this.logger.log(`Round timer expired, roomCode: ${roomCode}`);
|
|
259
|
+
|
|
260
|
+
io.to(roomCode).emit('round-time-expired', {
|
|
261
|
+
round: room.game.currentRound,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
setTimeout(() => {
|
|
265
|
+
this.logger.log(`Auto-advancing after round time expired, roomCode: ${roomCode}`);
|
|
266
|
+
this.advanceRound(roomCode, io);
|
|
267
|
+
}, AUTO_ADVANCE_DELAY);
|
|
268
|
+
}, remaining);
|
|
269
|
+
|
|
270
|
+
this.roundTimers.set(roomCode, timer);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private clearRoundTimer(roomCode: string): void {
|
|
274
|
+
const timer = this.roundTimers.get(roomCode);
|
|
275
|
+
if (timer) {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
this.roundTimers.delete(roomCode);
|
|
278
|
+
this.logger.debug(`Round timer cleared, roomCode: ${roomCode}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|