Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 38 additions & 36 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ All services are classes exported as singleton instances:
The frontend uses **two separate type hierarchies** for WebSocket messages:

1. **General messages** (`types/websocket-common.ts`): `EWSMessageType` + `IWSMessage<T>` — for `JOIN_ROOM`, `CHAT_SEND`, `ASR_TEXT_PUSH`, `SPEECH_TURN_END`, `VERDICT_RESULT`, etc.
2. **Drum messages** (`types/drum-websocket.ts`): `EDrumMessageType` + `IDrumMessage<T>` — for `DRUM_READY`, `DRUM_START`, `DRUM_TAP`, `DRUM_FINISH`, `DRUM_RESULT`
2. **Drum messages** (`types/drum-websocket.ts`): `EDrumMessageType` + `IDrumMessage<T>` — for `DRUM_READY`, `DRUM_START_REQUEST`, `DRUM_PLAYER_READY`, `DRUM_START`, `DRUM_TAP`, `DRUM_FINISH`, `DRUM_RESULT`
3. **Verdict messages** (`types/verdict-ws.ts`): Verdict-specific payload types — `IBackendVerdictResult`, `IVerdictResultPayload`, `IVerdictFailedPayload`, etc.
4. **Emoji messages** (`types/emoji-websocket.ts`): `IEmojiReceiveData` — for `EMOJI_RECEIVE` server→client payload

Expand All @@ -129,7 +129,7 @@ The backend uses a single unified `EWSMessageType` enum for all message types.
`DrumService` implements an early-listening pattern for messages that arrive between page navigations:

1. `startListening()` called from waiting-room when room becomes `READY` — registers WebSocket handler
2. Messages (`DRUM_READY`, `DRUM_START`) are queued with `receivedAtMs` timestamps
2. Messages (`DRUM_READY`, `DRUM_PLAYER_READY`, `DRUM_START`) are queued with `receivedAtMs` timestamps
3. `initialize()` called from drum-room `onLoad` — processes queued messages with original timestamps for accurate time sync

### Backend Structure (backend/src/)
Expand Down Expand Up @@ -239,46 +239,49 @@ Husky + lint-staged runs ESLint + Prettier on `.ts`, `.js`, `.json`, `.md`. Comm

**Client → Server**:

| Type | Description |
| ------------------ | ------------------------------------------------------------------- |
| `JOIN_ROOM` | Join a room via room code |
| `DRUM_TAP` | Record drum tap during game |
| `CHAT_SEND` | Send a chat message |
| `ASR_TEXT_PUSH` | Push ASR transcription text (throttled partials + immediate finals) |
| `EMOJI_SEND` | Send emoji reaction to opponent during chat |
| `SPEECH_TURN_END` | Notify server that player's speech turn is done |
| `VERDICT_RETRY` | Request retry after verdict generation failure |
| `POST_GAME_ACTION` | Send post-game action (execute_punishment / beg_for_mercy) |
| `LEAVE_ROOM` | Request to leave the room from verdict page |
| Type | Description |
| -------------------- | ------------------------------------------------------------------- |
| `JOIN_ROOM` | Join a room via room code |
| `DRUM_START_REQUEST` | Signal player is ready to start the drum game (triggers countdown) |
| `DRUM_TAP` | Record drum tap during game |
| `CHAT_SEND` | Send a chat message |
| `ASR_TEXT_PUSH` | Push ASR transcription text (throttled partials + immediate finals) |
| `EMOJI_SEND` | Send emoji reaction to opponent during chat |
| `SPEECH_TURN_END` | Notify server that player's speech turn is done |
| `VERDICT_RETRY` | Request retry after verdict generation failure |
| `POST_GAME_ACTION` | Send post-game action (execute_punishment / beg_for_mercy) |
| `LEAVE_ROOM` | Request to leave the room from verdict page |

**Server → Client**:

| Type | Description |
| -------------------- | --------------------------------------------------------- |
| `JOIN_ACK` | Confirm room join (broadcast) |
| `DRUM_READY` | Both players ready for drum game |
| `DRUM_START` | Drum game starts (includes `startAtMs` timing) |
| `DRUM_TAP` | Tap count update (forwarded to opponent) |
| `DRUM_FINISH` | Drum game ends |
| `DRUM_RESULT` | Final game results (scores + winner) |
| `CHAT_RECEIVE` | Receive chat message (broadcast) |
| `ASR_TEXT` | ASR transcription result (broadcast to other participant) |
| `EMOJI_RECEIVE` | Emoji reaction from opponent during chat |
| `SPEECH_TURN_SWITCH` | First speaker done, notify turn switch |
| `CHAT_COMPLETE` | Both speakers done, triggers verdict generation |
| `VERDICT_RESULT` | AI verdict result push (success) |
| `VERDICT_FAILED` | AI verdict generation failed (with canRetry flag) |
| `POST_GAME_EFFECT` | Post-game effect broadcast from opponent (stamp/emoji) |
| `LEAVE_ROOM_ACK` | Acknowledge leave room request |
| `ERROR` | Error notification |
| Type | Description |
| -------------------- | ------------------------------------------------------------------------- |
| `JOIN_ACK` | Confirm room join (broadcast) |
| `DRUM_READY` | Room ready — includes player info and server time sync |
| `DRUM_PLAYER_READY` | A player signalled ready (broadcast readyCount so UI can show both ready) |
| `DRUM_START` | Drum game starts (includes `startAtMs` timing) |
| `DRUM_TAP` | Tap count update (forwarded to opponent) |
| `DRUM_FINISH` | Drum game ends |
| `DRUM_RESULT` | Final game results (scores + winner) |
| `CHAT_RECEIVE` | Receive chat message (broadcast) |
| `ASR_TEXT` | ASR transcription result (broadcast to other participant) |
| `EMOJI_RECEIVE` | Emoji reaction from opponent during chat |
| `SPEECH_TURN_SWITCH` | First speaker done, notify turn switch |
| `CHAT_COMPLETE` | Both speakers done, triggers verdict generation |
| `VERDICT_RESULT` | AI verdict result push (success) |
| `VERDICT_FAILED` | AI verdict generation failed (with canRetry flag) |
| `POST_GAME_EFFECT` | Post-game effect broadcast from opponent (stamp/emoji) |
| `LEAVE_ROOM_ACK` | Acknowledge leave room request |
| `ERROR` | Error notification |

### Room Flow

1. Creator calls `POST /room/create` → gets `roomCode`
1. Creator calls `POST /v1/rooms` → gets `roomCode`
2. Both users connect via WebSocket, send `JOIN_ROOM` with `roomCode`
3. When 2 users join, room becomes `READY`
4. After `WAITING_ROOM_CONFIG.COUNTDOWN_MS` (3s), drum game auto-starts
5. Room states: `WAITING` (1 person) → `READY` (2 people) → `CLOSED`
3. When 2 users join, room becomes `READY` → server broadcasts `DRUM_READY` (player info + server time sync)
4. Each player sends `DRUM_START_REQUEST` when ready → server broadcasts `DRUM_PLAYER_READY` (readyCount)
5. When both ready → server broadcasts `DRUM_START` (with `startAtMs`) → 10s game → `DRUM_FINISH` → `DRUM_RESULT`
6. Room states: `WAITING` (1 person) → `READY` (2 people) → `CLOSED`

### Error Codes

Expand Down Expand Up @@ -351,4 +354,3 @@ docker build -t chatroom-backend:latest -f backend/Dockerfile backend/ # Produc
## Additional Documentation

- **Backend-specific**: See `backend/CLAUDE.md` for backend architecture, WebSocket message types, and API details
- **API Specification**: See `backend/docs/` for detailed API specifications
74 changes: 35 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,9 @@ npm run prepare # 初始化 Husky
### 后端 (backend/)

```bash
npm run dev # 开发模式 (ts-node)
npm run dev # 开发模式 (tsx watch, 热重载)
npm run build # 编译 TypeScript
npm start # 生产模式
npm run ws:test # 测试 WebSocket
npm run lint # ESLint 检查
```

Expand All @@ -107,10 +106,10 @@ npm run lint # ESLint 检查

### 击鼓游戏 (Drum Room)

- 房间就绪后自动启动
- 3 秒倒计时 + 10 秒点击竞争
- 服务器计时计分,防止作弊
- 胜负判定:分高者胜,平局房主胜
- 房间就绪后服务器广播 `DRUM_READY`(含玩家信息与服务器时间同步)
- 双方各发 `DRUM_START_REQUEST`,服务器广播 `DRUM_PLAYER_READY`(readyCount)
- 双方均准备后服务器广播 `DRUM_START`(含 startAtMs 时间戳),10 秒点击竞争
- 服务器计时计分,防止作弊;胜负判定:分高者胜,平局房主胜

### 聊天系统 (Chat Room)

Expand All @@ -137,32 +136,34 @@ npm run lint # ESLint 检查

## WebSocket 消息类型

| 类型 | 方向 | 说明 |
| -------------------- | ---- | ---------------- |
| `JOIN_ROOM` | C→S | 加入房间 |
| `JOIN_ACK` | S→C | 加入确认(广播) |
| `CHAT_SEND` | C→S | 发送文本消息 |
| `CHAT_RECEIVE` | S→C | 接收消息(广播) |
| `EMOJI_SEND` | C→S | 发送表情 |
| `EMOJI_RECEIVE` | S→C | 接收表情(广播) |
| `ASR_TEXT_PUSH` | C→S | 推送识别文本 |
| `ASR_TEXT` | S→C | 广播识别文本 |
| `DRUM_READY` | S→C | 游戏就绪 |
| `DRUM_START` | S→C | 游戏开始 |
| `DRUM_TAP` | 双向 | 点击事件 |
| `DRUM_FINISH` | S→C | 游戏结束 |
| `DRUM_RESULT` | S→C | 游戏结果 |
| `SPEECH_TURN_END` | C→S | 发言轮次结束 |
| `SPEECH_TURN_SWITCH` | S→C | 切换发言轮次 |
| `CHAT_COMPLETE` | S→C | 双方发言完毕 |
| `VERDICT_RESULT` | S→C | 判决结果推送 |
| `VERDICT_FAILED` | S→C | 判决生成失败 |
| `VERDICT_RETRY` | C→S | 请求重试判决 |
| `POST_GAME_ACTION` | C→S | 赛后互动操作 |
| `POST_GAME_EFFECT` | S→C | 赛后互动特效 |
| `LEAVE_ROOM` | C→S | 离开房间 |
| `LEAVE_ROOM_ACK` | S→C | 离开房间确认 |
| `ERROR` | S→C | 错误消息 |
| 类型 | 方向 | 说明 |
| -------------------- | ---- | ------------------------------ |
| `JOIN_ROOM` | C→S | 加入房间 |
| `JOIN_ACK` | S→C | 加入确认(广播) |
| `CHAT_SEND` | C→S | 发送文本消息 |
| `CHAT_RECEIVE` | S→C | 接收消息(广播) |
| `EMOJI_SEND` | C→S | 发送表情 |
| `EMOJI_RECEIVE` | S→C | 接收表情(广播) |
| `ASR_TEXT_PUSH` | C→S | 推送识别文本 |
| `ASR_TEXT` | S→C | 广播识别文本 |
| `DRUM_START_REQUEST` | C→S | 玩家准备好,请求开始 |
| `DRUM_READY` | S→C | 游戏就绪(含玩家信息) |
| `DRUM_PLAYER_READY` | S→C | 广播某玩家已准备(readyCount) |
| `DRUM_START` | S→C | 游戏开始(含 startAtMs) |
| `DRUM_TAP` | 双向 | 点击事件 |
| `DRUM_FINISH` | S→C | 游戏结束 |
| `DRUM_RESULT` | S→C | 游戏结果 |
| `SPEECH_TURN_END` | C→S | 发言轮次结束 |
| `SPEECH_TURN_SWITCH` | S→C | 切换发言轮次 |
| `CHAT_COMPLETE` | S→C | 双方发言完毕 |
| `VERDICT_RESULT` | S→C | 判决结果推送 |
| `VERDICT_FAILED` | S→C | 判决生成失败 |
| `VERDICT_RETRY` | C→S | 请求重试判决 |
| `POST_GAME_ACTION` | C→S | 赛后互动操作 |
| `POST_GAME_EFFECT` | S→C | 赛后互动特效 |
| `LEAVE_ROOM` | C→S | 离开房间 |
| `LEAVE_ROOM_ACK` | S→C | 离开房间确认 |
| `ERROR` | S→C | 错误消息 |

## 开发规范

Expand All @@ -185,13 +186,8 @@ npm run lint # ESLint 检查

## 文档

详细文档请查看 `docs/` 目录:

- [文档索引](docs/README.md)
- [前端文档](docs/miniprogram/)
- [后端文档](docs/backend/)
- [后端 README](backend/README.md)
- [前端 README](miniprogram/README.md)
- [CLAUDE.md](CLAUDE.md) — 前后端整体开发规范与架构说明
- [backend/CLAUDE.md](backend/CLAUDE.md) — 后端架构、WebSocket 消息协议与 API 详情

## 许可证

Expand Down
Binary file removed assets/crown.png
Binary file not shown.
Binary file removed assets/duck.png
Binary file not shown.
14 changes: 7 additions & 7 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,12 @@ Import the pre-created instance, not the class: `import { connectionManager } fr

### Drum Game Orchestration

The drum game flow is orchestrated by `WebSocketController` (not a handler), using `setTimeout` chains:
The drum game flow is orchestrated by `WebSocketController` (not a handler):

1. Room reaches `READY` → `WAITING_ROOM_CONFIG.COUNTDOWN_MS` delay
2. `DRUM_READY` broadcast → `DRUM_START` broadcast with timing
3. `DRUM_CONFIG.COUNTDOWN_MS` later → phase becomes `Running`
4. `DRUM_CONFIG.GAME_DURATION_MS` later → `DRUM_FINISH` → `DRUM_RESULT` → cleanup
1. Room reaches `READY` → after `WAITING_ROOM_CONFIG.COUNTDOWN_MS` → broadcast `DRUM_READY` (player info + server time sync)
2. Each player sends `DRUM_START_REQUEST` → server broadcasts `DRUM_PLAYER_READY` (readyCount)
3. When both players ready → broadcast `DRUM_START` with `startAtMs` timing
4. `DRUM_CONFIG.GAME_DURATION_MS` (10s) later → `DRUM_FINISH` → `DRUM_RESULT` → cleanup

### Validation Pattern

Expand Down Expand Up @@ -119,9 +119,9 @@ Schemas are in `src/models/schemas/`: `http-request.schema.ts`, `ws-message.sche

## WebSocket Message Protocol

**Client → Server**: `JOIN_ROOM`, `CHAT_SEND`, `DRUM_TAP`, `ASR_TEXT_PUSH`, `SPEECH_TURN_END`, `VERDICT_RETRY`
**Client → Server**: `JOIN_ROOM`, `DRUM_START_REQUEST`, `DRUM_TAP`, `CHAT_SEND`, `ASR_TEXT_PUSH`, `EMOJI_SEND`, `SPEECH_TURN_END`, `VERDICT_RETRY`, `POST_GAME_ACTION`, `LEAVE_ROOM`

**Server → Client**: `JOIN_ACK`, `CHAT_RECEIVE`, `DRUM_READY`, `DRUM_START`, `DRUM_TAP`, `DRUM_FINISH`, `DRUM_RESULT`, `ASR_TEXT`, `SPEECH_TURN_SWITCH`, `CHAT_COMPLETE`, `VERDICT_RESULT`, `VERDICT_FAILED`, `ERROR`
**Server → Client**: `JOIN_ACK`, `DRUM_READY`, `DRUM_PLAYER_READY`, `DRUM_START`, `DRUM_TAP`, `DRUM_FINISH`, `DRUM_RESULT`, `CHAT_RECEIVE`, `ASR_TEXT`, `EMOJI_RECEIVE`, `SPEECH_TURN_SWITCH`, `CHAT_COMPLETE`, `VERDICT_RESULT`, `VERDICT_FAILED`, `POST_GAME_EFFECT`, `LEAVE_ROOM_ACK`, `ERROR`

All messages: `{ type: EWSMessageType, data: T, timestamp: number }`

Expand Down
11 changes: 10 additions & 1 deletion backend/src/clients/openai.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,19 +250,28 @@ function validateJudgment(obj: unknown): IJudgmentResponse {
*
* Uses temperature 0.7 for creative/humorous output
*
* @param player1Nickname - Player 1's display nickname
* @param player1Speech - Player 1's speech content
* @param player2Nickname - Player 2's display nickname
* @param player2Speech - Player 2's speech content
* @returns Parsed and validated IJudgmentResponse
* @throws Error with human-readable message on failure
*/
export async function createJudgmentVerdict(
player1Nickname: string,
player1Speech: string,
player2Nickname: string,
player2Speech: string,
idempotencyKey?: string
): Promise<IJudgmentResponse> {
const client = getClient();

const userContent = buildJudgmentUserContent(player1Speech, player2Speech);
const userContent = buildJudgmentUserContent(
player1Nickname,
player1Speech,
player2Nickname,
player2Speech
);
const key = idempotencyKey ?? crypto.randomUUID();

const response = await client.chat.completions.create(
Expand Down
8 changes: 6 additions & 2 deletions backend/src/constants/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,20 +87,24 @@ export const JUDGMENT_SYSTEM_PROMPT = `你是"清汤大老爷",一位断案风
/**
* Build the user message for judgment verdict
*
* @param player1Nickname - Player 1's display nickname
* @param player1Speech - Player 1's speech content
* @param player2Nickname - Player 2's display nickname
* @param player2Speech - Player 2's speech content
* @returns Formatted user content string
*/
export function buildJudgmentUserContent(
player1Nickname: string,
player1Speech: string,
player2Nickname: string,
player2Speech: string
): string {
return `以下是两位当事人的陈述,请据此做出判决:

玩家1陈述
${player1Nickname}陈述
${player1Speech}

玩家2陈述
${player2Nickname}陈述
${player2Speech}

请根据以上内容生成判决书。`;
Expand Down
37 changes: 33 additions & 4 deletions backend/src/controllers/llm-judgement.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
RoomIdParamSchema,
} from '../models/schemas/llm-request.schema';
import { llmJudgementService } from '../services/core/llm-judgement.service';
import { roomManager } from '../services/websocket/room-manager';
import type { IBaseResponse } from '../types/http';
import { EHttpErrorCode } from '../types/http';
import type { IJudgmentResponse } from '../types/llm';
Expand Down Expand Up @@ -66,14 +67,42 @@ export class LlmJudgementController {
}

const { roomId } = paramResult.data;
const { player1Speech, player2Speech, idempotencyKey } =
bodyResult.data;
const { idempotencyKey } = bodyResult.data;

// Look up room to get participant identity and accumulated speech
const room = roomManager.getRoomById(roomId);
if (!room || room.participants.length < 2) {
const response: IBaseResponse<never> = {
success: false,
error: {
code: EHttpErrorCode.InvalidRequest,
message: '房间不存在或参与者不足',
},
};
res.status(400).json(response);
return;
}

const [p1, p2] = room.participants;
const texts = room.speechState?.texts ?? {};

// Call service (synchronous LLM call)
const result: IJudgmentResponse =
await llmJudgementService.createJudgment(roomId, {
player1Speech,
player2Speech,
player1: {
userId: p1.user.userId,
nickname: p1.user.nickname,
speech:
(texts[p1.user.userId] ?? '').trim() ||
'(无发言)',
},
player2: {
userId: p2.user.userId,
nickname: p2.user.nickname,
speech:
(texts[p2.user.userId] ?? '').trim() ||
'(无发言)',
},
idempotencyKey,
});

Expand Down
Loading
Loading